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. 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 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 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: requestBody:
required: true required: true
content: content:
@@ -1016,7 +1019,12 @@ paths:
tags: ["service-config"] tags: ["service-config"]
description: >- description: >-
Owner-gated (Service Config & Secrets theme M1). Removes the stored 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: responses:
'200': '200':
description: Value cleared 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"}) 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: case http.MethodPut:
var action valueAction var action valueAction
if err := json.NewDecoder(r.Body).Decode(&action); err != nil { if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
+9
View File
@@ -173,6 +173,15 @@ func PeerTransport() (*http.Transport, error) {
return maestroTransport() 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. // TrustState returns the pending authorizations and the approved trust store.
func (c *Client) TrustState(ctx context.Context) (*TrustState, error) { func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
var state TrustState var state TrustState
+106 -66
View File
@@ -1,8 +1,12 @@
// Package containers implements Maestro's runtime write-back delivery for the // Package containers implements Maestro's runtime write-back delivery for the
// Service Config & Secrets theme (D6). When the operator sets or clears a // Service Config & Secrets theme (D6). When the operator sets or clears a
// config value via the Settings panel, Maestro delivers it to the running // config value via the Settings panel, Maestro delivers it to the running
// service's POST /config endpoint over the existing mTLS peer edge (same // service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own
// transport as /action/* and manifest fetch). // 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 // 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 // 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" "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. // ConfigDelivery is one key-value pair to deliver to a running service.
type ConfigDelivery struct { type ConfigDelivery struct {
Field string // manifest config field name 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. // 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 { type configRequest struct {
Field string `json:"field"` Field string `json:"field"`
Value string `json:"value"` Value string `json:"value,omitempty"`
Type string `json:"type"` Type string `json:"type,omitempty"`
Action string `json:"action,omitempty"`
} }
// DeliverConfigValues delivers the given config pairs to the running service // 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 { if len(items) == 0 {
return 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 { for _, item := range items {
body, err := json.Marshal(configRequest{ body, err := json.Marshal(configRequest{
Field: item.Field, 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) logger.Warn("Config delivery to %s/%s: marshal failed: %v", serviceName, item.Field, err)
continue continue
} }
postConfig(ctx, serviceName, item.Field, "value", body)
}
}
url := fmt.Sprintf("%s://%s:%s/config", authmgmt.Scheme(), network.IPAddress.Address, port) // DeliverConfigClear tells the running service to clear a config field. The
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) // service decides what "clear" means (drop the value, fall back to defaults);
if err != nil { // like sets, it is best-effort and covered by the next start.
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, item.Field, err) func DeliverConfigClear(ctx context.Context, serviceName, field string) {
continue body, err := json.Marshal(configRequest{Field: field, Action: "clear"})
} if err != nil {
req.Header.Set("Content-Type", "application/json") logger.Warn("Config clear delivery to %s/%s: marshal failed: %v", serviceName, field, err)
return
resp, err := client.Do(req) }
if err != nil { postConfig(ctx, serviceName, field, "clear", body)
logger.Debug("Config delivery to %s/%s: service unreachable (%v); value will apply at next start", serviceName, item.Field, err) }
continue
} // postConfig performs one best-effort POST of body to the running service's
resp.Body.Close() // /config endpoint. NotFound/unreachable cases are logged at debug (the write
// still applies at next start); transport errors carry the field name only,
if resp.StatusCode >= 200 && resp.StatusCode < 300 { // never a value.
logger.Info("Delivered operator config %s/%s to running service %s", serviceName, item.Field, serviceName) func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
} else { dc, err := docker.NewDockerClient()
logger.Debug("Config delivery to %s/%s: service responded %d; value will apply at next start", serviceName, item.Field, resp.StatusCode) 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)
}
})
}
}