Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Picciano
7dceb659ef 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.
2022-10-29 21:11:40 +02:00
Brian Picciano
711d568036 Use a real private key for garage instances 2022-10-29 00:09:18 +02:00
20 changed files with 552 additions and 443 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

@ -12,9 +12,9 @@ const (
) )
// GaragePeers returns a Peer for each known garage instance in the network. // GaragePeers returns a Peer for each known garage instance in the network.
func (b Bootstrap) GaragePeers() []garage.Peer { func (b Bootstrap) GaragePeers() []garage.RemotePeer {
var peers []garage.Peer var peers []garage.RemotePeer
for _, host := range b.Hosts { for _, host := range b.Hosts {
@ -24,8 +24,9 @@ func (b Bootstrap) GaragePeers() []garage.Peer {
for _, instance := range host.Garage.Instances { for _, instance := range host.Garage.Instances {
peer := garage.Peer{ peer := garage.RemotePeer{
IP: host.Nebula.IP, ID: instance.ID,
IP: host.IP().String(),
RPCPort: instance.RPCPort, RPCPort: instance.RPCPort,
S3APIPort: instance.S3APIPort, S3APIPort: instance.S3APIPort,
} }
@ -50,14 +51,16 @@ func (b Bootstrap) GarageRPCPeerAddrs() []string {
// ChooseGaragePeer returns a Peer for a garage instance from the network. It // ChooseGaragePeer returns a Peer for a garage instance from the network. It
// will prefer a garage instance on this particular host, if there is one, but // will prefer a garage instance on this particular host, if there is one, but
// will otherwise return a random endpoint. // will otherwise return a random endpoint.
func (b Bootstrap) ChooseGaragePeer() garage.Peer { 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.Peer{ return garage.RemotePeer{
IP: thisHost.Nebula.IP, ID: inst.ID,
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,14 +18,15 @@ 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"`
} }
// GarageHost describes a single garage instance in the GarageHost. // GarageHost describes a single garage instance in the GarageHost.
type GarageHostInstance struct { type GarageHostInstance struct {
RPCPort int `yaml:"rpc_port"` ID string `yaml:"id"`
S3APIPort int `yaml:"s3_api_port"` RPCPort int `yaml:"rpc_port"`
S3APIPort int `yaml:"s3_api_port"`
} }
// GarageHost describes the garage configuration of a Host which is relevant for // GarageHost describes the garage configuration of a Host which is relevant for
@ -40,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

@ -4,6 +4,7 @@ import (
"context" "context"
"cryptic-net/bootstrap" "cryptic-net/bootstrap"
"cryptic-net/daemon" "cryptic-net/daemon"
"cryptic-net/garage"
"fmt" "fmt"
"time" "time"
) )
@ -25,7 +26,14 @@ func mergeDaemonConfigIntoBootstrap(
host.Garage = new(bootstrap.GarageHost) host.Garage = new(bootstrap.GarageHost)
for _, alloc := range allocs { for _, alloc := range allocs {
id, err := garage.InitAlloc(alloc.MetaPath)
if err != nil {
return bootstrap.Bootstrap{}, fmt.Errorf("initializing alloc at %q: %w", alloc.MetaPath, err)
}
host.Garage.Instances = append(host.Garage.Instances, bootstrap.GarageHostInstance{ host.Garage.Instances = append(host.Garage.Instances, bootstrap.GarageHostInstance{
ID: id,
RPCPort: alloc.RPCPort, RPCPort: alloc.RPCPort,
S3APIPort: alloc.S3APIPort, S3APIPort: alloc.S3APIPort,
}) })

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

@ -7,7 +7,6 @@ import (
"cryptic-net/garage" "cryptic-net/garage"
"fmt" "fmt"
"net" "net"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -24,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,
@ -48,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),
) )
@ -66,6 +65,25 @@ func waitForGarageAndNebula(
} }
// bootstrapGarageHostForAlloc returns the bootstrap.GarageHostInstance which
// corresponds with the given alloc from the daemon config. This will panic if
// no associated instance can be found.
//
// This assumes that mergeDaemonConfigIntoBootstrap has already been called.
func bootstrapGarageHostForAlloc(
host bootstrap.Host,
alloc daemon.ConfigStorageAllocation,
) bootstrap.GarageHostInstance {
for _, inst := range host.Garage.Instances {
if inst.RPCPort == alloc.RPCPort {
return inst
}
}
panic(fmt.Sprintf("could not find alloc %+v in the bootstrap data", alloc))
}
func garageWriteChildConfig( func garageWriteChildConfig(
hostBootstrap bootstrap.Bootstrap, hostBootstrap bootstrap.Bootstrap,
alloc daemon.ConfigStorageAllocation, alloc daemon.ConfigStorageAllocation,
@ -73,28 +91,17 @@ func garageWriteChildConfig(
string, error, string, error,
) { ) {
if err := os.MkdirAll(alloc.MetaPath, 0750); err != nil {
return "", fmt.Errorf("making directory %q: %w", alloc.MetaPath, err)
}
thisHost := hostBootstrap.ThisHost() thisHost := hostBootstrap.ThisHost()
id := bootstrapGarageHostForAlloc(thisHost, alloc).ID
peer := garage.Peer{ peer := garage.LocalPeer{
IP: thisHost.Nebula.IP, RemotePeer: garage.RemotePeer{
RPCPort: alloc.RPCPort, ID: id,
S3APIPort: alloc.S3APIPort, IP: thisHost.IP().String(),
} RPCPort: alloc.RPCPort,
S3APIPort: alloc.S3APIPort,
pubKey, privKey := peer.RPCPeerKey() },
AdminPort: alloc.AdminPort,
nodeKeyPath := filepath.Join(alloc.MetaPath, "node_key")
nodeKeyPubPath := filepath.Join(alloc.MetaPath, "node_keypub")
if err := os.WriteFile(nodeKeyPath, privKey, 0400); err != nil {
return "", fmt.Errorf("writing private key to %q: %w", nodeKeyPath, err)
} else if err := os.WriteFile(nodeKeyPubPath, pubKey, 0440); err != nil {
return "", fmt.Errorf("writing public key to %q: %w", nodeKeyPubPath, err)
} }
garageTomlPath := filepath.Join( garageTomlPath := filepath.Join(
@ -108,9 +115,9 @@ func garageWriteChildConfig(
RPCSecret: hostBootstrap.GarageRPCSecret, RPCSecret: hostBootstrap.GarageRPCSecret,
AdminToken: hostBootstrap.GarageAdminToken, AdminToken: hostBootstrap.GarageAdminToken,
RPCAddr: net.JoinHostPort(thisHost.Nebula.IP, strconv.Itoa(alloc.RPCPort)), RPCAddr: peer.RPCAddr(),
APIAddr: net.JoinHostPort(thisHost.Nebula.IP, strconv.Itoa(alloc.S3APIPort)), S3APIAddr: peer.S3APIAddr(),
AdminAddr: net.JoinHostPort(thisHost.Nebula.IP, strconv.Itoa(alloc.AdminPort)), AdminAddr: peer.AdminAddr(),
BootstrapPeers: hostBootstrap.GarageRPCPeerAddrs(), BootstrapPeers: hostBootstrap.GarageRPCPeerAddrs(),
}) })
@ -224,7 +231,6 @@ func garageApplyLayout(
adminClient = newGarageAdminClient(hostBootstrap, daemonConfig) adminClient = newGarageAdminClient(hostBootstrap, daemonConfig)
thisHost = hostBootstrap.ThisHost() thisHost = hostBootstrap.ThisHost()
hostName = thisHost.Name hostName = thisHost.Name
ip = thisHost.Nebula.IP
allocs = daemonConfig.Storage.Allocations allocs = daemonConfig.Storage.Allocations
) )
@ -239,13 +245,9 @@ func garageApplyLayout(
for _, alloc := range allocs { for _, alloc := range allocs {
peer := garage.Peer{ id := bootstrapGarageHostForAlloc(thisHost, alloc).ID
IP: ip,
RPCPort: alloc.RPCPort,
S3APIPort: alloc.S3APIPort,
}
clusterLayout[peer.RPCPeerID()] = peerLayout{ clusterLayout[id] = peerLayout{
Capacity: alloc.Capacity / 100, Capacity: alloc.Capacity / 100,
Zone: hostName, Zone: hostName,
Tags: []string{}, Tags: []string{},

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

@ -2,6 +2,15 @@
// setting up garage configs, processes, and deployments. // setting up garage configs, processes, and deployments.
package garage package garage
import (
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
const ( const (
// Region is the region which garage is configured with. // Region is the region which garage is configured with.
@ -15,3 +24,81 @@ const (
// cluster. We currently only support a factor of 3. // cluster. We currently only support a factor of 3.
ReplicationFactor = 3 ReplicationFactor = 3
) )
func nodeKeyPath(metaDirPath string) string {
return filepath.Join(metaDirPath, "node_key")
}
func nodeKeyPubPath(metaDirPath string) string {
return filepath.Join(metaDirPath, "node_key.pub")
}
// LoadAllocID returns the peer ID (ie the public key) of the node at the given
// meta directory.
func LoadAllocID(metaDirPath string) (string, error) {
nodeKeyPubPath := nodeKeyPubPath(metaDirPath)
pubKey, err := os.ReadFile(nodeKeyPubPath)
if err != nil {
return "", fmt.Errorf("reading %q: %w", nodeKeyPubPath, err)
}
return hex.EncodeToString(pubKey), nil
}
// InitAlloc initializes the meta directory and keys for a particular
// allocation, if it hasn't been done so already. It returns the peer ID (ie the
// public key) in any case.
func InitAlloc(metaDirPath string) (string, error) {
var err error
exists := func(path string) bool {
if err != nil {
return false
} else if _, err = os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return false
} else if err != nil {
err = fmt.Errorf("checking if %q exists: %w", path, err)
return false
}
return true
}
nodeKeyPath := nodeKeyPath(metaDirPath)
nodeKeyPubPath := nodeKeyPubPath(metaDirPath)
nodeKeyPathExists := exists(nodeKeyPath)
nodeKeyPubPathExists := exists(nodeKeyPubPath)
if err != nil {
return "", err
} else if nodeKeyPubPathExists != nodeKeyPathExists {
return "", fmt.Errorf("%q or %q exist without the other existing", nodeKeyPath, nodeKeyPubPath)
} else if nodeKeyPathExists {
return LoadAllocID(metaDirPath)
}
// node key hasn't been written, write it
if err := os.MkdirAll(metaDirPath, 0750); err != nil {
return "", fmt.Errorf("making directory %q: %w", metaDirPath, err)
}
pubKey, privKey := GeneratePeerKey()
if err := os.WriteFile(nodeKeyPath, privKey, 0400); err != nil {
return "", fmt.Errorf("writing private key to %q: %w", nodeKeyPath, err)
} else if err := os.WriteFile(nodeKeyPubPath, pubKey, 0440); err != nil {
return "", fmt.Errorf("writing public key to %q: %w", nodeKeyPubPath, err)
}
return "", nil
}

View File

@ -1,41 +0,0 @@
package garage
import "io"
type infiniteReader struct {
b []byte
i int
}
// NewInfiniteReader returns a reader which will produce the given bytes in
// repetition. len(b) must be greater than 0.
func NewInfiniteReader(b []byte) io.Reader {
if len(b) == 0 {
panic("len(b) must be greater than 0")
}
return &infiniteReader{b: b}
}
func (r *infiniteReader) Read(b []byte) (int, error) {
// here, have a puzzle
var n int
for {
n += copy(b[n:], r.b[r.i:])
if r.i > 0 {
n += copy(b[n:], r.b[:r.i])
}
r.i = (r.i + n) % len(r.b)
if n >= len(b) {
return n, nil
}
}
}

View File

@ -1,101 +0,0 @@
package garage
import (
"bytes"
"strconv"
"testing"
)
func TestInfiniteReader(t *testing.T) {
tests := []struct {
in []byte
size int
exp []string
}{
{
in: []byte("a"),
size: 1,
exp: []string{"a"},
},
{
in: []byte("ab"),
size: 1,
exp: []string{"a", "b"},
},
{
in: []byte("ab"),
size: 2,
exp: []string{"ab"},
},
{
in: []byte("ab"),
size: 3,
exp: []string{"aba", "bab"},
},
{
in: []byte("ab"),
size: 4,
exp: []string{"abab"},
},
{
in: []byte("ab"),
size: 5,
exp: []string{"ababa", "babab"},
},
{
in: []byte("abc"),
size: 1,
exp: []string{"a", "b", "c"},
},
{
in: []byte("abc"),
size: 2,
exp: []string{"ab", "ca", "bc"},
},
{
in: []byte("abc"),
size: 3,
exp: []string{"abc"},
},
{
in: []byte("abc"),
size: 4,
exp: []string{"abca", "bcab", "cabc"},
},
{
in: []byte("abc"),
size: 5,
exp: []string{"abcab", "cabca", "bcabc"},
},
}
for i, test := range tests {
t.Run(strconv.Itoa(i), func(t *testing.T) {
r := NewInfiniteReader(test.in)
buf := make([]byte, test.size)
assertRead := func(expBuf []byte) {
n, err := r.Read(buf)
if !bytes.Equal(buf, expBuf) {
t.Fatalf("expected bytes %q, got %q", expBuf, buf)
} else if n != len(buf) {
t.Fatalf("expected n %d, got %d", len(buf), n)
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
for i := 0; i < 3; i++ {
for _, expStr := range test.exp {
assertRead([]byte(expStr))
}
}
})
}
}

View File

@ -2,35 +2,32 @@ package garage
import ( import (
"crypto/ed25519" "crypto/ed25519"
"encoding/hex" "crypto/rand"
"fmt" "fmt"
"net" "net"
"strconv" "strconv"
) )
// Peer describes all information necessary to connect to a given garage node. // RemotePeer describes all information necessary to connect to a given garage
type Peer struct { // node.
type RemotePeer struct {
ID string
IP string IP string
RPCPort int RPCPort int
S3APIPort int S3APIPort int
} }
// RPCPeerKey deterministically generates a public/private keys which can // LocalPeer describes the configuration of a local garage instance.
// be used as a garage node key. type LocalPeer struct {
// RemotePeer
// DANGER: This function will deterministically produce public/private keys
// given some arbitrary input. This is NEVER what you want. It's only being used
// in cryptic-net for a very specific purpose for which I think it's ok and is
// very necessary, and people are probably _still_ going to yell at me.
//
func (p Peer) RPCPeerKey() (pubKey, privKey []byte) {
input := []byte(net.JoinHostPort(p.IP, strconv.Itoa(p.RPCPort)))
// Append the length of the input to the input, so that the input "foo" AdminPort int
// doesn't generate the same key as the input "foofoo". }
input = strconv.AppendInt(input, int64(len(input)), 10)
pubKey, privKey, err := ed25519.GenerateKey(NewInfiniteReader(input)) // GeneratePeerKey generates and returns a public/private key pair for a garage
// instance.
func GeneratePeerKey() (pubKey, privKey []byte) {
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -38,29 +35,23 @@ func (p Peer) RPCPeerKey() (pubKey, privKey []byte) {
return pubKey, privKey return pubKey, privKey
} }
// RPCPeerID returns the peer ID of the garage node for use in communicating
// over RPC.
//
// DANGER: See warning on RPCPeerKey.
func (p Peer) RPCPeerID() string {
pubKey, _ := p.RPCPeerKey()
return hex.EncodeToString(pubKey)
}
// RPCAddr returns the address of the peer's RPC port. // RPCAddr returns the address of the peer's RPC port.
func (p Peer) RPCAddr() string { func (p RemotePeer) RPCAddr() string {
return net.JoinHostPort(p.IP, strconv.Itoa(p.RPCPort)) return net.JoinHostPort(p.IP, strconv.Itoa(p.RPCPort))
} }
// RPCPeerAddr returns the full peer address (e.g. "id@ip:port") of the garage // RPCPeerAddr returns the full peer address (e.g. "id@ip:port") of the garage
// node for use in communicating over RPC. // node for use in communicating over RPC.
// func (p RemotePeer) RPCPeerAddr() string {
// DANGER: See warning on RPCPeerKey. return fmt.Sprintf("%s@%s", p.ID, p.RPCAddr())
func (p Peer) RPCPeerAddr() string {
return fmt.Sprintf("%s@%s", p.RPCPeerID(), p.RPCAddr())
} }
// S3APIAddr returns the address of the peer's S3 API port. // S3APIAddr returns the address of the peer's S3 API port.
func (p Peer) S3APIAddr() string { func (p RemotePeer) S3APIAddr() string {
return net.JoinHostPort(p.IP, strconv.Itoa(p.S3APIPort)) return net.JoinHostPort(p.IP, strconv.Itoa(p.S3APIPort))
} }
// AdminAddr returns the address of the peer's S3 API port.
func (p LocalPeer) AdminAddr() string {
return net.JoinHostPort(p.IP, strconv.Itoa(p.AdminPort))
}

View File

@ -17,7 +17,7 @@ type GarageTomlData struct {
AdminToken string AdminToken string
RPCAddr string RPCAddr string
APIAddr string S3APIAddr string
AdminAddr string AdminAddr string
BootstrapPeers []string BootstrapPeers []string
@ -39,7 +39,7 @@ bootstrap_peers = [{{- range .BootstrapPeers }}
{{ end -}}] {{ end -}}]
[s3_api] [s3_api]
api_bind_addr = "{{ .APIAddr }}" api_bind_addr = "{{ .S3APIAddr }}"
s3_region = "garage" s3_region = "garage"
[admin] [admin]

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