feat(config): deliver to always-mTLS Maestro peer edge (CN=maestro gate)

- /config delivery now targets the service's dedicated Maestro peer listener:
  https + authmgmt.MaestroTransport() (Maestro's own mTLS identity, never
  downgraded in dev), port from org.dcntrlized.peer_port label (default 9443)
- extract peerPort() label resolver + unit test
- expose MaestroTransport() on authmgmt for orchestrator-only edges
- spec + comments updated to describe the gated peer edge
This commit is contained in:
2026-09-02 09:37:02 +01:00
parent c540ad5d7f
commit 1558867d46
5 changed files with 171 additions and 69 deletions
+10 -2
View File
@@ -989,7 +989,10 @@ paths:
returned again — the response is only a ServiceConfigEntry with set:true.
The value is injected as the field's env key at start, and, when the
service is running and exposes POST /config (D6 write-back), delivered
to it at runtime over the mTLS peer edge.
to it at runtime. Delivery always targets the service's dedicated
Maestro peer listener (org.dcntrlized.peer_port label, default 9443)
over https with Maestro's own mTLS identity — CN=maestro is how the
service's /config gate authorizes the caller.
requestBody:
required: true
content:
@@ -1016,7 +1019,12 @@ paths:
tags: ["service-config"]
description: >-
Owner-gated (Service Config & Secrets theme M1). Removes the stored
value; on the next start the compose file's own default applies.
value; on the next start the compose file's own default applies. When
the service is running and exposes POST /config (D6 write-back), a
best-effort clear {field, action:"clear"} is delivered at runtime (same
https + Maestro mTLS identity edge, to the service's peer port, 9443 by
default) so the running process drops the value immediately
instead of waiting for a restart.
responses:
'200':
description: Value cleared
+6
View File
@@ -131,6 +131,12 @@ func (h *Handler) Value(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
// Runtime write-back (D6): best-effort "clear" to the running service's
// POST /config endpoint. The goroutine is fire-and-forget; the value is
// gone from the store either way, and the compose default reapplies at
// the next start (or immediately, if the service honors the clear).
go libcontainers.DeliverConfigClear(r.Context(), container.ServiceName, field)
case http.MethodPut:
var action valueAction
if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
+9
View File
@@ -173,6 +173,15 @@ func PeerTransport() (*http.Transport, error) {
return maestroTransport()
}
// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro)
// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges
// that must prove the caller is the orchestrator (e.g. /config delivery, which
// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV,
// since those services keep an always-mTLS peer listener.
func MaestroTransport() (*http.Transport, error) {
return maestroTransport()
}
// TrustState returns the pending authorizations and the approved trust store.
func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
var state TrustState
+106 -66
View File
@@ -1,8 +1,12 @@
// 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 over the existing mTLS peer edge (same
// transport as /action/* and manifest fetch).
// service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own
// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at
// the service's dedicated peer listener port (label org.dcntrlized.peer_port,
// default 9443), which the service gates to CN=maestro. 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
@@ -23,6 +27,26 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// peerPortLabel is the compose label a service declares to advertise the
// internal port of its always-mTLS Maestro peer listener.
const peerPortLabel = "org.dcntrlized.peer_port"
// defaultPeerPort is the fleet-wide default for that listener. Services keep/ve
// to it unless the label overrides it.
const defaultPeerPort = "9443"
// peerPort resolves the configured {peer listener port} from a container's
// compose labels, falling back to the fleet default.
func peerPort(labels map[string]string) string {
if labels == nil {
return defaultPeerPort
}
if p := labels[peerPortLabel]; p != "" {
return p
}
return defaultPeerPort
}
// ConfigDelivery is one key-value pair to deliver to a running service.
type ConfigDelivery struct {
Field string // manifest config field name
@@ -32,10 +56,12 @@ type ConfigDelivery struct {
}
// 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"`
Type string `json:"type"`
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
@@ -46,47 +72,6 @@ func DeliverConfigValues(ctx context.Context, serviceName string, items []Config
if len(items) == 0 {
return
}
dc, err := docker.NewDockerClient()
if err != nil {
logger.Warn("Config delivery to %s: cannot open docker client: %v", serviceName, err)
return
}
defer dc.Close()
inspectData, err := docker.ResolveContainer(ctx, dc, serviceName)
if err != nil {
logger.Debug("Config delivery to %s: service not running or not found (%v); values will apply at next start", serviceName, err)
return
}
shouldCache := false
network, _, err := docker.ExtractNetworkInfo(ctx, dc, inspectData)
if err != nil || !shouldCache {
logger.Debug("Config delivery to %s: cannot extract network info; values will apply at next start", serviceName)
return
}
port := inspectData.Config.Labels["org.dcntrlized.port"]
if port == "" && len(network.Ports) > 0 {
port = fmt.Sprintf("%d", network.Ports[0].Guest)
}
if port == "" {
logger.Debug("Config delivery to %s: no service port; values will apply at next start", serviceName)
return
}
transport, err := authmgmt.PeerTransport()
if err != nil {
logger.Warn("Config delivery to %s: cannot build peer transport: %v", serviceName, err)
return
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
for _, item := range items {
body, err := json.Marshal(configRequest{
Field: item.Field,
@@ -97,26 +82,81 @@ func DeliverConfigValues(ctx context.Context, serviceName string, items []Config
logger.Warn("Config delivery to %s/%s: marshal failed: %v", serviceName, item.Field, err)
continue
}
postConfig(ctx, serviceName, item.Field, "value", body)
}
}
url := fmt.Sprintf("%s://%s:%s/config", authmgmt.Scheme(), network.IPAddress.Address, port)
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, item.Field, err)
continue
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
logger.Debug("Config delivery to %s/%s: service unreachable (%v); value will apply at next start", serviceName, item.Field, err)
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Info("Delivered operator config %s/%s to running service %s", serviceName, item.Field, serviceName)
} else {
logger.Debug("Config delivery to %s/%s: service responded %d; value will apply at next start", serviceName, item.Field, resp.StatusCode)
}
// 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. 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
}
// /config is served on the service's dedicated Maestro peer listener
// (always mTLS, gated to CN=maestro), NOT the RN-facing flat edge. Maestro
// presents its own identity and targets the peer port; dev mode does not
// downgrade this edge.
port := peerPort(inspectData.Config.Labels)
transport, err := authmgmt.MaestroTransport()
if err != nil {
logger.Warn("Config delivery to %s/%s: cannot build Maestro peer transport: %v", serviceName, field, err)
return
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
url := fmt.Sprintf("https://%s:%s/config", network.IPAddress.Address, port)
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")
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)
}
}
+39
View File
@@ -0,0 +1,39 @@
// 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
import "testing"
func TestPeerPort(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want string
}{
{"nil labels fall back to default", nil, defaultPeerPort},
{"empty labels fall back to default", map[string]string{}, defaultPeerPort},
{"label present wins", map[string]string{"org.dcntrlized.peer_port": "9000"}, "9000"},
{"empty label value falls back to default", map[string]string{"org.dcntrlized.peer_port": ""}, defaultPeerPort},
{"unrelated labels fall back to default", map[string]string{"org.dcntrlized.port": "8080"}, defaultPeerPort},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := peerPort(tt.labels); got != tt.want {
t.Fatalf("peerPort(%v) = %q, want %q", tt.labels, got, tt.want)
}
})
}
}