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