diff --git a/cmd/zb/config.go b/cmd/zb/config.go index e52fda2..86a8288 100644 --- a/cmd/zb/config.go +++ b/cmd/zb/config.go @@ -15,6 +15,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "time" @@ -73,12 +74,14 @@ func (g *globalConfig) clone() *globalConfig { return nil } g = new(*g) + g.TrustedPublicKeys = slices.Clone(g.TrustedPublicKeys) if g.Server.Download != nil { g.Server.Download = new(*g.Server.Download) } if g.Server.Upload != nil { g.Server.Upload = new(*g.Server.Upload) } + g.Server.KeyFiles = slices.Clone(g.Server.KeyFiles) return g } @@ -170,6 +173,19 @@ func (g *globalConfig) resolveRelativePaths(dir string, prev *globalConfig) { g.Server.Upload = g.Server.Upload.resolve(baseURL) } } + if prev == nil || !slices.Equal(g.Server.KeyFiles, prev.Server.KeyFiles) { + // If the previous slice is a prefix of the current slice, + // then only resolve the newly added paths. + toResolve := g.Server.KeyFiles + if len(g.Server.KeyFiles) > len(prev.Server.KeyFiles) && + slices.Equal(g.Server.KeyFiles[:len(prev.Server.KeyFiles)], prev.Server.KeyFiles) { + toResolve = g.Server.KeyFiles[len(prev.Server.KeyFiles):] + } + + for i, path := range toResolve { + toResolve[i] = resolve(path) + } + } } // UnmarshalJSONFrom unmarshals the configuration object from the JSON decoder, @@ -242,6 +258,9 @@ func (g *globalConfig) UnmarshalJSONFrom(in *jsontext.Decoder) error { if reject, _ := jsonv2.GetOption(in.Options(), jsonv2.RejectUnknownMembers); reject { return fmt.Errorf("unmarshal config: unknown field %q", k) } + if err := in.SkipValue(); err != nil { + return fmt.Errorf("unmarshal config.server: %w", err) + } } } } diff --git a/cmd/zb/config_test.go b/cmd/zb/config_test.go index 5ca3e26..b8a5bd3 100644 --- a/cmd/zb/config_test.go +++ b/cmd/zb/config_test.go @@ -31,7 +31,7 @@ func TestGlobalConfigMergeFiles(t *testing.T) { tests := []struct { name string files []string - want globalConfig + want func(dir string) *globalConfig }{ { name: "MergeScalar", @@ -39,9 +39,11 @@ func TestGlobalConfigMergeFiles(t *testing.T) { `{"debug": true, "storeDirectory": "/foo"}` + "\n", `{"storeDirectory": "/bar"}` + "\n", }, - want: globalConfig{ - Debug: true, - Directory: "/bar", + want: func(dir string) *globalConfig { + return &globalConfig{ + Debug: true, + Directory: "/bar", + } }, }, { @@ -50,10 +52,12 @@ func TestGlobalConfigMergeFiles(t *testing.T) { `{"allowEnvironment": ["FOO"]}` + "\n", `{"allowEnvironment": ["BAR"]}` + "\n", }, - want: globalConfig{ - AllowEnv: stringAllowList{ - set: sets.New("BAR"), - }, + want: func(dir string) *globalConfig { + return &globalConfig{ + AllowEnv: stringAllowList{ + set: sets.New("BAR"), + }, + } }, }, { @@ -62,8 +66,10 @@ func TestGlobalConfigMergeFiles(t *testing.T) { `{"allowEnvironment": ["FOO"]}` + "\n", `{"allowEnvironment": true}` + "\n", }, - want: globalConfig{ - AllowEnv: stringAllowList{all: true}, + want: func(dir string) *globalConfig { + return &globalConfig{ + AllowEnv: stringAllowList{all: true}, + } }, }, { @@ -72,26 +78,46 @@ func TestGlobalConfigMergeFiles(t *testing.T) { `{"trustedPublicKeys": [{"format": "ed25519", "publicKey": "+NMDNfvjCmdT9mLr9zadYQXwF/mPLsToMw36yX7w6HCVCSK9J2WsMGPCAT9U2Y959NFgAfdiSWGRvWbXYlGUcA=="}]}` + "\n", `{"trustedPublicKeys": [{"format": "foo", "publicKey": "YmFy"}]}` + "\n", }, - want: globalConfig{ - TrustedPublicKeys: []*zbstore.RealizationPublicKey{ - { - Format: "ed25519", - Data: []byte{ - 0xf8, 0xd3, 0x03, 0x35, 0xfb, 0xe3, 0x0a, 0x67, - 0x53, 0xf6, 0x62, 0xeb, 0xf7, 0x36, 0x9d, 0x61, - 0x05, 0xf0, 0x17, 0xf9, 0x8f, 0x2e, 0xc4, 0xe8, - 0x33, 0x0d, 0xfa, 0xc9, 0x7e, 0xf0, 0xe8, 0x70, - 0x95, 0x09, 0x22, 0xbd, 0x27, 0x65, 0xac, 0x30, - 0x63, 0xc2, 0x01, 0x3f, 0x54, 0xd9, 0x8f, 0x79, - 0xf4, 0xd1, 0x60, 0x01, 0xf7, 0x62, 0x49, 0x61, - 0x91, 0xbd, 0x66, 0xd7, 0x62, 0x51, 0x94, 0x70, + want: func(dir string) *globalConfig { + return &globalConfig{ + TrustedPublicKeys: []*zbstore.RealizationPublicKey{ + { + Format: "ed25519", + Data: []byte{ + 0xf8, 0xd3, 0x03, 0x35, 0xfb, 0xe3, 0x0a, 0x67, + 0x53, 0xf6, 0x62, 0xeb, 0xf7, 0x36, 0x9d, 0x61, + 0x05, 0xf0, 0x17, 0xf9, 0x8f, 0x2e, 0xc4, 0xe8, + 0x33, 0x0d, 0xfa, 0xc9, 0x7e, 0xf0, 0xe8, 0x70, + 0x95, 0x09, 0x22, 0xbd, 0x27, 0x65, 0xac, 0x30, + 0x63, 0xc2, 0x01, 0x3f, 0x54, 0xd9, 0x8f, 0x79, + 0xf4, 0xd1, 0x60, 0x01, 0xf7, 0x62, 0x49, 0x61, + 0x91, 0xbd, 0x66, 0xd7, 0x62, 0x51, 0x94, 0x70, + }, + }, + { + Format: "foo", + Data: []byte{0x62, 0x61, 0x72}, }, }, - { - Format: "foo", - Data: []byte{0x62, 0x61, 0x72}, + } + }, + }, + { + name: "MergeServerSigningKeyFiles", + files: []string{ + `{"server": {"signingKeyFiles": ["foo.json", "bar.json"]}}` + "\n", + `{"server": {"signingKeyFiles": ["baz.json"]}}` + "\n", + }, + want: func(dir string) *globalConfig { + return &globalConfig{ + Server: serverConfig{ + KeyFiles: []string{ + filepath.Join(dir, "foo.json"), + filepath.Join(dir, "bar.json"), + filepath.Join(dir, "baz.json"), + }, }, - }, + } }, }, } @@ -113,7 +139,7 @@ func TestGlobalConfigMergeFiles(t *testing.T) { if err != nil { t.Error("mergeFiles:", err) } - if diff := cmp.Diff(&test.want, got, globalConfigCompareOptions); diff != "" { + if diff := cmp.Diff(test.want(dir), got, globalConfigCompareOptions); diff != "" { t.Errorf("-want +got:\n%s", diff) } }) @@ -129,6 +155,7 @@ func FuzzConfigMarshal(f *testing.F) { f.Add([]byte(`{"trustedPublicKeys": [{"format": "ed25519", "publicKey": "+NMDNfvjCmdT9mLr9zadYQXwF/mPLsToMw36yX7w6HCVCSK9J2WsMGPCAT9U2Y959NFgAfdiSWGRvWbXYlGUcA=="}]}` + "\n")) f.Add([]byte(`{"trustedPublicKeys": [{"format": "foo", "publicKey": "YmFy"}]}`)) f.Add([]byte(`{"netrcFile": "/etc/netrc"}` + "\n")) + f.Add([]byte(`{"server": {"signingKeyFiles": ["secret-key.json"]}}` + "\n")) f.Fuzz(func(t *testing.T, in []byte) { init := defaultGlobalConfig() diff --git a/cmd/zb/serve.go b/cmd/zb/serve.go index 5f51713..4070a9a 100644 --- a/cmd/zb/serve.go +++ b/cmd/zb/serve.go @@ -21,6 +21,8 @@ import ( "time" "github.com/coreos/go-systemd/v22/activation" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" "golang.org/x/sync/errgroup" "zb.256lights.llc/pkg/bytebuffer" "zb.256lights.llc/pkg/internal/backend" @@ -40,11 +42,6 @@ import ( const contentAddressTempFilePattern = "zb-ca-*" -type serverConfig struct { - Download *storeConfig `json:"download"` - Upload *storeConfig `json:"upload"` -} - type serveCommand struct { storeDatabaseFlags `kong:"embed"` @@ -79,7 +76,10 @@ func (c *serveCommand) Run(ctx context.Context, g *globalConfig, drain drainSign } return fmt.Errorf("sandboxing requested but unable to use (are you running with admin privileges?)") } - keyring, err := readKeyringFromFiles(c.KeyFiles) + allKeyFiles := make([]string, 0, len(g.Server.KeyFiles)+len(c.KeyFiles)) + allKeyFiles = append(allKeyFiles, g.Server.KeyFiles...) + allKeyFiles = append(allKeyFiles, c.KeyFiles...) + keyring, err := readKeyringFromFiles(allKeyFiles) if err != nil { return err } @@ -386,6 +386,64 @@ func buildUsersForGroup(ctx context.Context, name string) (gid int, buildUsers [ return gid, buildUsers, nil } +type serverConfig struct { + Download *storeConfig `json:"download"` + Upload *storeConfig `json:"upload"` + KeyFiles []string `json:"signingKeyFiles"` +} + +// UnmarshalJSONFrom unmarshals the server configuration object from the JSON decoder, +// merging any fields in the JSON object with existing values. +func (sc *serverConfig) UnmarshalJSONFrom(in *jsontext.Decoder) error { + tok, err := in.ReadToken() + if err != nil { + return err + } + if got := tok.Kind(); got != '{' { + return fmt.Errorf("config must be an object not a %v", got) + } + + for { + keyToken, err := in.ReadToken() + if err != nil { + return err + } + switch kind := keyToken.Kind(); kind { + case '}': + return nil + case '"': + // Keep going. + default: + return fmt.Errorf("unexpected non-string key (%v) in object", kind) + } + + switch k := keyToken.String(); k { + case "download": + if err := jsonv2.UnmarshalDecode(in, &sc.Download); err != nil { + return fmt.Errorf("unmarshal config.server.download: %w", err) + } + case "upload": + if err := jsonv2.UnmarshalDecode(in, &sc.Upload); err != nil { + return fmt.Errorf("unmarshal config.server.upload: %w", err) + } + case "signingKeyFiles": + newKeyFiles := sc.KeyFiles[len(sc.KeyFiles):] + + if err := jsonv2.UnmarshalDecode(in, &newKeyFiles); err != nil { + return fmt.Errorf("unmarshal config.server.signingKeyFiles: %w", err) + } + sc.KeyFiles = append(sc.KeyFiles, newKeyFiles...) + default: + if reject, _ := jsonv2.GetOption(in.Options(), jsonv2.RejectUnknownMembers); reject { + return fmt.Errorf("unmarshal config.server: unknown field %q", k) + } + if err := in.SkipValue(); err != nil { + return fmt.Errorf("unmarshal config.server: %w", err) + } + } + } +} + type sandboxPathsFlags struct { SandboxPaths map[string]string `kong:"name=sandbox-path,type=pathmap,placeholder=path,help=Paths to allow in sandbox (can be passed multiple times)"` ImplicitSystemDeps sets.Set[string] `kong:"name=implicit-system-dep,placeholder=path,help=Paths to always mount in sandbox (can be passed multiple times)"`