Files
Maestro/src/lib/containers/configdeliver.go
T
Admin 17d5307110 [refactor](maestro): retire the PKI and federation identity layer
Contract rev 9 removes the instance CA, client certificates, the peer listener and the trust store fleet-wide. Internal traffic is plaintext HTTP on each service single :8080 listener, so there is no peer identity left to authorize: delete lib/pki, lib/federation, devboot, the per-service database role model and the /api/peers* and /api/instances surfaces, and drop the console Trusted services UI. Config delivery now rides a dedicated ES256 token from lib/configsign rather than mTLS, and the instance fingerprint is derived from a persisted random key and is informational only. main.go also wires the surfaces introduced by the later commits, which this change is what makes possible.
2026-10-19 02:39:15 +01:00

155 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 containers implements Maestro's runtime write-back delivery for the
// Service Config & Secrets theme (D6). When the operator sets or clears a
// config value via the Settings panel, Maestro delivers it to the running
// service's POST /config endpoint on the service's single plaintext :8080
// listener, authenticated with a short-lived, single-use ES256 config token
// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17).
// There is no mTLS and no peer port: the fleet-wide internal transport is
// plaintext HTTP. A set carries {field, value, type}; a clear carries
// {field, action:"clear"} — the service owns the schema and branches on what it
// receives.
//
// Delivery is best-effort: failures are logged (with the field name, never
// the value) and do not prevent the panel write from succeeding. Values always
// also take effect at the next StartContainer via injectConfigSecrets (M1),
// so delivery is an accelerator, not the sole path.
package containers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// configPort is the single port every service serves its API, /config and web
// app on after rev 9. There is no peer listener and no compose label.
const configPort = "8080"
// ConfigDelivery is one key-value pair to deliver to a running service.
type ConfigDelivery struct {
Field string // manifest config field name
Key string // container env var name
Type string // "cleartext" | "password"
Value string // the operator-entered value (never logged)
}
// configRequest is the JSON body POSTed to the service's /config endpoint.
// Action is empty for a set (the existing contract) and "clear" for a clear.
type configRequest struct {
Field string `json:"field"`
Value string `json:"value,omitempty"`
Type string `json:"type,omitempty"`
Action string `json:"action,omitempty"`
}
// DeliverConfigValues delivers the given config pairs to the running service.
// If the service is not running or unreachable, the error is logged but not
// returned — the values will take effect at next start via injectConfigSecrets.
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
if len(items) == 0 {
return
}
for _, item := range items {
body, err := json.Marshal(configRequest{
Field: item.Field,
Value: item.Value,
Type: item.Type,
})
if err != nil {
logger.Warn("Config delivery to %s/%s: marshal failed: %v", serviceName, item.Field, err)
continue
}
postConfig(ctx, serviceName, item.Field, "value", body)
}
}
// DeliverConfigClear tells the running service to clear a config field. The
// service decides what "clear" means (drop the value, fall back to defaults);
// like sets, it is best-effort and covered by the next start.
func DeliverConfigClear(ctx context.Context, serviceName, field string) {
body, err := json.Marshal(configRequest{Field: field, Action: "clear"})
if err != nil {
logger.Warn("Config clear delivery to %s/%s: marshal failed: %v", serviceName, field, err)
return
}
postConfig(ctx, serviceName, field, "clear", body)
}
// postConfig performs one best-effort POST of body to the running service's
// /config endpoint on the single :8080 listener, authenticated with a freshly
// minted ES256 config token. NotFound/unreachable cases are logged at debug
// (the write still applies at next start); transport errors carry the field
// name only, never a value.
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
dc, err := docker.NewDockerClient()
if err != nil {
logger.Warn("Config delivery to %s/%s: cannot open docker client: %v", serviceName, field, err)
return
}
defer dc.Close()
inspectData, err := docker.ResolveContainer(ctx, dc, serviceName)
if err != nil {
logger.Debug("Config delivery to %s/%s: service not running or not found (%v); will apply at next start", serviceName, field, err)
return
}
network, _, err := docker.ExtractNetworkInfo(ctx, dc, inspectData)
if err != nil {
logger.Debug("Config delivery to %s/%s: cannot extract network info; will apply at next start", serviceName, field)
return
}
token, err := authmgmt.ConfigToken(serviceName)
if err != nil {
logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err)
return
}
client := &http.Client{Timeout: 5 * time.Second}
url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
logger.Debug("Config delivery to %s/%s: service unreachable (%v); will apply at next start", serviceName, field, err)
return
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Info("Delivered operator config %s/%s (%s) to running service", serviceName, field, verb)
} else {
logger.Debug("Config delivery to %s/%s: service responded %d; will apply at next start", serviceName, field, resp.StatusCode)
}
}