diff --git a/spec.yaml b/spec.yaml index 89ff51a..838e14b 100644 --- a/spec.yaml +++ b/spec.yaml @@ -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 diff --git a/src/handlers/config/config.go b/src/handlers/config/config.go index 454313a..a18b49b 100644 --- a/src/handlers/config/config.go +++ b/src/handlers/config/config.go @@ -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 { diff --git a/src/lib/authmgmt/client.go b/src/lib/authmgmt/client.go index 2a6ad33..5d394f5 100644 --- a/src/lib/authmgmt/client.go +++ b/src/lib/authmgmt/client.go @@ -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 diff --git a/src/lib/containers/configdeliver.go b/src/lib/containers/configdeliver.go index 26c2275..ec850fa 100644 --- a/src/lib/containers/configdeliver.go +++ b/src/lib/containers/configdeliver.go @@ -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 } - - 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) - } + 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. 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) + } +} \ No newline at end of file diff --git a/src/lib/containers/configdeliver_test.go b/src/lib/containers/configdeliver_test.go new file mode 100644 index 0000000..0d2bafb --- /dev/null +++ b/src/lib/containers/configdeliver_test.go @@ -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 . + +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) + } + }) + } +} \ No newline at end of file