feat(config): per-service operator config + secrets (theme M1)
Services can declare an operator-configurable config block in /_manifest
(ordered array of single-key objects, pure schema: type cleartext|password,
description?, key = container env var name). Maestro:
- lib/secrets: AES-256-GCM FileStore; dedicated secrets.key (32 bytes,
chmod 600, separate from session.key); secrets.json rows keyed
service/field; cleartext read-write, password write-only & encrypted at
rest, decrypted only at injection. Footure key rotation = delete the key.
- handlers/config (owner-gated via auth.RequireSession):
GET /api/services/{service}/config (merged schema+set view, password
values never returned), PUT/DELETE /api/services/{service}/config/{key}
(uniform ERR_NOT_FOUND / ERR_INVALID_PAYLOAD error shape).
- start.go: injectConfigSecrets runs after injectServiceIdentity; only
operator-set rows become env on non-oneoff services; unset fields keep
the compose default.
- Manifest.Config carried onto Container/RegistryContainer/InstalledMetadata
via updateInstalledMetadataFromManifest + watcher.
- Frontend: per-service Settings dialog (ServiceConfigDialog) + api
helpers; cleartext editable, password set/unset indicator only.
- spec.yaml documents the new endpoints/schemas.
This commit is contained in:
@@ -69,6 +69,7 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
|
||||
Capabilities: metadata.Capabilities,
|
||||
HasAuth: metadata.HasAuth,
|
||||
PublicKey: metadata.PublicKey,
|
||||
Config: metadata.Config,
|
||||
Title: metadata.Title,
|
||||
Vendor: metadata.Vendor,
|
||||
Author: metadata.Author,
|
||||
@@ -81,12 +82,12 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateInstalledMetadataFromManifest persists the capabilities, has_auth flag
|
||||
// and public key reported by a service manifest so installed-but-stopped
|
||||
// services can still surface them (e.g. the database chip, auth consumption)
|
||||
// without running.
|
||||
// updateInstalledMetadataFromManifest persists the capabilities, has_auth flag,
|
||||
// public key and config block reported by a service manifest so
|
||||
// installed-but-stopped services can still surface them (e.g. the database
|
||||
// chip, auth consumption, the config settings list) without running.
|
||||
func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest) {
|
||||
if m == nil || len(m.Capabilities) == 0 {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
dir, err := getContainerDataDir(serviceName)
|
||||
@@ -100,6 +101,9 @@ func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest)
|
||||
metadata.Capabilities = m.Capabilities
|
||||
metadata.HasAuth = m.HasAuth
|
||||
metadata.PublicKey = m.PublicKey
|
||||
if m.Config != nil {
|
||||
metadata.Config = m.Config
|
||||
}
|
||||
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
|
||||
logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
@@ -73,6 +74,13 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
if err := injectServiceIdentity(ctx, dc, project, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
// Operator-configured env (Service Config & Secrets theme M1): inject the
|
||||
// values the owner set through the Settings API for this service. Rows only
|
||||
// exist for fields the operator configured; unset fields keep the compose
|
||||
// default untouched.
|
||||
if err := injectConfigSecrets(project, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The compose file may reference the image by a short name (e.g.
|
||||
// "testservice") while install pulled it under its full registry reference.
|
||||
@@ -213,6 +221,57 @@ func mtlsDatabaseURL(serviceName string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// injectConfigSecrets injects the operator-configured env values (Service
|
||||
// Config & Secrets theme M1) into every non-oneoff compose service. Values
|
||||
// come from the encrypted secrets store (password rows are decrypted here, in
|
||||
// memory, only for the container); cleartext rows are stored plaintext.
|
||||
//
|
||||
// A row only exists for a field the operator actually configured, so unset
|
||||
// fields leave the compose file's own environment untouched — the operator-set
|
||||
// value wins, the compose default stays otherwise. Password rows decrypt once
|
||||
// in this pass and never reach logs or error strings.
|
||||
func injectConfigSecrets(project *composetypes.Project, serviceName string) error {
|
||||
dataRoot, err := getDataRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve data root: %w", err)
|
||||
}
|
||||
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open secrets store: %w", err)
|
||||
}
|
||||
|
||||
rows := store.All(serviceName)
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := make(map[string]string, len(rows))
|
||||
for _, view := range rows {
|
||||
row, found, err := store.Lookup(serviceName, view.Field)
|
||||
if err != nil {
|
||||
logger.Warn("Skipping config %s/%s: decrypt failed (secrets.key rotated?): %v", serviceName, view.Field, err)
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
env[row.Key] = row.Value
|
||||
}
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for name, s := range project.Services {
|
||||
if isOneoff(s) {
|
||||
continue
|
||||
}
|
||||
s.Environment = mergeEnv(s.Environment, env)
|
||||
project.Services[name] = s
|
||||
}
|
||||
logger.Info("Injected %d operator config values for %s", len(env), serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasDatabaseCapability reports whether the installed service declared the
|
||||
// database capability in its manifest (persisted at install/reconcile).
|
||||
func hasDatabaseCapability(serviceName string) bool {
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
func TestHostBindSource(t *testing.T) {
|
||||
@@ -37,3 +42,92 @@ func TestHostBindSource(t *testing.T) {
|
||||
t.Fatalf("ca path must be remapped too, got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectConfigSecrets covers the operator-config env injection (Service
|
||||
// Config & Secrets theme M1): set rows are injected into non-oneoff services
|
||||
// (passwords decrypted), one-off helpers are skipped, and fields without a
|
||||
// stored row never clobber the compose default.
|
||||
func TestInjectConfigSecrets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", root)
|
||||
dataRoot := filepath.Join(root, ".dcntrlized")
|
||||
|
||||
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore: %v", err)
|
||||
}
|
||||
if _, err := store.Set("svc", "owner_email", types.ConfigField{Type: types.ConfigCleartext, Key: "OWNER_EMAIL"}, "[email protected]"); err != nil {
|
||||
t.Fatalf("set cleartext: %v", err)
|
||||
}
|
||||
if _, err := store.Set("svc", "db_password", types.ConfigField{Type: types.ConfigPassword, Key: "DB_PASSWORD"}, "s3cr3t"); err != nil {
|
||||
t.Fatalf("set password: %v", err)
|
||||
}
|
||||
|
||||
strPtr := func(v string) *string { return &v }
|
||||
project := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
|
||||
"app": {
|
||||
Environment: map[string]*string{
|
||||
"DB_USER": strPtr("svc"),
|
||||
"UNSET_CFG": strPtr("compose-default"),
|
||||
},
|
||||
},
|
||||
"migrate": {
|
||||
CustomLabels: map[string]string{"org.dcntrlized.oneoff": "true"},
|
||||
Environment: map[string]*string{},
|
||||
},
|
||||
}}
|
||||
|
||||
if err := injectConfigSecrets(project, "svc"); err != nil {
|
||||
t.Fatalf("injectConfigSecrets: %v", err)
|
||||
}
|
||||
|
||||
app := project.Services["app"]
|
||||
if got := envValue(app.Environment, "OWNER_EMAIL"); got != "[email protected]" {
|
||||
t.Errorf("OWNER_EMAIL = %q, want [email protected]", got)
|
||||
}
|
||||
if got := envValue(app.Environment, "DB_PASSWORD"); got != "s3cr3t" {
|
||||
t.Errorf("DB_PASSWORD = %q, want decrypted s3cr3t", got)
|
||||
}
|
||||
// Explicit compose values stay intact unless the operator set the field.
|
||||
if got := envValue(app.Environment, "DB_USER"); got != "svc" {
|
||||
t.Errorf("DB_USER clobbered: %q", got)
|
||||
}
|
||||
if got := envValue(app.Environment, "UNSET_CFG"); got != "compose-default" {
|
||||
t.Errorf("UNSET_CFG clobbered: %q, want compose-default", got)
|
||||
}
|
||||
|
||||
// One-off helpers never receive app config env.
|
||||
migrate := project.Services["migrate"]
|
||||
if len(migrate.Environment) != 0 {
|
||||
t.Errorf("one-off migrate got config env: %v", migrate.Environment)
|
||||
}
|
||||
}
|
||||
|
||||
func envValue(env map[string]*string, key string) string {
|
||||
if env == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := env[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// TestInjectConfigSecretsNoRows ensures a composition with zero operator-set
|
||||
// rows is left completely untouched.
|
||||
func TestInjectConfigSecretsNoRows(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", root)
|
||||
|
||||
strPtr := func(v string) *string { return &v }
|
||||
project := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
|
||||
"app": {Environment: map[string]*string{"KEEP": strPtr("default")}},
|
||||
}}
|
||||
if err := injectConfigSecrets(project, "svc"); err != nil {
|
||||
t.Fatalf("injectConfigSecrets: %v", err)
|
||||
}
|
||||
if got := envValue(project.Services["app"].Environment, "KEEP"); got != "default" {
|
||||
t.Errorf("KEEP = %q, want default (store empty, nothing injected)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +112,14 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
||||
capabilities := existing.Capabilities
|
||||
hasAuth := existing.HasAuth
|
||||
publicKey := existing.PublicKey
|
||||
config := existing.Config
|
||||
if manifest != nil {
|
||||
capabilities = manifest.Capabilities
|
||||
hasAuth = manifest.HasAuth
|
||||
publicKey = manifest.PublicKey
|
||||
if manifest.Config != nil {
|
||||
config = manifest.Config
|
||||
}
|
||||
updateInstalledMetadataFromManifest(serviceLabel, manifest)
|
||||
}
|
||||
container := types.Container{
|
||||
@@ -132,6 +136,7 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
||||
Capabilities: capabilities,
|
||||
HasAuth: hasAuth,
|
||||
PublicKey: publicKey,
|
||||
Config: config,
|
||||
Title: existing.Title,
|
||||
Vendor: existing.Vendor,
|
||||
Author: existing.Author,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package secrets is Maestro's owner config & secret store for the Service
|
||||
// Config & Secrets theme (M1). It persists per-service operator-configured
|
||||
// values under <dataRoot>/maestro/secrets.json with atomic writes and chmod
|
||||
// 600, mirroring lib/owner's file-at-rest pattern.
|
||||
//
|
||||
// Values come in two flavours, decided by the service manifest's config type:
|
||||
//
|
||||
// - cleartext: stored in plaintext, read-write (viewed and edited).
|
||||
// - password: stored AES-256-GCM encrypted at rest, write-only. The
|
||||
// ciphertext is never decrypted for reads — a password row is only ever
|
||||
// reported as set:true/false. Decryption happens once, at container env
|
||||
// injection time.
|
||||
//
|
||||
// Rows are keyed by (service, manifest field name) — the config key the API and
|
||||
// UI address — and carry the container env var name (Key) as metadata, which is
|
||||
// what injection uses to place values into the compose environment.
|
||||
//
|
||||
// The AES key lives in a dedicated <dataRoot>/maestro/secrets.key (32 random
|
||||
// bytes, chmod 600), deliberately separate from session.key so a key rotation
|
||||
// or compromise is isolated to this store.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
// Row is one operator-set config value for a (service, field) pair.
|
||||
type Row struct {
|
||||
// Field is the manifest config field name.
|
||||
Field string `json:"field"`
|
||||
// Key is the container env var name the value is injected under.
|
||||
Key string `json:"key"`
|
||||
// Type is the manifest config type ("cleartext" | "password").
|
||||
Type string `json:"type"`
|
||||
// Value holds a cleartext row's plaintext; always empty for passwords.
|
||||
Value string `json:"value,omitempty"`
|
||||
// Ciphertext holds an encrypted password row; always empty for cleartext.
|
||||
Ciphertext string `json:"ciphertext,omitempty"`
|
||||
// Nonce pairs with Ciphertext for GCM.
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
// Set marks whether a value has been stored for password rows (the only
|
||||
// thing a reader may learn about a password).
|
||||
Set bool `json:"set"`
|
||||
}
|
||||
|
||||
// PublicView is the read-only projection of a stored row served to the API. A
|
||||
// password value is never revealed (only Set); a cleartext value is read-write
|
||||
// and carried in Value.
|
||||
type PublicView struct {
|
||||
Field string `json:"field"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Set bool `json:"set"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Store persists config rows for services. FileStore is the on-disk
|
||||
// implementation; a DB-backed store can slot in later.
|
||||
type Store interface {
|
||||
// Get returns the public view of a stored row (never a password value).
|
||||
Get(service, field string) (*PublicView, bool)
|
||||
// Lookup returns the stored row including its decrypted value, for
|
||||
// injection. Only password values are decrypted here; cleartext returns
|
||||
// its plaintext.
|
||||
Lookup(service, field string) (*Row, bool, error)
|
||||
// Set stores a value for a config field. The returned PublicView never
|
||||
// carries a password value.
|
||||
Set(service, field string, spec types.ConfigField, value string) (*PublicView, error)
|
||||
// Delete removes a stored value.
|
||||
Delete(service, field string) error
|
||||
// All returns the public view of every stored row for a service
|
||||
// (cleartext values included).
|
||||
All(service string) []PublicView
|
||||
// Path exposes the store file for diagnostics.
|
||||
Path() string
|
||||
}
|
||||
|
||||
// FileStore persists rows to <dir>/secrets.json encrypted with
|
||||
// <dir>/secrets.key.
|
||||
type FileStore struct {
|
||||
path string
|
||||
key []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ErrKeyMissing reports that the secrets key file does not exist — the store
|
||||
// has not been initialised for encryption yet.
|
||||
var ErrKeyMissing = errors.New("secrets key missing")
|
||||
|
||||
// NewFileStore opens (creating on demand) the secret key and returns a
|
||||
// FileStore targeting dir/secrets.json. dir is normally <dataRoot>/maestro.
|
||||
func NewFileStore(dir string) (*FileStore, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create secrets store directory: %w", err)
|
||||
}
|
||||
key, err := loadOrCreateKey(filepath.Join(dir, "secrets.key"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &FileStore{path: filepath.Join(dir, "secrets.json"), key: key}, nil
|
||||
}
|
||||
|
||||
// loadOrCreateKey reads the 32-byte AES key from keyPath, generating and
|
||||
// persisting it (chmod 600) the first time.
|
||||
func loadOrCreateKey(keyPath string) ([]byte, error) {
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err == nil {
|
||||
if len(data) != 32 {
|
||||
return nil, fmt.Errorf("secrets key %s has invalid length %d (want 32)", keyPath, len(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read secrets key: %w", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate secrets key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(keyPath, key); err != nil {
|
||||
return nil, fmt.Errorf("write secrets key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
type storeFile struct {
|
||||
Version int `json:"version"`
|
||||
Rows map[string]Row `json:"rows"` // keyed "<service>/<field>"
|
||||
}
|
||||
|
||||
func rowKey(service, field string) string {
|
||||
return service + "/" + field
|
||||
}
|
||||
|
||||
func (s *FileStore) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *FileStore) read() (*storeFile, error) {
|
||||
file := storeFile{Version: 1, Rows: map[string]Row{}}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return &file, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read secrets file: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, fmt.Errorf("parse secrets file: %w", err)
|
||||
}
|
||||
if file.Rows == nil {
|
||||
file.Rows = map[string]Row{}
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) write(file *storeFile) error {
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets file: %w", err)
|
||||
}
|
||||
return writeFileAtomic(s.path, data)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temp file + rename with 0600 perms.
|
||||
func writeFileAtomic(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encrypt seals plaintext with AES-256-GCM, returning "base64(nonce).base64(ciphertext)".
|
||||
func (s *FileStore) encrypt(plaintext string) (nonceB64, ciphertextB64 string, err error) {
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("init cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("init gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", "", fmt.Errorf("read nonce: %w", err)
|
||||
}
|
||||
sealed := gcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(nonce),
|
||||
base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// decrypt unseals a password row. callers must treat the result as sensitive.
|
||||
func (s *FileStore) decrypt(nonceB64, ciphertextB64 string) (string, error) {
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("init cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("init gcm: %w", err)
|
||||
}
|
||||
nonce, err := base64.StdEncoding.DecodeString(nonceB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
plain, err := gcm.Open(nil, nonce, sealed, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// Get returns the public view of a stored row. It never reveals a password
|
||||
// value.
|
||||
func (s *FileStore) Get(service, field string) (*PublicView, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.read()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
row, ok := file.Rows[rowKey(service, field)]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &PublicView{Field: row.Field, Key: row.Key, Type: row.Type, Set: row.Set}, true
|
||||
}
|
||||
|
||||
// Lookup returns the stored row, decrypting a password value for injection.
|
||||
func (s *FileStore) Lookup(service, field string) (*Row, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.read()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
row, ok := file.Rows[rowKey(service, field)]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
if row.Type == types.ConfigPassword && row.Set {
|
||||
plain, err := s.decrypt(row.Nonce, row.Ciphertext)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
row.Value = plain
|
||||
}
|
||||
return &row, true, nil
|
||||
}
|
||||
|
||||
// Set stores a value for a config field and returns its public view.
|
||||
func (s *FileStore) Set(service, field string, spec types.ConfigField, value string) (*PublicView, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := Row{Field: field, Key: spec.Key, Type: spec.Type, Set: true}
|
||||
if spec.Type == types.ConfigPassword {
|
||||
nonce, ct, err := s.encrypt(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Nonce, row.Ciphertext = nonce, ct
|
||||
} else {
|
||||
row.Value = value
|
||||
}
|
||||
file.Rows[rowKey(service, field)] = row
|
||||
if err := s.write(file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PublicView{Field: field, Key: spec.Key, Type: spec.Type, Description: spec.Description, Set: true}, nil
|
||||
}
|
||||
|
||||
// Delete removes a stored value for a config field.
|
||||
func (s *FileStore) Delete(service, field string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(file.Rows, rowKey(service, field))
|
||||
return s.write(file)
|
||||
}
|
||||
|
||||
// All returns the public view of every stored row for a service. Cleartext
|
||||
// rows carry their (read-write) value; password rows only Set.
|
||||
func (s *FileStore) All(service string) []PublicView {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
prefix := service + "/"
|
||||
var out []PublicView
|
||||
for k, row := range file.Rows {
|
||||
if k != prefix+row.Field {
|
||||
continue
|
||||
}
|
||||
v := PublicView{Field: row.Field, Key: row.Key, Type: row.Type, Set: row.Set}
|
||||
if row.Type == types.ConfigCleartext {
|
||||
v.Value = row.Value
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *FileStore {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := NewFileStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCleartextReadWrite(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
spec := types.ConfigField{Type: types.ConfigCleartext, Key: "MY_KEY", Description: "desc"}
|
||||
v, err := s.Set("svc", "my_field", spec, "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if !v.Set {
|
||||
t.Errorf("public view Set = false, want true")
|
||||
}
|
||||
if v.Field != "my_field" || v.Key != "MY_KEY" {
|
||||
t.Errorf("public view = %+v", v)
|
||||
}
|
||||
|
||||
pv, ok := s.Get("svc", "my_field")
|
||||
if !ok {
|
||||
t.Fatalf("Get: not found")
|
||||
}
|
||||
if pv.Type != types.ConfigCleartext || pv.Key != "MY_KEY" || !pv.Set {
|
||||
t.Errorf("public view = %+v", pv)
|
||||
}
|
||||
|
||||
row, found, err := s.Lookup("svc", "my_field")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Lookup: found=%v err=%v", found, err)
|
||||
}
|
||||
if row.Value != "hello" {
|
||||
t.Errorf("cleartext Lookup value = %q, want hello", row.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordWriteOnlyEncryptedAtRest(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
spec := types.ConfigField{Type: types.ConfigPassword, Key: "SECRET", Description: "s"}
|
||||
if _, err := s.Set("svc", "secret_field", spec, "s3cr3t"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
pv, ok := s.Get("svc", "secret_field")
|
||||
if !ok {
|
||||
t.Fatalf("Get: not found")
|
||||
}
|
||||
if pv.Set != true {
|
||||
t.Errorf("password public set = false, want true")
|
||||
}
|
||||
if pv.Value != "" {
|
||||
t.Errorf("password public view leaks value %q", pv.Value)
|
||||
}
|
||||
|
||||
// Ciphertext on disk must not contain the plaintext and must be AEAD-encrypted.
|
||||
raw, err := os.ReadFile(s.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read store file: %v", err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("s3cr3t")) {
|
||||
t.Errorf("store file leaks password plaintext")
|
||||
}
|
||||
if !bytes.Contains(raw, []byte("ciphertext")) {
|
||||
t.Errorf("store file has no ciphertext field for password row")
|
||||
}
|
||||
|
||||
// Decryption happens only via Lookup (the injection path).
|
||||
row, found, err := s.Lookup("svc", "secret_field")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Lookup: found=%v err=%v", found, err)
|
||||
}
|
||||
if row.Value != "s3cr3t" {
|
||||
t.Errorf("Lookup decrypted value = %q, want s3cr3t", row.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFileRequiredForDecrypt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewFileStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore: %v", err)
|
||||
}
|
||||
spec := types.ConfigField{Type: types.ConfigPassword, Key: "SECRET"}
|
||||
if _, err := s.Set("svc", "secret_field", spec, "v"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
// Deleting the key file must make the value unrecoverable: a fresh store
|
||||
// (e.g. after a restart) regenerates a new key and the old ciphertext can
|
||||
// no longer be decrypted.
|
||||
if err := os.Remove(filepath.Join(dir, "secrets.key")); err != nil {
|
||||
t.Fatalf("remove key: %v", err)
|
||||
}
|
||||
s2, err := NewFileStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore after key removal: %v", err)
|
||||
}
|
||||
_, found, err := s2.Lookup("svc", "secret_field")
|
||||
if err == nil {
|
||||
t.Errorf("Lookup after key removal: err = nil, want decryption error")
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Lookup lost the row after key removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
spec := types.ConfigField{Type: types.ConfigCleartext, Key: "K"}
|
||||
if _, err := s.Set("svc", "f", spec, "v"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := s.Delete("svc", "f"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, ok := s.Get("svc", "f"); ok {
|
||||
t.Errorf("Get after Delete: found, want not found")
|
||||
}
|
||||
if got := s.All("svc"); len(got) != 0 {
|
||||
t.Errorf("All after Delete = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllPerService(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Set("svc1", "a", types.ConfigField{Type: types.ConfigCleartext, Key: "A"}, "x"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if _, err := s.Set("svc1", "b", types.ConfigField{Type: types.ConfigPassword, Key: "B"}, "y"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if _, err := s.Set("svc2", "a", types.ConfigField{Type: types.ConfigCleartext, Key: "A"}, "z"); err != nil {
|
||||
t.Fatalf("Set svc2: %v", err)
|
||||
}
|
||||
all := s.All("svc1")
|
||||
if len(all) != 2 {
|
||||
t.Errorf("All(svc1) len = %d, want 2", len(all))
|
||||
}
|
||||
byField := map[string]PublicView{}
|
||||
for _, v := range all {
|
||||
byField[v.Field] = v
|
||||
}
|
||||
if cleartext := byField["a"]; cleartext.Value != "x" || !cleartext.Set {
|
||||
t.Errorf("cleartext view = %+v, want value x set", cleartext)
|
||||
}
|
||||
if password := byField["b"]; password.Set != true || password.Value != "" {
|
||||
t.Errorf("password view = %+v, want set only", password)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,11 @@ type Container struct {
|
||||
HasAuth bool `json:"has_auth,omitempty"`
|
||||
PublicKey string `json:"public_key,omitempty"`
|
||||
|
||||
// Config is the service's operator-configurable fields declared by its
|
||||
// manifest (Service Config & Secrets theme), surfaced so the UI can render
|
||||
// a per-service settings list.
|
||||
Config []ConfigItem `json:"config,omitempty"`
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
|
||||
@@ -34,6 +34,10 @@ type RegistryContainer struct {
|
||||
// Hidden marks infrastructure images (e.g. the shared database) that are
|
||||
// managed by Maestro but excluded from the service grid.
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
// Config is the service's operator-configurable fields declared by its
|
||||
// manifest (Service Config & Secrets theme M1), persisted so installed
|
||||
// services can surface them even while stopped.
|
||||
Config []ConfigItem `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type RawPackage struct {
|
||||
@@ -58,6 +62,10 @@ type InstalledMetadata struct {
|
||||
// services keep advertising auth consumption and its key.
|
||||
HasAuth bool `json:"has_auth,omitempty"`
|
||||
PublicKey string `json:"public_key,omitempty"`
|
||||
// Config is persisted from the service manifest (Service Config & Secrets
|
||||
// theme M1), letting installed-but-stopped services surface their
|
||||
// operator-configurable fields.
|
||||
Config []ConfigItem `json:"config,omitempty"`
|
||||
// AuthFingerprint pins the auth service's signing key after first contact
|
||||
// (security theme M2 §5, TOFU/D3). It is fetched from the auth service's
|
||||
// attestation at install time and injected into consumer containers as
|
||||
|
||||
@@ -23,4 +23,42 @@ type Manifest struct {
|
||||
HasAuth bool `json:"has_auth"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Icon string `json:"icon"`
|
||||
// Config is the service's operator-configurable fields (Service Config &
|
||||
// Secrets theme): an array of single-key objects. Each array element holds
|
||||
// exactly one key; the key names the field and its value is the field spec.
|
||||
// The manifest is a pure schema — it never carries a value.
|
||||
Config []ConfigItem `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigItem is a single-key config declaration in a service manifest. The
|
||||
// field name (key) identifies the config; the value describes it.
|
||||
type ConfigItem map[string]ConfigField
|
||||
|
||||
// ConfigField is the spec of one operator-configurable field.
|
||||
type ConfigField struct {
|
||||
// Type is "cleartext" (read-write) or "password" (write-only, encrypted at
|
||||
// rest).
|
||||
Type string `json:"type"`
|
||||
// Description is an optional human-readable explanation shown in the UI.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Key is the container env var name Maestro injects the value under.
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ConfigType values.
|
||||
const (
|
||||
ConfigCleartext = "cleartext"
|
||||
ConfigPassword = "password"
|
||||
)
|
||||
|
||||
// FieldName reports the single field name of a ConfigItem, or "" when the item
|
||||
// does not hold exactly one key (defensive read of a malformed manifest).
|
||||
func (c ConfigItem) FieldName() string {
|
||||
if len(c) != 1 {
|
||||
return ""
|
||||
}
|
||||
for k := range c {
|
||||
return k
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -93,3 +93,82 @@ func marshalJSONString(t *testing.T, s string) string {
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// TestManifestParsesConfigBlock covers the Service Config & Secrets manifest
|
||||
// block: an array of single-key objects carrying type/description/key. The
|
||||
// manifest is a pure schema — no values.
|
||||
func TestManifestParsesConfigBlock(t *testing.T) {
|
||||
raw := `{
|
||||
"name": "Calendar",
|
||||
"version": "4.0.0",
|
||||
"author": "Dcntrlized",
|
||||
"capabilities": [],
|
||||
"config": [
|
||||
{
|
||||
"google_oauth_client_id": {
|
||||
"type": "cleartext",
|
||||
"description": "OAuth client ID",
|
||||
"key": "GOOGLE_OAUTH_CLIENT_ID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"google_oauth_client_secret": {
|
||||
"type": "password",
|
||||
"description": "OAuth client secret",
|
||||
"key": "GOOGLE_OAUTH_CLIENT_SECRET"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal manifest with config: %v", err)
|
||||
}
|
||||
|
||||
if len(m.Config) != 2 {
|
||||
t.Fatalf("Config has %d items, want 2", len(m.Config))
|
||||
}
|
||||
|
||||
first := m.Config[0]
|
||||
if name := first.FieldName(); name != "google_oauth_client_id" {
|
||||
t.Errorf("first field name = %q, want google_oauth_client_id", name)
|
||||
}
|
||||
f := first["google_oauth_client_id"]
|
||||
if f.Type != ConfigCleartext {
|
||||
t.Errorf("first type = %q, want cleartext", f.Type)
|
||||
}
|
||||
if f.Key != "GOOGLE_OAUTH_CLIENT_ID" {
|
||||
t.Errorf("first key = %q, want GOOGLE_OAUTH_CLIENT_ID", f.Key)
|
||||
}
|
||||
if f.Description != "OAuth client ID" {
|
||||
t.Errorf("first description = %q, want OAuth client ID", f.Description)
|
||||
}
|
||||
|
||||
second := m.Config[1]
|
||||
sf := second["google_oauth_client_secret"]
|
||||
if sf.Type != ConfigPassword {
|
||||
t.Errorf("second type = %q, want password", sf.Type)
|
||||
}
|
||||
if sf.Key != "GOOGLE_OAUTH_CLIENT_SECRET" {
|
||||
t.Errorf("second key = %q, want GOOGLE_OAUTH_CLIENT_SECRET", sf.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManifestConfigAbsentDefaultsNil ensures a manifest without a config block
|
||||
// leaves Config nil (it must not appear as an empty array or zero structs).
|
||||
func TestManifestConfigAbsentDefaultsNil(t *testing.T) {
|
||||
raw := `{
|
||||
"name": "KeepNote",
|
||||
"version": "0.0.1",
|
||||
"author": "Dcntrlized",
|
||||
"capabilities": ["database"]
|
||||
}`
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal manifest: %v", err)
|
||||
}
|
||||
if m.Config != nil {
|
||||
t.Errorf("Config = %v, want nil", m.Config)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user