diff --git a/spec.yaml b/spec.yaml index e571fb7..d5634de 100644 --- a/spec.yaml +++ b/spec.yaml @@ -123,6 +123,18 @@ components: type: string example: "database" description: Capabilities declared by the service manifest (e.g. database). + has_auth: + type: boolean + description: >- + Whether the service consumes the shared auth service (manifest + has_auth). True for dcntrlized-auth itself and for auth-consuming + services. + public_key: + type: string + description: >- + The auth service's RS256 public key (manifest public_key); the hook + for a future Maestro dashboard auth that validates dcntrlized-auth + tokens. docker_name: type: string image: diff --git a/src/frontend/src/lib/domain/merge.ts b/src/frontend/src/lib/domain/merge.ts index 5c8182b..18e02d6 100644 --- a/src/frontend/src/lib/domain/merge.ts +++ b/src/frontend/src/lib/domain/merge.ts @@ -38,7 +38,8 @@ function isMaestro(name?: string): boolean { } function isHiddenContainer(c: Container): boolean { - return Boolean(c.hidden) || (c.role ?? '').toLowerCase() === 'db' + const role = (c.role ?? '').toLowerCase() + return Boolean(c.hidden) || role === 'db' || role === 'auth' } function isHiddenRegistry(r: RegistryItem): boolean { diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 50f3853..a730351 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -44,6 +44,10 @@ export interface Container { hidden?: boolean /** Capabilities declared by the service manifest (e.g. ["database"]). */ capabilities?: string[] + /** Whether the service consumes the shared auth service (manifest has_auth). */ + has_auth?: boolean + /** The auth service's RS256 public key (manifest public_key). */ + public_key?: string } /** A remote catalog entry, as served by GET /api/registry. */ diff --git a/src/lib/containers/infra.go b/src/lib/containers/infra.go index 8edb12c..0adc789 100644 --- a/src/lib/containers/infra.go +++ b/src/lib/containers/infra.go @@ -32,6 +32,8 @@ const ( DCNTRLIZEDNetwork = "dcntrlized_net" // DatabaseServiceName is the shared database service (image, container and DNS name). DatabaseServiceName = "dcntrlized-db" + // AuthServiceName is the shared auth service (image, container and DNS name). + AuthServiceName = "dcntrlized-auth" ) // EnsureDCNTRLIZEDNetwork creates the external dcntrlized_net network when it is @@ -53,6 +55,21 @@ func EnsureDCNTRLIZEDNetwork(ctx context.Context, dc *docker.DockerClient) error // leaves installation to the UI. The database is infrastructure, so this must // never be invoked for the database service itself (StartContainer guards that). func EnsureDatabaseRunning(ctx context.Context) error { + return ensureInfraRunning(ctx, DatabaseServiceName, "shared database") +} + +// EnsureAuthRunning ensures the shared auth service container is running. The +// auth service is hidden infrastructure: every consumer proxies /auth/* to it +// and validates action tokens with its key, so it must be up before services +// that depend on it start. Only starts when already installed. +func EnsureAuthRunning(ctx context.Context) error { + return ensureInfraRunning(ctx, AuthServiceName, "shared auth service") +} + +// ensureInfraRunning starts a hidden infrastructure container (shared database, +// shared auth service) when it is installed but not running. It never installs +// infrastructure itself — the UI does that from the registry. +func ensureInfraRunning(ctx context.Context, serviceName, label string) error { dc, err := docker.NewDockerClient() if err != nil { return fmt.Errorf("failed to initialize docker client: %w", err) @@ -63,29 +80,29 @@ func EnsureDatabaseRunning(ctx context.Context) error { return err } - if databaseRunning(ctx, dc) { + if infraRunning(ctx, dc, serviceName) { return nil } - if !IsInstalled(DatabaseServiceName) { - logger.Info("Shared database not installed; install %s from the registry to enable database-backed services", DatabaseServiceName) + if !IsInstalled(serviceName) { + logger.Info("%s not installed; install %s from the registry to enable services that need it", label, serviceName) return nil } - logger.Info("Starting shared database %s...", DatabaseServiceName) - if err := StartContainer(ctx, types.RegistryContainer{Name: DatabaseServiceName}); err != nil { - return fmt.Errorf("failed to start shared database %s: %w", DatabaseServiceName, err) + logger.Info("Starting %s %s...", label, serviceName) + if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil { + return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err) } return nil } -// databaseRunning reports whether the shared database container is currently up. -func databaseRunning(ctx context.Context, dc *docker.DockerClient) bool { +// infraRunning reports whether the named infrastructure container is currently up. +func infraRunning(ctx context.Context, dc *docker.DockerClient, serviceName string) bool { filter := filters.NewArgs() - filter.Add("label", "org.dcntrlized.service="+DatabaseServiceName) + filter.Add("label", "org.dcntrlized.service="+serviceName) containers, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter}) if err != nil { - logger.Warn("Failed to list database containers: %v", err) + logger.Warn("Failed to list %s containers: %v", serviceName, err) return false } for _, c := range containers { diff --git a/src/lib/containers/installed.go b/src/lib/containers/installed.go index 1b4e983..9a29379 100644 --- a/src/lib/containers/installed.go +++ b/src/lib/containers/installed.go @@ -67,6 +67,8 @@ func (cache *ContainerCache) LoadInstalledContainers() error { CreatedAt: metadata.CreatedAt, Hidden: metadata.Hidden, Capabilities: metadata.Capabilities, + HasAuth: metadata.HasAuth, + PublicKey: metadata.PublicKey, Title: metadata.Title, Vendor: metadata.Vendor, Author: metadata.Author, @@ -79,11 +81,12 @@ func (cache *ContainerCache) LoadInstalledContainers() error { return nil } -// updateInstalledMetadataCapabilities persists the capabilities reported by a -// service manifest so installed-but-stopped services can still surface them -// (e.g. the database chip) without running. -func updateInstalledMetadataCapabilities(serviceName string, capabilities []string) { - if len(capabilities) == 0 { +// updateInstalledMetadataFromManifest persists the capabilities, has_auth flag +// and public key reported by a service manifest so installed-but-stopped +// services can still surface them (e.g. the database chip, auth consumption) +// without running. +func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest) { + if m == nil || len(m.Capabilities) == 0 { return } dir, err := getContainerDataDir(serviceName) @@ -94,9 +97,11 @@ func updateInstalledMetadataCapabilities(serviceName string, capabilities []stri if err != nil { return } - metadata.Capabilities = capabilities + metadata.Capabilities = m.Capabilities + metadata.HasAuth = m.HasAuth + metadata.PublicKey = m.PublicKey if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil { - logger.Warn("Failed to persist capabilities for %s: %v", serviceName, err) + logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err) } } diff --git a/src/lib/containers/watcher.go b/src/lib/containers/watcher.go index 99163cb..8f88a29 100644 --- a/src/lib/containers/watcher.go +++ b/src/lib/containers/watcher.go @@ -110,9 +110,13 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc ver = existing.Version } capabilities := existing.Capabilities + hasAuth := existing.HasAuth + publicKey := existing.PublicKey if manifest != nil { capabilities = manifest.Capabilities - updateInstalledMetadataCapabilities(serviceLabel, manifest.Capabilities) + hasAuth = manifest.HasAuth + publicKey = manifest.PublicKey + updateInstalledMetadataFromManifest(serviceLabel, manifest) } container := types.Container{ ID: containerID, @@ -126,6 +130,8 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc Role: role, Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true", Capabilities: capabilities, + HasAuth: hasAuth, + PublicKey: publicKey, Title: existing.Title, Vendor: existing.Vendor, Author: existing.Author, @@ -241,6 +247,19 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker if c.Labels["org.dcntrlized.hidden"] == "true" { entry.Hidden = true } + // Running containers get their manifest fields (capabilities, has_auth, + // public_key) fetched best-effort so a Maestro restart surfaces them even + // when the container was already up before the watcher subscribed. + if c.State == "running" && c.Labels["org.dcntrlized.role"] != "db" { + if manifest, err := docker.FetchManifestWithRetry(ctx, c.ID[:12], c.Labels["org.dcntrlized.port"], 20, 50*time.Millisecond); err != nil { + log.Printf("Error fetching manifest for container %s: %v", c.ID[:12], err) + } else if manifest != nil { + entry.Capabilities = manifest.Capabilities + entry.HasAuth = manifest.HasAuth + entry.PublicKey = manifest.PublicKey + updateInstalledMetadataFromManifest(serviceLabel, manifest) + } + } cache.services[key] = entry cache.Unlock() } diff --git a/src/lib/types/docker.go b/src/lib/types/docker.go index 080a15c..ba14ed6 100644 --- a/src/lib/types/docker.go +++ b/src/lib/types/docker.go @@ -60,6 +60,12 @@ type Container struct { // (e.g. ["database"]), surfaced so the UI can render capability chips. Capabilities []string `json:"capabilities,omitempty"` + // HasAuth reports whether the service consumes the shared auth service + // (manifest has_auth). PublicKey is the auth service's RS256 public key + // (manifest public_key), the hook for a future Maestro dashboard auth. + HasAuth bool `json:"has_auth,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Title string `json:"title,omitempty"` Vendor string `json:"vendor,omitempty"` Author string `json:"author,omitempty"` diff --git a/src/lib/types/registry.go b/src/lib/types/registry.go index 3dfbf81..c1d2739 100644 --- a/src/lib/types/registry.go +++ b/src/lib/types/registry.go @@ -53,4 +53,9 @@ type InstalledMetadata struct { // Capabilities are persisted from the service manifest once the service has // run, so installed-but-stopped services can still surface them. Capabilities []string `json:"capabilities,omitempty"` + // HasAuth and PublicKey are persisted from the service manifest (the shared + // auth contract, see dcntrlized-auth). They let installed-but-stopped + // services keep advertising auth consumption and its key. + HasAuth bool `json:"has_auth,omitempty"` + PublicKey string `json:"public_key,omitempty"` } diff --git a/src/lib/types/service_test.go b/src/lib/types/service_test.go new file mode 100644 index 0000000..fabfeba --- /dev/null +++ b/src/lib/types/service_test.go @@ -0,0 +1,95 @@ +// 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 types + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestManifestParsesAuthPayload covers the dcntrlized-auth /_manifest payload: +// capabilities ["auth"], has_auth true and the RS256 public_key PEM. Maestro +// stores these so a later milestone can authenticate its dashboard against the +// shared auth service. +func TestManifestParsesAuthPayload(t *testing.T) { + const pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtestonly\n-----END PUBLIC KEY-----\n" + + raw := `{ + "name": "dcntrlized-auth", + "version": "0.1.0", + "author": "Dcntrlized", + "capabilities": ["auth"], + "has_auth": true, + "public_key": ` + marshalJSONString(t, pem) + ` + }` + + var m Manifest + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("failed to unmarshal auth manifest: %v", err) + } + + if m.Name != "dcntrlized-auth" { + t.Errorf("Name = %q, want dcntrlized-auth", m.Name) + } + if !m.HasAuth { + t.Errorf("HasAuth = false, want true") + } + if m.PublicKey != pem { + t.Errorf("PublicKey mismatch: got %q", m.PublicKey) + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != "auth" { + t.Errorf("Capabilities = %v, want [auth]", m.Capabilities) + } +} + +func TestManifestParsesRegularService(t *testing.T) { + // A regular consumer (e.g. KeepNote) declares has_auth only when it + // consumes shared auth; has_auth and public_key must default to zero + // values when absent. + raw := `{ + "name": "KeepNote", + "version": "0.0.1", + "author": "Dcntrlized", + "capabilities": ["database"] + }` + + var m Manifest + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("failed to unmarshal manifest: %v", err) + } + + if m.HasAuth { + t.Errorf("HasAuth = true, want false") + } + if m.PublicKey != "" { + t.Errorf("PublicKey = %q, want empty", m.PublicKey) + } + if len(m.Capabilities) != 1 || m.Capabilities[0] != "database" { + t.Errorf("Capabilities = %v, want [database]", m.Capabilities) + } +} + +// marshalJSONString renders s as a quoted JSON string, avoiding a missing +// encoding/json roundtrip in the test literals above. +func marshalJSONString(t *testing.T, s string) string { + t.Helper() + b, err := json.Marshal(s) + if err != nil { + t.Fatalf("failed to marshal string: %v", err) + } + return strings.TrimSpace(string(b)) +} diff --git a/src/main.go b/src/main.go index 2433182..86869fd 100644 --- a/src/main.go +++ b/src/main.go @@ -55,13 +55,16 @@ func main() { if err := cache.LoadInstalledContainers(); err != nil { log.Fatalf("Failed to load installed containers: %v", err) } - // Ensure the shared database is running before reconciling, so its - // container is picked up by the reconciliation below. Failures to start - // the database are not fatal: Maestro keeps running and the UI surfaces - // the database status for manual action. + // Ensure the shared database and auth service are running before + // reconciling, so their containers are picked up by the reconciliation + // below. Failures are not fatal: Maestro keeps running and the UI + // surfaces infrastructure status for manual action. if err := libcontainers.EnsureDatabaseRunning(ctx); err != nil { log.Printf("Warning: failed to ensure shared database: %v", err) } + if err := libcontainers.EnsureAuthRunning(ctx); err != nil { + log.Printf("Warning: failed to ensure shared auth service: %v", err) + } cache.ReconcileContainers(ctx, dc) go cache.WatchContainers(ctx, dc) }