192 lines
6.0 KiB
Go
192 lines
6.0 KiB
Go
// 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)
|
|
|
|
// 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)
|
|
}
|