From 950b6e5827ad20a03a6a5961fde4395c6663d03c Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Sat, 15 Aug 2026 23:27:21 +0100 Subject: [PATCH] =?UTF-8?q?feat(security):=20trusted-services=20surface=20?= =?UTF-8?q?+=20auth=20mgmt=20client=20(M2=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maestro is the UI/CA, not the enforcement: the auth service owns its authorized-keys trust store. This is Maestro's side of the flow. - lib/authmgmt: mTLS management client (Maestro's own identity cert from the instance CA) for /internal/{attestation,authorizations,revoke, pending}; RevokeServiceBestEffort for uninstall. - handlers/peers: GET /api/peers, /api/peers/attestation, POST approve/revoke. - uninstall: revoke the service's authorization in the auth trust store alongside the CRL identity revocation. - frontend: TrustedServices panel (pending first-contact authorizations + approved keys with authorize/ignore/revoke actions). - spec.yaml: document the new endpoints and schemas. - housekeeping: untrack pki test log artifacts, gofmt fixes in pki. --- .gitignore | 1 + spec.yaml | 147 +++++++++++ src/frontend/src/app.tsx | 3 + .../components/service/trusted-services.tsx | 181 +++++++++++++ src/frontend/src/lib/api/peers.ts | 39 +++ src/frontend/src/lib/types.ts | 31 +++ src/handlers/peers/peers.go | 146 +++++++++++ src/lib/authmgmt/client.go | 238 ++++++++++++++++++ src/lib/containers/uninstall.go | 24 +- src/lib/pki/crl.go | 14 +- src/lib/pki/identity.go | 18 +- src/lib/pki/logs/debug.log | 3 - src/lib/pki/logs/error.log | 3 - src/lib/pki/logs/info.log | 4 - src/lib/pki/logs/warn.log | 3 - src/main.go | 12 +- 16 files changed, 839 insertions(+), 28 deletions(-) create mode 100644 src/frontend/src/components/service/trusted-services.tsx create mode 100644 src/frontend/src/lib/api/peers.ts create mode 100644 src/handlers/peers/peers.go create mode 100644 src/lib/authmgmt/client.go delete mode 100644 src/lib/pki/logs/debug.log delete mode 100644 src/lib/pki/logs/error.log delete mode 100644 src/lib/pki/logs/info.log delete mode 100644 src/lib/pki/logs/warn.log 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 {