diff --git a/spec.yaml b/spec.yaml index 11a6a70..c1d27ef 100644 --- a/spec.yaml +++ b/spec.yaml @@ -334,6 +334,14 @@ components: The auth service's RS256 public key (manifest public_key); the hook for a future Maestro dashboard auth that validates dcntrlized-auth tokens. + config: + type: array + description: >- + Operator-configurable fields the service declares in its manifest + config block (Service Config & Secrets theme). Each element is a + single-key object: the key names the field, the value is its spec. + items: + $ref: '#/components/schemas/ServiceConfigDeclaration' docker_name: type: string image: @@ -357,6 +365,70 @@ components: source: type: string + ServiceConfigField: + type: object + description: >- + The spec of one operator-configurable field declared by a service's + manifest config block (Service Config & Secrets theme). The manifest is + a pure schema — it never carries a value. + required: [type, key] + properties: + type: + type: string + enum: [cleartext, password] + description: cleartext is read-write; password is write-only and encrypted at rest. + description: + type: string + description: Optional human-readable explanation shown in the UI. + key: + type: string + description: The container env var name Maestro injects the value under. + + ServiceConfigDeclaration: + type: object + description: >- + A single-key config declaration inside a service manifest's config + array. The key names the field; the value is the field spec. + additionalProperties: + $ref: '#/components/schemas/ServiceConfigField' + minProperties: 1 + maxProperties: 1 + + ServiceConfigEntry: + type: object + description: >- + The read-only merged view of one config field served by + GET /api/services/{service}/config: the schema plus the operator's set + state. Password values are never included; a cleartext value + (read-write) is. + required: [field, key, type, set] + properties: + field: + type: string + description: The manifest config field name (the config array element key). + key: + type: string + description: The container env var name. + type: + type: string + enum: [cleartext, password] + description: + type: string + set: + type: boolean + description: Whether a value is stored. The only thing a password row reveals. + value: + type: string + description: The stored value of a cleartext field; never present for passwords. + + ServiceConfigValue: + type: object + description: Request body for setting a config value (PUT .../config/{key}). + required: [value] + properties: + value: + type: string + paths: /api/actions/{containerId}/{actionName}: parameters: @@ -858,3 +930,97 @@ paths: description: Missing service or fingerprint '502': description: Failed to reach the shared auth service + + /api/services/{service}/config: + parameters: + - name: service + in: path + required: true + description: >- + The service (matched case-insensitively against the install record / + container label, as in /api/containers). + schema: + type: string + get: + summary: Merge a service's declared config schema with its stored values + tags: ["service-config"] + description: >- + Owner-gated (Service Config & Secrets theme M1). Lists each field the + service's manifest config block declares, merged with the operator's + set state. Password fields are only ever reported as set:false/true; + cleartext fields (read-write) carry their stored value. + responses: + '200': + description: The merged per-field view + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ServiceConfigEntry' + '401': + description: ERR_UNAUTHENTICATED - no valid owner session + '404': + description: ERR_NOT_FOUND - unknown service + '428': + description: ERR_SETUP_REQUIRED - owner setup not completed + + /api/services/{service}/config/{key}: + parameters: + - name: service + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + description: The manifest config field name (the config array element key). + schema: + type: string + put: + summary: Set a service config value + tags: ["service-config"] + description: >- + Owner-gated (Service Config & Secrets theme M1). Stores the value for + the field declared by the service manifest. Cleartext fields are + stored plaintext (read-write); password fields are AES-256-GCM + encrypted at rest, keyed by /maestro/secrets.key, and never + returned again — the response is only a ServiceConfigEntry with set:true. + The value is injected as the field's env key on the next start. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceConfigValue' + responses: + '200': + description: Value stored + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceConfigEntry' + '400': + description: ERR_INVALID_PAYLOAD - unparseable body or empty password + '401': + description: ERR_UNAUTHENTICATED - no valid owner session + '404': + description: ERR_NOT_FOUND - unknown service or config field + '428': + description: ERR_SETUP_REQUIRED - owner setup not completed + delete: + summary: Clear a service config value + tags: ["service-config"] + description: >- + Owner-gated (Service Config & Secrets theme M1). Removes the stored + value; on the next start the compose file's own default applies. + responses: + '200': + description: Value cleared + '401': + description: ERR_UNAUTHENTICATED - no valid owner session + '404': + description: ERR_NOT_FOUND - unknown service or config field + '428': + description: ERR_SETUP_REQUIRED - owner setup not completed diff --git a/src/frontend/src/components/service/service-card.tsx b/src/frontend/src/components/service/service-card.tsx index c3f87f1..d46c3da 100644 --- a/src/frontend/src/components/service/service-card.tsx +++ b/src/frontend/src/components/service/service-card.tsx @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download } from 'lucide-react' +import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal } from 'lucide-react' import { Button } from '@components/ui/button' import { Card, CardContent, CardFooter } from '@components/ui/card' import { statusMeta } from '@components/container/status' @@ -30,6 +30,7 @@ import type { DatabaseState } from './service-grid' interface ServiceCardProps { service: Service db: DatabaseState + onOpenConfig?: () => void } function withV(s?: string): string | null { @@ -46,7 +47,7 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'Unknown error' } -export function ServiceCard({ service, db }: ServiceCardProps) { +export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) { const meta = statusMeta(service.status) const installed = service.origin !== 'remote' const running = @@ -238,6 +239,20 @@ export function ServiceCard({ service, db }: ServiceCardProps) { )} + {installed && onOpenConfig && ( + + )} + {installed && service.hasUpdate && ( + + +
+ +
+ + + + ) +} + +interface ServiceConfigSectionProps { + serviceName: string + load: ServiceConfigLoadState + onRefresh: () => void +} + +function ServiceConfigSection({ serviceName, load, onRefresh }: ServiceConfigSectionProps) { + if (load.kind === 'loading') { + return ( +
+
+ ) + } + + if (load.kind === 'error') { + return ( +
+

+ Couldn't load config. + {load.message} +

+ +
+ ) + } + + if (load.entries.length === 0) { + return ( +
+ This service declares no operator-configurable settings. +
+ ) + } + + return ( +
+ {load.entries.map((entry) => ( + + ))} +
+ ) +} + +function ConfigRow({ + entry, + serviceName, + onDone, +}: { + entry: ServiceConfigEntry + serviceName: string + onDone: () => void +}) { + const [value, setValue] = useState('') + + const save = useAsyncAction(async () => { + try { + await putServiceConfig(serviceName, entry.field, value) + toast.success(`Saved ${entry.key || entry.field}`) + setValue('') + onDone() + } catch (error) { + toast.error(`Failed to save ${entry.key || entry.field}`, { description: errorMessage(error) }) + } + }) + + const clear = useAsyncAction(async () => { + try { + await deleteServiceConfig(serviceName, entry.field) + toast.success(`Cleared ${entry.key || entry.field}`, { + description: 'The compose default applies on next start', + }) + setValue('') + onDone() + } catch (error) { + toast.error(`Failed to clear ${entry.key || entry.field}`, { description: errorMessage(error) }) + } + }) + + const isPassword = entry.type === 'password' + const hasValue = value.length > 0 || !isPassword + const canSave = value.length > 0 || entry.set === false + + return ( +
+
+
+

+

+ {entry.description && ( +

{entry.description}

+ )} + {!isPassword && entry.set && ( +

Current: {entry.value}

+ )} +
+ {entry.set && isPassword && ( + + )} +
+ +
+ setValue((e.target as HTMLInputElement).value)} + placeholder={isPassword ? (entry.set ? 'Set a new value…' : 'Set a value…') : 'Value…'} + aria-label={`Value for ${entry.key || entry.field}`} + autoComplete="off" + className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground/60 focus-visible:ring-2 focus-visible:ring-ring/50" + /> + + {entry.set && !isPassword && ( + + )} +
+
+ ) +} \ No newline at end of file diff --git a/src/frontend/src/components/service/service-grid.tsx b/src/frontend/src/components/service/service-grid.tsx index a3f1cb7..e64a531 100644 --- a/src/frontend/src/components/service/service-grid.tsx +++ b/src/frontend/src/components/service/service-grid.tsx @@ -15,8 +15,10 @@ * along with this program. If not, see . */ +import { useState } from 'preact/hooks' import type { DatabaseStatus, RegistryItem, Service } from '@lib/types' import { ServiceCard } from './service-card' +import { ServiceConfigDialog } from './service-config' export interface DatabaseState { status: DatabaseStatus @@ -29,11 +31,24 @@ interface ServiceGridProps { } export function ServiceGrid({ services, db }: ServiceGridProps) { + const [configService, setConfigService] = useState(null) + return ( -
- {services.map((service) => ( - - ))} -
+ <> +
+ {services.map((service) => ( + 0 ? () => setConfigService(service) : undefined} + /> + ))} +
+ setConfigService(null)} + /> + ) } diff --git a/src/frontend/src/lib/api/client.ts b/src/frontend/src/lib/api/client.ts index 4a307f9..89cdc4a 100644 --- a/src/frontend/src/lib/api/client.ts +++ b/src/frontend/src/lib/api/client.ts @@ -83,3 +83,15 @@ export function post(path: string, body?: unknown): Promise { body: body !== undefined ? JSON.stringify(body) : undefined, }) } + +export function put(path: string, body?: unknown): Promise { + return request(path, { + method: 'PUT', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +export function del(path: string): Promise { + return request(path, { method: 'DELETE' }) +} diff --git a/src/frontend/src/lib/api/services.ts b/src/frontend/src/lib/api/services.ts new file mode 100644 index 0000000..f5ca85d --- /dev/null +++ b/src/frontend/src/lib/api/services.ts @@ -0,0 +1,41 @@ +/** + * 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 . + */ + +import { del, get, put } from './client' +import type { ServiceConfigEntry } from '../types' + +/** Fetches the merged config view for a service (Service Config & Secrets theme M1). */ +export function fetchServiceConfig(service: string): Promise { + return get(`/api/services/${encodeURIComponent(service)}/config`) +} + +/** Sets a config value. Cleartext values are stored read-write; password values encrypted. */ +export function putServiceConfig( + service: string, + field: string, + value: string +): Promise { + return put( + `/api/services/${encodeURIComponent(service)}/config/${encodeURIComponent(field)}`, + { value } + ) +} + +/** Clears a stored config value (the env falls back to the compose default on next start). */ +export function deleteServiceConfig(service: string, field: string): Promise { + return del(`/api/services/${encodeURIComponent(service)}/config/${encodeURIComponent(field)}`) +} \ No newline at end of file diff --git a/src/frontend/src/lib/domain/merge.ts b/src/frontend/src/lib/domain/merge.ts index 18e02d6..73a7476 100644 --- a/src/frontend/src/lib/domain/merge.ts +++ b/src/frontend/src/lib/domain/merge.ts @@ -57,6 +57,7 @@ function toService(c: Container): Service { version: c.version || versionFromImage(c.image), hasUpdate: false, capabilities: c.capabilities ?? [], + config: c.config, } } diff --git a/src/frontend/src/lib/hooks/useServiceConfig.ts b/src/frontend/src/lib/hooks/useServiceConfig.ts new file mode 100644 index 0000000..33111d7 --- /dev/null +++ b/src/frontend/src/lib/hooks/useServiceConfig.ts @@ -0,0 +1,56 @@ +/** + * 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 . + */ + +import { useCallback, useEffect, useState } from 'preact/hooks' +import { fetchServiceConfig } from '@lib/api/services' +import type { ServiceConfigEntry } from '@lib/types' + +export type ServiceConfigLoadState = + | { kind: 'loading' } + | { kind: 'ok'; entries: ServiceConfigEntry[] } + | { kind: 'error'; message: string } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} + +/** + * Loads the merged config view for one service (Service Config & Secrets theme + * M1). Gated by `enabled` (the auth gate) like the peer trust store hook; a + * mutation triggers an immediate refresh through the same hook. + */ +export function useServiceConfig(service: string | null, enabled = true) { + const [load, setLoad] = useState({ kind: 'loading' }) + + const refresh = useCallback(async () => { + if (!service) return + try { + const entries = await fetchServiceConfig(service) + setLoad({ kind: 'ok', entries }) + } catch (error) { + setLoad({ kind: 'error', message: errorMessage(error) }) + } + }, [service]) + + useEffect(() => { + if (!enabled || !service) return + setLoad({ kind: 'loading' }) + void refresh() + }, [enabled, service, refresh]) + + return { load, refresh } +} \ No newline at end of file diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 94ab4db..a7a6bb1 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -48,6 +48,35 @@ export interface Container { has_auth?: boolean /** The auth service's RS256 public key (manifest public_key). */ public_key?: string + /** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */ + config?: ServiceConfigDeclaration[] +} + +/** A config field spec declared by a service manifest (Service Config & Secrets theme). */ +export interface ServiceConfigField { + /** "cleartext" (read-write) or "password" (write-only, encrypted at rest). */ + type: 'cleartext' | 'password' + /** Optional human-readable explanation shown in the UI. */ + description?: string + /** The container env var name the value is injected under. */ + key: string +} + +/** A single-key config declaration: the key names the field, the value describes it. */ +export interface ServiceConfigDeclaration { + [field: string]: ServiceConfigField +} + +/** The read-only merged view served by GET /api/services/{service}/config. */ +export interface ServiceConfigEntry { + field: string + key: string + type: 'cleartext' | 'password' + description?: string + /** Whether a value is stored. The only thing a password row reveals. */ + set: boolean + /** The read-write value of a cleartext row; never present for passwords. */ + value?: string } /** A remote catalog entry, as served by GET /api/registry. */ @@ -90,6 +119,8 @@ export interface Service { hasUpdate: boolean /** Capabilities declared by the service manifest (e.g. ["database"]). */ capabilities: string[] + /** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */ + config?: ServiceConfigDeclaration[] } /** Live status of the shared database infra service. */ diff --git a/src/handlers/config/config.go b/src/handlers/config/config.go new file mode 100644 index 0000000..f04daed --- /dev/null +++ b/src/handlers/config/config.go @@ -0,0 +1,184 @@ +// 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 . + +// Package config serves Maestro's owner-gated Service Config & Secrets API +// (Service Config & Secrets theme M1). The service manifest's `config` block +// is the pure schema; the encrypted store holds the operator-entered values; +// this handler merges them for the UI and receives value changes. +// +// Routes (all owner-gated via auth.RequireSession in main.go): +// +// - GET /api/services/{service}/config merged view (never a password value) +// - PUT /api/services/{service}/config/{key} set a value +// - DELETE /api/services/{service}/config/{key} clear a value +// +// Errors follow the uniform {"error":{"code":…}} shape. +package config + +import ( + "encoding/json" + "net/http" + + libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" +) + +// Handler serves the service config + secrets API against the install cache +// (which carries each service's manifest config schema) and the encrypted +// store. +type Handler struct { + store secrets.Store + cache *libcontainers.ContainerCache +} + +// NewHandler builds the config API handler. +func NewHandler(store secrets.Store, cache *libcontainers.ContainerCache) *Handler { + return &Handler{store: store, cache: cache} +} + +// Entry is the merged per-field UI view: schema (type/key/description) plus the +// operator's set state. Password values are never included; a cleartext value +// (read-write) is. +type Entry 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"` +} + +// List serves GET /api/services/{service}/config: the merged view built from +// the service's manifest config schema and the stored rows. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + service := r.PathValue("service") + container, ok := h.cache.GetService(service) + if !ok { + writeError(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + + stored := map[string]secrets.PublicView{} + for _, v := range h.store.All(container.ServiceName) { + stored[v.Field] = v + } + + entries := make([]Entry, 0, len(container.Config)) + for _, item := range container.Config { + field := item.FieldName() + if field == "" { + continue + } + spec := item[field] + entry := Entry{ + Field: field, + Key: spec.Key, + Type: spec.Type, + Description: spec.Description, + } + if row, ok := stored[field]; ok && row.Set { + entry.Set = true + if row.Type == types.ConfigCleartext { + entry.Value = row.Value + } + } + entries = append(entries, entry) + } + + writeJSON(w, http.StatusOK, entries) +} + +// valueAction is the PUT request body: {value}. +type valueAction struct { + Value string `json:"value"` +} + +// Value serves PUT and DELETE on /api/services/{service}/config/{key}: set or +// clear a stored value. The {key} is the manifest config field name. +func (h *Handler) Value(w http.ResponseWriter, r *http.Request) { + service := r.PathValue("service") + field := r.PathValue("key") + + container, ok := h.cache.GetService(service) + if !ok { + writeError(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + spec, known := h.field(container, field) + if !known { + writeError(w, http.StatusNotFound, "ERR_NOT_FOUND") + return + } + + switch r.Method { + case http.MethodDelete: + if err := h.store.Delete(container.ServiceName, field); err != nil { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) + + case http.MethodPut: + var action valueAction + if err := json.NewDecoder(r.Body).Decode(&action); err != nil { + writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") + return + } + if spec.Type == types.ConfigPassword && action.Value == "" { + writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") + return + } + view, err := h.store.Set(container.ServiceName, field, spec, action.Value) + if err != nil { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + writeJSON(w, http.StatusOK, view) + + default: + writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED") + } +} + +// field looks a manifest config field up by its field name. +func (h *Handler) field(container types.Container, field string) (types.ConfigField, bool) { + for _, item := range container.Config { + if name := item.FieldName(); name == field { + return item[name], true + } + } + return types.ConfigField{}, false +} + +type errorBody struct { + Error struct { + Code string `json:"code"` + } `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, code string) { + var body errorBody + body.Error.Code = code + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/src/handlers/config/config_test.go b/src/handlers/config/config_test.go new file mode 100644 index 0000000..417e1a6 --- /dev/null +++ b/src/handlers/config/config_test.go @@ -0,0 +1,202 @@ +// 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 . + +package config + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" +) + +func newTestHandler(t *testing.T) (*Handler, *libcontainers.ContainerCache) { + t.Helper() + store, err := secrets.NewFileStore(t.TempDir()) + if err != nil { + t.Fatalf("NewFileStore: %v", err) + } + cache := libcontainers.NewContainerCache() + cache.AddService(types.Container{ + ServiceName: "svc", + Name: "svc", + Status: "installed", + Config: []types.ConfigItem{ + {"owner_email": {Type: types.ConfigCleartext, Key: "OWNER_EMAIL", Description: "ops email"}}, + {"db_password": {Type: types.ConfigPassword, Key: "DB_PASSWORD", Description: "db password"}}, + }, + }) + return NewHandler(store, cache), cache +} + +func TestListMergedView(t *testing.T) { + h, _ := newTestHandler(t) + req := httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil) + req.SetPathValue("service", "svc") + rr := httptest.NewRecorder() + h.List(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + var entries []Entry + if err := json.Unmarshal(rr.Body.Bytes(), &entries); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(entries) != 2 { + t.Fatalf("entries = %d, want 2", len(entries)) + } + byField := map[string]Entry{} + for _, e := range entries { + byField[e.Field] = e + } + if e := byField["owner_email"]; e.Type != types.ConfigCleartext || e.Set != false { + t.Errorf("owner_email = %+v, want cleartext unset", e) + } + if e := byField["db_password"]; e.Type != types.ConfigPassword || e.Set != false { + t.Errorf("db_password = %+v, want password unset", e) + } +} + +func TestPutCleartextReadableBack(t *testing.T) { + h, _ := newTestHandler(t) + body := bytes.NewReader([]byte(`{"value":"ops@example.com"}`)) + req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/owner_email", body) + req.SetPathValue("service", "svc") + req.SetPathValue("key", "owner_email") + rr := httptest.NewRecorder() + h.Value(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("PUT status = %d, want 200", rr.Code) + } + + // GET now reports set with the read-write value. + req = httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil) + req.SetPathValue("service", "svc") + rr = httptest.NewRecorder() + h.List(rr, req) + var entries []Entry + if err := json.Unmarshal(rr.Body.Bytes(), &entries); err != nil { + t.Fatalf("unmarshal: %v", err) + } + found := false + for _, e := range entries { + if e.Field == "owner_email" { + found = true + if !e.Set { + t.Errorf("owner_email set = false") + } + if e.Value != "ops@example.com" { + t.Errorf("owner_email value = %q, want ops@example.com", e.Value) + } + } + } + if !found { + t.Fatalf("owner_email missing from merged view") + } +} + +func TestPutPasswordNeverReturned(t *testing.T) { + h, _ := newTestHandler(t) + body := bytes.NewReader([]byte(`{"value":"s3cr3t-pw"}`)) + req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/db_password", body) + req.SetPathValue("service", "svc") + req.SetPathValue("key", "db_password") + rr := httptest.NewRecorder() + h.Value(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("PUT status = %d, want 200", rr.Code) + } + if strings.Contains(rr.Body.String(), "s3cr3t-pw") { + t.Errorf("PUT response leaks password value") + } + + req = httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil) + req.SetPathValue("service", "svc") + rr = httptest.NewRecorder() + h.List(rr, req) + resp := rr.Body.String() + if strings.Contains(resp, "s3cr3t-pw") { + t.Errorf("GET merged view leaks password value") + } + var entries []Entry + if err := json.Unmarshal([]byte(resp), &entries); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, e := range entries { + if e.Field == "db_password" { + if !e.Set { + t.Errorf("db_password set = false") + } + if e.Value != "" { + t.Errorf("db_password value leaked: %q", e.Value) + } + } + } +} + +func TestPutUnknownFieldNotFound(t *testing.T) { + h, _ := newTestHandler(t) + body := bytes.NewReader([]byte(`{"value":"x"}`)) + req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/nope", body) + req.SetPathValue("service", "svc") + req.SetPathValue("key", "nope") + rr := httptest.NewRecorder() + h.Value(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } + if !strings.Contains(rr.Body.String(), "ERR_NOT_FOUND") { + t.Errorf("body = %s, want ERR_NOT_FOUND", rr.Body.String()) + } +} + +func TestDeleteClears(t *testing.T) { + h, _ := newTestHandler(t) + body := bytes.NewReader([]byte(`{"value":"ops@example.com"}`)) + req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/owner_email", body) + req.SetPathValue("service", "svc") + req.SetPathValue("key", "owner_email") + h.Value(httptest.NewRecorder(), req) + + req = httptest.NewRequest(http.MethodDelete, "/api/services/svc/config/owner_email", nil) + req.SetPathValue("service", "svc") + req.SetPathValue("key", "owner_email") + rr := httptest.NewRecorder() + h.Value(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("DELETE status = %d, want 200", rr.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil) + req.SetPathValue("service", "svc") + rr = httptest.NewRecorder() + h.List(rr, req) + var entries []Entry + if err := json.Unmarshal(rr.Body.Bytes(), &entries); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, e := range entries { + if e.Field == "owner_email" && e.Set { + t.Errorf("owner_email set=true after DELETE") + } + } +} diff --git a/src/lib/containers/installed.go b/src/lib/containers/installed.go index 9a29379..0519f07 100644 --- a/src/lib/containers/installed.go +++ b/src/lib/containers/installed.go @@ -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) } diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go index 0fa610c..2886d71 100644 --- a/src/lib/containers/start.go +++ b/src/lib/containers/start.go @@ -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 { diff --git a/src/lib/containers/start_test.go b/src/lib/containers/start_test.go index 61c0723..b81f272 100644 --- a/src/lib/containers/start_test.go +++ b/src/lib/containers/start_test.go @@ -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"}, "ops@example.com"); 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 != "ops@example.com" { + t.Errorf("OWNER_EMAIL = %q, want ops@example.com", 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) + } +} diff --git a/src/lib/containers/watcher.go b/src/lib/containers/watcher.go index 8f88a29..18fac0c 100644 --- a/src/lib/containers/watcher.go +++ b/src/lib/containers/watcher.go @@ -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, diff --git a/src/lib/secrets/secrets.go b/src/lib/secrets/secrets.go new file mode 100644 index 0000000..7f264a4 --- /dev/null +++ b/src/lib/secrets/secrets.go @@ -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 . + +// Package secrets is Maestro's owner config & secret store for the Service +// Config & Secrets theme (M1). It persists per-service operator-configured +// values under /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 /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 /secrets.json encrypted with +// /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 /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 "/" +} + +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 +} diff --git a/src/lib/secrets/secrets_test.go b/src/lib/secrets/secrets_test.go new file mode 100644 index 0000000..1955756 --- /dev/null +++ b/src/lib/secrets/secrets_test.go @@ -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 . + +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) + } +} diff --git a/src/lib/types/docker.go b/src/lib/types/docker.go index ba14ed6..3fba7d1 100644 --- a/src/lib/types/docker.go +++ b/src/lib/types/docker.go @@ -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"` diff --git a/src/lib/types/registry.go b/src/lib/types/registry.go index f937e95..2ff374f 100644 --- a/src/lib/types/registry.go +++ b/src/lib/types/registry.go @@ -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 diff --git a/src/lib/types/service.go b/src/lib/types/service.go index 77b4786..fcae4c5 100644 --- a/src/lib/types/service.go +++ b/src/lib/types/service.go @@ -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 "" } diff --git a/src/lib/types/service_test.go b/src/lib/types/service_test.go index fabfeba..1687f42 100644 --- a/src/lib/types/service_test.go +++ b/src/lib/types/service_test.go @@ -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) + } +} diff --git a/src/main.go b/src/main.go index 4938bbc..9c15e80 100644 --- a/src/main.go +++ b/src/main.go @@ -26,6 +26,7 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth" + "git.dcntrlized.org/Dcntrlized/Maestro/handlers/config" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers" handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" @@ -36,6 +37,7 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" "git.dcntrlized.org/Dcntrlized/Maestro/lib/owner" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" ) //go:embed all:frontend/dist @@ -96,6 +98,10 @@ func main() { if err != nil { log.Fatalf("Failed to open session store: %v", err) } + secretStore, err := secrets.NewFileStore(maestroDir) + if err != nil { + log.Fatalf("Failed to initialize secrets store: %v", err) + } authService := auth.New(ownerStore, tokenService, sessionStore) if os.Getenv("DEV_MODE_STATIC_SERVICES") == "true" { @@ -182,6 +188,14 @@ func main() { mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject))) mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide))) + // Service Config & Secrets (theme M1): owner-gated per-service config and + // secret values. The manifest declares the schema; the encrypted store + // holds the values; this handler merges them for the UI and persists + // changes. + configHandler := config.NewHandler(secretStore, cache) + mux.Handle("/api/services/{service}/config", gate(http.HandlerFunc(configHandler.List))) + mux.Handle("/api/services/{service}/config/{key}", gate(http.HandlerFunc(configHandler.Value))) + frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist") if err != nil { log.Fatalf("Failed to parse embedded frontend subtree: %v", err)