// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package config serves Maestro's owner-gated Service Config & Secrets API // (Service Config & Secrets theme M1). The service manifest's `config` block // is the pure schema; the encrypted store holds the operator-entered values; // this handler merges them for the UI and receives value changes. // // Routes (all owner-gated via auth.RequireSession in main.go): // // - GET /api/services/{service}/config merged view (never a password value) // - PUT /api/services/{service}/config/{key} set a value // - DELETE /api/services/{service}/config/{key} clear a value // // Errors follow the uniform {"error":{"code":…}} shape. package config import ( "encoding/json" "net/http" libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" ) // Handler serves the service config + secrets API against the install cache // (which carries each service's manifest config schema) and the encrypted // store. type Handler struct { store secrets.Store cache *libcontainers.ContainerCache } // NewHandler builds the config API handler. func NewHandler(store secrets.Store, cache *libcontainers.ContainerCache) *Handler { return &Handler{store: store, cache: cache} } // Entry is the merged per-field UI view: schema (type/key/description) plus the // operator's set state. Password values are never included; a cleartext value // (read-write) is. type Entry struct { Field string `json:"field"` Key string `json:"key"` Type string `json:"type"` Description string `json:"description,omitempty"` Set bool `json:"set"` Value string `json:"value,omitempty"` } // List serves GET /api/services/{service}/config: the merged view built from // the service's manifest config schema and the stored rows. func (h *Handler) List(w http.ResponseWriter, r *http.Request) { service := r.PathValue("service") container, ok := h.cache.GetService(service) if !ok { writeError(w, http.StatusNotFound, "ERR_NOT_FOUND") return } stored := map[string]secrets.PublicView{} for _, v := range h.store.All(container.ServiceName) { stored[v.Field] = v } entries := make([]Entry, 0, len(container.Config)) for _, item := range container.Config { field := item.FieldName() if field == "" { continue } spec := item[field] entry := Entry{ Field: field, Key: spec.Key, Type: spec.Type, Description: spec.Description, } if row, ok := stored[field]; ok && row.Set { entry.Set = true if row.Type == types.ConfigCleartext { entry.Value = row.Value } } entries = append(entries, entry) } writeJSON(w, http.StatusOK, entries) } // valueAction is the PUT request body: {value}. type valueAction struct { Value string `json:"value"` } // Value serves PUT and DELETE on /api/services/{service}/config/{key}: set or // clear a stored value. The {key} is the manifest config field name. func (h *Handler) Value(w http.ResponseWriter, r *http.Request) { service := r.PathValue("service") field := r.PathValue("key") container, ok := h.cache.GetService(service) if !ok { writeError(w, http.StatusNotFound, "ERR_NOT_FOUND") return } spec, known := h.field(container, field) if !known { writeError(w, http.StatusNotFound, "ERR_NOT_FOUND") return } switch r.Method { case http.MethodDelete: if err := h.store.Delete(container.ServiceName, field); err != nil { writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") return } writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) case http.MethodPut: var action valueAction if err := json.NewDecoder(r.Body).Decode(&action); err != nil { writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") return } if spec.Type == types.ConfigPassword && action.Value == "" { writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD") return } view, err := h.store.Set(container.ServiceName, field, spec, action.Value) if err != nil { writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") return } writeJSON(w, http.StatusOK, view) // Runtime write-back (D6): best-effort delivery to the running service's // POST /config endpoint. The goroutine is fire-and-forget; values always // also take effect at next start via injectConfigSecrets (M1). go libcontainers.DeliverConfigValues(r.Context(), container.ServiceName, []libcontainers.ConfigDelivery{ {Field: field, Key: spec.Key, Type: spec.Type, Value: action.Value}, }) 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) }