Do proper type-based validation or hostnames and ipnets

This commit is contained in:
Brian Picciano 2024-07-12 15:30:21 +02:00
parent 1ee396c976
commit 736b23429c
18 changed files with 187 additions and 135 deletions

View File

@ -103,7 +103,7 @@ To create the network, and the `admin.json` file in the process, run:
```
sudo isle network create \
--name <name> \
--ip-net <ip/subnet-prefix> \
--ip-net <subnet> \
--domain <domain> \
--hostname <hostname> \
| gpg -e -r <my gpg email> \
@ -112,11 +112,6 @@ sudo isle network create \
A couple of notes here:
* The `--ip-net` parameter is formed from both the subnet and the IP you chose
within it. So if your subnet is `10.10.0.0/16`, and your chosen IP in that
subnet is `10.10.4.20`, then your `--ip-net` parameter will be
`10.10.4.20/16`.
* Only one gpg recipient is specified. If you intend on including other users as
network administrators you can add them to the recipients list at this step,
so they will be able to use the `admin.json` file as well. You can also

View File

@ -10,7 +10,7 @@ import (
"isle/admin"
"isle/garage"
"isle/nebula"
"net"
"net/netip"
"os"
"path/filepath"
"sort"
@ -51,7 +51,7 @@ type Bootstrap struct {
HostAssigned `json:"-"`
SignedHostAssigned nebula.Signed[HostAssigned] // signed by CA
Hosts map[string]Host
Hosts map[nebula.HostName]Host
}
// New initializes and returns a new Bootstrap file for a new host. This
@ -60,8 +60,8 @@ func New(
caCreds nebula.CACredentials,
adminCreationParams admin.CreationParams,
garage Garage,
name string,
ip net.IP,
name nebula.HostName,
ip netip.Addr,
) (
Bootstrap, error,
) {
@ -89,7 +89,7 @@ func New(
PrivateCredentials: hostPrivCreds,
HostAssigned: assigned,
SignedHostAssigned: signedAssigned,
Hosts: map[string]Host{},
Hosts: map[nebula.HostName]Host{},
}, nil
}
@ -145,7 +145,7 @@ func (b Bootstrap) ThisHost() Host {
}
// Hash returns a deterministic hash of the given hosts map.
func HostsHash(hostsMap map[string]Host) ([]byte, error) {
func HostsHash(hostsMap map[nebula.HostName]Host) ([]byte, error) {
hosts := make([]Host, 0, len(hostsMap))
for _, host := range hostsMap {

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"isle/garage"
"isle/nebula"
"path/filepath"
"dev.mediocregopher.com/mediocre-go-lib.git/mctx"
@ -46,12 +47,12 @@ func (b Bootstrap) GetGarageBootstrapHosts(
ctx context.Context,
logger *mlog.Logger,
) (
map[string]Host, error,
map[nebula.HostName]Host, error,
) {
client := b.GlobalBucketS3APIClient()
hosts := map[string]Host{}
hosts := map[nebula.HostName]Host{}
objInfoCh := client.ListObjects(
ctx, garage.GlobalBucket,

View File

@ -28,7 +28,7 @@ type GarageHost struct {
// HostAssigned are all fields related to a host which were assigned to it by an
// admin.
type HostAssigned struct {
Name string
Name nebula.HostName
PublicCredentials nebula.HostPublicCredentials
}

View File

@ -8,7 +8,7 @@ import (
"isle/admin"
"isle/bootstrap"
"isle/nebula"
"net"
"net/netip"
"os"
)
@ -46,17 +46,20 @@ var subCmdAdminCreateBootstrap = subCmd{
descr: "Creates a new bootstrap.json file for a particular host and writes it to stdout",
checkLock: false,
do: func(subCmdCtx subCmdCtx) error {
var (
flags = subCmdCtx.flagSet(false)
hostName nebula.HostName
ip netip.Addr
)
flags := subCmdCtx.flagSet(false)
hostName := flags.StringP(
"hostname", "h", "",
hostNameF := flags.VarPF(
textUnmarshalerFlag{&hostName},
"hostname", "h",
"Name of the host to generate bootstrap.json for",
)
ipStr := flags.StringP(
"ip", "i", "",
"IP of the new host",
ipF := flags.VarPF(
textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host",
)
adminPath := flags.StringP(
@ -68,20 +71,12 @@ var subCmdAdminCreateBootstrap = subCmd{
return fmt.Errorf("parsing flags: %w", err)
}
if *hostName == "" || *ipStr == "" || *adminPath == "" {
if !hostNameF.Changed ||
!ipF.Changed ||
*adminPath == "" {
return errors.New("--hostname, --ip, and --admin-path are required")
}
if err := validateHostName(*hostName); err != nil {
return fmt.Errorf("invalid hostname %q: %w", *hostName, err)
}
ip := net.ParseIP(*ipStr)
if ip == nil {
return fmt.Errorf("invalid ip %q", *ipStr)
}
adm, err := readAdmin(*adminPath)
if err != nil {
return fmt.Errorf("reading admin.json with --admin-path of %q: %w", *adminPath, err)
@ -97,7 +92,7 @@ var subCmdAdminCreateBootstrap = subCmd{
adm.Nebula.CACredentials,
adm.CreationParams,
garageBootstrap,
*hostName,
hostName,
ip,
)
if err != nil {
@ -122,17 +117,20 @@ var subCmdAdminCreateNebulaCert = subCmd{
descr: "Creates a signed nebula certificate file and writes it to stdout",
checkLock: false,
do: func(subCmdCtx subCmdCtx) error {
flags := subCmdCtx.flagSet(false)
hostName := flags.StringP(
"hostname", "h", "",
"Name of the host to generate bootstrap.json for",
var (
flags = subCmdCtx.flagSet(false)
hostName nebula.HostName
ip netip.Addr
)
ipStr := flags.StringP(
"ip", "i", "",
"IP of the new host",
hostNameF := flags.VarPF(
textUnmarshalerFlag{&hostName},
"hostname", "h",
"Name of the host to generate a certificate for",
)
ipF := flags.VarPF(
textUnmarshalerFlag{&ip}, "ip", "i", "IP of the new host",
)
adminPath := flags.StringP(
@ -149,20 +147,13 @@ var subCmdAdminCreateNebulaCert = subCmd{
return fmt.Errorf("parsing flags: %w", err)
}
if *hostName == "" || *ipStr == "" || *adminPath == "" || *pubKeyPath == "" {
if !hostNameF.Changed ||
!ipF.Changed ||
*adminPath == "" ||
*pubKeyPath == "" {
return errors.New("--hostname, --ip, --admin-path, and --pub-key-path are required")
}
if err := validateHostName(*hostName); err != nil {
return fmt.Errorf("invalid hostname %q: %w", *hostName, err)
}
ip := net.ParseIP(*ipStr)
if ip == nil {
return fmt.Errorf("invalid ip %q", *ipStr)
}
adm, err := readAdmin(*adminPath)
if err != nil {
return fmt.Errorf("reading admin.json with --admin-path of %q: %w", *adminPath, err)
@ -179,7 +170,7 @@ var subCmdAdminCreateNebulaCert = subCmd{
}
nebulaHostCert, err := nebula.NewHostCert(
adm.Nebula.CACredentials, hostPub, *hostName, ip,
adm.Nebula.CACredentials, hostPub, hostName, ip,
)
if err != nil {
return fmt.Errorf("creating cert: %w", err)

View File

@ -0,0 +1,22 @@
package main
import (
"encoding"
"fmt"
)
type textUnmarshalerFlag struct {
inner interface {
encoding.TextUnmarshaler
}
}
func (f textUnmarshalerFlag) Set(v string) error {
return f.inner.UnmarshalText([]byte(v))
}
func (f textUnmarshalerFlag) String() string {
return fmt.Sprint(f.inner)
}
func (f textUnmarshalerFlag) Type() string { return "string" }

View File

@ -42,7 +42,7 @@ var subCmdHostsList = subCmd{
for _, h := range hostsRes.Hosts {
host := host{
Name: h.Name,
Name: string(h.Name),
Storage: h.Garage,
}

View File

@ -29,16 +29,16 @@ var subCmdNetworkCreate = subCmd{
"Domain name that should be used as the root domain in the network.",
)
flags.StringVarP(
&req.IPNet, "ip-net", "i", "",
`IP+prefix (e.g. "10.10.0.1/16") which denotes the IP of this`+
` host, which will be the first host in the network, and the`+
` range of IPs which other hosts in the network can be`+
` assigned`,
ipNetF := flags.VarPF(
textUnmarshalerFlag{&req.IPNet}, "ip-net", "i",
`An IP subnet, in CIDR form, which will be the overall range of`+
` possible IPs in the network. The first IP in this network`+
` range will become this first host's IP.`,
)
flags.StringVarP(
&req.HostName, "hostname", "h", "",
hostNameF := flags.VarPF(
textUnmarshalerFlag{&req.HostName},
"hostname", "h",
"Name of this host, which will be the first host in the network",
)
@ -48,8 +48,8 @@ var subCmdNetworkCreate = subCmd{
if req.Name == "" ||
req.Domain == "" ||
req.IPNet == "" ||
req.HostName == "" {
!ipNetF.Changed ||
!hostNameF.Changed {
return errors.New("--name, --domain, --ip-net, and --hostname are required")
}

View File

@ -22,7 +22,7 @@ func dnsmasqPmuxProcConfig(
hostsSlice := make([]dnsmasq.ConfDataHost, 0, len(hostBootstrap.Hosts))
for _, host := range hostBootstrap.Hosts {
hostsSlice = append(hostsSlice, dnsmasq.ConfDataHost{
Name: host.Name,
Name: string(host.Name),
IP: host.IP().String(),
})
}

View File

@ -197,7 +197,7 @@ func GarageApplyLayout(
id := bootstrapGarageHostForAlloc(thisHost, alloc).ID
zone := hostName
zone := string(hostName)
if alloc.Zone != "" {
zone = alloc.Zone
}

View File

@ -11,29 +11,15 @@ import (
"io/fs"
"isle/admin"
"isle/bootstrap"
"isle/daemon/jsonrpc2"
"isle/garage"
"isle/nebula"
"net"
"os"
"regexp"
"sync"
"time"
"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
)
var hostNameRegexp = regexp.MustCompile(`^[a-z][a-z0-9\-]*$`)
func validateHostName(name string) error {
if !hostNameRegexp.MatchString(name) {
return errors.New("a host's name must start with a letter and only contain letters, numbers, and dashes")
}
return nil
}
// Daemon presents all functionality required for client frontends to interact
// with isle, typically via the unix socket.
type Daemon interface {
@ -41,15 +27,17 @@ type Daemon interface {
// CreateNetwork will initialize a new network using the given parameters.
// - name: Human-readable name of the network.
// - domain: Primary domain name that network services are served under.
// - ipNetStr: An IP + subnet mask which represents both the IP of this
// first host in the network, as well as the overall range of possible IPs
// in the network.
// - ipNet: An IP subnet, in CIDR form, which will be the overall range of
// possible IPs in the network. The first IP in this network range will
// become this first host's IP.
// - hostName: The name of this first host in the network.
//
// An Admin instance is returned, which is necessary to perform admin
// actions in the future.
CreateNetwork(
ctx context.Context, name, domain, ipNetStr, hostName string,
ctx context.Context, name, domain string,
ipNet nebula.IPNet,
hostName nebula.HostName,
) (
admin.Admin, error,
)
@ -65,7 +53,7 @@ type Daemon interface {
GetBootstrapHosts(
ctx context.Context,
) (
map[string]bootstrap.Host, error,
map[nebula.HostName]bootstrap.Host, error,
)
// Shutdown blocks until all resources held or created by the daemon,
@ -148,10 +136,7 @@ type daemon struct {
// it should restart itself only when there's something actually requiring a
// restart.
func NewDaemon(
logger *mlog.Logger,
daemonConfig Config,
envBinDirPath string,
opts *Opts,
logger *mlog.Logger, daemonConfig Config, envBinDirPath string, opts *Opts,
) (
Daemon, error,
) {
@ -478,24 +463,11 @@ func (d *daemon) restartLoop(ctx context.Context, readyCh chan<- struct{}) {
func (d *daemon) CreateNetwork(
ctx context.Context,
name, domain, ipNetStr, hostName string,
name, domain string, ipNet nebula.IPNet, hostName nebula.HostName,
) (
admin.Admin, error,
) {
ip, subnet, err := net.ParseCIDR(ipNetStr)
if err != nil {
return admin.Admin{}, jsonrpc2.NewInvalidParamsError(
"parsing %q as a CIDR: %v", ipNetStr, err,
)
}
if err := validateHostName(hostName); err != nil {
return admin.Admin{}, jsonrpc2.NewInvalidParamsError(
"invalid hostname: %v", err,
)
}
nebulaCACreds, err := nebula.NewCACredentials(domain, subnet)
nebulaCACreds, err := nebula.NewCACredentials(domain, ipNet)
if err != nil {
return admin.Admin{}, fmt.Errorf("creating nebula CA cert: %w", err)
}
@ -518,7 +490,7 @@ func (d *daemon) CreateNetwork(
adm.CreationParams,
garageBootstrap,
hostName,
ip,
ipNet.FirstAddr(),
)
if err != nil {
return adm, fmt.Errorf("initializing bootstrap data: %w", err)
@ -596,12 +568,12 @@ func (d *daemon) JoinNetwork(
func (d *daemon) GetBootstrapHosts(
ctx context.Context,
) (
map[string]bootstrap.Host, error,
map[nebula.HostName]bootstrap.Host, error,
) {
return withCurrBootstrap(d, func(
currBootstrap bootstrap.Bootstrap,
) (
map[string]bootstrap.Host, error,
map[nebula.HostName]bootstrap.Host, error,
) {
return currBootstrap.Hosts, nil
})

View File

@ -86,7 +86,7 @@ func (d *daemon) putGarageBoostrapHost(ctx context.Context) error {
filePath := filepath.Join(
garageGlobalBucketBootstrapHostsDirPath,
host.Name+".json.signed",
string(host.Name)+".json.signed",
)
_, err = client.PutObject(
@ -108,12 +108,12 @@ func (d *daemon) putGarageBoostrapHost(ctx context.Context) error {
func getGarageBootstrapHosts(
ctx context.Context, logger *mlog.Logger, currBootstrap bootstrap.Bootstrap,
) (
map[string]bootstrap.Host, error,
map[nebula.HostName]bootstrap.Host, error,
) {
var (
b = currBootstrap
client = b.GlobalBucketS3APIClient()
hosts = map[string]bootstrap.Host{}
hosts = map[nebula.HostName]bootstrap.Host{}
objInfoCh = client.ListObjects(
ctx, garage.GlobalBucket,

View File

@ -6,6 +6,7 @@ import (
"fmt"
"isle/admin"
"isle/bootstrap"
"isle/nebula"
"slices"
"golang.org/x/exp/maps"
@ -32,12 +33,13 @@ type CreateNetworkRequest struct {
// Primary domain name that network services are served under.
Domain string
// An IP + subnet mask which represents both the IP of this first host in
// the network, as well as the overall range of possible IPs in the network.
IPNet string
// An IP subnet, in CIDR form, which will be the overall range of possible
// IPs in the network. The first IP in this network range will become this
// first host's IP.
IPNet nebula.IPNet
// The name of this first host in the network.
HostName string
HostName nebula.HostName
}
// CreateNetwork passes through to the Daemon method of the same name.

22
go/nebula/hostname.go Normal file
View File

@ -0,0 +1,22 @@
package nebula
import (
"errors"
"regexp"
)
var hostNameRegexp = regexp.MustCompile(`^[a-z][a-z0-9\-]*$`)
// HostName is the name of a host in a network. HostNames may only have
// lowercase letters, numbers, and hyphens, and must start with a letter.
type HostName string
// UnmarshalText parses and validates a HostName from a text string.
func (h *HostName) UnmarshalText(b []byte) error {
if !hostNameRegexp.Match(b) {
return errors.New("a host's name must start with a letter and only contain letters, numbers, and dashes")
}
*h = HostName(b)
return nil
}

43
go/nebula/ip_net.go Normal file
View File

@ -0,0 +1,43 @@
package nebula
import (
"fmt"
"net"
"net/netip"
)
// IPNet is the CIDR of a nebula network.
type IPNet net.IPNet
// UnmarshalText parses and validates an IPNet from a text string.
func (n *IPNet) UnmarshalText(b []byte) error {
str := string(b)
_, subnet, err := net.ParseCIDR(str)
if err != nil {
return err
}
if cstr := subnet.String(); cstr != str {
return fmt.Errorf("IPNet is not given in its canonical form of %q", cstr)
}
if !subnet.IP.IsPrivate() {
return fmt.Errorf("IPNet is not in a private IP range")
}
*n = IPNet(*subnet)
return nil
}
func (n IPNet) String() string {
return (*net.IPNet)(&n).String()
}
func (n IPNet) MarshalText() ([]byte, error) {
return []byte(n.String()), nil
}
// FirstAddr returns the first IP address in the subnet.
func (n IPNet) FirstAddr() netip.Addr {
return netip.MustParseAddr(n.IP.String()).Next()
}

View File

@ -5,6 +5,7 @@ package nebula
import (
"fmt"
"net"
"net/netip"
"time"
"github.com/slackhq/nebula/cert"
@ -44,12 +45,15 @@ type CACredentials struct {
func NewHostCert(
caCreds CACredentials,
hostPub EncryptingPublicKey,
hostName string,
ip net.IP,
hostName HostName,
ip netip.Addr,
) (
Certificate, error,
) {
caCert := caCreds.Public.Cert
var (
caCert = caCreds.Public.Cert
ipSlice = net.IP(ip.AsSlice())
)
issuer, err := caCert.inner.Sha256Sum()
if err != nil {
@ -59,15 +63,15 @@ func NewHostCert(
expireAt := caCert.inner.Details.NotAfter.Add(-1 * time.Second)
subnet := caCert.inner.Details.Subnets[0]
if !subnet.Contains(ip) {
if !subnet.Contains(ipSlice) {
return Certificate{}, fmt.Errorf("invalid ip %q, not contained by network subnet %q", ip, subnet)
}
hostCert := cert.NebulaCertificate{
Details: cert.NebulaCertificateDetails{
Name: hostName,
Name: string(hostName),
Ips: []*net.IPNet{{
IP: ip,
IP: ipSlice,
Mask: subnet.Mask,
}},
NotBefore: time.Now(),
@ -92,7 +96,7 @@ func NewHostCert(
// NewHostCredentials generates a new key/cert for a nebula host using the CA
// key which will be found in the adminFS.
func NewHostCredentials(
caCreds CACredentials, hostName string, ip net.IP,
caCreds CACredentials, hostName HostName, ip netip.Addr,
) (
pub HostPublicCredentials, priv HostPrivateCredentials, err error,
) {
@ -125,7 +129,7 @@ func NewHostCredentials(
// NewCACredentials generates a CACredentials. The domain should be the network's root domain,
// and is included in the signing certificate's Name field.
func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) {
func NewCACredentials(domain string, subnet IPNet) (CACredentials, error) {
var (
signingPubKey, signingPrivKey = GenerateSigningPair()
now = time.Now()
@ -135,7 +139,7 @@ func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) {
caCert := cert.NebulaCertificate{
Details: cert.NebulaCertificateDetails{
Name: fmt.Sprintf("%s isle root cert", domain),
Subnets: []*net.IPNet{subnet},
Subnets: []*net.IPNet{(*net.IPNet)(&subnet)},
NotBefore: now,
NotAfter: expireAt,
PublicKey: signingPubKey,

View File

@ -1,22 +1,20 @@
package nebula
import (
"net"
)
import "net/netip"
var (
ip net.IP
ipNet *net.IPNet
ip netip.Addr
ipNet IPNet
caCredsA, caCredsB CACredentials
hostPubCredsA, hostPubCredsB HostPublicCredentials
hostPrivCredsA, hostPrivCredsB HostPrivateCredentials
)
func init() {
var err error
ip = netip.MustParseAddr("192.168.0.1")
ip, ipNet, err = net.ParseCIDR("192.168.0.1/24")
if err != nil {
var err error
if err := ipNet.UnmarshalText([]byte("192.168.0.0/24")); err != nil {
panic(err)
}

View File

@ -2,6 +2,8 @@ set -e
base="shared/1-data-1-empty"
ipNet="10.6.9.0/24"
primus_base="$base/primus"
primus_ip="10.6.9.1"
@ -60,7 +62,7 @@ EOF
isle network create \
--domain shared.test \
--hostname primus \
--ip-net "$current_ip/24" \
--ip-net "$ipNet" \
--name "testing" \
> admin.json