feat(dev): DCNTRLIZED_DEV drives dev mode; dev fleet runs real mTLS

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.
This commit is contained in:
2026-08-16 15:51:49 +01:00
parent e569466dc3
commit addaabc0a8
12 changed files with 178 additions and 96 deletions
+1 -3
View File
@@ -14,11 +14,9 @@ services:
context: . context: .
dockerfile: Dockerfile.debug dockerfile: Dockerfile.debug
environment: environment:
- DEV_MODE=true
- DCNTRLIZED_DEV=true - DCNTRLIZED_DEV=true
- INSECURE_INTERNAL=1
- XDG_DATA_HOME=/data - 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_REGISTRY_RO_TOKEN=${GITEA_REGISTRY_RO_TOKEN}
- GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN} - GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN}
- GITEA_ORG=${GITEA_ORG:-Dcntrlized} - GITEA_ORG=${GITEA_ORG:-Dcntrlized}
-2
View File
@@ -19,8 +19,6 @@ services:
- GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN} - GITEA_RW_PACKAGE_TOKEN=${GITEA_RW_PACKAGE_TOKEN}
- GITEA_ORG=${GITEA_ORG:-Dcntrlized} - GITEA_ORG=${GITEA_ORG:-Dcntrlized}
- GITEA_USER=${GITEA_USER:-admin} - GITEA_USER=${GITEA_USER:-admin}
- GITEA_URL=${GITEA_URL:-http://192.168.122.183:3000}
- DCNTRLIZED_DEV=${DCNTRLIZED_DEV:-true}
- GIT_TOKEN=${GIT_TOKEN} - GIT_TOKEN=${GIT_TOKEN}
- REGISTRY_USER=${REGISTRY_USER:-admin} - REGISTRY_USER=${REGISTRY_USER:-admin}
+1 -1
View File
@@ -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). - 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. - 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`; - 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`). `192.168.122.183:3000`).
- Maestro itself runs as a container (image `maestro`); its own container is hidden from the list. - 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`. - Update detection compares the installed version against the registry latest via semver; versions are normalized to `X.Y.Z`.
+1 -1
View File
@@ -21,7 +21,7 @@ export default defineConfig(({ command }): UserConfig => {
? { ? {
'/api': 'http://127.0.0.1:8080', '/api': 'http://127.0.0.1:8080',
'/gitea-api': { '/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, changeOrigin: true,
rewrite: (path) => path.replace(/^\/gitea-api/, ''), rewrite: (path) => path.replace(/^\/gitea-api/, ''),
}, },
+3 -2
View File
@@ -68,8 +68,9 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
// Construct the URL for the action endpoint, preserving the query string. // Construct the URL for the action endpoint, preserving the query string.
// The service peer edge is mutual TLS (M2 §2): Maestro presents its own // 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 // identity cert and the service's listener requires a CA-issued client
// cert, so only Maestro can drive actions (T4). Under INSECURE_INTERNAL=1 // cert, so only Maestro can drive actions (T4). In dev mode
// (dev escape hatch) it stays plaintext. // (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) actionURL := fmt.Sprintf("%s://%s:%s/action/%s", authmgmt.Scheme(), network.IPAddress.Address, port, actionName)
if r.URL.RawQuery != "" { if r.URL.RawQuery != "" {
actionURL += "?" + r.URL.RawQuery actionURL += "?" + r.URL.RawQuery
+17 -15
View File
@@ -32,6 +32,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
) )
@@ -95,7 +96,13 @@ func New() (*Client, error) {
return nil, err 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 == "" { if baseURL == "" {
baseURL = AuthServiceURL baseURL = AuthServiceURL
} }
@@ -144,27 +151,22 @@ func maestroTransport() (*http.Transport, error) {
}, nil }, nil
} }
// InsecureInternal reports whether the dev escape hatch is enabled // Scheme returns "http" in dev mode and "https" otherwise, for building
// (INSECURE_INTERNAL=1): peer edges run plaintext, mirroring the services' // satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a
// listeners in the dev compose files. // plaintext listener so the RN clients (which cannot do mTLS) can reach them;
func InsecureInternal() bool { // production satellites bind mTLS listeners.
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).
func Scheme() string { func Scheme() string {
if InsecureInternal() { if env.IsDev() {
return "http" return "http"
} }
return "https" return "https"
} }
// PeerTransport returns the transport Maestro uses for the service peer edges // PeerTransport returns the transport Maestro uses for the satellite peer
// (M2 §2). Normally mutual TLS with Maestro's identity; a plaintext transport // edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners),
// under INSECURE_INTERNAL=1 so the dev fleet keeps working mid-migration. // mutual TLS with Maestro's identity in production.
func PeerTransport() (*http.Transport, error) { func PeerTransport() (*http.Transport, error) {
if InsecureInternal() { if env.IsDev() {
return &http.Transport{}, nil return &http.Transport{}, nil
} }
return maestroTransport() return maestroTransport()
-27
View File
@@ -18,7 +18,6 @@ package containers
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -70,15 +69,6 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
return err 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. // The compose file may reference the image by a short name (e.g.
// "testservice") while install pulled it under its full registry reference. // "testservice") while install pulled it under its full registry reference.
// Make sure those short names exist so Up() can find them. // 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 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 // pinAuthFingerprint returns the service's pinned auth fingerprint, fetching
// and persisting it when the install record does not have one yet (M2 §5). // 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 // Best-effort: when the auth service is unreachable, it returns "" and the
-42
View File
@@ -14,45 +14,3 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers 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 }
+102
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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)
}
}
+34
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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")
}
+4 -3
View File
@@ -31,6 +31,7 @@ import (
"strings" "strings"
"time" "time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
@@ -42,10 +43,10 @@ import (
// BaseURL returns the Gitea instance base URL (scheme://host[:port]). // BaseURL returns the Gitea instance base URL (scheme://host[:port]).
// Production defaults to git.dcntrlized.org; dev mode (DCNTRLIZED_DEV=true) // 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 { func BaseURL() string {
if os.Getenv("DCNTRLIZED_DEV") == "true" { if env.IsDev() {
if u := strings.TrimRight(os.Getenv("GITEA_URL"), "/"); u != "" { if u := strings.TrimRight(env.GiteaURL(), "/"); u != "" {
return u return u
} }
} }
+15
View File
@@ -28,7 +28,9 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry" handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" 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/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
) )
@@ -64,6 +66,13 @@ func main() {
if _, err := pki.EnsureCA(dataRoot); err != nil { if _, err := pki.EnsureCA(dataRoot); err != nil {
log.Fatalf("Failed to provision instance CA: %v", err) 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 { if err := cache.LoadInstalledContainers(); err != nil {
log.Fatalf("Failed to load installed containers: %v", err) log.Fatalf("Failed to load installed containers: %v", err)
} }
@@ -77,6 +86,12 @@ func main() {
if err := libcontainers.EnsureAuthRunning(ctx); err != nil { if err := libcontainers.EnsureAuthRunning(ctx); err != nil {
log.Printf("Warning: failed to ensure shared auth service: %v", err) 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) cache.ReconcileContainers(ctx, dc)
go cache.WatchContainers(ctx, dc) go cache.WatchContainers(ctx, dc)
} }