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.
This commit is contained in:
Brian Picciano 2022-10-29 21:11:40 +02:00
parent 711d568036
commit 7dceb659ef
14 changed files with 390 additions and 230 deletions

View File

@ -35,7 +35,7 @@ type CreationParams struct {
type Admin struct { type Admin struct {
CreationParams CreationParams CreationParams CreationParams
NebulaCACert nebula.CACert NebulaCACredentials nebula.CACredentials
GarageRPCSecret string GarageRPCSecret string
GarageGlobalBucketS3APICredentials garage.S3APICredentials GarageGlobalBucketS3APICredentials garage.S3APICredentials
@ -67,8 +67,8 @@ func FromFS(adminFS fs.FS) (Admin, error) {
into *string into *string
path string path string
}{ }{
{&a.NebulaCACert.CACert, nebulaCertsCACertPath}, {&a.NebulaCACredentials.CACertPEM, nebulaCertsCACertPath},
{&a.NebulaCACert.CAKey, nebulaCertsCAKeyPath}, {&a.NebulaCACredentials.CAKeyPEM, nebulaCertsCAKeyPath},
{&a.GarageRPCSecret, garageRPCSecretPath}, {&a.GarageRPCSecret, garageRPCSecretPath},
} }
@ -122,8 +122,8 @@ func (a Admin) WriteTo(into io.Writer) error {
value string value string
path string path string
}{ }{
{a.NebulaCACert.CACert, nebulaCertsCACertPath}, {a.NebulaCACredentials.CACertPEM, nebulaCertsCACertPath},
{a.NebulaCACert.CAKey, nebulaCertsCAKeyPath}, {a.NebulaCACredentials.CAKeyPEM, nebulaCertsCAKeyPath},
{a.GarageRPCSecret, garageRPCSecretPath}, {a.GarageRPCSecret, garageRPCSecretPath},
} }

View File

@ -45,7 +45,7 @@ type Bootstrap struct {
Hosts map[string]Host Hosts map[string]Host
HostName string HostName string
NebulaHostCert nebula.HostCert NebulaHostCredentials nebula.HostCredentials
GarageRPCSecret string GarageRPCSecret string
GarageAdminToken string GarageAdminToken string
@ -84,9 +84,9 @@ func FromFS(bootstrapFS fs.FS) (Bootstrap, error) {
path string path string
}{ }{
{&b.HostName, hostNamePath}, {&b.HostName, hostNamePath},
{&b.NebulaHostCert.CACert, nebulaCertsCACertPath}, {&b.NebulaHostCredentials.CACertPEM, nebulaCertsCACertPath},
{&b.NebulaHostCert.HostCert, nebulaCertsHostCertPath}, {&b.NebulaHostCredentials.HostCertPEM, nebulaCertsHostCertPath},
{&b.NebulaHostCert.HostKey, nebulaCertsHostKeyPath}, {&b.NebulaHostCredentials.HostKeyPEM, nebulaCertsHostKeyPath},
{&b.GarageRPCSecret, garageRPCSecretPath}, {&b.GarageRPCSecret, garageRPCSecretPath},
{&b.GarageAdminToken, garageAdminTokenPath}, {&b.GarageAdminToken, garageAdminTokenPath},
} }
@ -165,9 +165,9 @@ func (b Bootstrap) WriteTo(into io.Writer) error {
path string path string
}{ }{
{b.HostName, hostNamePath}, {b.HostName, hostNamePath},
{b.NebulaHostCert.CACert, nebulaCertsCACertPath}, {b.NebulaHostCredentials.CACertPEM, nebulaCertsCACertPath},
{b.NebulaHostCert.HostCert, nebulaCertsHostCertPath}, {b.NebulaHostCredentials.HostCertPEM, nebulaCertsHostCertPath},
{b.NebulaHostCert.HostKey, nebulaCertsHostKeyPath}, {b.NebulaHostCredentials.HostKeyPEM, nebulaCertsHostKeyPath},
{b.GarageRPCSecret, garageRPCSecretPath}, {b.GarageRPCSecret, garageRPCSecretPath},
{b.GarageAdminToken, garageAdminTokenPath}, {b.GarageAdminToken, garageAdminTokenPath},
} }
@ -209,19 +209,3 @@ func HostsHash(hostsMap map[string]Host) ([]byte, error) {
return h.Sum(nil), nil 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
}

View File

@ -26,7 +26,7 @@ func (b Bootstrap) GaragePeers() []garage.RemotePeer {
peer := garage.RemotePeer{ peer := garage.RemotePeer{
ID: instance.ID, ID: instance.ID,
IP: host.Nebula.IP, IP: host.IP().String(),
RPCPort: instance.RPCPort, RPCPort: instance.RPCPort,
S3APIPort: instance.S3APIPort, S3APIPort: instance.S3APIPort,
} }
@ -56,10 +56,11 @@ func (b Bootstrap) ChooseGaragePeer() garage.RemotePeer {
thisHost := b.ThisHost() thisHost := b.ThisHost()
if thisHost.Garage != nil && len(thisHost.Garage.Instances) > 0 { if thisHost.Garage != nil && len(thisHost.Garage.Instances) > 0 {
inst := thisHost.Garage.Instances[0] inst := thisHost.Garage.Instances[0]
return garage.RemotePeer{ return garage.RemotePeer{
ID: inst.ID, ID: inst.ID,
IP: thisHost.Nebula.IP, IP: thisHost.IP().String(),
RPCPort: inst.RPCPort, RPCPort: inst.RPCPort,
S3APIPort: inst.S3APIPort, S3APIPort: inst.S3APIPort,
} }

View File

@ -4,8 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"cryptic-net/garage" "cryptic-net/garage"
"cryptic-net/nebula"
"fmt" "fmt"
"log" "os"
"path/filepath" "path/filepath"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
@ -17,23 +18,32 @@ const (
garageGlobalBucketBootstrapHostsDirPath = "bootstrap/hosts" garageGlobalBucketBootstrapHostsDirPath = "bootstrap/hosts"
) )
// PutGarageBoostrapHost places the <hostname>.yml file for the given host into // PutGarageBoostrapHost places the <hostname>.yml.signed file for this host
// garage so that other hosts are able to see relevant configuration for it. // into garage so that other hosts are able to see relevant configuration for
// // it.
// The given client should be for the global bucket. func (b Bootstrap) PutGarageBoostrapHost(ctx context.Context) error {
func PutGarageBoostrapHost(
ctx context.Context, client garage.S3APIClient, host Host, host := b.ThisHost()
) error { client := b.GlobalBucketS3APIClient()
hostB, err := yaml.Marshal(host)
if err != nil {
return fmt.Errorf("yaml encoding host data: %w", err)
}
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if err := yaml.NewEncoder(buf).Encode(host); err != nil { err = nebula.SignAndWrap(buf, b.NebulaHostCredentials.HostKeyPEM, hostB)
log.Fatalf("yaml encoding host data: %v", err) 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()), ctx, garage.GlobalBucket, filePath, buf, int64(buf.Len()),
minio.PutObjectOptions{}, minio.PutObjectOptions{},
) )
@ -45,15 +55,18 @@ func PutGarageBoostrapHost(
return nil return nil
} }
// RemoveGarageBootstrapHost removes the <hostname>.yml for the given host from // RemoveGarageBootstrapHost removes the <hostname>.yml.signed for the given
// garage. // host from garage.
// //
// The given client should be for the global bucket. // The given client should be for the global bucket.
func RemoveGarageBootstrapHost( func RemoveGarageBootstrapHost(
ctx context.Context, client garage.S3APIClient, hostName string, ctx context.Context, client garage.S3APIClient, hostName string,
) error { ) error {
filePath := filepath.Join(garageGlobalBucketBootstrapHostsDirPath, hostName+".yml") filePath := filepath.Join(
garageGlobalBucketBootstrapHostsDirPath,
hostName+".yml.signed",
)
return client.RemoveObject( return client.RemoveObject(
ctx, garage.GlobalBucket, filePath, ctx, garage.GlobalBucket, filePath,
@ -61,16 +74,17 @@ func RemoveGarageBootstrapHost(
) )
} }
// GetGarageBootstrapHosts loads the <hostname>.yml file for all hosts stored in // GetGarageBootstrapHosts loads the <hostname>.yml.signed file for all hosts
// garage. // stored in garage.
// func (b Bootstrap) GetGarageBootstrapHosts(
// The given client should be for the global bucket. ctx context.Context,
func GetGarageBootstrapHosts(
ctx context.Context, client garage.S3APIClient,
) ( ) (
map[string]Host, error, map[string]Host, error,
) { ) {
caCertPEM := b.NebulaHostCredentials.CACertPEM
client := b.GlobalBucketS3APIClient()
hosts := map[string]Host{} hosts := map[string]Host{}
objInfoCh := client.ListObjects( objInfoCh := client.ListObjects(
@ -95,15 +109,30 @@ func GetGarageBootstrapHosts(
return nil, fmt.Errorf("retrieving object %q: %w", objInfo.Key, err) return nil, fmt.Errorf("retrieving object %q: %w", objInfo.Key, err)
} }
var host Host hostB, sig, err := nebula.Unwrap(obj)
err = yaml.NewDecoder(obj).Decode(&host)
obj.Close() obj.Close()
if err != nil { 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) 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 hosts[host.Name] = host
} }

View File

@ -1,8 +1,10 @@
package bootstrap package bootstrap
import ( import (
"cryptic-net/nebula"
"fmt" "fmt"
"io/fs" "io/fs"
"net"
"path/filepath" "path/filepath"
"strings" "strings"
@ -16,7 +18,7 @@ const (
// NebulaHost describes the nebula configuration of a Host which is relevant for // NebulaHost describes the nebula configuration of a Host which is relevant for
// other hosts to know. // other hosts to know.
type NebulaHost struct { type NebulaHost struct {
IP string `yaml:"ip"` CertPEM string `yaml:"crt"`
PublicAddr string `yaml:"public_addr,omitempty"` PublicAddr string `yaml:"public_addr,omitempty"`
} }
@ -41,6 +43,18 @@ type Host struct {
Garage *GarageHost `yaml:"garage,omitempty"` 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) { func loadHosts(bootstrapFS fs.FS) (map[string]Host, error) {
hosts := map[string]Host{} hosts := map[string]Host{}

View File

@ -117,12 +117,12 @@ var subCmdAdminCreateNetwork = subCmd{
return fmt.Errorf("daemon config with at least 3 allocations was not provided") 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 { if err != nil {
return fmt.Errorf("creating nebula CA cert: %w", err) 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 { if err != nil {
return fmt.Errorf("creating nebula cert for host: %w", err) return fmt.Errorf("creating nebula cert for host: %w", err)
} }
@ -138,12 +138,12 @@ var subCmdAdminCreateNetwork = subCmd{
*hostName: bootstrap.Host{ *hostName: bootstrap.Host{
Name: *hostName, Name: *hostName,
Nebula: bootstrap.NebulaHost{ Nebula: bootstrap.NebulaHost{
IP: ip.String(), CertPEM: nebulaHostCreds.HostCertPEM,
}, },
}, },
}, },
HostName: *hostName, HostName: *hostName,
NebulaHostCert: nebulaHostCert, NebulaHostCredentials: nebulaHostCreds,
GarageRPCSecret: randStr(32), GarageRPCSecret: randStr(32),
GarageAdminToken: randStr(32), GarageAdminToken: randStr(32),
GarageGlobalBucketS3APICredentials: garage.NewS3APICredentials(), GarageGlobalBucketS3APICredentials: garage.NewS3APICredentials(),
@ -213,7 +213,7 @@ var subCmdAdminCreateNetwork = subCmd{
err = admin.Admin{ err = admin.Admin{
CreationParams: adminCreationParams, CreationParams: adminCreationParams,
NebulaCACert: nebulaCACert, NebulaCACredentials: nebulaCACreds,
GarageRPCSecret: hostBootstrap.GarageRPCSecret, GarageRPCSecret: hostBootstrap.GarageRPCSecret,
GarageGlobalBucketS3APICredentials: hostBootstrap.GarageGlobalBucketS3APICredentials, GarageGlobalBucketS3APICredentials: hostBootstrap.GarageGlobalBucketS3APICredentials,
GarageAdminBucketS3APICredentials: garage.NewS3APICredentials(), GarageAdminBucketS3APICredentials: garage.NewS3APICredentials(),
@ -240,6 +240,11 @@ var subCmdAdminMakeBootstrap = subCmd{
"Name of the host to generate bootstrap.tgz for", "Name of the host to generate bootstrap.tgz for",
) )
ipStr := flags.StringP(
"ip", "i", "",
"IP of the new host",
)
adminPath := flags.StringP( adminPath := flags.StringP(
"admin-path", "a", "", "admin-path", "a", "",
`Path to admin.tgz file. If the given path is "-" then stdin is used.`, `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) return fmt.Errorf("parsing flags: %w", err)
} }
if *name == "" || *adminPath == "" { if *name == "" || *ipStr == "" || *adminPath == "" {
return errors.New("--name and --admin-path are required") return errors.New("--name, --ip, and --admin-path are required")
} }
hostBootstrap, err := loadHostBootstrap() if err := validateHostName(*name); err != nil {
if err != nil { return fmt.Errorf("invalid hostname %q: %w", *name, err)
return fmt.Errorf("loading host bootstrap: %w", err) }
ip := net.ParseIP(*ipStr)
if ip == nil {
return fmt.Errorf("invalid ip %q", *ipStr)
} }
adm, err := readAdmin(*adminPath) 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) return fmt.Errorf("reading admin.tgz with --admin-path of %q: %w", *adminPath, err)
} }
client := hostBootstrap.GlobalBucketS3APIClient() hostBootstrap, err := loadHostBootstrap()
// 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)
if err != nil { 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] nebulaHostCreds, err := nebula.NewHostCredentials(adm.NebulaCACredentials, *name, ip)
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)
if err != nil { if err != nil {
return fmt.Errorf("creating new nebula host key/cert: %w", err) return fmt.Errorf("creating new nebula host key/cert: %w", err)
} }
@ -292,10 +286,10 @@ var subCmdAdminMakeBootstrap = subCmd{
newHostBootstrap := bootstrap.Bootstrap{ newHostBootstrap := bootstrap.Bootstrap{
AdminCreationParams: adm.CreationParams, AdminCreationParams: adm.CreationParams,
Hosts: hosts, Hosts: hostBootstrap.Hosts,
HostName: *name, HostName: *name,
NebulaHostCert: nebulaHostCert, NebulaHostCredentials: nebulaHostCreds,
GarageRPCSecret: adm.GarageRPCSecret, GarageRPCSecret: adm.GarageRPCSecret,
GarageAdminToken: randStr(32), GarageAdminToken: randStr(32),

View File

@ -12,7 +12,6 @@ import (
"cryptic-net/bootstrap" "cryptic-net/bootstrap"
"cryptic-net/daemon" "cryptic-net/daemon"
"cryptic-net/garage"
"code.betamike.com/cryptic-io/pmux/pmuxlib" "code.betamike.com/cryptic-io/pmux/pmuxlib"
) )
@ -42,16 +41,21 @@ import (
func reloadBootstrap( func reloadBootstrap(
ctx context.Context, ctx context.Context,
hostBootstrap bootstrap.Bootstrap, hostBootstrap bootstrap.Bootstrap,
s3Client garage.S3APIClient,
) ( ) (
bootstrap.Bootstrap, bool, error, bootstrap.Bootstrap, bool, error,
) { ) {
newHosts, err := bootstrap.GetGarageBootstrapHosts(ctx, s3Client) thisHost := hostBootstrap.ThisHost()
newHosts, err := hostBootstrap.GetGarageBootstrapHosts(ctx)
if err != nil { if err != nil {
return bootstrap.Bootstrap{}, false, fmt.Errorf("getting hosts from garage: %w", err) 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) newHostsHash, err := bootstrap.HostsHash(newHosts)
if err != nil { if err != nil {
return bootstrap.Bootstrap{}, false, fmt.Errorf("calculating hash of new hosts: %w", err) return bootstrap.Bootstrap{}, false, fmt.Errorf("calculating hash of new hosts: %w", err)
@ -66,13 +70,8 @@ func reloadBootstrap(
return hostBootstrap, false, nil return hostBootstrap, false, nil
} }
newHostBootstrap := hostBootstrap.WithHosts(newHosts) hostBootstrap.Hosts = newHosts
return hostBootstrap, true, nil
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
} }
// runs a single pmux process of daemon, returning only once the env.Context has // runs a single pmux process of daemon, returning only once the env.Context has
@ -87,14 +86,6 @@ func runDaemonPmuxOnce(
bootstrap.Bootstrap, error, 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) nebulaPmuxProcConfig, err := nebulaPmuxProcConfig(hostBootstrap, daemonConfig)
if err != nil { if err != nil {
return bootstrap.Bootstrap{}, fmt.Errorf("generating nebula config: %w", err) return bootstrap.Bootstrap{}, fmt.Errorf("generating nebula config: %w", err)
@ -143,11 +134,9 @@ func runDaemonPmuxOnce(
return return
} }
thisHost := hostBootstrap.ThisHost()
err := doOnce(ctx, func(ctx context.Context) error { err := doOnce(ctx, func(ctx context.Context) error {
fmt.Fprintln(os.Stderr, "updating host info in garage") fmt.Fprintln(os.Stderr, "updating host info in garage")
return bootstrap.PutGarageBoostrapHost(ctx, s3Client, thisHost) return hostBootstrap.PutGarageBoostrapHost(ctx)
}) })
if err != nil { if err != nil {
@ -194,7 +183,7 @@ func runDaemonPmuxOnce(
err error 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) return bootstrap.Bootstrap{}, fmt.Errorf("reloading bootstrap: %w", err)
} else if changed { } else if changed {

View File

@ -20,19 +20,22 @@ func dnsmasqPmuxProcConfig(
confPath := filepath.Join(envRuntimeDirPath, "dnsmasq.conf") 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 { 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 { 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{ confData := dnsmasq.ConfData{
Resolvers: daemonConfig.DNS.Resolvers, Resolvers: daemonConfig.DNS.Resolvers,
Domain: hostBootstrap.AdminCreationParams.Domain, Domain: hostBootstrap.AdminCreationParams.Domain,
IP: hostBootstrap.ThisHost().Nebula.IP, IP: hostBootstrap.ThisHost().IP().String(),
Hosts: hostsSlice, Hosts: hostsSlice,
} }

View File

@ -23,7 +23,7 @@ func newGarageAdminClient(
return garage.NewAdminClient( return garage.NewAdminClient(
net.JoinHostPort( net.JoinHostPort(
thisHost.Nebula.IP, thisHost.IP().String(),
strconv.Itoa(daemonConfig.Storage.Allocations[0].AdminPort), strconv.Itoa(daemonConfig.Storage.Allocations[0].AdminPort),
), ),
hostBootstrap.GarageAdminToken, hostBootstrap.GarageAdminToken,
@ -47,7 +47,7 @@ func waitForGarageAndNebula(
for _, alloc := range allocs { for _, alloc := range allocs {
adminAddr := net.JoinHostPort( adminAddr := net.JoinHostPort(
hostBootstrap.ThisHost().Nebula.IP, hostBootstrap.ThisHost().IP().String(),
strconv.Itoa(alloc.AdminPort), strconv.Itoa(alloc.AdminPort),
) )
@ -97,7 +97,7 @@ func garageWriteChildConfig(
peer := garage.LocalPeer{ peer := garage.LocalPeer{
RemotePeer: garage.RemotePeer{ RemotePeer: garage.RemotePeer{
ID: id, ID: id,
IP: thisHost.Nebula.IP, IP: thisHost.IP().String(),
RPCPort: alloc.RPCPort, RPCPort: alloc.RPCPort,
S3APIPort: alloc.S3APIPort, S3APIPort: alloc.S3APIPort,
}, },

View File

@ -4,7 +4,6 @@ import (
"cryptic-net/bootstrap" "cryptic-net/bootstrap"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"regexp" "regexp"
"sort" "sort"
@ -23,60 +22,6 @@ func validateHostName(name string) error {
return nil 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{ var subCmdHostsList = subCmd{
name: "list", name: "list",
descr: "Lists all hosts in the network, and their IPs", 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) return fmt.Errorf("loading host bootstrap: %w", err)
} }
client := hostBootstrap.GlobalBucketS3APIClient() hostsMap, err := hostBootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx)
hostsMap, err := bootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx, client)
if err != nil { if err != nil {
return fmt.Errorf("retrieving hosts from garage: %w", err) 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", descr: "Sub-commands having to do with configuration of hosts in the network",
do: func(subCmdCtx subCmdCtx) error { do: func(subCmdCtx subCmdCtx) error {
return subCmdCtx.doSubCmd( return subCmdCtx.doSubCmd(
subCmdHostsAdd,
subCmdHostsDelete, subCmdHostsDelete,
subCmdHostsList, subCmdHostsList,
) )

View File

@ -18,8 +18,7 @@ import (
// interface has been initialized. // interface has been initialized.
func waitForNebula(ctx context.Context, hostBootstrap bootstrap.Bootstrap) error { func waitForNebula(ctx context.Context, hostBootstrap bootstrap.Bootstrap) error {
ipStr := hostBootstrap.ThisHost().Nebula.IP ip := hostBootstrap.ThisHost().IP()
ip := net.ParseIP(ipStr)
lUdpAddr := &net.UDPAddr{IP: ip, Port: 0} lUdpAddr := &net.UDPAddr{IP: ip, Port: 0}
rUdpAddr := &net.UDPAddr{IP: ip, Port: 45535} rUdpAddr := &net.UDPAddr{IP: ip, Port: 45535}
@ -52,15 +51,16 @@ func nebulaPmuxProcConfig(
continue continue
} }
lighthouseHostIPs = append(lighthouseHostIPs, host.Nebula.IP) ip := host.IP().String()
staticHostMap[host.Nebula.IP] = []string{host.Nebula.PublicAddr} lighthouseHostIPs = append(lighthouseHostIPs, ip)
staticHostMap[ip] = []string{host.Nebula.PublicAddr}
} }
config := map[string]interface{}{ config := map[string]interface{}{
"pki": map[string]string{ "pki": map[string]string{
"ca": hostBootstrap.NebulaHostCert.CACert, "ca": hostBootstrap.NebulaHostCredentials.CACertPEM,
"cert": hostBootstrap.NebulaHostCert.HostCert, "cert": hostBootstrap.NebulaHostCredentials.HostCertPEM,
"key": hostBootstrap.NebulaHostCert.HostKey, "key": hostBootstrap.NebulaHostCredentials.HostKeyPEM,
}, },
"static_host_map": staticHostMap, "static_host_map": staticHostMap,
"punchy": map[string]bool{ "punchy": map[string]bool{

View File

@ -1,18 +1,23 @@
package dnsmasq package dnsmasq
import ( import (
"cryptic-net/bootstrap"
"fmt" "fmt"
"os" "os"
"text/template" "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. // ConfData describes all the data needed to populate a dnsmasq.conf file.
type ConfData struct { type ConfData struct {
Resolvers []string Resolvers []string
Domain string Domain string
IP string IP string
Hosts []bootstrap.Host Hosts []ConfDataHost
} }
var confTpl = template.Must(template.New("").Parse(` var confTpl = template.Must(template.New("").Parse(`

View File

@ -3,8 +3,11 @@
package nebula package nebula
import ( import (
"crypto"
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"encoding/pem"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -14,65 +17,69 @@ import (
"golang.org/x/crypto/curve25519" "golang.org/x/crypto/curve25519"
) )
// HostCert contains the certificate and private key files which will need to // ErrInvalidSignature is returned from functions when a signature validation
// be present on a particular host. Each file is PEM encoded. // fails.
type HostCert struct { var ErrInvalidSignature = errors.New("invalid signature")
CACert string
HostKey string // HostCredentials contains the certificate and private key files which will
HostCert string // 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 // CACredentials contains the certificate and private files which can be used to
// HostCerts. Each file is PEM encoded. // create and validate HostCredentials. Each file is PEM encoded.
type CACert struct { type CACredentials struct {
CACert string CACertPEM string
CAKey string CAKeyPEM string
} }
// NewHostCert generates a new key/cert for a nebula host using the CA key // NewHostCredentials generates a new key/cert for a nebula host using the CA
// which will be found in the adminFS. // key which will be found in the adminFS.
func NewHostCert( func NewHostCredentials(
caCert CACert, hostName string, ip net.IP, caCreds CACredentials, hostName string, ip net.IP,
) ( ) (
HostCert, error, HostCredentials, error,
) { ) {
// The logic here is largely based on // The logic here is largely based on
// https://github.com/slackhq/nebula/blob/v1.4.0/cmd/nebula-cert/sign.go // 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 { 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 { 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 { 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) { 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 hostPub, hostKey []byte
{ {
var pubkey, privkey [32]byte var pubkey, privkey [32]byte
if _, err := io.ReadFull(rand.Reader, privkey[:]); err != nil { 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) curve25519.ScalarBaseMult(&pubkey, &privkey)
hostPub, hostKey = pubkey[:], privkey[:] hostPub, hostKey = pubkey[:], privkey[:]
} }
hostCrt := cert.NebulaCertificate{ hostCert := cert.NebulaCertificate{
Details: cert.NebulaCertificateDetails{ Details: cert.NebulaCertificateDetails{
Name: hostName, Name: hostName,
Ips: []*net.IPNet{{ Ips: []*net.IPNet{{
@ -87,31 +94,31 @@ func NewHostCert(
}, },
} }
if err := hostCrt.CheckRootConstrains(caCrt); err != nil { if err := hostCert.CheckRootConstrains(caCert); err != nil {
return HostCert{}, fmt.Errorf("validating certificate constraints: %w", err) return HostCredentials{}, fmt.Errorf("validating certificate constraints: %w", err)
} }
if err := hostCrt.Sign(caKey); err != nil { if err := hostCert.Sign(caKey); err != nil {
return HostCert{}, fmt.Errorf("signing host cert with ca.key: %w", err) return HostCredentials{}, fmt.Errorf("signing host cert with ca.key: %w", err)
} }
hostKeyPEM := cert.MarshalX25519PrivateKey(hostKey) hostKeyPEM := cert.MarshalX25519PrivateKey(hostKey)
hostCrtPEM, err := hostCrt.MarshalToPEM() hostCertPEM, err := hostCert.MarshalToPEM()
if err != nil { if err != nil {
return HostCert{}, fmt.Errorf("marshalling host.crt: %w", err) return HostCredentials{}, fmt.Errorf("marshalling host.crt: %w", err)
} }
return HostCert{ return HostCredentials{
CACert: caCert.CACert, CACertPEM: caCreds.CACertPEM,
HostKey: string(hostKeyPEM), HostKeyPEM: string(hostKeyPEM),
HostCert: string(hostCrtPEM), HostCertPEM: string(hostCertPEM),
}, nil }, 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. // 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) pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil { if err != nil {
@ -121,7 +128,7 @@ func NewCACert(domain string, subnet *net.IPNet) (CACert, error) {
now := time.Now() now := time.Now()
expireAt := now.Add(2 * 365 * 24 * time.Hour) expireAt := now.Add(2 * 365 * 24 * time.Hour)
caCrt := cert.NebulaCertificate{ caCert := cert.NebulaCertificate{
Details: cert.NebulaCertificateDetails{ Details: cert.NebulaCertificateDetails{
Name: fmt.Sprintf("%s cryptic-net root cert", domain), Name: fmt.Sprintf("%s cryptic-net root cert", domain),
Subnets: []*net.IPNet{subnet}, Subnets: []*net.IPNet{subnet},
@ -132,19 +139,134 @@ func NewCACert(domain string, subnet *net.IPNet) (CACert, error) {
}, },
} }
if err := caCrt.Sign(privKey); err != nil { if err := caCert.Sign(privKey); err != nil {
return CACert{}, fmt.Errorf("signing caCrt: %w", err) return CACredentials{}, fmt.Errorf("signing caCert: %w", err)
} }
caKeyPEM := cert.MarshalEd25519PrivateKey(privKey) caKeyPEM := cert.MarshalEd25519PrivateKey(privKey)
caCrtPem, err := caCrt.MarshalToPEM() caCertPEM, err := caCert.MarshalToPEM()
if err != nil { if err != nil {
return CACert{}, fmt.Errorf("marshaling caCrt: %w", err) return CACredentials{}, fmt.Errorf("marshaling caCert: %w", err)
} }
return CACert{ return CACredentials{
CACert: string(caCrtPem), CACertPEM: string(caCertPEM),
CAKey: string(caKeyPEM), CAKeyPEM: string(caKeyPEM),
}, nil }, 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
}

View File

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