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.
## 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 <name> \
--ip <ip> \
> bootstrap.json
isle hosts create --hostname <name> >bootstrap.json
```
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",
)
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,
},
},

View File

@ -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,
)

View File

@ -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

View File

@ -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" ]
}

View File

@ -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
)"