diff --git a/docs/admin/adding-a-host-to-the-network.md b/docs/admin/adding-a-host-to-the-network.md index 439c82a..c27650a 100644 --- a/docs/admin/adding-a-host-to-the-network.md +++ b/docs/admin/adding-a-host-to-the-network.md @@ -15,22 +15,13 @@ conform to the following rules: * It should end with a letter or number. -## Step 2: Choose IP - -The admin should choose an IP for the host. The IP you choose for the new host -should be one which is not yet used by any other host and is in a subnet which -was configured when creating the network. - -## Step 3: Create a `bootstrap.json` File +## Step 2: Create a `bootstrap.json` File To create a `bootstrap.json` file for the new host, the admin should perform the following command from their own host: ``` -isle hosts create \ - --hostname \ - --ip \ - > bootstrap.json +isle hosts create --hostname >bootstrap.json ``` The resulting `bootstrap.json` file should be treated as a secret file and diff --git a/go/cmd/entrypoint/hosts.go b/go/cmd/entrypoint/hosts.go index 69b7375..7f9ae1b 100644 --- a/go/cmd/entrypoint/hosts.go +++ b/go/cmd/entrypoint/hosts.go @@ -29,7 +29,7 @@ var subCmdHostsCreate = subCmd{ "Name of the host to generate bootstrap.json for", ) - ipF := flags.VarPF( + flags.VarP( textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host", ) @@ -43,8 +43,8 @@ var subCmdHostsCreate = subCmd{ return fmt.Errorf("parsing flags: %w", err) } - if !hostNameF.Changed || !ipF.Changed { - return errors.New("--hostname and --ip are required") + if !hostNameF.Changed { + return errors.New("--hostname is required") } var res daemon.CreateHostResult @@ -54,8 +54,8 @@ var subCmdHostsCreate = subCmd{ "CreateHost", daemon.CreateHostRequest{ HostName: hostName, - IP: ip, Opts: daemon.CreateHostOpts{ + IP: ip, CanCreateHosts: *canCreateHosts, }, }, diff --git a/go/daemon/daemon.go b/go/daemon/daemon.go index 01a34d7..82cc382 100644 --- a/go/daemon/daemon.go +++ b/go/daemon/daemon.go @@ -3,7 +3,9 @@ package daemon import ( + "bytes" "context" + "crypto/rand" "errors" "fmt" "io" @@ -23,6 +25,10 @@ import ( // CreateHostOpts are optional parameters to the CreateHost method. type CreateHostOpts struct { + // IP address of the new host. An IP address will be randomly chosen if one + // is not given here. + IP netip.Addr + // CanCreateHosts indicates that the bootstrap produced by CreateHost should // give the new host the ability to create new hosts as well. CanCreateHosts bool @@ -82,7 +88,6 @@ type Daemon interface { CreateHost( ctx context.Context, hostName nebula.HostName, - ip netip.Addr, // TODO automatically choose IP address opts CreateHostOpts, ) ( JoiningBootstrap, error, @@ -601,10 +606,88 @@ func makeCACreds( } } +func chooseAvailableIP(b bootstrap.Bootstrap) (netip.Addr, error) { + var ( + cidrIPNet = b.CAPublicCredentials.Cert.Unwrap().Details.Subnets[0] + cidrMask = cidrIPNet.Mask + cidrIPB = cidrIPNet.IP + + cidr = netip.MustParsePrefix(cidrIPNet.String()) + cidrIP = cidr.Addr() + cidrSuffixBits = cidrIP.BitLen() - cidr.Bits() + + inUseIPs = make(map[netip.Addr]struct{}, len(b.Hosts)) + ) + + for _, host := range b.Hosts { + inUseIPs[host.IP()] = struct{}{} + } + + // first check that there are any addresses at all. We can determine the + // number of possible addresses using the network CIDR. The first IP in a + // subnet is the network identifier, and is reserved. The last IP is the + // broadcast IP, and is also reserved. Hence, the -2. + usableIPs := (1 << cidrSuffixBits) - 2 + if len(inUseIPs) >= usableIPs { + return netip.Addr{}, errors.New("no available IPs") + } + + // We need to know the subnet broadcast address, so we don't accidentally + // produce it. + cidrBCastIPB := bytes.Clone(cidrIPB) + for i := range cidrBCastIPB { + cidrBCastIPB[i] |= ^cidrMask[i] + } + cidrBCastIP, ok := netip.AddrFromSlice(cidrBCastIPB) + if !ok { + panic(fmt.Sprintf("invalid broadcast ip calculated: %x", cidrBCastIP)) + } + + // Try a handful of times to pick an IP at random. This is preferred, as it + // leaves less room for two different CreateHost calls to choose the same + // IP. + for range 20 { + b := make([]byte, len(cidrIPB)) + if _, err := rand.Read(b); err != nil { + return netip.Addr{}, fmt.Errorf("reading random bytes: %w", err) + } + + for i := range b { + b[i] = cidrIPB[i] | (b[i] & ^cidrMask[i]) + } + + ip, ok := netip.AddrFromSlice(b) + if !ok { + panic(fmt.Sprintf("generated invalid IP: %x", b)) + } else if !cidr.Contains(ip) { + panic(fmt.Sprintf( + "generated IP %v which is not in cidr %v", ip, cidr, + )) + } + + if ip == cidrIP || ip == cidrBCastIP { + continue + } + + if _, inUse := inUseIPs[ip]; !inUse { + return ip, nil + } + } + + // If randomly picking fails then just go through IPs one by one until the + // free one is found. + for ip := cidrIP.Next(); ip != cidrBCastIP; ip = ip.Next() { + if _, inUse := inUseIPs[ip]; !inUse { + return ip, nil + } + } + + panic("All ips are in-use, but somehow that wasn't determined earlier") +} + func (d *daemon) CreateHost( ctx context.Context, hostName nebula.HostName, - ip netip.Addr, opts CreateHostOpts, ) ( JoiningBootstrap, error, @@ -613,6 +696,16 @@ func (d *daemon) CreateHost( currBootstrap := d.currBootstrap d.l.RUnlock() + ip := opts.IP + if ip == (netip.Addr{}) { + var err error + if ip, err = chooseAvailableIP(currBootstrap); err != nil { + return JoiningBootstrap{}, fmt.Errorf( + "choosing available IP: %w", err, + ) + } + } + caSigningPrivateKey, err := getNebulaCASigningPrivateKey( ctx, d.secretsStore, ) diff --git a/go/daemon/rpc.go b/go/daemon/rpc.go index d4edc54..4abee38 100644 --- a/go/daemon/rpc.go +++ b/go/daemon/rpc.go @@ -6,7 +6,6 @@ import ( "fmt" "isle/bootstrap" "isle/nebula" - "net/netip" "slices" "golang.org/x/exp/maps" @@ -130,7 +129,6 @@ func (r *RPC) RemoveHost(ctx context.Context, req RemoveHostRequest) (struct{}, // All fields are required. type CreateHostRequest struct { HostName nebula.HostName - IP netip.Addr Opts CreateHostOpts } @@ -147,7 +145,7 @@ func (r *RPC) CreateHost( CreateHostResult, error, ) { joiningBootstrap, err := r.daemon.CreateHost( - ctx, req.HostName, req.IP, req.Opts, + ctx, req.HostName, req.Opts, ) if err != nil { return CreateHostResult{}, err diff --git a/tests/cases/hosts/00-list.sh b/tests/cases/hosts/00-list.sh index f2cbe1e..3f516c3 100644 --- a/tests/cases/hosts/00-list.sh +++ b/tests/cases/hosts/00-list.sh @@ -9,7 +9,7 @@ function do_tests { [ "$(echo "$hosts" | jq -r '.[0].Storage.Instances|length')" = "3" ] [ "$(echo "$hosts" | jq -r '.[1].Name')" = "secondus" ] - [ "$(echo "$hosts" | jq -r '.[1].VPN.IP')" = "10.6.9.2" ] + [ "$(echo "$hosts" | jq -r '.[1].VPN.IP')" = "$secondus_ip" ] [ "$(echo "$hosts" | jq -r '.[1].Storage.Instances|length')" = "0" ] } 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 264b1f1..ff61e2b 100644 --- a/tests/utils/with-1-data-1-empty-node-network.sh +++ b/tests/utils/with-1-data-1-empty-node-network.sh @@ -8,7 +8,6 @@ primus_base="$base/primus" primus_ip="10.6.9.1" secondus_base="$base/secondus" -secondus_ip="10.6.9.2" function as_primus { current_ip="$primus_ip" @@ -68,7 +67,6 @@ EOF echo "Creating secondus bootstrap" isle hosts create \ --hostname secondus \ - --ip "$secondus_ip" \ > "$secondus_bootstrap" ( @@ -91,3 +89,11 @@ EOF isle network join -b "$secondus_bootstrap" ) fi + +secondus_ip="$( + nebula-cert print -json \ + -path <(jq -r '.Bootstrap.Hosts["secondus"].PublicCredentials.Cert' "$secondus_bootstrap") \ + | jq -r '.details.ips[0]' \ + | cut -d/ -f1 +)" +