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:
2026-08-31 00:48:51 +01:00
parent ca4c66110e
commit a9e2182396
22 changed files with 1831 additions and 12 deletions
+166
View File
@@ -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 <dataRoot>/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
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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) {
</Button>
)}
{installed && onOpenConfig && (
<Button
data-slot="card-config"
variant="ghost"
size="icon-sm"
isDisabled={busy}
onPress={onOpenConfig}
aria-label={`Configure ${service.name}`}
className="mr-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<SlidersHorizontal aria-hidden="true" className="size-3" />
</Button>
)}
{installed && service.hasUpdate && (
<Button
data-slot="card-update-action"
@@ -0,0 +1,265 @@
/**
* 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/>.
*/
import { Check, Clipboard, EyeOff, LoaderCircle, Save, SlidersHorizontal, X } from 'lucide-react'
import { useState } from 'preact/hooks'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
import { Button } from '@components/ui/button'
import { toast } from 'sonner'
import { deleteServiceConfig, putServiceConfig } from '@lib/api/services'
import { useServiceConfig, type ServiceConfigLoadState } from '@lib/hooks/useServiceConfig'
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
import type { Service, ServiceConfigEntry } from '@lib/types'
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
interface ServiceConfigDialogProps {
service: Service | null
onClose: () => void
}
/**
* The per-service config & secrets surface (Service Config & Secrets theme M1).
* Opens from the service card for installed services that declare a `config`
* block. Cleartext fields are read-write; password fields are write-only —
* the panel only ever shows a set/unset indicator for them.
*/
export function ServiceConfigDialog({ service, onClose }: ServiceConfigDialogProps) {
const open = service !== null
const { load, refresh } = useServiceConfig(service?.name ?? null, open)
return (
<ModalOverlay
isOpen={open}
onOpenChange={(o) => {
if (!o) onClose()
}}
isDismissable
className="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm"
>
<Modal className="w-full max-w-lg min-w-0">
<Dialog
aria-label="Service settings"
className="max-h-[min(100dvh-2rem,44rem)] w-full min-w-0 overflow-y-auto rounded-xl border border-border bg-popover text-popover-foreground shadow-toast outline-none"
>
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground">
<SlidersHorizontal className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
{service?.title || service?.name || 'Service'} settings
</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Operator-configured values declared by the service manifest. Passwords are stored
encrypted and are never shown again — only whether they are set. Applies on next
start.
</p>
</div>
<Button data-slot="service-config-close" variant="ghost" size="icon-sm" aria-label="Close" onPress={onClose}>
<X aria-hidden="true" className="size-4" />
</Button>
</header>
<div className="grid min-w-0 gap-4 px-5 py-5">
<ServiceConfigSection serviceName={service?.name ?? ''} load={load} onRefresh={refresh} />
</div>
</Dialog>
</Modal>
</ModalOverlay>
)
}
interface ServiceConfigSectionProps {
serviceName: string
load: ServiceConfigLoadState
onRefresh: () => void
}
function ServiceConfigSection({ serviceName, load, onRefresh }: ServiceConfigSectionProps) {
if (load.kind === 'loading') {
return (
<div
data-slot="service-config-loading"
className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"
>
<LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
Loading config…
</div>
)
}
if (load.kind === 'error') {
return (
<div
data-slot="service-config-error"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2.5 text-sm"
>
<p className="min-w-0 text-muted-foreground">
Couldn't load config.
<span className="ml-1.5 text-destructive">{load.message}</span>
</p>
<Button size="xs" variant="outline" onPress={onRefresh}>
Retry
</Button>
</div>
)
}
if (load.entries.length === 0) {
return (
<div
data-slot="service-config-empty"
className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-8 text-sm text-muted-foreground"
>
This service declares no operator-configurable settings.
</div>
)
}
return (
<div data-slot="service-config-section" className="grid min-w-0 gap-3">
{load.entries.map((entry) => (
<ConfigRow
key={entry.field}
entry={entry}
serviceName={serviceName}
onDone={onRefresh}
/>
))}
</div>
)
}
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 (
<div
data-slot="service-config-row"
className="grid min-w-0 gap-2 rounded-lg border border-border bg-muted/40 px-3 py-3"
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium">
<Clipboard aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.key || entry.field}</span>
{isPassword && entry.set && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-success/25 bg-success/10 px-1.5 py-px text-[10px] font-medium text-success">
<Check aria-hidden="true" className="size-2.5" />
set
</span>
)}
{isPassword && !entry.set && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-muted/60 px-1.5 py-px text-[10px] font-medium text-muted-foreground">
<EyeOff aria-hidden="true" className="size-2.5" />
not set
</span>
)}
</p>
{entry.description && (
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{entry.description}</p>
)}
{!isPassword && entry.set && (
<p className="mt-0.5 font-mono text-xs text-muted-foreground/80">Current: {entry.value}</p>
)}
</div>
{entry.set && isPassword && (
<Button
size="xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
isDisabled={clear.pending}
onPress={clear.run}
>
{clear.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Clear
</Button>
)}
</div>
<div className="flex min-w-0 items-center gap-2">
<input
data-slot="service-config-value"
type={isPassword ? 'password' : 'text'}
value={value}
onInput={(e) => 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"
/>
<Button
size="xs"
isDisabled={save.pending || !hasValue || (isPassword && value.length === 0) || !canSave}
onPress={save.run}
>
{save.pending ? <LoaderCircle className="animate-spin" /> : <Save />}
Save
</Button>
{entry.set && !isPassword && (
<Button
size="xs"
variant="outline"
className="text-muted-foreground"
isDisabled={clear.pending}
onPress={clear.run}
>
{clear.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Clear
</Button>
)}
</div>
</div>
)
}
@@ -15,8 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Service | null>(null)
return (
<>
<div data-slot="service-grid" className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{services.map((service) => (
<ServiceCard key={service.id} service={service} db={db} />
<ServiceCard
key={service.id}
service={service}
db={db}
onOpenConfig={service.config && service.config.length > 0 ? () => setConfigService(service) : undefined}
/>
))}
</div>
<ServiceConfigDialog
service={configService}
onClose={() => setConfigService(null)}
/>
</>
)
}
+12
View File
@@ -83,3 +83,15 @@ export function post<T = void>(path: string, body?: unknown): Promise<T> {
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
export function put<T = void>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: 'PUT',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
export function del<T = void>(path: string): Promise<T> {
return request<T>(path, { method: 'DELETE' })
}
+41
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ServiceConfigEntry[]> {
return get<ServiceConfigEntry[]>(`/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<ServiceConfigEntry> {
return put<ServiceConfigEntry>(
`/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<void> {
return del<void>(`/api/services/${encodeURIComponent(service)}/config/${encodeURIComponent(field)}`)
}
+1
View File
@@ -57,6 +57,7 @@ function toService(c: Container): Service {
version: c.version || versionFromImage(c.image),
hasUpdate: false,
capabilities: c.capabilities ?? [],
config: c.config,
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ServiceConfigLoadState>({ 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 }
}
+31
View File
@@ -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. */
+184
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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)
}
+202
View File
@@ -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 <https://www.gnu.org/licenses/>.
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":"[email protected]"}`))
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 != "[email protected]" {
t.Errorf("owner_email value = %q, want [email protected]", 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":"[email protected]"}`))
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")
}
}
}
+9 -5
View File
@@ -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)
}
+59
View File
@@ -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 {
+94
View File
@@ -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)
}
}
+5
View File
@@ -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,
+345
View File
@@ -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
}
+180
View File
@@ -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)
}
}
+5
View File
@@ -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"`
+8
View File
@@ -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
+38
View File
@@ -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 ""
}
+79
View File
@@ -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)
}
}
+14
View File
@@ -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)