diff --git a/entrypoint/src/admin/admin.go b/entrypoint/src/admin/admin.go index ad8bdc6..b7be9c6 100644 --- a/entrypoint/src/admin/admin.go +++ b/entrypoint/src/admin/admin.go @@ -35,7 +35,7 @@ type CreationParams struct { type Admin struct { CreationParams CreationParams - NebulaCACert nebula.CACert + NebulaCACredentials nebula.CACredentials GarageRPCSecret string GarageGlobalBucketS3APICredentials garage.S3APICredentials @@ -67,8 +67,8 @@ func FromFS(adminFS fs.FS) (Admin, error) { into *string path string }{ - {&a.NebulaCACert.CACert, nebulaCertsCACertPath}, - {&a.NebulaCACert.CAKey, nebulaCertsCAKeyPath}, + {&a.NebulaCACredentials.CACertPEM, nebulaCertsCACertPath}, + {&a.NebulaCACredentials.CAKeyPEM, nebulaCertsCAKeyPath}, {&a.GarageRPCSecret, garageRPCSecretPath}, } @@ -122,8 +122,8 @@ func (a Admin) WriteTo(into io.Writer) error { value string path string }{ - {a.NebulaCACert.CACert, nebulaCertsCACertPath}, - {a.NebulaCACert.CAKey, nebulaCertsCAKeyPath}, + {a.NebulaCACredentials.CACertPEM, nebulaCertsCACertPath}, + {a.NebulaCACredentials.CAKeyPEM, nebulaCertsCAKeyPath}, {a.GarageRPCSecret, garageRPCSecretPath}, } diff --git a/entrypoint/src/bootstrap/bootstrap.go b/entrypoint/src/bootstrap/bootstrap.go index aa22b91..00f82dd 100644 --- a/entrypoint/src/bootstrap/bootstrap.go +++ b/entrypoint/src/bootstrap/bootstrap.go @@ -45,7 +45,7 @@ type Bootstrap struct { Hosts map[string]Host HostName string - NebulaHostCert nebula.HostCert + NebulaHostCredentials nebula.HostCredentials GarageRPCSecret string GarageAdminToken string @@ -84,9 +84,9 @@ func FromFS(bootstrapFS fs.FS) (Bootstrap, error) { path string }{ {&b.HostName, hostNamePath}, - {&b.NebulaHostCert.CACert, nebulaCertsCACertPath}, - {&b.NebulaHostCert.HostCert, nebulaCertsHostCertPath}, - {&b.NebulaHostCert.HostKey, nebulaCertsHostKeyPath}, + {&b.NebulaHostCredentials.CACertPEM, nebulaCertsCACertPath}, + {&b.NebulaHostCredentials.HostCertPEM, nebulaCertsHostCertPath}, + {&b.NebulaHostCredentials.HostKeyPEM, nebulaCertsHostKeyPath}, {&b.GarageRPCSecret, garageRPCSecretPath}, {&b.GarageAdminToken, garageAdminTokenPath}, } @@ -165,9 +165,9 @@ func (b Bootstrap) WriteTo(into io.Writer) error { path string }{ {b.HostName, hostNamePath}, - {b.NebulaHostCert.CACert, nebulaCertsCACertPath}, - {b.NebulaHostCert.HostCert, nebulaCertsHostCertPath}, - {b.NebulaHostCert.HostKey, nebulaCertsHostKeyPath}, + {b.NebulaHostCredentials.CACertPEM, nebulaCertsCACertPath}, + {b.NebulaHostCredentials.HostCertPEM, nebulaCertsHostCertPath}, + {b.NebulaHostCredentials.HostKeyPEM, nebulaCertsHostKeyPath}, {b.GarageRPCSecret, garageRPCSecretPath}, {b.GarageAdminToken, garageAdminTokenPath}, } @@ -209,19 +209,3 @@ func HostsHash(hostsMap map[string]Host) ([]byte, error) { return h.Sum(nil), nil } - -// WithHosts returns a copy of the Bootstrap with the given set of Hosts applied -// to it. It will _not_ overwrite the Host for _this_ host, however. -func (b Bootstrap) WithHosts(hosts map[string]Host) Bootstrap { - - hostsCopy := make(map[string]Host, len(hosts)) - - for name, host := range hosts { - hostsCopy[name] = host - } - - hostsCopy[b.HostName] = b.ThisHost() - - b.Hosts = hostsCopy - return b -} diff --git a/entrypoint/src/bootstrap/garage.go b/entrypoint/src/bootstrap/garage.go index 621a446..94e20bf 100644 --- a/entrypoint/src/bootstrap/garage.go +++ b/entrypoint/src/bootstrap/garage.go @@ -26,7 +26,7 @@ func (b Bootstrap) GaragePeers() []garage.RemotePeer { peer := garage.RemotePeer{ ID: instance.ID, - IP: host.Nebula.IP, + IP: host.IP().String(), RPCPort: instance.RPCPort, S3APIPort: instance.S3APIPort, } @@ -56,10 +56,11 @@ func (b Bootstrap) ChooseGaragePeer() garage.RemotePeer { thisHost := b.ThisHost() if thisHost.Garage != nil && len(thisHost.Garage.Instances) > 0 { + inst := thisHost.Garage.Instances[0] return garage.RemotePeer{ ID: inst.ID, - IP: thisHost.Nebula.IP, + IP: thisHost.IP().String(), RPCPort: inst.RPCPort, S3APIPort: inst.S3APIPort, } diff --git a/entrypoint/src/bootstrap/garage_global_bucket.go b/entrypoint/src/bootstrap/garage_global_bucket.go index 77a8e25..9c24131 100644 --- a/entrypoint/src/bootstrap/garage_global_bucket.go +++ b/entrypoint/src/bootstrap/garage_global_bucket.go @@ -4,8 +4,9 @@ import ( "bytes" "context" "cryptic-net/garage" + "cryptic-net/nebula" "fmt" - "log" + "os" "path/filepath" "github.com/minio/minio-go/v7" @@ -17,23 +18,32 @@ const ( garageGlobalBucketBootstrapHostsDirPath = "bootstrap/hosts" ) -// PutGarageBoostrapHost places the .yml file for the given host into -// garage so that other hosts are able to see relevant configuration for it. -// -// The given client should be for the global bucket. -func PutGarageBoostrapHost( - ctx context.Context, client garage.S3APIClient, host Host, -) error { +// PutGarageBoostrapHost places the .yml.signed file for this host +// into garage so that other hosts are able to see relevant configuration for +// it. +func (b Bootstrap) PutGarageBoostrapHost(ctx context.Context) error { + + host := b.ThisHost() + client := b.GlobalBucketS3APIClient() + + hostB, err := yaml.Marshal(host) + if err != nil { + return fmt.Errorf("yaml encoding host data: %w", err) + } buf := new(bytes.Buffer) - if err := yaml.NewEncoder(buf).Encode(host); err != nil { - log.Fatalf("yaml encoding host data: %v", err) + err = nebula.SignAndWrap(buf, b.NebulaHostCredentials.HostKeyPEM, hostB) + if err != nil { + return fmt.Errorf("signing encoded host data: %w", err) } - filePath := filepath.Join(garageGlobalBucketBootstrapHostsDirPath, host.Name+".yml") + filePath := filepath.Join( + garageGlobalBucketBootstrapHostsDirPath, + host.Name+".yml.signed", + ) - _, err := client.PutObject( + _, err = client.PutObject( ctx, garage.GlobalBucket, filePath, buf, int64(buf.Len()), minio.PutObjectOptions{}, ) @@ -45,15 +55,18 @@ func PutGarageBoostrapHost( return nil } -// RemoveGarageBootstrapHost removes the .yml for the given host from -// garage. +// RemoveGarageBootstrapHost removes the .yml.signed for the given +// host from garage. // // The given client should be for the global bucket. func RemoveGarageBootstrapHost( ctx context.Context, client garage.S3APIClient, hostName string, ) error { - filePath := filepath.Join(garageGlobalBucketBootstrapHostsDirPath, hostName+".yml") + filePath := filepath.Join( + garageGlobalBucketBootstrapHostsDirPath, + hostName+".yml.signed", + ) return client.RemoveObject( ctx, garage.GlobalBucket, filePath, @@ -61,16 +74,17 @@ func RemoveGarageBootstrapHost( ) } -// GetGarageBootstrapHosts loads the .yml file for all hosts stored in -// garage. -// -// The given client should be for the global bucket. -func GetGarageBootstrapHosts( - ctx context.Context, client garage.S3APIClient, +// GetGarageBootstrapHosts loads the .yml.signed file for all hosts +// stored in garage. +func (b Bootstrap) GetGarageBootstrapHosts( + ctx context.Context, ) ( map[string]Host, error, ) { + caCertPEM := b.NebulaHostCredentials.CACertPEM + client := b.GlobalBucketS3APIClient() + hosts := map[string]Host{} objInfoCh := client.ListObjects( @@ -95,15 +109,30 @@ func GetGarageBootstrapHosts( return nil, fmt.Errorf("retrieving object %q: %w", objInfo.Key, err) } - var host Host - - err = yaml.NewDecoder(obj).Decode(&host) + hostB, sig, err := nebula.Unwrap(obj) obj.Close() if err != nil { + return nil, fmt.Errorf("unwrapping signature from %q: %w", objInfo.Key, err) + } + + var host Host + if err = yaml.Unmarshal(hostB, &host); err != nil { return nil, fmt.Errorf("yaml decoding object %q: %w", objInfo.Key, err) } + hostCertPEM := host.Nebula.CertPEM + + if err := nebula.ValidateSignature(hostCertPEM, hostB, sig); err != nil { + fmt.Fprintf(os.Stderr, "invalid host data for %q: %w\n", objInfo.Key, err) + continue + } + + if err := nebula.ValidateHostCertPEM(caCertPEM, hostCertPEM); err != nil { + fmt.Fprintf(os.Stderr, "invalid nebula cert for %q: %w\n", objInfo.Key, err) + continue + } + hosts[host.Name] = host } diff --git a/entrypoint/src/bootstrap/hosts.go b/entrypoint/src/bootstrap/hosts.go index c5fb37b..f2bb1fa 100644 --- a/entrypoint/src/bootstrap/hosts.go +++ b/entrypoint/src/bootstrap/hosts.go @@ -1,8 +1,10 @@ package bootstrap import ( + "cryptic-net/nebula" "fmt" "io/fs" + "net" "path/filepath" "strings" @@ -16,7 +18,7 @@ const ( // NebulaHost describes the nebula configuration of a Host which is relevant for // other hosts to know. type NebulaHost struct { - IP string `yaml:"ip"` + CertPEM string `yaml:"crt"` PublicAddr string `yaml:"public_addr,omitempty"` } @@ -41,6 +43,18 @@ type Host struct { Garage *GarageHost `yaml:"garage,omitempty"` } +// IP returns the IP address encoded in the Host's nebula certificate, or panics +// if there is an error. +func (h Host) IP() net.IP { + + ip, err := nebula.IPFromHostCertPEM(h.Nebula.CertPEM) + if err != nil { + panic(fmt.Errorf("could not parse IP out of cert for host %q: %w", h.Name, err)) + } + + return ip +} + func loadHosts(bootstrapFS fs.FS) (map[string]Host, error) { hosts := map[string]Host{} diff --git a/entrypoint/src/cmd/entrypoint/admin.go b/entrypoint/src/cmd/entrypoint/admin.go index ce76d50..25b4a05 100644 --- a/entrypoint/src/cmd/entrypoint/admin.go +++ b/entrypoint/src/cmd/entrypoint/admin.go @@ -117,12 +117,12 @@ var subCmdAdminCreateNetwork = subCmd{ return fmt.Errorf("daemon config with at least 3 allocations was not provided") } - nebulaCACert, err := nebula.NewCACert(*domain, subnet) + nebulaCACreds, err := nebula.NewCACredentials(*domain, subnet) if err != nil { return fmt.Errorf("creating nebula CA cert: %w", err) } - nebulaHostCert, err := nebula.NewHostCert(nebulaCACert, *hostName, ip) + nebulaHostCreds, err := nebula.NewHostCredentials(nebulaCACreds, *hostName, ip) if err != nil { return fmt.Errorf("creating nebula cert for host: %w", err) } @@ -138,12 +138,12 @@ var subCmdAdminCreateNetwork = subCmd{ *hostName: bootstrap.Host{ Name: *hostName, Nebula: bootstrap.NebulaHost{ - IP: ip.String(), + CertPEM: nebulaHostCreds.HostCertPEM, }, }, }, HostName: *hostName, - NebulaHostCert: nebulaHostCert, + NebulaHostCredentials: nebulaHostCreds, GarageRPCSecret: randStr(32), GarageAdminToken: randStr(32), GarageGlobalBucketS3APICredentials: garage.NewS3APICredentials(), @@ -213,7 +213,7 @@ var subCmdAdminCreateNetwork = subCmd{ err = admin.Admin{ CreationParams: adminCreationParams, - NebulaCACert: nebulaCACert, + NebulaCACredentials: nebulaCACreds, GarageRPCSecret: hostBootstrap.GarageRPCSecret, GarageGlobalBucketS3APICredentials: hostBootstrap.GarageGlobalBucketS3APICredentials, GarageAdminBucketS3APICredentials: garage.NewS3APICredentials(), @@ -240,6 +240,11 @@ var subCmdAdminMakeBootstrap = subCmd{ "Name of the host to generate bootstrap.tgz for", ) + ipStr := flags.StringP( + "ip", "i", "", + "IP of the new host", + ) + adminPath := flags.StringP( "admin-path", "a", "", `Path to admin.tgz file. If the given path is "-" then stdin is used.`, @@ -249,13 +254,18 @@ var subCmdAdminMakeBootstrap = subCmd{ return fmt.Errorf("parsing flags: %w", err) } - if *name == "" || *adminPath == "" { - return errors.New("--name and --admin-path are required") + if *name == "" || *ipStr == "" || *adminPath == "" { + return errors.New("--name, --ip, and --admin-path are required") } - hostBootstrap, err := loadHostBootstrap() - if err != nil { - return fmt.Errorf("loading host bootstrap: %w", err) + if err := validateHostName(*name); err != nil { + return fmt.Errorf("invalid hostname %q: %w", *name, err) + } + + ip := net.ParseIP(*ipStr) + + if ip == nil { + return fmt.Errorf("invalid ip %q", *ipStr) } adm, err := readAdmin(*adminPath) @@ -263,28 +273,12 @@ var subCmdAdminMakeBootstrap = subCmd{ return fmt.Errorf("reading admin.tgz with --admin-path of %q: %w", *adminPath, err) } - client := hostBootstrap.GlobalBucketS3APIClient() - - // NOTE this isn't _technically_ required, but if the `hosts add` - // command for this host has been run recently then it might not have - // made it into the bootstrap file yet, and so won't be in - // `hostBootstrap`. - hosts, err := bootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx, client) + hostBootstrap, err := loadHostBootstrap() if err != nil { - return fmt.Errorf("retrieving host info from garage: %w", err) + return fmt.Errorf("loading host bootstrap: %w", err) } - host, ok := hosts[*name] - if !ok { - return fmt.Errorf("couldn't find host into for %q in garage, has `cryptic-net hosts add` been run yet?", *name) - } - - ip := net.ParseIP(host.Nebula.IP) - if ip == nil { - return fmt.Errorf("invalid IP stored with host %q: %q", *name, host.Nebula.IP) - } - - nebulaHostCert, err := nebula.NewHostCert(adm.NebulaCACert, host.Name, ip) + nebulaHostCreds, err := nebula.NewHostCredentials(adm.NebulaCACredentials, *name, ip) if err != nil { return fmt.Errorf("creating new nebula host key/cert: %w", err) } @@ -292,10 +286,10 @@ var subCmdAdminMakeBootstrap = subCmd{ newHostBootstrap := bootstrap.Bootstrap{ AdminCreationParams: adm.CreationParams, - Hosts: hosts, + Hosts: hostBootstrap.Hosts, HostName: *name, - NebulaHostCert: nebulaHostCert, + NebulaHostCredentials: nebulaHostCreds, GarageRPCSecret: adm.GarageRPCSecret, GarageAdminToken: randStr(32), diff --git a/entrypoint/src/cmd/entrypoint/daemon.go b/entrypoint/src/cmd/entrypoint/daemon.go index 172b128..1533997 100644 --- a/entrypoint/src/cmd/entrypoint/daemon.go +++ b/entrypoint/src/cmd/entrypoint/daemon.go @@ -12,7 +12,6 @@ import ( "cryptic-net/bootstrap" "cryptic-net/daemon" - "cryptic-net/garage" "code.betamike.com/cryptic-io/pmux/pmuxlib" ) @@ -42,16 +41,21 @@ import ( func reloadBootstrap( ctx context.Context, hostBootstrap bootstrap.Bootstrap, - s3Client garage.S3APIClient, ) ( bootstrap.Bootstrap, bool, error, ) { - newHosts, err := bootstrap.GetGarageBootstrapHosts(ctx, s3Client) + thisHost := hostBootstrap.ThisHost() + + newHosts, err := hostBootstrap.GetGarageBootstrapHosts(ctx) if err != nil { return bootstrap.Bootstrap{}, false, fmt.Errorf("getting hosts from garage: %w", err) } + // the daemon's view of this host's bootstrap info takes precedence over + // whatever is in garage + newHosts[thisHost.Name] = thisHost + newHostsHash, err := bootstrap.HostsHash(newHosts) if err != nil { return bootstrap.Bootstrap{}, false, fmt.Errorf("calculating hash of new hosts: %w", err) @@ -66,13 +70,8 @@ func reloadBootstrap( return hostBootstrap, false, nil } - newHostBootstrap := hostBootstrap.WithHosts(newHosts) - - if err := writeBootstrapToDataDir(newHostBootstrap); err != nil { - return bootstrap.Bootstrap{}, false, fmt.Errorf("writing new bootstrap.tgz to data dir: %w", err) - } - - return newHostBootstrap, true, nil + hostBootstrap.Hosts = newHosts + return hostBootstrap, true, nil } // runs a single pmux process of daemon, returning only once the env.Context has @@ -87,14 +86,6 @@ func runDaemonPmuxOnce( bootstrap.Bootstrap, error, ) { - thisHost := hostBootstrap.ThisHost() - fmt.Fprintf(os.Stderr, "host name is %q, ip is %q\n", thisHost.Name, thisHost.Nebula.IP) - - // create s3Client anew on every loop, in case the topology has - // changed and we should be connecting to a different garage - // endpoint. - s3Client := hostBootstrap.GlobalBucketS3APIClient() - nebulaPmuxProcConfig, err := nebulaPmuxProcConfig(hostBootstrap, daemonConfig) if err != nil { return bootstrap.Bootstrap{}, fmt.Errorf("generating nebula config: %w", err) @@ -143,11 +134,9 @@ func runDaemonPmuxOnce( return } - thisHost := hostBootstrap.ThisHost() - err := doOnce(ctx, func(ctx context.Context) error { fmt.Fprintln(os.Stderr, "updating host info in garage") - return bootstrap.PutGarageBoostrapHost(ctx, s3Client, thisHost) + return hostBootstrap.PutGarageBoostrapHost(ctx) }) if err != nil { @@ -194,7 +183,7 @@ func runDaemonPmuxOnce( err error ) - if hostBootstrap, changed, err = reloadBootstrap(ctx, hostBootstrap, s3Client); err != nil { + if hostBootstrap, changed, err = reloadBootstrap(ctx, hostBootstrap); err != nil { return bootstrap.Bootstrap{}, fmt.Errorf("reloading bootstrap: %w", err) } else if changed { diff --git a/entrypoint/src/cmd/entrypoint/dnsmasq_util.go b/entrypoint/src/cmd/entrypoint/dnsmasq_util.go index 54fd279..871366b 100644 --- a/entrypoint/src/cmd/entrypoint/dnsmasq_util.go +++ b/entrypoint/src/cmd/entrypoint/dnsmasq_util.go @@ -20,19 +20,22 @@ func dnsmasqPmuxProcConfig( confPath := filepath.Join(envRuntimeDirPath, "dnsmasq.conf") - hostsSlice := make([]bootstrap.Host, 0, len(hostBootstrap.Hosts)) + hostsSlice := make([]dnsmasq.ConfDataHost, 0, len(hostBootstrap.Hosts)) for _, host := range hostBootstrap.Hosts { - hostsSlice = append(hostsSlice, host) + hostsSlice = append(hostsSlice, dnsmasq.ConfDataHost{ + Name: host.Name, + IP: host.IP().String(), + }) } sort.Slice(hostsSlice, func(i, j int) bool { - return hostsSlice[i].Nebula.IP < hostsSlice[j].Nebula.IP + return hostsSlice[i].IP < hostsSlice[j].IP }) confData := dnsmasq.ConfData{ Resolvers: daemonConfig.DNS.Resolvers, Domain: hostBootstrap.AdminCreationParams.Domain, - IP: hostBootstrap.ThisHost().Nebula.IP, + IP: hostBootstrap.ThisHost().IP().String(), Hosts: hostsSlice, } diff --git a/entrypoint/src/cmd/entrypoint/garage_util.go b/entrypoint/src/cmd/entrypoint/garage_util.go index ec88cca..0460231 100644 --- a/entrypoint/src/cmd/entrypoint/garage_util.go +++ b/entrypoint/src/cmd/entrypoint/garage_util.go @@ -23,7 +23,7 @@ func newGarageAdminClient( return garage.NewAdminClient( net.JoinHostPort( - thisHost.Nebula.IP, + thisHost.IP().String(), strconv.Itoa(daemonConfig.Storage.Allocations[0].AdminPort), ), hostBootstrap.GarageAdminToken, @@ -47,7 +47,7 @@ func waitForGarageAndNebula( for _, alloc := range allocs { adminAddr := net.JoinHostPort( - hostBootstrap.ThisHost().Nebula.IP, + hostBootstrap.ThisHost().IP().String(), strconv.Itoa(alloc.AdminPort), ) @@ -97,7 +97,7 @@ func garageWriteChildConfig( peer := garage.LocalPeer{ RemotePeer: garage.RemotePeer{ ID: id, - IP: thisHost.Nebula.IP, + IP: thisHost.IP().String(), RPCPort: alloc.RPCPort, S3APIPort: alloc.S3APIPort, }, diff --git a/entrypoint/src/cmd/entrypoint/hosts.go b/entrypoint/src/cmd/entrypoint/hosts.go index 98d4571..184a9c4 100644 --- a/entrypoint/src/cmd/entrypoint/hosts.go +++ b/entrypoint/src/cmd/entrypoint/hosts.go @@ -4,7 +4,6 @@ import ( "cryptic-net/bootstrap" "errors" "fmt" - "net" "os" "regexp" "sort" @@ -23,60 +22,6 @@ func validateHostName(name string) error { return nil } -var subCmdHostsAdd = subCmd{ - name: "add", - descr: "Adds a host to the network", - checkLock: true, - do: func(subCmdCtx subCmdCtx) error { - - flags := subCmdCtx.flagSet(false) - - name := flags.StringP( - "name", "n", "", - "Name of the new host", - ) - - ip := flags.StringP( - "ip", "i", "", - "IP of the new host", - ) - - if err := flags.Parse(subCmdCtx.args); err != nil { - return fmt.Errorf("parsing flags: %w", err) - } - - if *name == "" || *ip == "" { - return errors.New("--name and --ip are required") - } - - if err := validateHostName(*name); err != nil { - return fmt.Errorf("invalid hostname %q: %w", *name, err) - } - - if net.ParseIP(*ip) == nil { - return fmt.Errorf("invalid ip %q", *ip) - } - - // TODO validate that the IP is in the correct CIDR - - hostBootstrap, err := loadHostBootstrap() - if err != nil { - return fmt.Errorf("loading host bootstrap: %w", err) - } - - client := hostBootstrap.GlobalBucketS3APIClient() - - host := bootstrap.Host{ - Name: *name, - Nebula: bootstrap.NebulaHost{ - IP: *ip, - }, - } - - return bootstrap.PutGarageBoostrapHost(subCmdCtx.ctx, client, host) - }, -} - var subCmdHostsList = subCmd{ name: "list", descr: "Lists all hosts in the network, and their IPs", @@ -88,9 +33,7 @@ var subCmdHostsList = subCmd{ return fmt.Errorf("loading host bootstrap: %w", err) } - client := hostBootstrap.GlobalBucketS3APIClient() - - hostsMap, err := bootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx, client) + hostsMap, err := hostBootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx) if err != nil { return fmt.Errorf("retrieving hosts from garage: %w", err) } @@ -143,7 +86,6 @@ var subCmdHosts = subCmd{ descr: "Sub-commands having to do with configuration of hosts in the network", do: func(subCmdCtx subCmdCtx) error { return subCmdCtx.doSubCmd( - subCmdHostsAdd, subCmdHostsDelete, subCmdHostsList, ) diff --git a/entrypoint/src/cmd/entrypoint/nebula_util.go b/entrypoint/src/cmd/entrypoint/nebula_util.go index ceace33..9221dfe 100644 --- a/entrypoint/src/cmd/entrypoint/nebula_util.go +++ b/entrypoint/src/cmd/entrypoint/nebula_util.go @@ -18,8 +18,7 @@ import ( // interface has been initialized. func waitForNebula(ctx context.Context, hostBootstrap bootstrap.Bootstrap) error { - ipStr := hostBootstrap.ThisHost().Nebula.IP - ip := net.ParseIP(ipStr) + ip := hostBootstrap.ThisHost().IP() lUdpAddr := &net.UDPAddr{IP: ip, Port: 0} rUdpAddr := &net.UDPAddr{IP: ip, Port: 45535} @@ -52,15 +51,16 @@ func nebulaPmuxProcConfig( continue } - lighthouseHostIPs = append(lighthouseHostIPs, host.Nebula.IP) - staticHostMap[host.Nebula.IP] = []string{host.Nebula.PublicAddr} + ip := host.IP().String() + lighthouseHostIPs = append(lighthouseHostIPs, ip) + staticHostMap[ip] = []string{host.Nebula.PublicAddr} } config := map[string]interface{}{ "pki": map[string]string{ - "ca": hostBootstrap.NebulaHostCert.CACert, - "cert": hostBootstrap.NebulaHostCert.HostCert, - "key": hostBootstrap.NebulaHostCert.HostKey, + "ca": hostBootstrap.NebulaHostCredentials.CACertPEM, + "cert": hostBootstrap.NebulaHostCredentials.HostCertPEM, + "key": hostBootstrap.NebulaHostCredentials.HostKeyPEM, }, "static_host_map": staticHostMap, "punchy": map[string]bool{ diff --git a/entrypoint/src/dnsmasq/tpl.go b/entrypoint/src/dnsmasq/tpl.go index 9822d75..746ec49 100644 --- a/entrypoint/src/dnsmasq/tpl.go +++ b/entrypoint/src/dnsmasq/tpl.go @@ -1,18 +1,23 @@ package dnsmasq import ( - "cryptic-net/bootstrap" "fmt" "os" "text/template" ) +// ConfDataHost describes a host which can be resolved by dnsmasq. +type ConfDataHost struct { + Name string + IP string +} + // ConfData describes all the data needed to populate a dnsmasq.conf file. type ConfData struct { Resolvers []string Domain string IP string - Hosts []bootstrap.Host + Hosts []ConfDataHost } var confTpl = template.Must(template.New("").Parse(` diff --git a/entrypoint/src/nebula/nebula.go b/entrypoint/src/nebula/nebula.go index 3d39ab3..4fe986c 100644 --- a/entrypoint/src/nebula/nebula.go +++ b/entrypoint/src/nebula/nebula.go @@ -3,8 +3,11 @@ package nebula import ( + "crypto" "crypto/ed25519" "crypto/rand" + "encoding/pem" + "errors" "fmt" "io" "net" @@ -14,65 +17,69 @@ import ( "golang.org/x/crypto/curve25519" ) -// HostCert contains the certificate and private key files which will need to -// be present on a particular host. Each file is PEM encoded. -type HostCert struct { - CACert string - HostKey string - HostCert string +// ErrInvalidSignature is returned from functions when a signature validation +// fails. +var ErrInvalidSignature = errors.New("invalid signature") + +// HostCredentials contains the certificate and private key files which will +// need to be present on a particular host. Each file is PEM encoded. +type HostCredentials struct { + CACertPEM string + HostKeyPEM string + HostCertPEM string } -// CACert contains the certificate and private files which can be used to create -// HostCerts. Each file is PEM encoded. -type CACert struct { - CACert string - CAKey string +// CACredentials contains the certificate and private files which can be used to +// create and validate HostCredentials. Each file is PEM encoded. +type CACredentials struct { + CACertPEM string + CAKeyPEM string } -// NewHostCert generates a new key/cert for a nebula host using the CA key -// which will be found in the adminFS. -func NewHostCert( - caCert CACert, hostName string, ip net.IP, +// 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, ) ( - HostCert, error, + HostCredentials, error, ) { // The logic here is largely based on // https://github.com/slackhq/nebula/blob/v1.4.0/cmd/nebula-cert/sign.go - caKey, _, err := cert.UnmarshalEd25519PrivateKey([]byte(caCert.CAKey)) + caKey, _, err := cert.UnmarshalEd25519PrivateKey([]byte(caCreds.CAKeyPEM)) if err != nil { - return HostCert{}, fmt.Errorf("unmarshaling ca.key: %w", err) + return HostCredentials{}, fmt.Errorf("unmarshaling ca.key: %w", err) } - caCrt, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCert.CACert)) + caCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCreds.CACertPEM)) if err != nil { - return HostCert{}, fmt.Errorf("unmarshaling ca.crt: %w", err) + return HostCredentials{}, fmt.Errorf("unmarshaling ca.crt: %w", err) } - issuer, err := caCrt.Sha256Sum() + issuer, err := caCert.Sha256Sum() if err != nil { - return HostCert{}, fmt.Errorf("getting ca.crt issuer: %w", err) + return HostCredentials{}, fmt.Errorf("getting ca.crt issuer: %w", err) } - expireAt := caCrt.Details.NotAfter.Add(-1 * time.Second) + expireAt := caCert.Details.NotAfter.Add(-1 * time.Second) - subnet := caCrt.Details.Subnets[0] + subnet := caCert.Details.Subnets[0] if !subnet.Contains(ip) { - return HostCert{}, fmt.Errorf("invalid ip %q, not contained by network subnet %q", ip, subnet) + return HostCredentials{}, fmt.Errorf("invalid ip %q, not contained by network subnet %q", ip, subnet) } var hostPub, hostKey []byte { var pubkey, privkey [32]byte if _, err := io.ReadFull(rand.Reader, privkey[:]); err != nil { - return HostCert{}, fmt.Errorf("reading random bytes to form private key: %w", err) + return HostCredentials{}, fmt.Errorf("reading random bytes to form private key: %w", err) } curve25519.ScalarBaseMult(&pubkey, &privkey) hostPub, hostKey = pubkey[:], privkey[:] } - hostCrt := cert.NebulaCertificate{ + hostCert := cert.NebulaCertificate{ Details: cert.NebulaCertificateDetails{ Name: hostName, Ips: []*net.IPNet{{ @@ -87,31 +94,31 @@ func NewHostCert( }, } - if err := hostCrt.CheckRootConstrains(caCrt); err != nil { - return HostCert{}, fmt.Errorf("validating certificate constraints: %w", err) + if err := hostCert.CheckRootConstrains(caCert); err != nil { + return HostCredentials{}, fmt.Errorf("validating certificate constraints: %w", err) } - if err := hostCrt.Sign(caKey); err != nil { - return HostCert{}, fmt.Errorf("signing host cert with ca.key: %w", err) + if err := hostCert.Sign(caKey); err != nil { + return HostCredentials{}, fmt.Errorf("signing host cert with ca.key: %w", err) } hostKeyPEM := cert.MarshalX25519PrivateKey(hostKey) - hostCrtPEM, err := hostCrt.MarshalToPEM() + hostCertPEM, err := hostCert.MarshalToPEM() if err != nil { - return HostCert{}, fmt.Errorf("marshalling host.crt: %w", err) + return HostCredentials{}, fmt.Errorf("marshalling host.crt: %w", err) } - return HostCert{ - CACert: caCert.CACert, - HostKey: string(hostKeyPEM), - HostCert: string(hostCrtPEM), + return HostCredentials{ + CACertPEM: caCreds.CACertPEM, + HostKeyPEM: string(hostKeyPEM), + HostCertPEM: string(hostCertPEM), }, nil } -// NewCACert generates a CACert. The domain should be the network's root domain, +// NewCACredentials generates a CACredentials. The domain should be the network's root domain, // and is included in the signing certificate's Name field. -func NewCACert(domain string, subnet *net.IPNet) (CACert, error) { +func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) { pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -121,7 +128,7 @@ func NewCACert(domain string, subnet *net.IPNet) (CACert, error) { now := time.Now() expireAt := now.Add(2 * 365 * 24 * time.Hour) - caCrt := cert.NebulaCertificate{ + caCert := cert.NebulaCertificate{ Details: cert.NebulaCertificateDetails{ Name: fmt.Sprintf("%s cryptic-net root cert", domain), Subnets: []*net.IPNet{subnet}, @@ -132,19 +139,134 @@ func NewCACert(domain string, subnet *net.IPNet) (CACert, error) { }, } - if err := caCrt.Sign(privKey); err != nil { - return CACert{}, fmt.Errorf("signing caCrt: %w", err) + if err := caCert.Sign(privKey); err != nil { + return CACredentials{}, fmt.Errorf("signing caCert: %w", err) } caKeyPEM := cert.MarshalEd25519PrivateKey(privKey) - caCrtPem, err := caCrt.MarshalToPEM() + caCertPEM, err := caCert.MarshalToPEM() if err != nil { - return CACert{}, fmt.Errorf("marshaling caCrt: %w", err) + return CACredentials{}, fmt.Errorf("marshaling caCert: %w", err) } - return CACert{ - CACert: string(caCrtPem), - CAKey: string(caKeyPEM), + return CACredentials{ + CACertPEM: string(caCertPEM), + CAKeyPEM: string(caKeyPEM), }, nil } + +// ValidateHostCertPEM checks if the given host certificate was signed by the +// given CA certificate, and returns ErrInvalidSignature if validation fails. +func ValidateHostCertPEM(caCertPEM, hostCertPEM string) error { + + caCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCertPEM)) + if err != nil { + return fmt.Errorf("unmarshaling CA certificate as PEM: %w", err) + } + + hostCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(hostCertPEM)) + if err != nil { + return fmt.Errorf("unmarshaling host certificate as PEM: %w", err) + } + + caPubKey := ed25519.PublicKey(caCert.Details.PublicKey) + + if !hostCert.CheckSignature(caPubKey) { + return ErrInvalidSignature + } + + return nil +} + +// IPFromHostCertPEM is a convenience function for parsing the IP of a host out +// of its nebula cert. +func IPFromHostCertPEM(hostCertPEM string) (net.IP, error) { + + hostCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(hostCertPEM)) + if err != nil { + return nil, fmt.Errorf("unmarshaling host certificate as PEM: %w", err) + } + + ips := hostCert.Details.Ips + if len(ips) == 0 { + return nil, fmt.Errorf("malformed nebula host cert: no IPs") + } + + return ips[0].IP, nil +} + +// SignAndWrap signs the given bytes using the keyPEM, and writes an +// encoded, versioned structure containing the signature and the given bytes. +func SignAndWrap(into io.Writer, keyPEM string, b []byte) error { + + key, _, err := cert.UnmarshalEd25519PrivateKey([]byte(keyPEM)) + if err != nil { + return fmt.Errorf("unmarshaling private key: %w", err) + } + + sig, err := key.Sign(rand.Reader, b, crypto.Hash(0)) + if err != nil { + return fmt.Errorf("generating signature: %w", err) + } + + if _, err := into.Write([]byte("0")); err != nil { + return fmt.Errorf("writing version byte: %w", err) + } + + err = pem.Encode(into, &pem.Block{ + Type: "SIGNATURE", + Bytes: sig, + }) + + if err != nil { + return fmt.Errorf("writing PEM encoding of signature: %w", err) + } + + if _, err := into.Write(b); err != nil { + return fmt.Errorf("writing input bytes: %w", err) + } + + return nil +} + +// Unwrap reads a stream of bytes which was produced by SignAndWrap, and returns +// the original inpute to SignAndWrap as well as the signature which was +// created. ValidateSignature can be used to validate the signature. +func Unwrap(from io.Reader) (b, sig []byte, err error) { + + full, err := io.ReadAll(from) + if err != nil { + return nil, nil, fmt.Errorf("reading full input: %w", err) + } else if len(full) < 3 { + return nil, nil, fmt.Errorf("input too small") + } else if full[0] != '0' { + return nil, nil, fmt.Errorf("unexpected version byte: %d", full[0]) + } + + full = full[1:] + + pemBlock, rest := pem.Decode(full) + if pemBlock == nil { + return nil, nil, fmt.Errorf("PEM-encoded signature could not be decoded") + } + + return rest, pemBlock.Bytes, nil +} + +// ValidateSignature can be used to validate a signature produced by Unwrap. +func ValidateSignature(certPEM string, b, sig []byte) error { + + cert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(certPEM)) + if err != nil { + return fmt.Errorf("unmarshaling certificate as PEM: %w", err) + } + + pubKey := ed25519.PublicKey(cert.Details.PublicKey) + + if !ed25519.Verify(pubKey, b, sig) { + return ErrInvalidSignature + } + + return nil +} diff --git a/entrypoint/src/nebula/nebula_test.go b/entrypoint/src/nebula/nebula_test.go new file mode 100644 index 0000000..c5622c5 --- /dev/null +++ b/entrypoint/src/nebula/nebula_test.go @@ -0,0 +1,77 @@ +package nebula + +import ( + "bytes" + "errors" + "net" + "testing" +) + +var ( + ip net.IP + ipNet *net.IPNet + caCredsA, caCredsB CACredentials +) + +func init() { + var err error + + ip, ipNet, err = net.ParseCIDR("192.168.0.1/24") + if err != nil { + panic(err) + } + + caCredsA, err = NewCACredentials("a.example.com", ipNet) + if err != nil { + panic(err) + } + + caCredsB, err = NewCACredentials("b.example.com", ipNet) + if err != nil { + panic(err) + } +} + +func TestValidateHostCredentials(t *testing.T) { + + hostCreds, err := NewHostCredentials(caCredsA, "foo", ip) + if err != nil { + t.Fatal(err) + } + + err = ValidateHostCertPEM(hostCreds.CACertPEM, hostCreds.HostCertPEM) + if err != nil { + t.Fatal(err) + } + + err = ValidateHostCertPEM(caCredsB.CACertPEM, hostCreds.HostCertPEM) + if !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("expected ErrInvalidSignature, got %v", err) + } +} + +func TestSignAndWrap(t *testing.T) { + + b := []byte("foo bar baz") + buf := new(bytes.Buffer) + + if err := SignAndWrap(buf, caCredsA.CAKeyPEM, b); err != nil { + t.Fatal(err) + } + + gotB, gotSig, err := Unwrap(buf) + if err != nil { + t.Fatal(err) + + } else if !bytes.Equal(b, gotB) { + t.Fatalf("got %q but expected %q", gotB, b) + } + + if err := ValidateSignature(caCredsA.CACertPEM, b, gotSig); err != nil { + t.Fatal(err) + } + + if err := ValidateSignature(caCredsB.CACertPEM, b, gotSig); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("expected ErrInvalidSignature but got %v", err) + } +}