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.
155 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|