diff --git a/docs/admin/adding-a-host-to-the-network.md b/docs/admin/adding-a-host-to-the-network.md index 1d9dc41..cd32993 100644 --- a/docs/admin/adding-a-host-to-the-network.md +++ b/docs/admin/adding-a-host-to-the-network.md @@ -29,7 +29,7 @@ To create a `bootstrap.json` file for the new host, the admin should perform the following command from their own host: ``` -isle admin create-bootstrap \ +isle hosts create \ --hostname \ --ip \ --admin-path \ @@ -48,12 +48,12 @@ The user can now proceed with calling `isle network join`, as described in the ### Encrypted `admin.json` If `admin.json` is kept in an encrypted format on disk (it should be!) then the -decrypted form can be piped into `create-bootstrap` over stdin. For example, if +decrypted form can be piped into `isle hosts create` over stdin. For example, if GPG is being used to secure `admin.json` then the following could be used to generate a `bootstrap.json`: ``` -gpg -d | isle admin create-bootstrap \ +gpg -d | isle hosts create \ --hostname \ --ip \ --admin-path - \ diff --git a/go/cmd/entrypoint/admin.go b/go/cmd/entrypoint/admin.go index 3e05040..a39212c 100644 --- a/go/cmd/entrypoint/admin.go +++ b/go/cmd/entrypoint/admin.go @@ -1,25 +1,11 @@ package main import ( - "crypto/rand" - "encoding/hex" - "errors" "fmt" "isle/admin" - "isle/bootstrap" - "isle/nebula" - "net/netip" "os" ) -func randStr(l int) string { - b := make([]byte, l) - if _, err := rand.Read(b); err != nil { - panic(err) - } - return hex.EncodeToString(b) -} - func readAdmin(path string) (admin.Admin, error) { if path == "-" { @@ -40,83 +26,3 @@ func readAdmin(path string) (admin.Admin, error) { return admin.FromReader(f) } - -var subCmdAdminCreateBootstrap = subCmd{ - name: "create-bootstrap", - descr: "Creates a new bootstrap.json file for a particular host and writes it to stdout", - do: func(subCmdCtx subCmdCtx) error { - var ( - flags = subCmdCtx.flagSet(false) - hostName nebula.HostName - ip netip.Addr - ) - - hostNameF := flags.VarPF( - textUnmarshalerFlag{&hostName}, - "hostname", "h", - "Name of the host to generate bootstrap.json for", - ) - - ipF := flags.VarPF( - textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host", - ) - - adminPath := flags.StringP( - "admin-path", "a", "", - `Path to admin.json file. If the given path is "-" then stdin is used.`, - ) - - if err := flags.Parse(subCmdCtx.args); err != nil { - return fmt.Errorf("parsing flags: %w", err) - } - - if !hostNameF.Changed || - !ipF.Changed || - *adminPath == "" { - return errors.New("--hostname, --ip, and --admin-path are required") - } - - adm, err := readAdmin(*adminPath) - if err != nil { - return fmt.Errorf("reading admin.json with --admin-path of %q: %w", *adminPath, err) - } - - garageBootstrap := bootstrap.Garage{ - RPCSecret: adm.Garage.RPCSecret, - AdminToken: randStr(32), - GlobalBucketS3APICredentials: adm.Garage.GlobalBucketS3APICredentials, - } - - newHostBootstrap, err := bootstrap.New( - adm.Nebula.CACredentials, - adm.CreationParams, - garageBootstrap, - hostName, - ip, - ) - if err != nil { - return fmt.Errorf("initializing bootstrap data: %w", err) - } - - hostsRes, err := subCmdCtx.getHosts() - if err != nil { - return fmt.Errorf("getting hosts: %w", err) - } - - for _, host := range hostsRes.Hosts { - newHostBootstrap.Hosts[host.Name] = host - } - - return newHostBootstrap.WriteTo(os.Stdout) - }, -} - -var subCmdAdmin = subCmd{ - name: "admin", - descr: "Sub-commands which only admins can run", - do: func(subCmdCtx subCmdCtx) error { - return subCmdCtx.doSubCmd( - subCmdAdminCreateBootstrap, - ) - }, -} diff --git a/go/cmd/entrypoint/hosts.go b/go/cmd/entrypoint/hosts.go index a260c31..957e6c6 100644 --- a/go/cmd/entrypoint/hosts.go +++ b/go/cmd/entrypoint/hosts.go @@ -7,10 +7,70 @@ import ( "isle/daemon" "isle/jsonutil" "isle/nebula" + "net/netip" "os" "sort" ) +var subCmdHostsCreate = subCmd{ + name: "create", + descr: "Creates a new host in the network, writing its new bootstrap.json to stdout", + do: func(subCmdCtx subCmdCtx) error { + var ( + flags = subCmdCtx.flagSet(false) + hostName nebula.HostName + ip netip.Addr + ) + + hostNameF := flags.VarPF( + textUnmarshalerFlag{&hostName}, + "hostname", "h", + "Name of the host to generate bootstrap.json for", + ) + + ipF := flags.VarPF( + textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host", + ) + + adminPath := flags.StringP( + "admin-path", "a", "", + `Path to admin.json file. If the given path is "-" then stdin is used.`, + ) + + if err := flags.Parse(subCmdCtx.args); err != nil { + return fmt.Errorf("parsing flags: %w", err) + } + + if !hostNameF.Changed || + !ipF.Changed || + *adminPath == "" { + return errors.New("--hostname, --ip, and --admin-path are required") + } + + adm, err := readAdmin(*adminPath) + if err != nil { + return fmt.Errorf("reading admin.json with --admin-path of %q: %w", *adminPath, err) + } + + var res daemon.CreateHostResult + err = subCmdCtx.daemonRCPClient.Call( + subCmdCtx.ctx, + &res, + "CreateHost", + daemon.CreateHostRequest{ + CASigningPrivateKey: adm.Nebula.CACredentials.SigningPrivateKey, + HostName: hostName, + IP: ip, + }, + ) + if err != nil { + return fmt.Errorf("calling CreateHost: %w", err) + } + + return res.HostBootstrap.WriteTo(os.Stdout) + }, +} + var subCmdHostsList = subCmd{ name: "list", descr: "Lists all hosts in the network, and their IPs", @@ -88,6 +148,7 @@ var subCmdHosts = subCmd{ descr: "Sub-commands having to do with configuration of hosts in the network", do: func(subCmdCtx subCmdCtx) error { return subCmdCtx.doSubCmd( + subCmdHostsCreate, subCmdHostsRemove, subCmdHostsList, ) diff --git a/go/cmd/entrypoint/main.go b/go/cmd/entrypoint/main.go index 5000045..22e82d4 100644 --- a/go/cmd/entrypoint/main.go +++ b/go/cmd/entrypoint/main.go @@ -61,7 +61,6 @@ func main() { ctx: ctx, logger: logger, }.doSubCmd( - subCmdAdmin, subCmdDaemon, subCmdGarage, subCmdHosts, diff --git a/go/daemon/daemon.go b/go/daemon/daemon.go index 03ddef0..a4c83f0 100644 --- a/go/daemon/daemon.go +++ b/go/daemon/daemon.go @@ -13,6 +13,7 @@ import ( "isle/bootstrap" "isle/garage" "isle/nebula" + "net/netip" "os" "sync" "time" @@ -55,6 +56,17 @@ type Daemon interface { // RemoveHost removes the host of the given name from the network. RemoveHost(context.Context, nebula.HostName) error + // CreateHost creates a bootstrap for a new host with the given name and IP + // address. + CreateHost( + ctx context.Context, + caSigningPrivateKey nebula.SigningPrivateKey, // TODO load from secrets storage + hostName nebula.HostName, + ip netip.Addr, // TODO automatically choose IP address + ) ( + bootstrap.Bootstrap, error, + ) + // CreateNebulaCertificate creates and signs a new nebula certficate for an // existing host, given the public key for that host. This is currently // mostly useful for creating certs for mobile devices. @@ -617,6 +629,54 @@ func makeCACreds( } } +func (d *daemon) CreateHost( + ctx context.Context, + caSigningPrivateKey nebula.SigningPrivateKey, + hostName nebula.HostName, + ip netip.Addr, +) ( + bootstrap.Bootstrap, error, +) { + return withCurrBootstrap(d, func( + currBootstrap bootstrap.Bootstrap, + ) ( + bootstrap.Bootstrap, error, + ) { + var ( + garageRPCSecret = currBootstrap.Garage.RPCSecret + garageGlobalBucketS3APICreds = currBootstrap.Garage.GlobalBucketS3APICredentials + ) + + // TODO check if garageRPCSecret is actually set + + garageBootstrap := bootstrap.Garage{ + RPCSecret: garageRPCSecret, + AdminToken: randStr(32), + GlobalBucketS3APICredentials: garageGlobalBucketS3APICreds, + } + + newHostBootstrap, err := bootstrap.New( + makeCACreds(currBootstrap, caSigningPrivateKey), + currBootstrap.AdminCreationParams, + garageBootstrap, + hostName, + ip, + ) + if err != nil { + return bootstrap.Bootstrap{}, fmt.Errorf( + "initializing bootstrap data: %w", err, + ) + } + + newHostBootstrap.Hosts = currBootstrap.Hosts + + // TODO persist new bootstrap to garage. Requires making the daemon + // config change watching logic smarter, so only dnsmasq gets restarted. + + return newHostBootstrap, nil + }) +} + func (d *daemon) CreateNebulaCertificate( ctx context.Context, caSigningPrivateKey nebula.SigningPrivateKey, diff --git a/go/daemon/rpc.go b/go/daemon/rpc.go index c0c8959..15c9912 100644 --- a/go/daemon/rpc.go +++ b/go/daemon/rpc.go @@ -7,6 +7,7 @@ import ( "isle/admin" "isle/bootstrap" "isle/nebula" + "net/netip" "slices" "golang.org/x/exp/maps" @@ -131,6 +132,38 @@ func (r *RPC) RemoveHost(ctx context.Context, req RemoveHostRequest) (struct{}, return struct{}{}, r.daemon.RemoveHost(ctx, req.HostName) } +// CreateHostRequest contains the arguments to the +// CreateHost RPC method. +// +// All fields are required. +type CreateHostRequest struct { + CASigningPrivateKey nebula.SigningPrivateKey // TODO load from secrets storage + HostName nebula.HostName + IP netip.Addr +} + +// CreateHostResult wraps the results from the CreateHost RPC method. +type CreateHostResult struct { + HostBootstrap bootstrap.Bootstrap +} + +// CreateHost passes the call through to the Daemon method of the +// same name. +func (r *RPC) CreateHost( + ctx context.Context, req CreateHostRequest, +) ( + CreateHostResult, error, +) { + hostBootstrap, err := r.daemon.CreateHost( + ctx, req.CASigningPrivateKey, req.HostName, req.IP, + ) + if err != nil { + return CreateHostResult{}, err + } + + return CreateHostResult{HostBootstrap: hostBootstrap}, nil +} + // CreateNebulaCertificateRequest contains the arguments to the // CreateNebulaCertificate RPC method. // diff --git a/tests/utils/with-1-data-1-empty-node-network.sh b/tests/utils/with-1-data-1-empty-node-network.sh index 00796dd..fbbad80 100644 --- a/tests/utils/with-1-data-1-empty-node-network.sh +++ b/tests/utils/with-1-data-1-empty-node-network.sh @@ -67,7 +67,7 @@ EOF > admin.json echo "Creating secondus bootstrap" - isle admin create-bootstrap \ + isle hosts create \ --admin-path admin.json \ --hostname secondus \ --ip "$secondus_ip" \