diff --git a/.gitignore b/.gitignore
index 4c49bd7..7a6501a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.env
+logs/
diff --git a/spec.yaml b/spec.yaml
index 0332d4c..cfd5296 100644
--- a/spec.yaml
+++ b/spec.yaml
@@ -29,6 +29,62 @@ components:
message:
type: string
+ PendingAuthorization:
+ type: object
+ description: A first-contact authorization request awaiting the instance owner (M2 §3).
+ properties:
+ service:
+ type: string
+ example: "keepnote"
+ fingerprint:
+ type: string
+ example: "sha256:aa:bb:cc:dd"
+ first_seen:
+ type: string
+ last_seen:
+ type: string
+
+ AuthorizedKey:
+ type: object
+ description: An approved peer row of the auth service's trust store.
+ properties:
+ service:
+ type: string
+ fingerprint:
+ type: string
+ approved_by:
+ type: string
+ approved_at:
+ type: string
+ revoked_at:
+ type: string
+ nullable: true
+
+ PeerAction:
+ type: object
+ required: [service, fingerprint]
+ properties:
+ service:
+ type: string
+ description: The service name (cert CN / org.dcntrlized.service).
+ fingerprint:
+ type: string
+ description: The SHA256 public-key fingerprint (sha256:…) shown to the owner.
+
+ Attestation:
+ type: object
+ description: The auth service's public signing key statement (D3).
+ properties:
+ public_key:
+ type: string
+ fingerprint:
+ type: string
+ example: "sha256:…"
+ kid:
+ type: string
+ issued_at:
+ type: string
+
RegistryContainer:
type: object
required: [name, image]
@@ -399,3 +455,94 @@ paths:
responses:
'101':
description: Switching Protocols - WebSocket upgrade
+
+ /api/peers:
+ get:
+ summary: List pending authorizations and the approved peer trust store
+ tags: ["security"]
+ description: >
+ Security theme M2 §3. Returns the shared auth service's first-contact
+ authorization requests (services whose certificate is valid but has
+ not been approved by the instance owner yet) and the approved keys.
+ Maestro is only the UI; the auth service owns the enforcement.
+ responses:
+ '200':
+ description: Combined pending + approved trust state
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ pending:
+ type: array
+ items:
+ $ref: '#/components/schemas/PendingAuthorization'
+ authorized:
+ type: array
+ items:
+ $ref: '#/components/schemas/AuthorizedKey'
+ '500':
+ description: Internal server error (auth management client unavailable)
+ '502':
+ description: Failed to reach the shared auth service
+
+ /api/peers/attestation:
+ get:
+ summary: Read the auth service's public signing key statement
+ tags: ["security"]
+ description: >
+ The auth service's RSA public key, its kid, and the sha256 fingerprint
+ peers should pin after first contact (TOFU, D3).
+ responses:
+ '200':
+ description: Auth public key attestation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Attestation'
+ '502':
+ description: Failed to reach the shared auth service
+
+ /api/peers/approve:
+ post:
+ summary: Approve a peer's fingerprint in the auth service trust store
+ tags: ["security"]
+ description: >
+ Forwards the instance owner's approval for a service's identity
+ fingerprint. After this call the service's requests to /auth/* proceed
+ normally (enforced by the auth service itself).
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PeerAction'
+ responses:
+ '200':
+ description: Peer approved
+ '400':
+ description: Missing service or fingerprint
+ '502':
+ description: Failed to reach the shared auth service
+
+ /api/peers/revoke:
+ post:
+ summary: Revoke a peer's fingerprint (returns it to the no-op path)
+ tags: ["security"]
+ description: >
+ Forwards the instance owner's revocation. Calls from that fingerprint
+ become uniform 403 no-ops again immediately (the SSH authorized_keys
+ "delete the line" action).
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PeerAction'
+ responses:
+ '200':
+ description: Peer revoked
+ '400':
+ description: Missing service or fingerprint
+ '502':
+ description: Failed to reach the shared auth service
diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx
index 475e3fe..ebe0473 100644
--- a/src/frontend/src/app.tsx
+++ b/src/frontend/src/app.tsx
@@ -24,6 +24,7 @@ import type { DatabaseStatus, ServiceFilter } from '@lib/types'
import { Header } from '@components/layout/header'
import { Toolbar } from '@components/layout/toolbar'
import { ServiceGrid } from '@components/service/service-grid'
+import { TrustedServices } from '@components/service/trusted-services'
import { Toaster } from '@components/ui/sonner'
import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states'
@@ -106,6 +107,8 @@ export function App() {
}}
/>
+
+
Showing {visible.length} of {counts.total} services
diff --git a/src/frontend/src/components/service/trusted-services.tsx b/src/frontend/src/components/service/trusted-services.tsx
new file mode 100644
index 0000000..473d04a
--- /dev/null
+++ b/src/frontend/src/components/service/trusted-services.tsx
@@ -0,0 +1,181 @@
+/**
+ * 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 .
+ */
+
+import { useEffect, useState } from 'preact/hooks'
+import { BadgeCheck, Check, LoaderCircle, ShieldCheck, X } from 'lucide-react'
+import { Button } from '@components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@components/ui/card'
+import { toast } from 'sonner'
+import { approvePeer, fetchTrustState, revokePeer } from '@lib/api/peers'
+import { useAsyncAction } from '@lib/hooks/useAsyncAction'
+import { cn } from '@lib/utils'
+import type { PeerAuthorized, PeerPending, PeerTrustState } from '@lib/types'
+
+type LoadState =
+ | { kind: 'loading' }
+ | { kind: 'ok'; state: PeerTrustState }
+ | { kind: 'error'; message: string }
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Unknown error'
+}
+
+function shortFingerprint(fp: string): string {
+ if (fp.length <= 16) return fp
+ return `${fp.slice(0, 16)}…`
+}
+
+/**
+ * The trusted-services surface (security theme M2 §3): pending first-contact
+ * authorizations and the approved trust store. Maestro is only the UI — the
+ * auth service owns the enforcement, so this panel is a convenience, not a
+ * security boundary. Hidden entirely when the management API is unreachable.
+ */
+export function TrustedServices() {
+ const [state, setState] = useState({ kind: 'loading' })
+
+ const load = async () => {
+ try {
+ const trust = await fetchTrustState()
+ setState({ kind: 'ok', state: trust })
+ } catch (error) {
+ setState({ kind: 'error', message: errorMessage(error) })
+ }
+ }
+
+ useEffect(() => {
+ void load()
+ }, [])
+
+ if (state.kind === 'loading') return null
+ if (state.kind === 'error') return null
+ if (state.state.pending.length === 0 && state.state.authorized.length === 0) return null
+
+ const pending = state.state.pending
+ const authorized = state.state.authorized.filter((k) => !k.revoked_at)
+
+ return (
+
+
+
+
+ Trusted services
+
+
+ Services asking to talk to the auth service. Approve a fingerprint to authorize it (SSH
+ authorized_keys, applied to the service fleet); revoke cuts it off immediately.
+
+
+
+ {pending.length > 0 && (
+
+ {pending.map((p) => (
+
+ ))}
+
+ )}
+
+ {authorized.length > 0 && (
+
+ {authorized.map((k) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void }) {
+ const approve = useAsyncAction(async () => {
+ try {
+ await approvePeer(entry.service, entry.fingerprint)
+ toast.success(`Authorized ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
+ onDone()
+ } catch (error) {
+ toast.error(`Failed to authorize ${entry.service}`, { description: errorMessage(error) })
+ }
+ })
+
+ const deny = useAsyncAction(async () => {
+ try {
+ await revokePeer(entry.service, entry.fingerprint)
+ toast.success(`Ignored ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
+ onDone()
+ } catch (error) {
+ toast.error(`Failed to ignore ${entry.service}`, { description: errorMessage(error) })
+ }
+ })
+
+ return (
+
+
+
+
+ {entry.service}
+
+
{entry.fingerprint}
+
+
+
+
+
+
+ )
+}
+
+function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) {
+ const revoke = useAsyncAction(async () => {
+ try {
+ await revokePeer(entry.service, entry.fingerprint)
+ toast.success(`Revoked ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
+ onDone()
+ } catch (error) {
+ toast.error(`Failed to revoke ${entry.service}`, { description: errorMessage(error) })
+ }
+ })
+
+ return (
+
+
+
+
+ {entry.service}
+
+
+ {entry.fingerprint}
+ by {entry.approved_by}
+
+
+
+
+ )
+}
diff --git a/src/frontend/src/lib/api/peers.ts b/src/frontend/src/lib/api/peers.ts
new file mode 100644
index 0000000..822c71f
--- /dev/null
+++ b/src/frontend/src/lib/api/peers.ts
@@ -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 .
+ */
+
+import { get, post } from './client'
+import type { PeerAttestation, PeerTrustState } from '../types'
+
+/** Fetches the pending + approved peer trust state (security theme M2 §3). */
+export function fetchTrustState(): Promise {
+ return get('/api/peers')
+}
+
+/** Fetches the auth service's public signing key attestation. */
+export function fetchAttestation(): Promise {
+ return get('/api/peers/attestation')
+}
+
+/** Approves a peer's fingerprint in the auth service's trust store. */
+export function approvePeer(service: string, fingerprint: string): Promise {
+ return post('/api/peers/approve', { service, fingerprint })
+}
+
+/** Revokes a peer's fingerprint (returns it to the no-op path immediately). */
+export function revokePeer(service: string, fingerprint: string): Promise {
+ return post('/api/peers/revoke', { service, fingerprint })
+}
diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts
index a730351..cae8d15 100644
--- a/src/frontend/src/lib/types.ts
+++ b/src/frontend/src/lib/types.ts
@@ -102,3 +102,34 @@ export interface FilterOption {
label: string
hint: string
}
+
+/** A first-contact authorization request waiting for the instance owner (security theme M2 §3). */
+export interface PeerPending {
+ service: string
+ fingerprint: string
+ first_seen: string
+ last_seen: string
+}
+
+/** An approved peer row of the auth service's trust store. */
+export interface PeerAuthorized {
+ service: string
+ fingerprint: string
+ approved_by: string
+ approved_at: string
+ revoked_at?: string | null
+}
+
+/** Combined pending + approved trust state served by GET /api/peers. */
+export interface PeerTrustState {
+ pending: PeerPending[]
+ authorized: PeerAuthorized[]
+}
+
+/** The auth service's public signing key statement (GET /api/peers/attestation). */
+export interface PeerAttestation {
+ public_key: string
+ fingerprint: string
+ kid: string
+ issued_at: string
+}
diff --git a/src/handlers/peers/peers.go b/src/handlers/peers/peers.go
new file mode 100644
index 0000000..1758dfe
--- /dev/null
+++ b/src/handlers/peers/peers.go
@@ -0,0 +1,146 @@
+// 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 peers serves the trusted-services surface: pending first-contact
+// authorizations and the approve/revoke actions for the instance owner
+// (security theme M2 §3). Maestro is the UI; the auth service owns the
+// enforcement.
+package peers
+
+import (
+ "encoding/json"
+ "net/http"
+ "sync"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
+)
+
+// Handler wraps a lazily-initialized auth management client so identity
+// issuance happens once per process, not per request.
+type Handler struct {
+ mu sync.Mutex
+ client *authmgmt.Client
+}
+
+// NewHandler creates the peers handler. The underlying mTLS client is
+// constructed on first use.
+func NewHandler() *Handler {
+ return &Handler{}
+}
+
+// List serves GET /api/peers — the combined pending + approved trust state.
+func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
+ client, err := h.ensureClient()
+ if err != nil {
+ http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ state, err := client.TrustState(r.Context())
+ if err != nil {
+ http.Error(w, "failed to read trust state: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, state)
+}
+
+// Attestation serves GET /api/peers/attestation — the auth service's public
+// signing key and its pinnable fingerprint.
+func (h *Handler) Attestation(w http.ResponseWriter, r *http.Request) {
+ client, err := h.ensureClient()
+ if err != nil {
+ http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ att, err := client.Attestation(r.Context())
+ if err != nil {
+ http.Error(w, "failed to read attestation: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, att)
+}
+
+type authzAction struct {
+ Service string `json:"service"`
+ Fingerprint string `json:"fingerprint"`
+}
+
+// Approve serves POST /api/peers/approve — forwards the owner's approval to
+// the auth service's trust store.
+func (h *Handler) Approve(w http.ResponseWriter, r *http.Request) {
+ var action authzAction
+ if !decodeAction(r, w, &action) {
+ return
+ }
+ client, err := h.ensureClient()
+ if err != nil {
+ http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := client.Approve(r.Context(), action.Service, action.Fingerprint); err != nil {
+ http.Error(w, "failed to approve peer: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "approved"})
+}
+
+// Revoke serves POST /api/peers/revoke — forwards the owner's revocation
+// (returns the peer to the no-op path immediately).
+func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
+ var action authzAction
+ if !decodeAction(r, w, &action) {
+ return
+ }
+ client, err := h.ensureClient()
+ if err != nil {
+ http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := client.Revoke(r.Context(), action.Service, action.Fingerprint); err != nil {
+ http.Error(w, "failed to revoke peer: "+err.Error(), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
+}
+
+func (h *Handler) ensureClient() (*authmgmt.Client, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.client == nil {
+ client, err := authmgmt.New()
+ if err != nil {
+ return nil, err
+ }
+ h.client = client
+ }
+ return h.client, nil
+}
+
+func decodeAction(r *http.Request, w http.ResponseWriter, out *authzAction) bool {
+ if err := json.NewDecoder(r.Body).Decode(out); err != nil {
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return false
+ }
+ if out.Service == "" || out.Fingerprint == "" {
+ http.Error(w, "service and fingerprint are required", http.StatusBadRequest)
+ return false
+ }
+ return true
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/src/lib/authmgmt/client.go b/src/lib/authmgmt/client.go
new file mode 100644
index 0000000..7e3a5c0
--- /dev/null
+++ b/src/lib/authmgmt/client.go
@@ -0,0 +1,238 @@
+// 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 authmgmt is Maestro's client for the shared auth service's
+// management endpoints (security theme M2 §3). Maestro is only the CA and the
+// UI here: it lists pending first-contact requests, forwards the owner's
+// approve/revoke decisions to the auth service, and reads its key
+// attestation. Enforcement always stays inside the auth service (D5).
+package authmgmt
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
+)
+
+// AuthServiceURL is the default DNS name of the shared auth service on the
+// dcntrlized network. The auth service listens with ClientAuth=RequireAndVerify
+// trusting the instance CA (M2 §1).
+const AuthServiceURL = "https://dcntrlized-auth:8080"
+
+// Client talks to the auth service over mTLS using Maestro's own identity
+// certificate (CN=maestro), which the auth service's RequireMaestro gate
+// recognizes as the orchestrator.
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+// Attestation is the auth service's public signing key statement (D3): the key
+// a peer should verify access tokens with, plus its TOFU-pinnable fingerprint.
+type Attestation struct {
+ PublicKey string `json:"public_key"`
+ Fingerprint string `json:"fingerprint"`
+ Kid string `json:"kid"`
+ IssuedAt string `json:"issued_at"`
+}
+
+// AuthorizedKey is an approved peer row of the auth service's trust store.
+type AuthorizedKey struct {
+ Service string `json:"service"`
+ Fingerprint string `json:"fingerprint"`
+ ApprovedBy string `json:"approved_by"`
+ ApprovedAt string `json:"approved_at"`
+ RevokedAt *string `json:"revoked_at"`
+}
+
+// PendingAuthorization is a first-contact request awaiting the owner's
+// decision.
+type PendingAuthorization struct {
+ Service string `json:"service"`
+ Fingerprint string `json:"fingerprint"`
+ FirstSeen string `json:"first_seen"`
+ LastSeen string `json:"last_seen"`
+}
+
+// TrustState is the combined pending + approved view the dashboard shows.
+type TrustState struct {
+ Pending []PendingAuthorization `json:"pending"`
+ Authorized []AuthorizedKey `json:"authorized"`
+}
+
+type authzRequest struct {
+ Service string `json:"service"`
+ Fingerprint string `json:"fingerprint"`
+}
+
+// New builds the mTLS management client, provisioning Maestro's own identity
+// from the instance CA (the CA holder issues its own orchestrator cert).
+func New() (*Client, error) {
+ dataRoot, err := dataRoot()
+ if err != nil {
+ return nil, fmt.Errorf("resolve data root: %w", err)
+ }
+ ca, err := pki.EnsureCA(dataRoot)
+ if err != nil {
+ return nil, fmt.Errorf("ensure instance CA: %w", err)
+ }
+ identity, err := pki.EnsureServiceIdentity(ca, "maestro")
+ if err != nil {
+ return nil, fmt.Errorf("provision maestro identity: %w", err)
+ }
+
+ cert, err := tls.LoadX509KeyPair(identity.CertPath, identity.KeyPath)
+ if err != nil {
+ return nil, fmt.Errorf("load maestro identity: %w", err)
+ }
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM(ca.CertPEM) {
+ return nil, fmt.Errorf("no certificates parsed from instance CA")
+ }
+
+ baseURL := os.Getenv("AUTH_SERVICE_URL")
+ if baseURL == "" {
+ baseURL = AuthServiceURL
+ }
+
+ return &Client{
+ baseURL: baseURL,
+ http: &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ RootCAs: pool,
+ MinVersion: tls.VersionTLS12,
+ },
+ },
+ },
+ }, nil
+}
+
+// TrustState returns the pending authorizations and the approved trust store.
+func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
+ var state TrustState
+ if err := c.do(ctx, http.MethodGet, "/internal/pending", nil, &state); err != nil {
+ return nil, err
+ }
+ return &state, nil
+}
+
+// Attestation returns the auth service's public key statement.
+func (c *Client) Attestation(ctx context.Context) (*Attestation, error) {
+ var att Attestation
+ if err := c.do(ctx, http.MethodGet, "/internal/attestation", nil, &att); err != nil {
+ return nil, err
+ }
+ return &att, nil
+}
+
+// Approve forwards the owner's approval for a peer's fingerprint to the auth
+// service's own trust store.
+func (c *Client) Approve(ctx context.Context, service, fingerprint string) error {
+ return c.do(ctx, http.MethodPost, "/internal/authorizations",
+ authzRequest{Service: service, Fingerprint: fingerprint}, nil)
+}
+
+// Revoke forwards the owner's revocation for a peer's fingerprint (returns the
+// peer to the no-op path immediately).
+func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error {
+ return c.do(ctx, http.MethodPost, "/internal/revoke",
+ authzRequest{Service: service, Fingerprint: fingerprint}, nil)
+}
+
+func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
+ var payload []byte
+ if body != nil {
+ payload, _ = json.Marshal(body)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("auth service %s %s: %w", method, path, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("auth service %s %s: status %d", method, path, resp.StatusCode)
+ }
+ if out != nil {
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ return fmt.Errorf("decode auth service response: %w", err)
+ }
+ }
+ return nil
+}
+
+// RevokeServiceBestEffort revokes a peer's authorization in the auth service
+// trust store, logging and swallowing failures: uninstall must proceed even
+// when the auth service is down (M2 §3 step 5 — "or automatically on
+// uninstall").
+func RevokeServiceBestEffort(ctx context.Context, service, fingerprint string) {
+ client, err := New()
+ if err != nil {
+ bestEffort(err, "build client for revoking "+service)
+ return
+ }
+ if err := client.Revoke(ctx, service, fingerprint); err != nil {
+ bestEffort(err, "revoke "+service)
+ }
+}
+
+// dataRoot resolves the directory storing the instance PKI, mirroring the
+// containers package's resolution (DCNTRLIZED_INSTALL_DIR → XDG → HOME). Kept
+// local to avoid an import cycle: containers imports authmgmt for the
+// uninstall-revoke hook.
+func dataRoot() (string, error) {
+ if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
+ return dir, nil
+ }
+ if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
+ return filepath.Join(xdg, ".dcntrlized"), nil
+ }
+ if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
+ return filepath.Join(xdgHome, ".dcntrlized"), nil
+ }
+ if home := os.Getenv("HOME"); home != "" {
+ return filepath.Join(home, ".dcntrlized"), nil
+ }
+ return filepath.Join("/data", ".dcntrlized"), nil
+}
+
+// bestEffort logs-and-continues: management calls must never take down the
+// orchestrator (e.g. uninstall revoking while the auth service is down).
+func bestEffort(err error, what string) {
+ if err != nil {
+ logger.Warn("authmgmt: %s failed (best-effort): %v", what, err)
+ }
+}
diff --git a/src/lib/containers/uninstall.go b/src/lib/containers/uninstall.go
index 9827198..55e7409 100644
--- a/src/lib/containers/uninstall.go
+++ b/src/lib/containers/uninstall.go
@@ -21,6 +21,7 @@ import (
"os"
"strings"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
@@ -48,10 +49,16 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
// Revoke the service's identity certificate and publish the CRL entry
// (security theme M1 §2 / M3 §2). A revoked service stops authenticating
- // on the mTLS edges within one CRL freshness window.
+ // on the mTLS edges within one CRL freshness window. The authorization in
+ // the auth service's trust store is revoked too (M2 §3 step 5) — the
+ // fingerprint is read before the identity files are removed.
+ fingerprint := serviceFingerprint(serviceName)
if err := revokeServiceIdentity(serviceName); err != nil {
logger.Warn("Failed to revoke identity for %s: %v", serviceName, err)
}
+ if fingerprint != "" {
+ authmgmt.RevokeServiceBestEffort(ctx, serviceName, fingerprint)
+ }
if _, err := os.Stat(targetDir); err == nil {
// Stop and remove the project containers/networks via compose down when
@@ -92,6 +99,21 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
return nil
}
+// serviceFingerprint reads the service's identity fingerprint (the public key
+// fingerprint the auth service's trust store is keyed on), or "" when the
+// identity was never issued.
+func serviceFingerprint(serviceName string) string {
+ dataRoot, err := getDataRoot()
+ if err != nil {
+ return ""
+ }
+ meta, err := pki.LoadServiceMeta(dataRoot, serviceName)
+ if err != nil {
+ return ""
+ }
+ return meta.Fingerprint
+}
+
// revokeServiceIdentity revokes the service's certificate in the instance CA.
// Best-effort: uninstall proceeds even if the CA or identity is missing.
func revokeServiceIdentity(serviceName string) error {
diff --git a/src/lib/pki/crl.go b/src/lib/pki/crl.go
index 6da5513..fc2d958 100644
--- a/src/lib/pki/crl.go
+++ b/src/lib/pki/crl.go
@@ -31,10 +31,10 @@ import (
// RevokedCert is the persisted record of a revoked certificate (CRL source).
type RevokedCert struct {
- Serial string `json:"serial"`
- Service string `json:"service"`
- RevokedAt time.Time `json:"revoked_at"`
- Reason string `json:"reason,omitempty"`
+ Serial string `json:"serial"`
+ Service string `json:"service"`
+ RevokedAt time.Time `json:"revoked_at"`
+ Reason string `json:"reason,omitempty"`
}
// RevokedSerialsDir returns the directory holding revoked certificates.
@@ -123,9 +123,9 @@ func regenerateCRL(ca *CA) error {
}
template := &x509.RevocationList{
- Number: mustSerial(),
- ThisUpdate: now(),
- NextUpdate: now().Add(24 * time.Hour),
+ Number: mustSerial(),
+ ThisUpdate: now(),
+ NextUpdate: now().Add(24 * time.Hour),
RevokedCertificates: revokedEntries(serials),
}
diff --git a/src/lib/pki/identity.go b/src/lib/pki/identity.go
index e258132..84e9150 100644
--- a/src/lib/pki/identity.go
+++ b/src/lib/pki/identity.go
@@ -43,12 +43,12 @@ type ServiceMeta struct {
// proves *who* a peer is (name ↔ key, CA-signed); authorization is a separate
// human-gated step owned by the receiving service (security theme D5).
type Identity struct {
- Service string
- Cert *x509.Certificate
- Key *ecdsa.PrivateKey
- CertPath string
- KeyPath string
- Meta ServiceMeta
+ Service string
+ Cert *x509.Certificate
+ Key *ecdsa.PrivateKey
+ CertPath string
+ KeyPath string
+ Meta ServiceMeta
}
// ServiceDir returns the identity directory for a service.
@@ -72,6 +72,12 @@ func HasServiceIdentity(dataRoot, serviceName string) bool {
return err == nil
}
+// LoadServiceMeta reads a service's identity issuance record without touching
+// the CA. It returns an error when the identity has not been issued yet.
+func LoadServiceMeta(dataRoot, serviceName string) (ServiceMeta, error) {
+ return readMeta(ServiceDir(dataRoot, serviceName))
+}
+
// EnsureServiceIdentity loads the service's leaf identity, issuing a fresh one
// when missing or expiring within the refresh window. Issuance is idempotent
// and safe to call before every start/install.
diff --git a/src/lib/pki/logs/debug.log b/src/lib/pki/logs/debug.log
deleted file mode 100644
index f38b990..0000000
--- a/src/lib/pki/logs/debug.log
+++ /dev/null
@@ -1,3 +0,0 @@
-========== Start Log ==========
-[INFO] Logger initialized
-[2026-08-15T23:09:40+01:00]
diff --git a/src/lib/pki/logs/error.log b/src/lib/pki/logs/error.log
deleted file mode 100644
index f38b990..0000000
--- a/src/lib/pki/logs/error.log
+++ /dev/null
@@ -1,3 +0,0 @@
-========== Start Log ==========
-[INFO] Logger initialized
-[2026-08-15T23:09:40+01:00]
diff --git a/src/lib/pki/logs/info.log b/src/lib/pki/logs/info.log
deleted file mode 100644
index 8eef1d3..0000000
--- a/src/lib/pki/logs/info.log
+++ /dev/null
@@ -1,4 +0,0 @@
-========== Start Log ==========
-[INFO] Logger initialized
-[2026-08-15T23:09:40+01:00]
-INFO: 2026/08/15 22:09:40 pki: refreshing expiring identity for keepnote
diff --git a/src/lib/pki/logs/warn.log b/src/lib/pki/logs/warn.log
deleted file mode 100644
index f38b990..0000000
--- a/src/lib/pki/logs/warn.log
+++ /dev/null
@@ -1,3 +0,0 @@
-========== Start Log ==========
-[INFO] Logger initialized
-[2026-08-15T23:09:40+01:00]
diff --git a/src/main.go b/src/main.go
index 76028e6..6921866 100644
--- a/src/main.go
+++ b/src/main.go
@@ -25,8 +25,9 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
+ "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
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/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
)
@@ -92,6 +93,15 @@ func main() {
mux.HandleFunc("/api/actions/{containerId}/{actionName}", actions.RunAction)
mux.HandleFunc("/api/registry", handlers.GetRegistry)
+ // Trusted-services surface (security theme M2 §3): pending first-contact
+ // authorizations and the owner's approve/revoke actions, forwarded to the
+ // auth service's own trust store.
+ peersHandler := peers.NewHandler()
+ mux.HandleFunc("/api/peers", peersHandler.List)
+ mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation)
+ mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
+ mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
+
if os.Getenv("DEV_MODE") != "true" {
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
if err != nil {