From 56f796e3fbb562ad1fe6c6d6bf8f5f2e8a15eb1b Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sat, 13 Jul 2024 14:34:06 +0200 Subject: [PATCH] Implement basic secrets architecture, use it for garage RPC secret --- go/admin/admin.go | 1 - go/bootstrap/bootstrap.go | 4 -- go/bootstrap/garage.go | 27 ---------- go/cmd/entrypoint/garage.go | 11 ++-- go/daemon/child_garage.go | 24 +++++---- go/daemon/child_pmux.go | 11 +++- go/daemon/children.go | 16 +++++- go/daemon/daemon.go | 88 ++++++++++++++++++++++--------- go/daemon/env.go | 28 ---------- go/daemon/garage_client_params.go | 47 +++++++++++++++++ go/daemon/global_bucket.go | 28 ++++++---- go/daemon/jigs.go | 36 +++++++++++++ go/daemon/rpc.go | 15 ++---- go/garage/garage.go | 2 + go/garage/secrets.go | 20 +++++++ go/nebula/secrets.go | 14 +++++ go/secrets/secrets.go | 13 +++++ go/secrets/store.go | 66 +++++++++++++++++++++++ go/secrets/store_fs.go | 83 +++++++++++++++++++++++++++++ go/secrets/store_fs_test.go | 42 +++++++++++++++ 20 files changed, 455 insertions(+), 121 deletions(-) create mode 100644 go/daemon/garage_client_params.go create mode 100644 go/garage/secrets.go create mode 100644 go/nebula/secrets.go create mode 100644 go/secrets/secrets.go create mode 100644 go/secrets/store.go create mode 100644 go/secrets/store_fs.go create mode 100644 go/secrets/store_fs_test.go diff --git a/go/admin/admin.go b/go/admin/admin.go index 6bfe797..1e3bb79 100644 --- a/go/admin/admin.go +++ b/go/admin/admin.go @@ -25,7 +25,6 @@ type Admin struct { } Garage struct { - RPCSecret string GlobalBucketS3APICredentials garage.S3APICredentials } } diff --git a/go/bootstrap/bootstrap.go b/go/bootstrap/bootstrap.go index 9b87595..3f7c31e 100644 --- a/go/bootstrap/bootstrap.go +++ b/go/bootstrap/bootstrap.go @@ -30,10 +30,6 @@ func AppDirPath(appDirPath string) string { // Garage contains parameters needed to connect to and use the garage cluster. type Garage struct { - // TODO this should be part of some new configuration section related to - // secrets which may or may not be granted to this host - RPCSecret string - AdminToken string // TODO this should be part of admin.CreationParams diff --git a/go/bootstrap/garage.go b/go/bootstrap/garage.go index 921f639..e155205 100644 --- a/go/bootstrap/garage.go +++ b/go/bootstrap/garage.go @@ -4,24 +4,6 @@ import ( "isle/garage" ) -// GarageClientParams contains all the data needed to instantiate garage -// clients. -type GarageClientParams struct { - Peer garage.RemotePeer - GlobalBucketS3APICredentials garage.S3APICredentials - RPCSecret string -} - -// GlobalBucketS3APIClient returns an S3 client pre-configured with access to -// the global bucket. -func (p GarageClientParams) GlobalBucketS3APIClient() garage.S3APIClient { - var ( - addr = p.Peer.S3APIAddr() - creds = p.GlobalBucketS3APICredentials - ) - return garage.NewS3APIClient(addr, creds) -} - // GaragePeers returns a Peer for each known garage instance in the network. func (b Bootstrap) GaragePeers() []garage.RemotePeer { @@ -69,12 +51,3 @@ func (b Bootstrap) ChooseGaragePeer() garage.RemotePeer { panic("no garage instances configured") } - -// GarageClientParams returns a GarageClientParams. -func (b Bootstrap) GarageClientParams() GarageClientParams { - return GarageClientParams{ - Peer: b.ChooseGaragePeer(), - GlobalBucketS3APICredentials: b.Garage.GlobalBucketS3APICredentials, - RPCSecret: b.Garage.RPCSecret, - } -} diff --git a/go/cmd/entrypoint/garage.go b/go/cmd/entrypoint/garage.go index 093e21b..d789941 100644 --- a/go/cmd/entrypoint/garage.go +++ b/go/cmd/entrypoint/garage.go @@ -1,12 +1,13 @@ package main import ( + "errors" "fmt" "os" "path/filepath" "syscall" - "isle/bootstrap" + "isle/daemon" ) // minio-client keeps a configuration directory which contains various pieces of @@ -52,7 +53,7 @@ var subCmdGarageMC = subCmd{ return fmt.Errorf("parsing flags: %w", err) } - var clientParams bootstrap.GarageClientParams + var clientParams daemon.GarageClientParams err := subCmdCtx.daemonRCPClient.Call( subCmdCtx.ctx, &clientParams, "GetGarageClientParams", nil, ) @@ -119,7 +120,7 @@ var subCmdGarageCLI = subCmd{ descr: "Runs the garage binary, automatically configured to point to the garage sub-process of a running isle daemon", do: func(subCmdCtx subCmdCtx) error { - var clientParams bootstrap.GarageClientParams + var clientParams daemon.GarageClientParams err := subCmdCtx.daemonRCPClient.Call( subCmdCtx.ctx, &clientParams, "GetGarageClientParams", nil, ) @@ -127,6 +128,10 @@ var subCmdGarageCLI = subCmd{ return fmt.Errorf("calling GetGarageClientParams: %w", err) } + if clientParams.RPCSecret == "" { + return errors.New("this host does not have the garage RPC secret") + } + var ( binPath = binPath("garage") args = append([]string{"garage"}, subCmdCtx.args...) diff --git a/go/daemon/child_garage.go b/go/daemon/child_garage.go index ba28c84..43da3a0 100644 --- a/go/daemon/child_garage.go +++ b/go/daemon/child_garage.go @@ -101,7 +101,7 @@ func bootstrapGarageHostForAlloc( } func garageWriteChildConfig( - runtimeDirPath string, + rpcSecret, runtimeDirPath string, hostBootstrap bootstrap.Bootstrap, alloc ConfigStorageAllocation, ) ( @@ -129,7 +129,7 @@ func garageWriteChildConfig( MetaPath: alloc.MetaPath, DataPath: alloc.DataPath, - RPCSecret: hostBootstrap.Garage.RPCSecret, + RPCSecret: rpcSecret, AdminToken: hostBootstrap.Garage.AdminToken, LocalPeer: peer, @@ -144,20 +144,28 @@ func garageWriteChildConfig( } func garagePmuxProcConfigs( + ctx context.Context, logger *mlog.Logger, - runtimeDirPath, binDirPath string, + rpcSecret, runtimeDirPath, binDirPath string, daemonConfig Config, hostBootstrap bootstrap.Bootstrap, ) ( []pmuxlib.ProcessConfig, error, ) { + var ( + pmuxProcConfigs []pmuxlib.ProcessConfig + allocs = daemonConfig.Storage.Allocations + ) - var pmuxProcConfigs []pmuxlib.ProcessConfig + if len(allocs) > 0 && rpcSecret == "" { + logger.WarnString(ctx, "Not starting garage instances for storage allocations, missing garage RPC secret") + return nil, nil + } - for _, alloc := range daemonConfig.Storage.Allocations { + for _, alloc := range allocs { childConfigPath, err := garageWriteChildConfig( - runtimeDirPath, hostBootstrap, alloc, + rpcSecret, runtimeDirPath, hostBootstrap, alloc, ) if err != nil { return nil, fmt.Errorf("writing child config file for alloc %+v: %w", alloc, err) @@ -176,9 +184,7 @@ func garagePmuxProcConfigs( return pmuxProcConfigs, nil } -// TODO don't expose this publicly once cluster creation is done via Daemon -// interface. -func GarageApplyLayout( +func garageApplyLayout( ctx context.Context, logger *mlog.Logger, daemonConfig Config, diff --git a/go/daemon/child_pmux.go b/go/daemon/child_pmux.go index 0a5b702..117017f 100644 --- a/go/daemon/child_pmux.go +++ b/go/daemon/child_pmux.go @@ -9,8 +9,13 @@ import ( ) func (c *Children) newPmuxConfig( - binDirPath string, daemonConfig Config, hostBootstrap bootstrap.Bootstrap, -) (pmuxlib.Config, error) { + ctx context.Context, + garageRPCSecret, binDirPath string, + daemonConfig Config, + hostBootstrap bootstrap.Bootstrap, +) ( + pmuxlib.Config, error, +) { nebulaPmuxProcConfig, err := nebulaPmuxProcConfig( c.opts.EnvVars.RuntimeDirPath, binDirPath, @@ -34,7 +39,9 @@ func (c *Children) newPmuxConfig( } garagePmuxProcConfigs, err := garagePmuxProcConfigs( + ctx, c.logger, + garageRPCSecret, c.opts.EnvVars.RuntimeDirPath, binDirPath, daemonConfig, diff --git a/go/daemon/children.go b/go/daemon/children.go index 2df1712..16bdf99 100644 --- a/go/daemon/children.go +++ b/go/daemon/children.go @@ -2,8 +2,11 @@ package daemon import ( "context" + "errors" "fmt" "isle/bootstrap" + "isle/garage" + "isle/secrets" "code.betamike.com/micropelago/pmux/pmuxlib" "dev.mediocregopher.com/mediocre-go-lib.git/mlog" @@ -27,15 +30,22 @@ type Children struct { func NewChildren( ctx context.Context, logger *mlog.Logger, + binDirPath string, + secretsStore secrets.Store, daemonConfig Config, hostBootstrap bootstrap.Bootstrap, - binDirPath string, opts *Opts, ) ( *Children, error, ) { opts = opts.withDefaults() + logger.Info(ctx, "Loading secrets") + garageRPCSecret, err := garage.GetRPCSecret(ctx, secretsStore) + if err != nil && !errors.Is(err, secrets.ErrNotFound) { + return nil, fmt.Errorf("loading garage RPC secret: %w", err) + } + pmuxCtx, pmuxCancelFn := context.WithCancel(context.Background()) c := &Children{ @@ -45,7 +55,9 @@ func NewChildren( pmuxStoppedCh: make(chan struct{}), } - pmuxConfig, err := c.newPmuxConfig(binDirPath, daemonConfig, hostBootstrap) + pmuxConfig, err := c.newPmuxConfig( + ctx, garageRPCSecret, binDirPath, daemonConfig, hostBootstrap, + ) if err != nil { return nil, fmt.Errorf("generating pmux config: %w", err) } diff --git a/go/daemon/daemon.go b/go/daemon/daemon.go index a4c83f0..128d4f7 100644 --- a/go/daemon/daemon.go +++ b/go/daemon/daemon.go @@ -13,8 +13,10 @@ import ( "isle/bootstrap" "isle/garage" "isle/nebula" + "isle/secrets" "net/netip" "os" + "path/filepath" "sync" "time" @@ -53,6 +55,10 @@ type Daemon interface { // GetBootstraps returns the currently active Bootstrap. GetBootstrap(context.Context) (bootstrap.Bootstrap, error) + // GetGarageClientParams returns a GarageClientParams for the current + // network state. + GetGarageClientParams(context.Context) (GarageClientParams, error) + // RemoveHost removes the host of the given name from the network. RemoveHost(context.Context, nebula.HostName) error @@ -134,6 +140,8 @@ type daemon struct { envBinDirPath string opts *Opts + secretsStore secrets.Store + l sync.RWMutex state int children *Children @@ -181,6 +189,17 @@ func NewDaemon( return nil, fmt.Errorf("initializing daemon directories: %w", err) } + var ( + secretsPath = filepath.Join(d.opts.EnvVars.StateDirPath, "secrets") + err error + ) + + if d.secretsStore, err = secrets.NewFSStore(secretsPath); err != nil { + return nil, fmt.Errorf( + "initializing secrets store at %q: %w", secretsPath, err, + ) + } + currBootstrap, err := bootstrap.FromFile(bootstrapFilePath) if errors.Is(err, fs.ErrNotExist) { // daemon has never had a network created or joined @@ -276,7 +295,7 @@ func (d *daemon) checkBootstrap( thisHost := hostBootstrap.ThisHost() - newHosts, err := getGarageBootstrapHosts(ctx, d.logger, hostBootstrap) + newHosts, err := d.getGarageBootstrapHosts(ctx, d.logger, hostBootstrap) if err != nil { return bootstrap.Bootstrap{}, false, fmt.Errorf("getting hosts from garage: %w", err) } @@ -348,7 +367,7 @@ func (d *daemon) postInit(ctx context.Context) bool { d.logger, "Applying garage layout", func(ctx context.Context) error { - return GarageApplyLayout( + return garageApplyLayout( ctx, d.logger, d.daemonConfig, d.currBootstrap, ) }, @@ -402,7 +421,7 @@ func (d *daemon) postInit(ctx context.Context) bool { d.logger, "Updating host info in garage", func(ctx context.Context) error { - return putGarageBoostrapHost(ctx, d.logger, d.currBootstrap) + return d.putGarageBoostrapHost(ctx, d.logger, d.currBootstrap) }, ) { return false @@ -437,15 +456,16 @@ func (d *daemon) restartLoop(ctx context.Context, readyCh chan<- struct{}) { children, err := NewChildren( ctx, d.logger.WithNamespace("children"), + d.envBinDirPath, + d.secretsStore, d.daemonConfig, d.currBootstrap, - d.envBinDirPath, d.opts, ) if errors.Is(err, context.Canceled) { return } else if err != nil { - d.logger.Error(ctx, "failed to initialize daemon", err) + d.logger.Error(ctx, "failed to initialize child processes", err) if !wait(1 * time.Second) { return } @@ -502,17 +522,25 @@ func (d *daemon) CreateNetwork( return admin.Admin{}, fmt.Errorf("creating nebula CA cert: %w", err) } - adm := admin.Admin{ - CreationParams: admin.CreationParams{ - ID: randStr(32), - Name: name, - Domain: domain, - }, - } + var ( + adm = admin.Admin{ + CreationParams: admin.CreationParams{ + ID: randStr(32), + Name: name, + Domain: domain, + }, + } - garageBootstrap := bootstrap.Garage{ - RPCSecret: randStr(32), - AdminToken: randStr(32), + garageBootstrap = bootstrap.Garage{ + AdminToken: randStr(32), + } + ) + + garageRPCSecret := randStr(32) + + err = garage.SetRPCSecret(ctx, d.secretsStore, garageRPCSecret) + if err != nil { + return admin.Admin{}, fmt.Errorf("storing garage RPC secret: %w", err) } hostBootstrap, err := bootstrap.New( @@ -563,7 +591,6 @@ func (d *daemon) CreateNetwork( d.l.RUnlock() adm.Nebula.CACredentials = nebulaCACreds - adm.Garage.RPCSecret = hostBootstrap.Garage.RPCSecret adm.Garage.GlobalBucketS3APICredentials = garageGlobalBucketCreds return adm, nil @@ -605,6 +632,20 @@ func (d *daemon) GetBootstrap(ctx context.Context) (bootstrap.Bootstrap, error) }) } +func (d *daemon) GetGarageClientParams( + ctx context.Context, +) ( + GarageClientParams, error, +) { + return withCurrBootstrap(d, func( + currBootstrap bootstrap.Bootstrap, + ) ( + GarageClientParams, error, + ) { + return d.getGarageClientParams(ctx, currBootstrap) + }) +} + func (d *daemon) RemoveHost(ctx context.Context, hostName nebula.HostName) error { // TODO RemoveHost should publish a certificate revocation for the host // being removed. @@ -613,7 +654,12 @@ func (d *daemon) RemoveHost(ctx context.Context, hostName nebula.HostName) error ) ( struct{}, error, ) { - client := currBootstrap.GarageClientParams().GlobalBucketS3APIClient() + garageClientParams, err := d.getGarageClientParams(ctx, currBootstrap) + if err != nil { + return struct{}{}, fmt.Errorf("get garage client params: %w", err) + } + + client := garageClientParams.GlobalBucketS3APIClient() return struct{}{}, removeGarageBootstrapHost(ctx, client, hostName) }) return err @@ -642,15 +688,9 @@ func (d *daemon) CreateHost( ) ( bootstrap.Bootstrap, error, ) { - var ( - garageRPCSecret = currBootstrap.Garage.RPCSecret - garageGlobalBucketS3APICreds = currBootstrap.Garage.GlobalBucketS3APICredentials - ) - - // TODO check if garageRPCSecret is actually set + garageGlobalBucketS3APICreds := currBootstrap.Garage.GlobalBucketS3APICredentials garageBootstrap := bootstrap.Garage{ - RPCSecret: garageRPCSecret, AdminToken: randStr(32), GlobalBucketS3APICredentials: garageGlobalBucketS3APICreds, } diff --git a/go/daemon/env.go b/go/daemon/env.go index 3da1b92..f0498d2 100644 --- a/go/daemon/env.go +++ b/go/daemon/env.go @@ -22,34 +22,6 @@ type EnvVars struct { func (e EnvVars) init() error { var errs []error - mkDir := func(path string) error { - { - parentPath := filepath.Dir(path) - parentInfo, err := os.Stat(parentPath) - if err != nil { - return fmt.Errorf("checking parent path %q: %w", parentPath, err) - } else if !parentInfo.IsDir() { - return fmt.Errorf("%q is not a directory", parentPath) - } - } - - info, err := os.Stat(path) - if errors.Is(err, fs.ErrNotExist) { - // fine - } else if err != nil { - return fmt.Errorf("checking path: %w", err) - } else if !info.IsDir() { - return fmt.Errorf("path is not a directory") - } else { - return nil - } - - if err := os.Mkdir(path, 0700); err != nil { - return fmt.Errorf("creating directory: %w", err) - } - return nil - } - if err := mkDir(e.RuntimeDirPath); err != nil { errs = append(errs, fmt.Errorf( "creating runtime directory %q: %w", diff --git a/go/daemon/garage_client_params.go b/go/daemon/garage_client_params.go new file mode 100644 index 0000000..c853181 --- /dev/null +++ b/go/daemon/garage_client_params.go @@ -0,0 +1,47 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "isle/bootstrap" + "isle/garage" + "isle/secrets" +) + +// GarageClientParams contains all the data needed to instantiate garage +// clients. +type GarageClientParams struct { + Peer garage.RemotePeer + GlobalBucketS3APICredentials garage.S3APICredentials + + // RPCSecret may be empty, if the secret is not available on the host. + RPCSecret string +} + +func (d *daemon) getGarageClientParams( + ctx context.Context, currBootstrap bootstrap.Bootstrap, +) ( + GarageClientParams, error, +) { + rpcSecret, err := garage.GetRPCSecret(ctx, d.secretsStore) + if err != nil && !errors.Is(err, secrets.ErrNotFound) { + return GarageClientParams{}, fmt.Errorf("getting garage rpc secret: %w", err) + } + + return GarageClientParams{ + Peer: currBootstrap.ChooseGaragePeer(), + GlobalBucketS3APICredentials: currBootstrap.Garage.GlobalBucketS3APICredentials, + RPCSecret: rpcSecret, + }, nil +} + +// GlobalBucketS3APIClient returns an S3 client pre-configured with access to +// the global bucket. +func (p GarageClientParams) GlobalBucketS3APIClient() garage.S3APIClient { + var ( + addr = p.Peer.S3APIAddr() + creds = p.GlobalBucketS3APICredentials + ) + return garage.NewS3APIClient(addr, creds) +} diff --git a/go/daemon/global_bucket.go b/go/daemon/global_bucket.go index d663087..eeb96bb 100644 --- a/go/daemon/global_bucket.go +++ b/go/daemon/global_bucket.go @@ -62,24 +62,28 @@ func garageInitializeGlobalBucket( // putGarageBoostrapHost places the .json.signed file for this host // into garage so that other hosts are able to see relevant configuration for // it. -func putGarageBoostrapHost( +func (d *daemon) putGarageBoostrapHost( ctx context.Context, logger *mlog.Logger, currBootstrap bootstrap.Bootstrap, ) error { + garageClientParams, err := d.getGarageClientParams(ctx, currBootstrap) + if err != nil { + return fmt.Errorf("getting garage client params: %w", err) + } + var ( - b = currBootstrap - host = b.ThisHost() - client = b.GarageClientParams().GlobalBucketS3APIClient() + host = currBootstrap.ThisHost() + client = garageClientParams.GlobalBucketS3APIClient() ) configured, err := nebula.Sign( - host.HostConfigured, b.PrivateCredentials.SigningPrivateKey, + host.HostConfigured, currBootstrap.PrivateCredentials.SigningPrivateKey, ) if err != nil { return fmt.Errorf("signing host configured data: %w", err) } hostB, err := json.Marshal(bootstrap.AuthenticatedHost{ - Assigned: b.SignedHostAssigned, + Assigned: currBootstrap.SignedHostAssigned, Configured: configured, }) if err != nil { @@ -107,14 +111,18 @@ func putGarageBoostrapHost( return nil } -func getGarageBootstrapHosts( +func (d *daemon) getGarageBootstrapHosts( ctx context.Context, logger *mlog.Logger, currBootstrap bootstrap.Bootstrap, ) ( map[nebula.HostName]bootstrap.Host, error, ) { + garageClientParams, err := d.getGarageClientParams(ctx, currBootstrap) + if err != nil { + return nil, fmt.Errorf("getting garage client params: %w", err) + } + var ( - b = currBootstrap - client = b.GarageClientParams().GlobalBucketS3APIClient() + client = garageClientParams.GlobalBucketS3APIClient() hosts = map[nebula.HostName]bootstrap.Host{} objInfoCh = client.ListObjects( @@ -152,7 +160,7 @@ func getGarageBootstrapHosts( continue } - host, err := authedHost.Unwrap(b.CAPublicCredentials) + host, err := authedHost.Unwrap(currBootstrap.CAPublicCredentials) if err != nil { logger.Warn(ctx, "Host could not be authenticated", err) } diff --git a/go/daemon/jigs.go b/go/daemon/jigs.go index f02fc99..e44693a 100644 --- a/go/daemon/jigs.go +++ b/go/daemon/jigs.go @@ -4,6 +4,11 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" "time" "dev.mediocregopher.com/mediocre-go-lib.git/mlog" @@ -38,3 +43,34 @@ func randStr(l int) string { } return hex.EncodeToString(b) } + +// mkDir is like os.Mkdir but it returns better error messages. If the directory +// already exists then nil is returned. +func mkDir(path string) error { + { + parentPath := filepath.Dir(path) + parentInfo, err := os.Stat(parentPath) + if err != nil { + return fmt.Errorf("checking fs node of parent %q: %w", parentPath, err) + } else if !parentInfo.IsDir() { + return fmt.Errorf("%q is not a directory", parentPath) + } + } + + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + // fine + } else if err != nil { + return fmt.Errorf("checking fs node: %w", err) + } else if !info.IsDir() { + return fmt.Errorf("exists but is not a directory") + } else { + return nil + } + + if err := os.Mkdir(path, 0700); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + return nil +} diff --git a/go/daemon/rpc.go b/go/daemon/rpc.go index 15c9912..c80c001 100644 --- a/go/daemon/rpc.go +++ b/go/daemon/rpc.go @@ -87,21 +87,14 @@ func (r *RPC) GetHosts( return GetHostsResult{hosts}, nil } -// GetGarageClientParams returns a GarageClientParams which can be used to -// interact with garage. +// GetGarageClientParams passes the call through to the Daemon method of the +// same name. func (r *RPC) GetGarageClientParams( ctx context.Context, req struct{}, ) ( - bootstrap.GarageClientParams, error, + GarageClientParams, error, ) { - b, err := r.daemon.GetBootstrap(ctx) - if err != nil { - return bootstrap.GarageClientParams{}, fmt.Errorf( - "retrieving bootstrap: %w", err, - ) - } - - return b.GarageClientParams(), nil + return r.daemon.GetGarageClientParams(ctx) } // GetNebulaCAPublicCredentials returns the CAPublicCredentials for the network. diff --git a/go/garage/garage.go b/go/garage/garage.go index 729a451..b5bcb4b 100644 --- a/go/garage/garage.go +++ b/go/garage/garage.go @@ -11,6 +11,8 @@ const ( // accessible to all hosts in the network. GlobalBucket = "global-shared" + // GlobalBucketS3APICredentialsName is the main alias of the shared API key + // used to write to the global bucket. GlobalBucketS3APICredentialsName = "global-shared-key" // ReplicationFactor indicates the replication factor set on the garage diff --git a/go/garage/secrets.go b/go/garage/secrets.go new file mode 100644 index 0000000..d5a85f9 --- /dev/null +++ b/go/garage/secrets.go @@ -0,0 +1,20 @@ +package garage + +import "isle/secrets" + +var ( + rpcSecretSecretID = secrets.NewID("garage", "rpc-secret") + globalBucketS3APICredentialsSecretID = secrets.NewID("garage", "global-bucket-s3-api-credentials") +) + +// Get/Set functions for garage-related secrets. +var ( + GetRPCSecret, SetRPCSecret = secrets.GetSetFunctions[string]( + rpcSecretSecretID, + ) + + GetGlobalBucketS3APICredentials, + SetGlobalBucketS3APICredentials = secrets.GetSetFunctions[S3APICredentials]( + globalBucketS3APICredentialsSecretID, + ) +) diff --git a/go/nebula/secrets.go b/go/nebula/secrets.go new file mode 100644 index 0000000..8b2a0ff --- /dev/null +++ b/go/nebula/secrets.go @@ -0,0 +1,14 @@ +package nebula + +import ( + "isle/secrets" +) + +var ( + caSigningPrivateKeySecretID = secrets.NewID("nebula", "ca-signing-private-key") +) + +// Get/Set functions for the CA SigningPrivateKey secret. +var GetCASigningPrivateKey, SetCASigningPrivateKey = secrets.GetSetFunctions[SigningPrivateKey]( + caSigningPrivateKeySecretID, +) diff --git a/go/secrets/secrets.go b/go/secrets/secrets.go new file mode 100644 index 0000000..52811df --- /dev/null +++ b/go/secrets/secrets.go @@ -0,0 +1,13 @@ +// Package secrets manages the storage and distributions of secret values that +// hosts need to perform various actions. +package secrets + +import "fmt" + +// ID is a unique identifier for a Secret. +type ID string + +// NewID returns a new ID within the given namespace. +func NewID(namespace, id string) ID { + return ID(fmt.Sprintf("%s-%s", namespace, id)) +} diff --git a/go/secrets/store.go b/go/secrets/store.go new file mode 100644 index 0000000..8b65061 --- /dev/null +++ b/go/secrets/store.go @@ -0,0 +1,66 @@ +package secrets + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotFound is returned when an ID could not be found. +var ErrNotFound = errors.New("not found") + +// Store is used to persist and retrieve secrets. If a Store serializes a +// payload it will do so using JSON. +type Store interface { + // Set stores the secret payload of the given ID. + Set(context.Context, ID, any) error + + // Get retrieves the secret of the given ID, setting it into the given + // pointer value, or returns ErrNotFound. + Get(context.Context, any, ID) error +} + +// GetSetFunctions returns a Get/Set function pair for the given ID and payload +// type. +func GetSetFunctions[T any]( + id ID, +) ( + func(context.Context, Store) (T, error), // Get + func(context.Context, Store, T) error, // Set +) { + var ( + get = func(ctx context.Context, store Store) (T, error) { + var v T + err := store.Get(ctx, &v, id) + return v, err + } + set = func(ctx context.Context, store Store, v T) error { + return store.Set(ctx, id, v) + } + ) + return get, set +} + +// MultiSet will call Set on the given Store for every key-value pair in the +// given map. +func MultiSet(ctx context.Context, s Store, m map[ID]any) error { + var errs []error + for id, payload := range m { + if err := s.Set(ctx, id, payload); err != nil { + errs = append(errs, fmt.Errorf("setting payload for %q: %w", id, err)) + } + } + return errors.Join(errs...) +} + +// MultiGet will call Get on the given Store for every key-value pair in the +// given map. Each value in the map must be a pointer receiver. +func MultiGet(ctx context.Context, s Store, m map[ID]any) error { + var errs []error + for id, into := range m { + if err := s.Get(ctx, into, id); err != nil { + errs = append(errs, fmt.Errorf("getting payload for %q: %w", id, err)) + } + } + return errors.Join(errs...) +} diff --git a/go/secrets/store_fs.go b/go/secrets/store_fs.go new file mode 100644 index 0000000..4571c42 --- /dev/null +++ b/go/secrets/store_fs.go @@ -0,0 +1,83 @@ +package secrets + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +type fsStore struct { + dirPath string +} + +type fsStorePayload[Body any] struct { + Version int + Body Body +} + +// NewFSStore returns a Store which will store secrets to the given directory. +func NewFSStore(dirPath string) (Store, error) { + err := os.Mkdir(dirPath, 0700) + if err != nil && !errors.Is(err, fs.ErrExist) { + return nil, fmt.Errorf("making directory: %w", err) + } + return &fsStore{dirPath}, nil +} + +func (s *fsStore) path(id ID) string { + return filepath.Join(s.dirPath, string(id)) +} + +func (s *fsStore) Set(_ context.Context, id ID, payload any) error { + path := s.path(id) + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("creating file %q: %w", path, err) + } + defer f.Close() + + if err := json.NewEncoder(f).Encode(fsStorePayload[any]{ + Version: 1, + Body: payload, + }); err != nil { + return fmt.Errorf("writing JSON encoded payload to %q: %w", path, err) + } + + return nil +} + +func (s *fsStore) Get(_ context.Context, into any, id ID) error { + path := s.path(id) + + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return ErrNotFound + } else if err != nil { + return fmt.Errorf("creating file %q: %w", path, err) + } + defer f.Close() + + var fullPayload fsStorePayload[json.RawMessage] + if err := json.NewDecoder(f).Decode(&fullPayload); err != nil { + return fmt.Errorf("decoding JSON payload from %q: %w", path, err) + } + + if fullPayload.Version != 1 { + return fmt.Errorf( + "unexpected JSON payload version %d", fullPayload.Version, + ) + } + + if err := json.Unmarshal(fullPayload.Body, into); err != nil { + return fmt.Errorf( + "decoding JSON payload body from %q into %T: %w", path, into, err, + ) + } + + return nil +} diff --git a/go/secrets/store_fs_test.go b/go/secrets/store_fs_test.go new file mode 100644 index 0000000..cae9876 --- /dev/null +++ b/go/secrets/store_fs_test.go @@ -0,0 +1,42 @@ +package secrets + +import ( + "context" + "errors" + "testing" +) + +func Test_fsStore(t *testing.T) { + type payload struct { + Foo int + } + + var ( + ctx = context.Background() + dir = t.TempDir() + id = NewID("testing", "a") + ) + + store, err := NewFSStore(dir) + if err != nil { + t.Fatal(err) + } + + var got payload + if err := store.Get(ctx, &got, id); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected %v, got: %v", ErrNotFound, err) + } + + want := payload{Foo: 5} + if err := store.Set(ctx, id, want); err != nil { + t.Fatal(err) + } + + if err := store.Get(ctx, &got, id); err != nil { + t.Fatal(err) + } + + if want != got { + t.Fatalf("wanted %+v, got: %+v", want, got) + } +}