feat(security): trusted-services surface + auth mgmt client (M2 §3)

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.
This commit is contained in:
2026-08-15 23:27:21 +01:00
parent dc79629c7e
commit 950b6e5827
16 changed files with 839 additions and 28 deletions
+1
View File
@@ -1 +1,2 @@
.env
logs/
+147
View File
@@ -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
+3
View File
@@ -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() {
}}
/>
<TrustedServices />
<p className="text-xs text-muted-foreground" role="status">
Showing {visible.length} of {counts.total} services
</p>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<LoadState>({ 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 (
<Card data-slot="trusted-services">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="size-4 text-muted-foreground" />
Trusted services
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
{pending.length > 0 && (
<CardContent data-slot="trusted-pending" className="grid gap-2">
{pending.map((p) => (
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={load} />
))}
</CardContent>
)}
{authorized.length > 0 && (
<CardContent data-slot="trusted-approved" className="grid gap-2">
{authorized.map((k) => (
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={load} />
))}
</CardContent>
)}
</Card>
)
}
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 (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium">
<span className="inline-block size-1.5 shrink-0 rounded-full bg-amber-500" aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="xs" variant="outline" onPress={() => void deny.run()} isDisabled={deny.pending}>
{deny.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Ignore
</Button>
<Button size="xs" onPress={() => void approve.run()} isDisabled={approve.pending}>
{approve.pending ? <LoaderCircle className="animate-spin" /> : <Check />}
Authorize
</Button>
</div>
</div>
)
}
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 (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium">
<BadgeCheck className={cn('size-4', 'text-emerald-500')} aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
{entry.fingerprint}
<span className="ml-2 text-muted-foreground/70">by {entry.approved_by}</span>
</p>
</div>
<Button
size="xs"
variant="destructive"
onPress={() => void revoke.run()}
isDisabled={revoke.pending}
>
{revoke.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Revoke
</Button>
</div>
)
}
+39
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<PeerTrustState> {
return get<PeerTrustState>('/api/peers')
}
/** Fetches the auth service's public signing key attestation. */
export function fetchAttestation(): Promise<PeerAttestation> {
return get<PeerAttestation>('/api/peers/attestation')
}
/** Approves a peer's fingerprint in the auth service's trust store. */
export function approvePeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/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<void> {
return post<void>('/api/peers/revoke', { service, fingerprint })
}
+31
View File
@@ -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
}
+146
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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)
}
+238
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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)
}
}
+23 -1
View File
@@ -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 {
+6
View File
@@ -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.
-3
View File
@@ -1,3 +0,0 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
-3
View File
@@ -1,3 +0,0 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
-4
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
+10
View File
@@ -25,6 +25,7 @@ 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"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
@@ -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 {