Automatically choose IP for new hosts

This commit is contained in:
Brian Picciano 2024-07-21 17:03:59 +02:00
parent 1411370b0e
commit ee30199c4c
6 changed files with 111 additions and 23 deletions

View File

@ -15,22 +15,13 @@ conform to the following rules:
* It should end with a letter or number. * It should end with a letter or number.
## Step 2: Choose IP ## Step 2: Create a `bootstrap.json` File
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
To create a `bootstrap.json` file for the new host, the admin should perform the To create a `bootstrap.json` file for the new host, the admin should perform the
following command from their own host: following command from their own host:
``` ```
isle hosts create \ isle hosts create --hostname <name> >bootstrap.json
--hostname <name> \
--ip <ip> \
> bootstrap.json
``` ```
The resulting `bootstrap.json` file should be treated as a secret file and The resulting `bootstrap.json` file should be treated as a secret file and

View File

@ -29,7 +29,7 @@ var subCmdHostsCreate = subCmd{
"Name of the host to generate bootstrap.json for", "Name of the host to generate bootstrap.json for",
) )
ipF := flags.VarPF( flags.VarP(
textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host", textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host",
) )
@ -43,8 +43,8 @@ var subCmdHostsCreate = subCmd{
return fmt.Errorf("parsing flags: %w", err) return fmt.Errorf("parsing flags: %w", err)
} }
if !hostNameF.Changed || !ipF.Changed { if !hostNameF.Changed {
return errors.New("--hostname and --ip are required") return errors.New("--hostname is required")
} }
var res daemon.CreateHostResult var res daemon.CreateHostResult
@ -54,8 +54,8 @@ var subCmdHostsCreate = subCmd{
"CreateHost", "CreateHost",
daemon.CreateHostRequest{ daemon.CreateHostRequest{
HostName: hostName, HostName: hostName,
IP: ip,
Opts: daemon.CreateHostOpts{ Opts: daemon.CreateHostOpts{
IP: ip,
CanCreateHosts: *canCreateHosts, CanCreateHosts: *canCreateHosts,
}, },
}, },

View File

@ -3,7 +3,9 @@
package daemon package daemon
import ( import (
"bytes"
"context" "context"
"crypto/rand"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -23,6 +25,10 @@ import (
// CreateHostOpts are optional parameters to the CreateHost method. // CreateHostOpts are optional parameters to the CreateHost method.
type CreateHostOpts struct { 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 // CanCreateHosts indicates that the bootstrap produced by CreateHost should
// give the new host the ability to create new hosts as well. // give the new host the ability to create new hosts as well.
CanCreateHosts bool CanCreateHosts bool
@ -82,7 +88,6 @@ type Daemon interface {
CreateHost( CreateHost(
ctx context.Context, ctx context.Context,
hostName nebula.HostName, hostName nebula.HostName,
ip netip.Addr, // TODO automatically choose IP address
opts CreateHostOpts, opts CreateHostOpts,
) ( ) (
JoiningBootstrap, error, 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( func (d *daemon) CreateHost(
ctx context.Context, ctx context.Context,
hostName nebula.HostName, hostName nebula.HostName,
ip netip.Addr,
opts CreateHostOpts, opts CreateHostOpts,
) ( ) (
JoiningBootstrap, error, JoiningBootstrap, error,
@ -613,6 +696,16 @@ func (d *daemon) CreateHost(
currBootstrap := d.currBootstrap currBootstrap := d.currBootstrap
d.l.RUnlock() 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( caSigningPrivateKey, err := getNebulaCASigningPrivateKey(
ctx, d.secretsStore, ctx, d.secretsStore,
) )

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"isle/bootstrap" "isle/bootstrap"
"isle/nebula" "isle/nebula"
"net/netip"
"slices" "slices"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
@ -130,7 +129,6 @@ func (r *RPC) RemoveHost(ctx context.Context, req RemoveHostRequest) (struct{},
// All fields are required. // All fields are required.
type CreateHostRequest struct { type CreateHostRequest struct {
HostName nebula.HostName HostName nebula.HostName
IP netip.Addr
Opts CreateHostOpts Opts CreateHostOpts
} }
@ -147,7 +145,7 @@ func (r *RPC) CreateHost(
CreateHostResult, error, CreateHostResult, error,
) { ) {
joiningBootstrap, err := r.daemon.CreateHost( joiningBootstrap, err := r.daemon.CreateHost(
ctx, req.HostName, req.IP, req.Opts, ctx, req.HostName, req.Opts,
) )
if err != nil { if err != nil {
return CreateHostResult{}, err return CreateHostResult{}, err

View File

@ -9,7 +9,7 @@ function do_tests {
[ "$(echo "$hosts" | jq -r '.[0].Storage.Instances|length')" = "3" ] [ "$(echo "$hosts" | jq -r '.[0].Storage.Instances|length')" = "3" ]
[ "$(echo "$hosts" | jq -r '.[1].Name')" = "secondus" ] [ "$(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" ] [ "$(echo "$hosts" | jq -r '.[1].Storage.Instances|length')" = "0" ]
} }

View File

@ -8,7 +8,6 @@ primus_base="$base/primus"
primus_ip="10.6.9.1" primus_ip="10.6.9.1"
secondus_base="$base/secondus" secondus_base="$base/secondus"
secondus_ip="10.6.9.2"
function as_primus { function as_primus {
current_ip="$primus_ip" current_ip="$primus_ip"
@ -68,7 +67,6 @@ EOF
echo "Creating secondus bootstrap" echo "Creating secondus bootstrap"
isle hosts create \ isle hosts create \
--hostname secondus \ --hostname secondus \
--ip "$secondus_ip" \
> "$secondus_bootstrap" > "$secondus_bootstrap"
( (
@ -91,3 +89,11 @@ EOF
isle network join -b "$secondus_bootstrap" isle network join -b "$secondus_bootstrap"
) )
fi 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
)"