Services can declare an operator-configurable config block in /_manifest
(ordered array of single-key objects, pure schema: type cleartext|password,
description?, key = container env var name). Maestro:
- lib/secrets: AES-256-GCM FileStore; dedicated secrets.key (32 bytes,
chmod 600, separate from session.key); secrets.json rows keyed
service/field; cleartext read-write, password write-only & encrypted at
rest, decrypted only at injection. Footure key rotation = delete the key.
- handlers/config (owner-gated via auth.RequireSession):
GET /api/services/{service}/config (merged schema+set view, password
values never returned), PUT/DELETE /api/services/{service}/config/{key}
(uniform ERR_NOT_FOUND / ERR_INVALID_PAYLOAD error shape).
- start.go: injectConfigSecrets runs after injectServiceIdentity; only
operator-set rows become env on non-oneoff services; unset fields keep
the compose default.
- Manifest.Config carried onto Container/RegistryContainer/InstalledMetadata
via updateInstalledMetadataFromManifest + watcher.
- Frontend: per-service Settings dialog (ServiceConfigDialog) + api
helpers; cleartext editable, password set/unset indicator only.
- spec.yaml documents the new endpoints/schemas.
455 lines
16 KiB
Go
455 lines
16 KiB
Go
// 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 (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
|
"github.com/docker/compose/v2/pkg/api"
|
|
)
|
|
|
|
// identityMountDir is where Maestro bind-mounts the instance CA and the
|
|
// service's identity certificate inside every peer container.
|
|
const identityMountDir = "/dcntrlized/pki"
|
|
|
|
// StartContainer creates and starts the service containers defined in the
|
|
// service's docker-compose.yaml.
|
|
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
|
dc, err := docker.NewDockerClient()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize docker client: %w", err)
|
|
}
|
|
defer dc.Close()
|
|
|
|
serviceName := resolveServiceName(ctx, dc, item.Name)
|
|
project, err := loadComposeProject(ctx, serviceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Infrastructure first: the external dcntrlized_net network must exist
|
|
// before any compose project is started, and services that depend on the
|
|
// shared database need it running before they boot.
|
|
if err := EnsureDCNTRLIZEDNetwork(ctx, dc); err != nil {
|
|
return err
|
|
}
|
|
if serviceName != DatabaseServiceName {
|
|
if err := EnsureDatabaseRunning(ctx); err != nil {
|
|
logger.Warn("Failed to ensure shared database before starting %s: %v", serviceName, err)
|
|
}
|
|
}
|
|
|
|
// Issue (or refresh) the service's identity certificate from the instance
|
|
// CA and mount it plus the CA bundle into the container. The compose file
|
|
// downloaded from git never declares these mounts — Maestro injects them
|
|
// (security theme M1 §2). Failing to provision identity is fatal: a peer
|
|
// without a cert cannot authenticate on the mTLS edges. Database-capable
|
|
// services additionally get their per-service database role DSN here (M2
|
|
// §4): on an armed (mTLS) shared database the shared-superuser URL from
|
|
// their compose file cannot connect at all.
|
|
if err := injectServiceIdentity(ctx, dc, project, serviceName); err != nil {
|
|
return err
|
|
}
|
|
// Operator-configured env (Service Config & Secrets theme M1): inject the
|
|
// values the owner set through the Settings API for this service. Rows only
|
|
// exist for fields the operator configured; unset fields keep the compose
|
|
// default untouched.
|
|
if err := injectConfigSecrets(project, serviceName); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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.
|
|
installedImage := item.Image
|
|
if installedImage == "" {
|
|
if dir, dirErr := getContainerDataDir(serviceName); dirErr == nil {
|
|
if metadata, metaErr := readInstalledMetadata(dir); metaErr == nil {
|
|
installedImage = metadata.Image
|
|
}
|
|
}
|
|
}
|
|
pinServiceImages(project, installedImage)
|
|
ensureServiceImages(ctx, dc, project, installedImage)
|
|
|
|
composeService, err := newComposeService(dc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
logger.Info("Starting service %s...", serviceName)
|
|
err = composeService.Up(ctx, project, api.UpOptions{
|
|
Create: api.CreateOptions{
|
|
Recreate: api.RecreateDiverged,
|
|
AssumeYes: true,
|
|
},
|
|
Start: api.StartOptions{},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start service %s: %w", serviceName, err)
|
|
}
|
|
|
|
logger.Info("Successfully started service: %s", serviceName)
|
|
return nil
|
|
}
|
|
|
|
// injectServiceIdentity issues (or refreshes) the service's identity
|
|
// certificate from the instance CA and injects the CA bundle, the identity
|
|
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
|
|
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
|
|
// AUTH_CLIENT_KEY) so consumers need no per-service config.
|
|
//
|
|
// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's
|
|
// fingerprint recorded at install. A service that already has the pin skips the
|
|
// fetch; one installed before this feature (or whose install could not reach
|
|
// the auth service) backfills it here, best-effort.
|
|
//
|
|
// For services declaring the database capability (M2 §4), when the shared
|
|
// database runs mTLS-armed, every app service additionally receives a
|
|
// per-service DATABASE_URL (role = the service's cert CN, verify-full against
|
|
// the instance CA), overriding the shared-superuser URL their compose file
|
|
// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL
|
|
// env so they can reach the armed database under the same role.
|
|
func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
|
|
dataRoot, err := getDataRoot()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve data root: %w", err)
|
|
}
|
|
|
|
ca, err := pki.EnsureCA(dataRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("ensure instance CA: %w", err)
|
|
}
|
|
identity, err := pki.EnsureServiceIdentity(ca, serviceName)
|
|
if err != nil {
|
|
return fmt.Errorf("provision identity for %s: %w", serviceName, err)
|
|
}
|
|
|
|
mounts := []string{
|
|
fmt.Sprintf("%s:%s/ca.crt:ro", hostBindSource(dataRoot, pki.CertPath(dataRoot)), identityMountDir),
|
|
fmt.Sprintf("%s:%s/tls.crt:ro", hostBindSource(dataRoot, identity.CertPath), identityMountDir),
|
|
fmt.Sprintf("%s:%s/tls.key:ro", hostBindSource(dataRoot, identity.KeyPath), identityMountDir),
|
|
}
|
|
env := map[string]string{
|
|
"SERVICE_NAME": serviceName,
|
|
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
|
|
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
|
|
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
|
|
}
|
|
|
|
// Per-service database role DSN (M2 §4). Only on an armed database — a
|
|
// plain (compose-managed dev) Postgres cannot satisfy verify-full, and its
|
|
// stock trust rule needs no per-service roles anyway. Best-effort: without
|
|
// the capability in the install record (pre-manifest installs) nothing is
|
|
// injected and the compose defaults apply.
|
|
dbEnv := map[string]string{}
|
|
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
|
|
if dbctl.New(dc).Armed(ctx) {
|
|
env["DATABASE_URL"] = mtlsDatabaseURL(serviceName)
|
|
dbEnv = map[string]string{
|
|
"PGUSER": serviceName,
|
|
"PGSSLMODE": "verify-full",
|
|
"PGSSLCERT": identityMountDir + "/tls.crt",
|
|
"PGSSLKEY": identityMountDir + "/tls.key",
|
|
"PGSSLROOTCERT": identityMountDir + "/ca.crt",
|
|
}
|
|
logger.Info("Injected per-service database DSN for %s (armed shared database)", serviceName)
|
|
}
|
|
}
|
|
|
|
// The auth service is the attester itself — nothing to pin there. For every
|
|
// other peer, pin its signing key (fetch + persist once).
|
|
if serviceName != AuthServiceName {
|
|
if pinned := pinAuthFingerprint(ctx, serviceName); pinned != "" {
|
|
env["AUTH_PINNED_FINGERPRINT"] = pinned
|
|
}
|
|
}
|
|
|
|
for name, s := range project.Services {
|
|
if isOneoff(s) {
|
|
// Migrate/seed helpers talk libpq/psycopg straight to the shared
|
|
// database; on an armed database they need the identity mount plus
|
|
// the same per-service role (their compose file's PGUSER is the
|
|
// plain-dev default).
|
|
if len(dbEnv) > 0 {
|
|
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
|
|
s.Environment = mergeEnv(s.Environment, dbEnv)
|
|
project.Services[name] = s
|
|
}
|
|
continue
|
|
}
|
|
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
|
|
s.Environment = mergeEnv(s.Environment, env)
|
|
project.Services[name] = s
|
|
}
|
|
logger.Info("Injected service identity for %s (fingerprint %s)", serviceName, identity.Meta.Fingerprint)
|
|
return nil
|
|
}
|
|
|
|
// mtlsDatabaseURL builds the per-service connection URL for the shared
|
|
// database: role = the service's cert CN, no password (clientcert=verify-full
|
|
// makes the certificate the credential), TLS verified against the instance CA.
|
|
func mtlsDatabaseURL(serviceName string) string {
|
|
return fmt.Sprintf(
|
|
"postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key",
|
|
serviceName, DatabaseServiceName, dbctl.DBName,
|
|
identityMountDir, identityMountDir, identityMountDir,
|
|
)
|
|
}
|
|
|
|
// injectConfigSecrets injects the operator-configured env values (Service
|
|
// Config & Secrets theme M1) into every non-oneoff compose service. Values
|
|
// come from the encrypted secrets store (password rows are decrypted here, in
|
|
// memory, only for the container); cleartext rows are stored plaintext.
|
|
//
|
|
// A row only exists for a field the operator actually configured, so unset
|
|
// fields leave the compose file's own environment untouched — the operator-set
|
|
// value wins, the compose default stays otherwise. Password rows decrypt once
|
|
// in this pass and never reach logs or error strings.
|
|
func injectConfigSecrets(project *composetypes.Project, serviceName string) error {
|
|
dataRoot, err := getDataRoot()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve data root: %w", err)
|
|
}
|
|
store, err := secrets.NewFileStore(filepath.Join(dataRoot, "maestro"))
|
|
if err != nil {
|
|
return fmt.Errorf("open secrets store: %w", err)
|
|
}
|
|
|
|
rows := store.All(serviceName)
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
|
|
env := make(map[string]string, len(rows))
|
|
for _, view := range rows {
|
|
row, found, err := store.Lookup(serviceName, view.Field)
|
|
if err != nil {
|
|
logger.Warn("Skipping config %s/%s: decrypt failed (secrets.key rotated?): %v", serviceName, view.Field, err)
|
|
continue
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
env[row.Key] = row.Value
|
|
}
|
|
if len(env) == 0 {
|
|
return nil
|
|
}
|
|
|
|
for name, s := range project.Services {
|
|
if isOneoff(s) {
|
|
continue
|
|
}
|
|
s.Environment = mergeEnv(s.Environment, env)
|
|
project.Services[name] = s
|
|
}
|
|
logger.Info("Injected %d operator config values for %s", len(env), serviceName)
|
|
return nil
|
|
}
|
|
|
|
// hasDatabaseCapability reports whether the installed service declared the
|
|
// database capability in its manifest (persisted at install/reconcile).
|
|
func hasDatabaseCapability(serviceName string) bool {
|
|
dir, err := getContainerDataDir(serviceName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
metadata, err := readInstalledMetadata(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, c := range metadata.Capabilities {
|
|
if c == "database" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hostBindSource maps a dataRoot-relative path to a bind-mount source the
|
|
// Docker daemon can resolve. Bind sources are resolved on the daemon's host
|
|
// namespace, but inside the dev container the data root lives at a
|
|
// container-internal path (/data/.dcntrlized via XDG_DATA_HOME=/data) that
|
|
// does not exist on the host — the daemon would silently create it as an
|
|
// empty directory. DCNTRLIZED_HOST_DATA_ROOT (compose-injected, e.g.
|
|
// $HOME/.dcntrlized) carries the host path backing the data root; when set,
|
|
// the container-internal prefix is swapped for it. On a host install the path
|
|
// is already host-resolvable and returned unchanged.
|
|
func hostBindSource(dataRoot, p string) string {
|
|
if host := os.Getenv("DCNTRLIZED_HOST_DATA_ROOT"); host != "" && strings.HasPrefix(p, dataRoot) {
|
|
return filepath.Join(host, strings.TrimPrefix(p, dataRoot))
|
|
}
|
|
return p
|
|
}
|
|
|
|
// 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
|
|
// next start retries.
|
|
func pinAuthFingerprint(ctx context.Context, serviceName string) string {
|
|
dir, err := getContainerDataDir(serviceName)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
metadata, err := readInstalledMetadata(dir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if metadata.AuthFingerprint != "" {
|
|
return metadata.AuthFingerprint
|
|
}
|
|
|
|
att, err := fetchAuthAttestation(ctx)
|
|
if err != nil {
|
|
logger.Warn("Auth pin for %s: attestation fetch failed (will retry at next start): %v", serviceName, err)
|
|
return ""
|
|
}
|
|
if att.Fingerprint == "" {
|
|
return ""
|
|
}
|
|
metadata.AuthFingerprint = att.Fingerprint
|
|
metadata.AuthKid = att.Kid
|
|
metadata.AuthAttestedAt = att.IssuedAt
|
|
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
|
|
logger.Warn("Auth pin for %s: persist failed: %v", serviceName, err)
|
|
}
|
|
logger.Info("Pinned auth fingerprint %s for %s", att.Fingerprint, serviceName)
|
|
return att.Fingerprint
|
|
}
|
|
|
|
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
|
|
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
|
|
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
|
|
for _, m := range mounts {
|
|
parts := strings.SplitN(m, ":", 3)
|
|
v := composetypes.ServiceVolumeConfig{
|
|
Type: "bind",
|
|
Source: parts[0],
|
|
Target: parts[1],
|
|
}
|
|
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
|
|
v.ReadOnly = true
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isOneoff reports whether a compose service is a one-shot helper (e.g. the
|
|
// migrate container) via the org.dcntrlized.oneoff label the compose file
|
|
// declares.
|
|
func isOneoff(s composetypes.ServiceConfig) bool {
|
|
if s.CustomLabels == nil {
|
|
return false
|
|
}
|
|
v, ok := s.CustomLabels["org.dcntrlized.oneoff"]
|
|
if !ok {
|
|
v = s.Labels["org.dcntrlized.oneoff"]
|
|
}
|
|
return v == "true"
|
|
}
|
|
|
|
// mergeEnv merges a map into the compose service's env slice, overriding any
|
|
// existing value with the same key.
|
|
func mergeEnv(existing map[string]*string, add map[string]string) map[string]*string {
|
|
if existing == nil {
|
|
existing = map[string]*string{}
|
|
}
|
|
for k, v := range add {
|
|
val := v
|
|
existing[k] = &val
|
|
}
|
|
return existing
|
|
}
|
|
|
|
// pinServiceImages rewrites each compose service's image reference to the exact
|
|
// installed image (e.g. git.../testservice:3.4.0) when its base name matches, so
|
|
// containers run pinned to a concrete semver tag instead of a moving "latest" tag.
|
|
func pinServiceImages(project *composetypes.Project, installedImage string) {
|
|
if installedImage == "" {
|
|
return
|
|
}
|
|
for name, s := range project.Services {
|
|
if s.Image == "" || s.Image == installedImage {
|
|
continue
|
|
}
|
|
if baseImageName(s.Image) != baseImageName(installedImage) {
|
|
continue
|
|
}
|
|
logger.Info("Pinning service %s image %s to installed image %s", name, s.Image, installedImage)
|
|
s.Image = installedImage
|
|
project.Services[name] = s
|
|
}
|
|
}
|
|
|
|
// ensureServiceImages makes sure every image referenced by the compose project
|
|
// exists locally. Registries install the image under its full reference (e.g.
|
|
// git.../testservice:3.4) while the service's compose file often references it
|
|
// by a short name (e.g. "testservice"); when the short name is missing locally
|
|
// and its base name matches the installed image, it is tagged so start works.
|
|
func ensureServiceImages(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, installedImage string) {
|
|
if installedImage == "" {
|
|
return
|
|
}
|
|
for name, s := range project.Services {
|
|
ref := s.Image
|
|
if ref == "" || ref == installedImage {
|
|
continue
|
|
}
|
|
if imageExists(ctx, dc, ref) {
|
|
continue
|
|
}
|
|
if baseImageName(ref) != baseImageName(installedImage) {
|
|
continue
|
|
}
|
|
logger.Info("Tagging %s as %s for compose service %s", installedImage, ref, name)
|
|
if err := dc.Client.ImageTag(ctx, installedImage, ref); err != nil {
|
|
logger.Warn("Failed to tag %s as %s: %v", installedImage, ref, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// imageExists reports whether an image reference exists locally.
|
|
func imageExists(ctx context.Context, dc *docker.DockerClient, ref string) bool {
|
|
_, _, err := dc.Client.ImageInspectWithRaw(ctx, ref)
|
|
return err == nil
|
|
}
|
|
|
|
// baseImageName strips the registry/namespace and tag from an image reference,
|
|
// returning the repository name (e.g. "testservice").
|
|
func baseImageName(ref string) string {
|
|
base := ref
|
|
if i := strings.LastIndex(base, "/"); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
if i := strings.LastIndex(base, ":"); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
return base
|
|
}
|