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
+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)
}