From addaabc0a8b11aaf84e7ea305cba98693dc50560 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Sun, 16 Aug 2026 15:51:49 +0100 Subject: [PATCH] feat(dev): DCNTRLIZED_DEV drives dev mode; dev fleet runs real mTLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single canonical dev flag is DCNTRLIZED_DEV=true, injected by every docker-compose.debug/dev.yaml. Dev mode now selects endpoints and behaviors without downgrading control-plane security: - lib/env: IsDev()/GiteaURL(); registry BaseURL keys on DCNTRLIZED_DEV and DCNTRLIZED_GITEA_URL (renamed from GITEA_URL). - authmgmt: Scheme()/PeerTransport() (satellite edges) key on DCNTRLIZED_DEV — dev satellites keep a plaintext RN-facing listener, production mTLS. The auth-management edge (New()) stays mTLS in both, preferring DCNTRLIZED_AUTH_URL in dev. - Removed INSECURE_INTERNAL + propagateInsecureInternal from Maestro: the orchestrator no longer runs plaintext, and no longer injects the escape hatch into services it starts. - lib/devboot: at boot (dev only) Maestro provisions the identities its compose-managed peers bind-mount at /dcntrlized/pki (before infra boots) and approves the known fleet in the auth trust store. Unknown peers still record a pending authorization and are refused (mechanisms active). - Composes: prod docker-compose.yaml is clean (no DCNTRLIZED_DEV/GITEA_URL); the debug compose carries the dev flag + DCNTRLIZED_GITEA_URL only. --- docker-compose.debug.yaml | 4 +- docker-compose.yaml | 2 - src/frontend/PRODUCT.md | 2 +- src/frontend/vite.config.ts | 2 +- src/handlers/actions/actions.go | 5 +- src/lib/authmgmt/client.go | 32 +++++----- src/lib/containers/start.go | 27 -------- src/lib/containers/start_test.go | 42 ------------- src/lib/devboot/devboot.go | 102 +++++++++++++++++++++++++++++++ src/lib/env/env.go | 34 +++++++++++ src/lib/registry/registry.go | 7 ++- src/main.go | 15 +++++ 12 files changed, 178 insertions(+), 96 deletions(-) create mode 100644 src/lib/devboot/devboot.go create mode 100644 src/lib/env/env.go diff --git a/docker-compose.debug.yaml b/docker-compose.debug.yaml index fd8d73a..ed8bd56 100644 --- a/docker-compose.debug.yaml +++ b/docker-compose.debug.yaml @@ -14,11 +14,9 @@ services: context: . dockerfile: Dockerfile.debug environment: - - DEV_MODE=true - DCNTRLIZED_DEV=true - - INSECURE_INTERNAL=1 - XDG_DATA_HOME=/data - - GITEA_URL=${GITEA_URL:-http://192.168.122.183:3000} + - DCNTRLIZED_GITEA_URL=${DCNTRLIZED_GITEA_URL:-http://192.168.122.183:3000} - GITEA_REGISTRY_RO_TOKEN=${GITEA_REGISTRY_RO_TOKEN} - GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN} - GITEA_ORG=${GITEA_ORG:-Dcntrlized} diff --git a/docker-compose.yaml b/docker-compose.yaml index d8504d4..75cf657 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,8 +19,6 @@ services: - GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN} - GITEA_ORG=${GITEA_ORG:-Dcntrlized} - GITEA_USER=${GITEA_USER:-admin} - - GITEA_URL=${GITEA_URL:-http://192.168.122.183:3000} - - DCNTRLIZED_DEV=${DCNTRLIZED_DEV:-true} - GIT_TOKEN=${GIT_TOKEN} - REGISTRY_USER=${REGISTRY_USER:-admin} diff --git a/src/frontend/PRODUCT.md b/src/frontend/PRODUCT.md index 10111e6..61225a8 100644 --- a/src/frontend/PRODUCT.md +++ b/src/frontend/PRODUCT.md @@ -26,7 +26,7 @@ The mechanism is a Git-backed, decentralized service registry: services are publ - Real-time statuses come from Docker (running, exited, stopped, created, restarting) plus UI states (installed, uninstalled). - Service metadata (title, vendor, author, description, source, licenses, revision) comes from OCI annotations published with each image. - Upstream registry defaults to git.dcntrlized.org (Gitea), org `Dcntrlized`; - in dev (`DCNTRLIZED_DEV=true`) it targets `GITEA_URL` (the dev VM + in dev (`DCNTRLIZED_DEV=true`) it targets `DCNTRLIZED_GITEA_URL` (the dev VM `192.168.122.183:3000`). - Maestro itself runs as a container (image `maestro`); its own container is hidden from the list. - Update detection compares the installed version against the registry latest via semver; versions are normalized to `X.Y.Z`. diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index f5bf3c1..ffc0a76 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig(({ command }): UserConfig => { ? { '/api': 'http://127.0.0.1:8080', '/gitea-api': { - target: process.env.GITEA_URL || 'https://git.dcntrlized.org', // Gitea base URL (override with GITEA_URL in dev) + target: process.env.DCNTRLIZED_GITEA_URL || 'https://git.dcntrlized.org', // Gitea base URL (override with DCNTRLIZED_GITEA_URL in dev) changeOrigin: true, rewrite: (path) => path.replace(/^\/gitea-api/, ''), }, diff --git a/src/handlers/actions/actions.go b/src/handlers/actions/actions.go index b96ec46..d7095d1 100644 --- a/src/handlers/actions/actions.go +++ b/src/handlers/actions/actions.go @@ -68,8 +68,9 @@ func RunAction(w http.ResponseWriter, r *http.Request) { // Construct the URL for the action endpoint, preserving the query string. // The service peer edge is mutual TLS (M2 §2): Maestro presents its own // identity cert and the service's listener requires a CA-issued client - // cert, so only Maestro can drive actions (T4). Under INSECURE_INTERNAL=1 - // (dev escape hatch) it stays plaintext. + // cert, so only Maestro can drive actions (T4). In dev mode + // (DCNTRLIZED_DEV=true) the satellites keep a plaintext listener for the + // RN clients, so the action edge stays plaintext there too. actionURL := fmt.Sprintf("%s://%s:%s/action/%s", authmgmt.Scheme(), network.IPAddress.Address, port, actionName) if r.URL.RawQuery != "" { actionURL += "?" + r.URL.RawQuery diff --git a/src/lib/authmgmt/client.go b/src/lib/authmgmt/client.go index 71d27dc..fd49e81 100644 --- a/src/lib/authmgmt/client.go +++ b/src/lib/authmgmt/client.go @@ -32,6 +32,7 @@ import ( "path/filepath" "time" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" ) @@ -95,7 +96,13 @@ func New() (*Client, error) { return nil, err } - baseURL := os.Getenv("AUTH_SERVICE_URL") + baseURL := "" + if env.IsDev() { + baseURL = os.Getenv("DCNTRLIZED_AUTH_URL") + } + if baseURL == "" { + baseURL = os.Getenv("AUTH_SERVICE_URL") + } if baseURL == "" { baseURL = AuthServiceURL } @@ -144,27 +151,22 @@ func maestroTransport() (*http.Transport, error) { }, nil } -// InsecureInternal reports whether the dev escape hatch is enabled -// (INSECURE_INTERNAL=1): peer edges run plaintext, mirroring the services' -// listeners in the dev compose files. -func InsecureInternal() bool { - return os.Getenv("INSECURE_INTERNAL") == "1" -} - -// Scheme returns "http" under the dev escape hatch and "https" otherwise, for -// building service peer URLs (RunAction, manifest fetch). +// Scheme returns "http" in dev mode and "https" otherwise, for building +// satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a +// plaintext listener so the RN clients (which cannot do mTLS) can reach them; +// production satellites bind mTLS listeners. func Scheme() string { - if InsecureInternal() { + if env.IsDev() { return "http" } return "https" } -// PeerTransport returns the transport Maestro uses for the service peer edges -// (M2 §2). Normally mutual TLS with Maestro's identity; a plaintext transport -// under INSECURE_INTERNAL=1 so the dev fleet keeps working mid-migration. +// PeerTransport returns the transport Maestro uses for the satellite peer +// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners), +// mutual TLS with Maestro's identity in production. func PeerTransport() (*http.Transport, error) { - if InsecureInternal() { + if env.IsDev() { return &http.Transport{}, nil } return maestroTransport() diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go index 28cc8bb..ff050a4 100644 --- a/src/lib/containers/start.go +++ b/src/lib/containers/start.go @@ -18,7 +18,6 @@ package containers import ( "context" "fmt" - "os" "path/filepath" "strings" @@ -70,15 +69,6 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error { return err } - // Dev escape hatch (M2 §2): when Maestro itself runs with - // INSECURE_INTERNAL=1, start services the dev way too — propagate the flag - // into the compose services so infra auto-started by Maestro (e.g. a - // resurrected dcntrlized-auth, see infra.go) keeps a plaintext listener - // instead of coming up as mTLS against the plaintext dev fleet. - if os.Getenv("INSECURE_INTERNAL") == "1" { - propagateInsecureInternal(project) - } - // The compose file may reference the image by a short name (e.g. // "testservice") while install pulled it under its full registry reference. // Make sure those short names exist so Up() can find them. @@ -171,23 +161,6 @@ func injectServiceIdentity(ctx context.Context, project *composetypes.Project, s return nil } -// propagateInsecureInternal injects INSECURE_INTERNAL=1 into every non-oneoff -// compose service when Maestro itself runs in dev mode (the same flag the dev -// debug composes set). Mirroring those composes keeps peer edges plaintext, so -// a service auto-installed and started by Maestro (e.g. a resurrected -// dcntrlized-auth) matches the compose-managed plaintext dev fleet instead of -// binding an mTLS listener the satellites cannot reach. -func propagateInsecureInternal(project *composetypes.Project) { - for name, s := range project.Services { - if isOneoff(s) { - continue - } - s.Environment = mergeEnv(s.Environment, map[string]string{"INSECURE_INTERNAL": "1"}) - project.Services[name] = s - } - logger.Info("Dev mode (INSECURE_INTERNAL=1): propagated plaintext escape hatch to %d service(s)", len(project.Services)) -} - // pinAuthFingerprint returns the service's pinned auth fingerprint, fetching // and persisting it when the install record does not have one yet (M2 §5). // Best-effort: when the auth service is unreachable, it returns "" and the diff --git a/src/lib/containers/start_test.go b/src/lib/containers/start_test.go index 4f51314..87830f1 100644 --- a/src/lib/containers/start_test.go +++ b/src/lib/containers/start_test.go @@ -14,45 +14,3 @@ // along with this program. If not, see . package containers - -import ( - "testing" - - composetypes "github.com/compose-spec/compose-go/v2/types" -) - -// TestPropagateInsecureInternal verifies the dev escape hatch is propagated to -// every non-oneoff compose service (and only those), mirroring what the dev -// debug composes set so Maestro-started services keep plaintext peer edges. -func TestPropagateInsecureInternal(t *testing.T) { - project := &composetypes.Project{ - Services: composetypes.Services{ - "app": { - Environment: map[string]*string{"PORT": strptr("8080")}, - }, - "worker": { - Environment: nil, - }, - "migrate": { - CustomLabels: map[string]string{"org.dcntrlized.oneoff": "true"}, - }, - }, - } - - propagateInsecureInternal(project) - - for _, name := range []string{"app", "worker"} { - s := project.Services[name] - v, ok := s.Environment["INSECURE_INTERNAL"] - if !ok || v == nil || *v != "1" { - t.Errorf("service %s: expected INSECURE_INTERNAL=1, got %v", name, v) - } - } - - s := project.Services["migrate"] - if _, ok := s.Environment["INSECURE_INTERNAL"]; ok { - t.Error("oneoff migrate service must not receive the plaintext escape hatch") - } -} - -func strptr(s string) *string { return &s } diff --git a/src/lib/devboot/devboot.go b/src/lib/devboot/devboot.go new file mode 100644 index 0000000..80d5869 --- /dev/null +++ b/src/lib/devboot/devboot.go @@ -0,0 +1,102 @@ +// 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 devboot provisions the dev fleet the DCNTRLIZED_DEV way: Maestro +// issues the identities its docker-compose.dev/debug.yaml peers bind-mount at +// /dcntrlized/pki, and approves those satellites in the auth service trust +// store. Dev mode runs the real security mechanisms (mTLS peer edges, +// authorized-keys enforcement) — this only removes the human-in-the-loop for +// the *known* dev fleet; any unknown peer still records a pending +// authorization and is refused. +package devboot + +import ( + "context" + + "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" +) + +// Infra are the shared services whose identities the dev composes mount. They +// are peers/attesters themselves, not approval candidates (auth is the +// trust-store owner, Maestro uses the internal RequireMaestro endpoints). +var Infra = []string{"dcntrlized-auth"} + +// Fleet lists the dev-mode satellite identities Maestro issues and approves. +// Their CNs must match the service names used in the dev composes' pki mounts. +var Fleet = []string{"keepnote", "calendar", "testservice"} + +// EnsureIdentities provisions the instance CA identities the dev composes +// bind-mount at /dcntrlized/pki, so compose-managed services come up with a +// valid mTLS identity before they ever dial the auth service. Run before the +// shared infrastructure is ensured, because dcntrlized-auth's mTLS listener +// refuses to start without its identity. +func EnsureIdentities() { + dataRoot, err := containers.DataRoot() + if err != nil { + logger.Warn("devboot: resolve data root: %v", err) + return + } + ca, err := pki.EnsureCA(dataRoot) + if err != nil { + logger.Warn("devboot: ensure instance CA: %v", err) + return + } + for _, name := range append(append([]string{}, Infra...), Fleet...) { + if _, err := pki.EnsureServiceIdentity(ca, name); err != nil { + logger.Warn("devboot: ensure identity for %s: %v", name, err) + } + } + logger.Info("devboot: ensured dev fleet identities (%d infra + %d satellites)", len(Infra), len(Fleet)) +} + +// ApproveFleet approves the dev satellites in the auth service trust store — +// the owner decision a fresh first-contact would pend, made once for the known +// fleet. Best-effort: if the auth service is not up yet, the first-contact +// no-op path records the pendings and the next boot approves them. +func ApproveFleet(ctx context.Context) { + if len(Fleet) == 0 { + return + } + client, err := authmgmt.New() + if err != nil { + logger.Warn("devboot: build auth client: %v", err) + return + } + dataRoot, err := containers.DataRoot() + if err != nil { + logger.Warn("devboot: resolve data root: %v", err) + return + } + ca, err := pki.EnsureCA(dataRoot) + if err != nil { + logger.Warn("devboot: ensure instance CA: %v", err) + return + } + for _, name := range Fleet { + identity, err := pki.EnsureServiceIdentity(ca, name) + if err != nil { + logger.Warn("devboot: load identity for %s: %v", name, err) + continue + } + if err := client.Approve(ctx, name, identity.Meta.Fingerprint); err != nil { + logger.Warn("devboot: approve %s: %v", name, err) + continue + } + logger.Info("devboot: approved dev peer %s (%s)", name, identity.Meta.Fingerprint) + } +} diff --git a/src/lib/env/env.go b/src/lib/env/env.go new file mode 100644 index 0000000..3b69242 --- /dev/null +++ b/src/lib/env/env.go @@ -0,0 +1,34 @@ +// 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 env centralizes environment-derived configuration. DCNTRLIZED_DEV is +// the single canonical dev-mode flag, injected by every docker-compose.dev.yaml +// and docker-compose.debug.yaml. It selects dev-only endpoints and behaviors +// (registry URL, plaintext satellite peer edges) — it never downgrades the +// transport security of the control-plane edges, which run mTLS in dev too. +package env + +import "os" + +// IsDev reports whether Maestro runs in dev mode (DCNTRLIZED_DEV=true). +func IsDev() bool { + return os.Getenv("DCNTRLIZED_DEV") == "true" +} + +// GiteaURL returns the dev Gitea base URL (DCNTRLIZED_GITEA_URL). Empty in +// production, where the hardcoded https://git.dcntrlized.org is used. +func GiteaURL() string { + return os.Getenv("DCNTRLIZED_GITEA_URL") +} diff --git a/src/lib/registry/registry.go b/src/lib/registry/registry.go index 0b8576c..ccde62e 100644 --- a/src/lib/registry/registry.go +++ b/src/lib/registry/registry.go @@ -31,6 +31,7 @@ import ( "strings" "time" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" @@ -42,10 +43,10 @@ import ( // BaseURL returns the Gitea instance base URL (scheme://host[:port]). // Production defaults to git.dcntrlized.org; dev mode (DCNTRLIZED_DEV=true) -// overrides it with GITEA_URL when set. +// overrides it with DCNTRLIZED_GITEA_URL when set. func BaseURL() string { - if os.Getenv("DCNTRLIZED_DEV") == "true" { - if u := strings.TrimRight(os.Getenv("GITEA_URL"), "/"); u != "" { + if env.IsDev() { + if u := strings.TrimRight(env.GiteaURL(), "/"); u != "" { return u } } diff --git a/src/main.go b/src/main.go index bb8f27c..d8d6a88 100644 --- a/src/main.go +++ b/src/main.go @@ -28,7 +28,9 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry" libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" ) @@ -64,6 +66,13 @@ func main() { if _, err := pki.EnsureCA(dataRoot); err != nil { log.Fatalf("Failed to provision instance CA: %v", err) } + // Dev mode: provision the identities the docker-compose.dev/debug.yaml + // peers bind-mount at /dcntrlized/pki, BEFORE the shared infrastructure + // boots — dcntrlized-auth's mTLS listener refuses to start without its + // identity. Must run before EnsureDatabaseRunning/EnsureAuthRunning. + if env.IsDev() { + devboot.EnsureIdentities() + } if err := cache.LoadInstalledContainers(); err != nil { log.Fatalf("Failed to load installed containers: %v", err) } @@ -77,6 +86,12 @@ func main() { if err := libcontainers.EnsureAuthRunning(ctx); err != nil { log.Printf("Warning: failed to ensure shared auth service: %v", err) } + // Dev mode: approve the known fleet in the auth trust store once the + // auth service is up, so the compose-managed satellites' mTLS proxies + // are authorized (the decision a first contact would pend). + if env.IsDev() { + devboot.ApproveFleet(ctx) + } cache.ReconcileContainers(ctx, dc) go cache.WatchContainers(ctx, dc) }