From 7dceb659ef94ce9edbb93fc015e9e12e9696d525 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sat, 29 Oct 2022 21:11:40 +0200 Subject: [PATCH] Store full nebula cert for each host in garage, rather than just the IP This allows each host to verify the cert against the CA cert. We also now have each host sign the yaml file that it posts to garage, to ensure that a host can't arbitrarily overwrite another host's file. --- entrypoint/src/admin/admin.go | 10 +- entrypoint/src/bootstrap/bootstrap.go | 30 +-- entrypoint/src/bootstrap/garage.go | 5 +- .../src/bootstrap/garage_global_bucket.go | 77 +++++-- entrypoint/src/bootstrap/hosts.go | 16 +- entrypoint/src/cmd/entrypoint/admin.go | 56 ++--- entrypoint/src/cmd/entrypoint/daemon.go | 33 +-- entrypoint/src/cmd/entrypoint/dnsmasq_util.go | 11 +- entrypoint/src/cmd/entrypoint/garage_util.go | 6 +- entrypoint/src/cmd/entrypoint/hosts.go | 60 +---- entrypoint/src/cmd/entrypoint/nebula_util.go | 14 +- entrypoint/src/dnsmasq/tpl.go | 9 +- entrypoint/src/nebula/nebula.go | 216 ++++++++++++++---- entrypoint/src/nebula/nebula_test.go | 77 +++++++ 14 files changed, 390 insertions(+), 230 deletions(-) create mode 100644 entrypoint/src/nebula/nebula_test.go 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) + } +}