[refactor](maestro): retire the PKI and federation identity layer

Contract rev 9 removes the instance CA, client certificates, the peer listener and the trust store fleet-wide. Internal traffic is plaintext HTTP on each service single :8080 listener, so there is no peer identity left to authorize: delete lib/pki, lib/federation, devboot, the per-service database role model and the /api/peers* and /api/instances surfaces, and drop the console Trusted services UI. Config delivery now rides a dedicated ES256 token from lib/configsign rather than mTLS, and the instance fingerprint is derived from a persisted random key and is informational only. main.go also wires the surfaces introduced by the later commits, which this change is what makes possible.
This commit is contained in:
2026-10-19 02:39:15 +01:00
parent 3220944b12
commit 17d5307110
29 changed files with 735 additions and 2512 deletions
+1 -8
View File
@@ -18,7 +18,6 @@
import { useMemo, useState } from 'preact/hooks'
import { useContainerStore } from '@lib/hooks/useContainerStore'
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
import { useTrustStore } from '@lib/hooks/useTrustStore'
import { useAuth } from '@lib/hooks/useAuth'
import { mergeServices } from '@lib/domain/merge'
import { filterServices, serviceCounts } from '@lib/domain/filter'
@@ -41,7 +40,6 @@ export function App() {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<ServiceFilter>('all')
const [settingsOpen, setSettingsOpen] = useState(false)
const trust = useTrustStore(settingsOpen, authed)
const services = useMemo(() => {
const items = registry.state.kind === 'ok' ? registry.state.items : []
@@ -87,11 +85,8 @@ export function App() {
const openSettings = () => {
setSettingsOpen(true)
void trust.refresh()
}
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
if (auth.gate === 'loading') {
return (
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
@@ -114,7 +109,7 @@ export function App() {
installed={counts.installed}
updates={counts.updates}
connection={status}
pendingTrust={pendingTrust}
pendingTrust={0}
onShowUpdates={showUpdates}
onOpenSettings={openSettings}
/>
@@ -155,8 +150,6 @@ export function App() {
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
trust={trust.load}
onRefreshTrust={trust.refresh}
username={auth.username}
onSignOut={() => {
setSettingsOpen(false)
@@ -1,302 +0,0 @@
/**
* 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 { BadgeCheck, Check, ChevronRight, EyeOff, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react'
import { Button } from '@components/ui/button'
import { Disclosure, DisclosurePanel, Heading } from 'react-aria-components'
import { toast } from 'sonner'
import { approvePeer, hidePeer, rejectPeer, revokePeer } from '@lib/api/peers'
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
import { cn } from '@lib/utils'
import type { PeerAuthorized, PeerPending } from '@lib/types'
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)}…`
}
function fmtTime(iso?: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString()
}
interface TrustedServicesSectionProps {
load: TrustLoadState
onRefresh: () => void
}
/**
* The trusted-services surface (security theme M2 §3): pending first-contact
* authorizations, the approved trust store, and revoked keys. Maestro is only
* the UI — the auth service owns the enforcement, so this panel is a
* convenience, not a security boundary. Rendered as a section inside the
* settings dialog.
*/
export function TrustedServicesSection({ load, onRefresh }: TrustedServicesSectionProps) {
if (load.kind === 'loading') {
return (
<div
data-slot="trusted-loading"
className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"
>
<LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
Loading trust state…
</div>
)
}
if (load.kind === 'error') {
return (
<div
data-slot="trusted-error"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2.5 text-sm"
>
<p className="min-w-0 text-muted-foreground">
Couldn't reach the auth service.
<span className="ml-1.5 text-destructive">{load.message}</span>
</p>
<Button size="xs" variant="outline" onPress={onRefresh}>
<RotateCcw aria-hidden="true" className="size-3" />
Retry
</Button>
</div>
)
}
const pending = load.state.pending ?? []
const authorized = load.state.authorized ?? []
const active = authorized.filter((k) => !k.revoked_at && !k.hidden_at)
const revoked = authorized.filter((k) => k.revoked_at && !k.hidden_at)
if (pending.length === 0 && active.length === 0 && revoked.length === 0) {
return (
<div
data-slot="trusted-empty"
className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-8 text-sm text-muted-foreground"
>
<ShieldCheck className="size-4 text-muted-foreground/60" aria-hidden="true" />
No services have asked to talk to the auth service yet.
</div>
)
}
return (
<div data-slot="trusted-section" className="grid min-w-0 gap-4">
{pending.length > 0 && (
<div data-slot="trusted-pending" className="grid min-w-0 gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-warning">
Requested access ({pending.length})
</p>
{pending.map((p) => (
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={onRefresh} />
))}
</div>
)}
{active.length > 0 && (
<div data-slot="trusted-approved" className="grid min-w-0 gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-success">
Accepted ({active.length})
</p>
{active.map((k) => (
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={onRefresh} />
))}
</div>
)}
{revoked.length > 0 && (
<Disclosure data-slot="trusted-revoked" defaultExpanded={false} className="group min-w-0">
<Heading className="m-0">
<Button
slot="trigger"
variant="ghost"
size="sm"
className="w-full justify-start gap-1.5 px-1 font-medium text-muted-foreground hover:text-foreground"
>
<ChevronRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-200 group-data-[expanded]:rotate-90"
/>
Revoked ({revoked.length})
</Button>
</Heading>
<DisclosurePanel className="mt-1 grid min-w-0 gap-2 px-1">
{revoked.map((k) => (
<RevokedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={onRefresh} />
))}
</DisclosurePanel>
</Disclosure>
)}
</div>
)
}
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 reject = useAsyncAction(async () => {
try {
await rejectPeer(entry.service, entry.fingerprint)
toast.success(`Rejected ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
onDone()
} catch (error) {
toast.error(`Failed to reject ${entry.service}`, { description: errorMessage(error) })
}
})
return (
<div className="flex min-w-0 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-warning" aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
<p className="text-xs text-muted-foreground/70">First seen {fmtTime(entry.first_seen)}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="xs" variant="outline" onPress={() => void reject.run()} isDisabled={reject.pending}>
{reject.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Reject
</Button>
<Button size="xs" onPress={() => void approve.run()} isDisabled={approve.pending}>
{approve.pending ? <LoaderCircle className="animate-spin" /> : <Check />}
Accept
</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 min-w-0 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="size-4 text-success" 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>
<p className="text-xs text-muted-foreground/70">Accepted {fmtTime(entry.approved_at)}</p>
</div>
<Button
size="xs"
variant="destructive"
onPress={() => void revoke.run()}
isDisabled={revoke.pending}
>
{revoke.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Revoke
</Button>
</div>
)
}
function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) {
const reauthorize = useAsyncAction(async () => {
try {
await approvePeer(entry.service, entry.fingerprint)
toast.success(`Re-authorized ${entry.service}`, {
description: shortFingerprint(entry.fingerprint),
})
onDone()
} catch (error) {
toast.error(`Failed to re-authorize ${entry.service}`, { description: errorMessage(error) })
}
})
const hide = useAsyncAction(async () => {
try {
await hidePeer(entry.service, entry.fingerprint)
toast.success(`Hidden ${entry.service}`, {
description: `${shortFingerprint(entry.fingerprint)} removed from the dashboard`,
})
onDone()
} catch (error) {
toast.error(`Failed to hide ${entry.service}`, { description: errorMessage(error) })
}
})
return (
<div
className={cn(
'flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2'
)}
>
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<X className="size-4 text-muted-foreground/60" aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground/80">
{entry.fingerprint}
<span className="ml-2 text-muted-foreground/60">by {entry.approved_by}</span>
</p>
<p className="text-xs text-muted-foreground/70">Revoked {fmtTime(entry.revoked_at)}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="icon-xs"
variant="ghost"
onPress={() => void hide.run()}
isDisabled={hide.pending}
aria-label={`Hide ${entry.service} permanently from the dashboard`}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
{hide.pending ? <LoaderCircle className="animate-spin" /> : <EyeOff />}
</Button>
<Button
size="xs"
variant="outline"
onPress={() => void reauthorize.run()}
isDisabled={reauthorize.pending}
>
{reauthorize.pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
Re-authorize
</Button>
</div>
</div>
)
}
-49
View File
@@ -1,49 +0,0 @@
/**
* 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 })
}
/** Rejects a pending first-contact request without authorizing it. */
export function rejectPeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/api/peers/reject', { service, fingerprint })
}
/** Permanently hides a revoked peer from the dashboard (row stays in the trust store). */
export function hidePeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/api/peers/hide', { service, fingerprint })
}
@@ -1,65 +0,0 @@
/**
* 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 { useCallback, useEffect, useState } from 'preact/hooks'
import { fetchTrustState } from '@lib/api/peers'
import type { PeerTrustState } from '@lib/types'
export type TrustLoadState =
| { kind: 'loading' }
| { kind: 'ok'; state: PeerTrustState }
| { kind: 'error'; message: string }
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
/**
* Owns the peer trust state for the console. Fetches once on mount and then
* polls: every 10s while the settings dialog is open (the operator is watching
* the trust store), every 2 minutes otherwise (keeps the header pending badge
* fresh). Refreshes replace the last known state silently so an in-flight
* mutation never flashes a loading spinner over live rows; mutations also
* trigger an immediate refresh through the same hook.
*/
export function useTrustStore(open: boolean, enabled = true) {
const [load, setLoad] = useState<TrustLoadState>({ kind: 'loading' })
const refresh = useCallback(async () => {
try {
const state = await fetchTrustState()
setLoad({ kind: 'ok', state })
} catch (error) {
setLoad({ kind: 'error', message: errorMessage(error) })
}
}, [])
useEffect(() => {
if (!enabled) return
void refresh()
}, [enabled, refresh])
useEffect(() => {
if (!enabled) return
const interval = window.setInterval(() => {
void refresh()
}, open ? 10_000 : 120_000)
return () => window.clearInterval(interval)
}, [open, enabled, refresh])
return { load, refresh }
}
+2 -2
View File
@@ -7,7 +7,9 @@ require (
github.com/docker/cli v28.5.1+incompatible
github.com/docker/compose/v2 v2.40.3
github.com/docker/docker v28.5.2+incompatible
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.51.0
)
require (
@@ -60,7 +62,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -136,7 +137,6 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
-186
View File
@@ -1,186 +0,0 @@
// 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"})
}
// Reject serves POST /api/peers/reject — denies a pending first-contact
// request without authorizing it (the auth service drops the request and
// audits the decision).
func (h *Handler) Reject(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.Reject(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to reject peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
}
// Hide serves POST /api/peers/hide — permanently hides a revoked peer from
// the dashboard: the auth service keeps the row (audit trail survives) but
// marks it hidden; a re-approval restores visibility.
func (h *Handler) Hide(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.Hide(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to hide peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "hidden"})
}
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)
}
-74
View File
@@ -1,74 +0,0 @@
// 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 actions
import (
"bytes"
"fmt"
"net/http"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
)
type ActionProxyPayload struct {
Method string
Action string
Data string `json:"data"`
}
type ActionProxyResponse struct {
StatusCode int
Body string
}
func ActionProxy(container_id, action string, payload ActionProxyPayload) (ActionProxyResponse, error) {
// Forward the action to the container with the given ID, and return the result to the frontend
containers := containers.NewContainerCache().GetServices()
for _, container := range containers {
if container.ID == container_id {
// Forward the action to this container
body := bytes.NewBuffer([]byte(payload.Data))
port := "80"
if len(container.Network.Ports) > 0 {
port = fmt.Sprintf("%d", container.Network.Ports[0].Guest)
}
req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], port, payload.Action), body)
if err != nil {
return ActionProxyResponse{}, err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return ActionProxyResponse{}, err
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
return ActionProxyResponse{
StatusCode: resp.StatusCode,
Body: buf.String(),
}, nil
}
}
return ActionProxyResponse{}, fmt.Errorf("container with ID %s not found", container_id)
}
+44 -184
View File
@@ -23,8 +23,6 @@ package authmgmt
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
@@ -32,26 +30,27 @@ import (
"path/filepath"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"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"
// dcntrlized network. After rev 9 the fleet-wide internal transport is
// plaintext HTTP on the single :8080 listener; there is no mTLS anywhere.
const AuthServiceURL = "http://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.
// Client talks to the auth service over plaintext HTTP. It no longer presents
// any client certificate: the peer gate was removed fleet-wide in rev 9.
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.
// a peer should verify access tokens with, plus the fingerprint Maestro pins
// as an app-level check (M2 §5). Rev 9 removed the peer trust store, the
// pending/approved authorization flow and every management call that used to
// live in this package.
type Attestation struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
@@ -59,44 +58,9 @@ type Attestation struct {
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"`
HiddenAt *string `json:"hidden_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).
// New builds the plaintext management client. There is no identity to
// provision and no transport to configure after the PKI removal.
func New() (*Client, error) {
transport, err := maestroTransport()
if err != nil {
return nil, err
}
baseURL := ""
if env.IsDev() {
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
@@ -112,83 +76,51 @@ func New() (*Client, error) {
baseURL: baseURL,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
},
}, nil
}
// maestroTransport builds the mutual-TLS transport Maestro uses as a peer: its
// own identity certificate (CN=maestro) signed by the instance CA, trusting the
// CA as root. This is the client side of every Maestro peer edge.
func maestroTransport() (*http.Transport, 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")
}
return &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS12,
},
}, nil
}
// Scheme returns "http" in dev mode and "https" otherwise, for building
// satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a
// plaintext listener so the RN clients (which cannot do mTLS) can reach them;
// production satellites bind mTLS listeners.
// Scheme returns the scheme every satellite peer edge uses after rev 9:
// always "http" (plaintext on the single :8080 listener). Kept as a function
// so the call sites (manifest fetch, action routing) do not hard-code it.
func Scheme() string {
if env.IsDev() {
return "http"
}
return "https"
// ConfigToken mints a fresh, single-use ES256 config token for one service
// (contract rev 9 §17.3.6). It loads (or creates) Maestro's DEDICATED
// config-signing keypair from the data root and returns the compact JWS. The
// service holds only the public half (DCNTRLIZED_CONFIG_PUBKEY) and verifies;
// Maestro only ever mints. Wire format is frozen in §17.3.3.
func ConfigToken(serviceName string) (string, error) {
root, err := dataRoot()
if err != nil {
return "", err
}
signer, err := configsign.LoadOrCreate(root)
if err != nil {
return "", err
}
return signer.Mint(serviceName, time.Now())
}
// PeerTransport returns the transport Maestro uses for the satellite peer
// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners),
// mutual TLS with Maestro's identity in production.
func PeerTransport() (*http.Transport, error) {
if env.IsDev() {
return &http.Transport{}, nil
// dataRoot resolves the directory holding Maestro's private key material
// (DCNTRLIZED_INSTALL_DIR → XDG → HOME), mirroring the containers package's
// resolution. Kept local to avoid an import cycle: containers imports authmgmt.
func dataRoot() (string, error) {
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
return dir, nil
}
return maestroTransport()
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, ".dcntrlized"), nil
}
// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro)
// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges
// that must prove the caller is the orchestrator (e.g. /config delivery, which
// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV,
// since those services keep an always-mTLS peer listener.
func MaestroTransport() (*http.Transport, error) {
return maestroTransport()
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
return filepath.Join(xdgHome, ".dcntrlized"), 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
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".dcntrlized"), nil
}
return &state, nil
return filepath.Join("/data", ".dcntrlized"), nil
}
// Attestation returns the auth service's public key statement.
@@ -200,35 +132,6 @@ func (c *Client) Attestation(ctx context.Context) (*Attestation, error) {
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)
}
// Reject denies a pending first-contact request: the request is dropped and
// audited; the peer stays on the no-op path until the owner approves it.
func (c *Client) Reject(ctx context.Context, service, fingerprint string) error {
return c.do(ctx, http.MethodPost, "/internal/reject",
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
}
// Hide permanently hides a revoked peer from the dashboard: the auth service
// keeps the row (audit trail survives) but marks it hidden_at; a re-approval
// clears the flag and makes the key visible again.
func (c *Client) Hide(ctx context.Context, service, fingerprint string) error {
return c.do(ctx, http.MethodPost, "/internal/hide",
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 {
@@ -258,46 +161,3 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) 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)
}
}
+54
View File
@@ -0,0 +1,54 @@
// 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
import (
"os"
"strings"
"testing"
)
// TestSchemeIsAlwaysPlaintext locks the rev-9 transport decision: every
// satellite peer edge (manifest fetch, action routing) is plaintext HTTP on the
// single :8080 listener. A future https/peer-port regression must fail here.
func TestSchemeIsAlwaysPlaintext(t *testing.T) {
if got := Scheme(); got != "http" {
t.Fatalf("Scheme() = %q, want %q", got, "http")
}
}
// TestConfigTokenMintsForService exercises the contract-named mint helper
// (rev 9 §17.3.6) end to end against a temp data root: it must create the
// dedicated config-signing keypair, return a 3-segment JWS bound to the target
// service, and expose a public key whose kid matches the token header.
func TestConfigTokenMintsForService(t *testing.T) {
dir := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", dir)
token, err := ConfigToken("calendar")
if err != nil {
t.Fatalf("ConfigToken: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token has %d segments, want 3", len(parts))
}
for _, name := range []string{"config-signing.key", "config-signing.pub"} {
if _, err := os.Stat(dir + "/maestro/" + name); err != nil {
t.Fatalf("expected %s to exist: %v", name, err)
}
}
}
+226
View File
@@ -0,0 +1,226 @@
// 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 configsign owns Maestro's DEDICATED /config signing keypair and the
// ES256 token it mints for runtime operator-config delivery (contract rev 9
// §17). The keypair is generated and held by Maestro in its data root and is
// deliberately NOT the instance CA: the PKI identity layer is removed fleet-wide,
// and a service only ever receives the public key (verify, never mint).
package configsign
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
)
const (
// keyFileName is the PKCS#8 PEM private key in <dataRoot>/maestro.
keyFileName = "config-signing.key"
// pubFileName is the PKIX PEM public key in <dataRoot>/maestro.
pubFileName = "config-signing.pub"
// Issuer is the fixed iss claim every config token carries.
Issuer = "dcntrlized-maestro"
// Scope is the fixed scope claim; the service requires exactly this value.
Scope = "config"
// TokenTTL is the frozen 60 s token lifetime.
TokenTTL = 60 * time.Second
// PublicKeyEnv is the env var Maestro injects into a service container: the
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM) of the
// dedicated config-signing public key. The service loads it to verify
// /config tokens and can never mint them.
PublicKeyEnv = "DCNTRLIZED_CONFIG_PUBKEY"
)
// Signer holds the loaded keypair and mints config tokens.
type Signer struct {
key *ecdsa.PrivateKey
kid string
pubDER []byte
pubPEM []byte
}
// LoadOrCreate opens <dataRoot>/maestro/config-signing.key, generating the
// keypair on first use. The private key is written atomically with mode 0600;
// the public key is written alongside it with mode 0644.
func LoadOrCreate(dataRoot string) (*Signer, error) {
dir := filepath.Join(dataRoot, "maestro")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("configsign: mkdir %s: %w", dir, err)
}
keyPath := filepath.Join(dir, keyFileName)
pubPath := filepath.Join(dir, pubFileName)
if raw, err := os.ReadFile(keyPath); err == nil {
block, _ := pem.Decode(raw)
if block == nil {
return nil, fmt.Errorf("configsign: %s is not PEM", keyPath)
}
key, err := parsePrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("configsign: parse %s: %w", keyPath, err)
}
return newSigner(key, pubPath)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("configsign: read %s: %w", keyPath, err)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("configsign: generate key: %w", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("configsign: marshal private key: %w", err)
}
if err := writeFileAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
return nil, err
}
return newSigner(key, pubPath)
}
// newSigner derives the kid and (re)writes the public key file.
func newSigner(key *ecdsa.PrivateKey, pubPath string) (*Signer, error) {
spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return nil, fmt.Errorf("configsign: marshal public key: %w", err)
}
sum := sha256.Sum256(spki)
s := &Signer{
key: key,
kid: hex.EncodeToString(sum[:16]),
pubDER: spki,
pubPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}),
}
if err := writeFileAtomic(pubPath, s.pubPEM, 0o644); err != nil {
return nil, err
}
return s, nil
}
// Kid is the key id placed in the JWT header; a service can recompute it from
// the public key it holds.
func (s *Signer) Kid() string { return s.kid }
// PublicKeyBase64 is the single-line env value Maestro injects into services:
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM). The service
// decodes it once and has both the key and the kid input (kid is defined over
// this exact DER).
func (s *Signer) PublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(s.pubDER)
}
// Mint returns a fresh ES256 config token for one service. Claims are frozen by
// contract rev 9: iss=dcntrlized-maestro, aud=sub=<service>, scope="config",
// iat/nbf/exp=now..now+60s, and a random 128-bit jti (single-use).
func (s *Signer) Mint(service string, now time.Time) (string, error) {
jti := make([]byte, 16)
if _, err := rand.Read(jti); err != nil {
return "", fmt.Errorf("configsign: jti: %w", err)
}
header := map[string]string{"alg": "ES256", "typ": "JWT", "kid": s.kid}
claims := map[string]any{
"iss": Issuer,
"aud": service,
"sub": service,
"scope": Scope,
"iat": now.Unix(),
"nbf": now.Unix(),
"exp": now.Add(TokenTTL).Unix(),
"jti": hex.EncodeToString(jti),
}
hb, err := json.Marshal(header)
if err != nil {
return "", err
}
cb, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := b64(hb) + "." + b64(cb)
digest := sha256.Sum256([]byte(signingInput))
r, ss, err := ecdsa.Sign(rand.Reader, s.key, digest[:])
if err != nil {
return "", fmt.Errorf("configsign: sign: %w", err)
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
ss.FillBytes(sig[32:])
return signingInput + "." + b64(sig), nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func parsePrivateKey(der []byte) (*ecdsa.PrivateKey, error) {
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
ec, ok := k.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA key")
}
return ec, nil
}
return x509.ParseECPrivateKey(der)
}
// writeFileAtomic writes data to path via a temp file + rename so a crash can
// never leave a half-written private key.
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return fmt.Errorf("configsign: write %s: %w", tmp, err)
}
if err := os.Chmod(tmp, mode); err != nil {
return fmt.Errorf("configsign: chmod %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("configsign: rename %s: %w", path, err)
}
return nil
}
// Verify is a test helper: it checks a token the Signer minted against its own
// public key (the same computation a service performs). It is not used by
// Maestro at runtime — Maestro only mints.
func (s *Signer) Verify(token string) error {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return fmt.Errorf("malformed token")
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
return fmt.Errorf("bad signature encoding")
}
r := new(big.Int).SetBytes(sig[:32])
ss := new(big.Int).SetBytes(sig[32:])
if !ecdsa.Verify(&s.key.PublicKey, digest[:], r, ss) {
return fmt.Errorf("signature does not verify")
}
return nil
}
+229
View File
@@ -0,0 +1,229 @@
// 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 configsign
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"math/big"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestLoadOrCreatePersistsKeypair(t *testing.T) {
root := t.TempDir()
s1, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
keyPath := filepath.Join(root, "maestro", keyFileName)
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat private key: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("private key mode = %o, want 600", perm)
}
pubInfo, err := os.Stat(filepath.Join(root, "maestro", pubFileName))
if err != nil {
t.Fatalf("stat public key: %v", err)
}
if perm := pubInfo.Mode().Perm(); perm != 0o644 {
t.Fatalf("public key mode = %o, want 644", perm)
}
// Reload must reuse the same key (kid stable).
s2, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate reload: %v", err)
}
if s1.Kid() != s2.Kid() {
t.Fatalf("kid changed across reload: %q vs %q", s1.Kid(), s2.Kid())
}
}
func TestKidIsHexOfSPKIHalf(t *testing.T) {
root := t.TempDir()
s, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(s.Kid()) {
t.Fatalf("kid = %q, want 32 lowercase hex chars", s.Kid())
}
der, err := base64.StdEncoding.DecodeString(s.PublicKeyBase64())
if err != nil {
t.Fatalf("PublicKeyBase64 is not base64: %v", err)
}
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
t.Fatalf("public key env value is not DER SPKI: %v", err)
}
sum := sha256.Sum256(der)
if want := hex.EncodeToString(sum[:16]); s.Kid() != want {
t.Fatalf("kid = %q, want sha256(spki)[:16] = %q", s.Kid(), want)
}
}
func TestMintClaimsAndSignature(t *testing.T) {
root := t.TempDir()
s, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
now := time.Unix(1_800_000_000, 0)
token, err := s.Mint("calendar", now)
if err != nil {
t.Fatalf("Mint: %v", err)
}
if err := s.Verify(token); err != nil {
t.Fatalf("minted token does not verify: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token has %d parts, want 3", len(parts))
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
t.Fatalf("signature must be raw 64-byte r||s (len=%d err=%v)", len(sig), err)
}
var header map[string]string
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
if err := json.Unmarshal(hb, &header); err != nil {
t.Fatalf("header: %v", err)
}
if header["alg"] != "ES256" || header["typ"] != "JWT" || header["kid"] != s.Kid() {
t.Fatalf("header = %v", header)
}
var claims map[string]any
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatalf("claims: %v", err)
}
want := map[string]any{
"iss": "dcntrlized-maestro",
"aud": "calendar",
"sub": "calendar",
"scope": "config",
"iat": float64(now.Unix()),
"nbf": float64(now.Unix()),
"exp": float64(now.Add(60 * time.Second).Unix()),
}
for k, v := range want {
if claims[k] != v {
t.Errorf("claim %s = %v, want %v", k, claims[k], v)
}
}
jti, _ := claims["jti"].(string)
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(jti) {
t.Fatalf("jti = %q, want 32 lowercase hex chars", jti)
}
}
// TestContractGoldenFixture cross-checks Maestro's kid derivation and ES256
// interpretation against the worked example frozen in contract rev 9 §17.3.7.
// It does not use the Signer (which holds a private key): it verifies the
// contract's token with the contract's public key, exactly as a service would.
func TestContractGoldenFixture(t *testing.T) {
const (
pubB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqBhe2nIg0c3qf2C8yIVGs9/qSqLvW8eFUliCT1yKnWmUhX5RdRCOIOo59M7APFzywtwSOxMMivQh9N5rVWXxCQ=="
kidHex = "088efbd65ad78ceed9dfe6495367ea6d"
token = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjA4OGVmYmQ2NWFkNzhjZWVkOWRmZTY0OTUzNjdlYTZkIn0.eyJpc3MiOiJkY250cmxpemVkLW1hZXN0cm8iLCJhdWQiOiJjYWxlbmRhciIsInN1YiI6ImNhbGVuZGFyIiwic2NvcGUiOiJjb25maWciLCJpYXQiOjE3NzAwMDAwMDAsIm5iZiI6MTc3MDAwMDAwMCwiZXhwIjoxNzcwMDAwMDYwLCJqdGkiOiIwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZiJ9.4F8OmePWBO4lZsR0bOG5HK6cttvtTmKjwhcYMZcY5neIGnnmTlYsGtGJzMmhaC7eM5OEz6d0rPft8ZWNiQvyfQ"
)
der, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatalf("decode pubkey: %v", err)
}
sum := sha256.Sum256(der)
if got := hex.EncodeToString(sum[:16]); got != kidHex {
t.Fatalf("kid = %s, contract says %s", got, kidHex)
}
parsed, err := x509.ParsePKIXPublicKey(der)
if err != nil {
t.Fatalf("parse pubkey: %v", err)
}
pub, ok := parsed.(*ecdsa.PublicKey)
if !ok || pub.Curve != elliptic.P256() {
t.Fatalf("pubkey is not P-256 ECDSA: %T", parsed)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("fixture token has %d segments", len(parts))
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
t.Fatalf("fixture signature not raw 64 bytes (len=%d err=%v)", len(sig), err)
}
if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
t.Fatal("contract fixture token does not verify with the contract public key")
}
var claims map[string]any
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatalf("fixture payload: %v", err)
}
if claims["iss"] != Issuer || claims["aud"] != "calendar" || claims["sub"] != "calendar" || claims["scope"] != Scope {
t.Fatalf("fixture claims mismatch: %v", claims)
}
if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 60 {
t.Fatalf("fixture exp-iat = %v, want 60", exp-iat)
}
if claims["nbf"] != claims["iat"] {
t.Fatalf("fixture nbf != iat: %v", claims)
}
}
func TestMintJTIUniqueAndTamperRejected(t *testing.T) {
root := t.TempDir()
s, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
now := time.Now()
a, err := s.Mint("calendar", now)
if err != nil {
t.Fatal(err)
}
b, err := s.Mint("calendar", now)
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("two mints produced the same token (jti must be random)")
}
// Flip one payload byte → signature must fail.
parts := strings.Split(a, ".")
parts[1] = strings.Replace(parts[1], "e", "f", 1)
if err := s.Verify(strings.Join(parts, ".")); err == nil {
t.Fatal("tampered token verified")
}
}
+23 -46
View File
@@ -16,12 +16,13 @@
// Package containers implements Maestro's runtime write-back delivery for the
// Service Config & Secrets theme (D6). When the operator sets or clears a
// config value via the Settings panel, Maestro delivers it to the running
// service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own
// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at
// the service's dedicated peer listener port (label org.dcntrlized.peer_port,
// default 9443), which the service gates to CN=maestro. A set carries
// {field, value, type}; a clear carries {field, action:"clear"} — the service
// owns the schema and branches on what it receives.
// service's POST /config endpoint on the service's single plaintext :8080
// listener, authenticated with a short-lived, single-use ES256 config token
// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17).
// There is no mTLS and no peer port: the fleet-wide internal transport is
// plaintext HTTP. A set carries {field, value, type}; a clear carries
// {field, action:"clear"} — the service owns the schema and branches on what it
// receives.
//
// Delivery is best-effort: failures are logged (with the field name, never
// the value) and do not prevent the panel write from succeeding. Values always
@@ -42,32 +43,16 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// peerPortLabel is the compose label a service declares to advertise the
// internal port of its always-mTLS Maestro peer listener.
const peerPortLabel = "org.dcntrlized.peer_port"
// defaultPeerPort is the fleet-wide default for that listener. Services keep/ve
// to it unless the label overrides it.
const defaultPeerPort = "9443"
// peerPort resolves the configured {peer listener port} from a container's
// compose labels, falling back to the fleet default.
func peerPort(labels map[string]string) string {
if labels == nil {
return defaultPeerPort
}
if p := labels[peerPortLabel]; p != "" {
return p
}
return defaultPeerPort
}
// configPort is the single port every service serves its API, /config and web
// app on after rev 9. There is no peer listener and no compose label.
const configPort = "8080"
// ConfigDelivery is one key-value pair to deliver to a running service.
type ConfigDelivery struct {
Field string // manifest config field name
Key string // container env var name
Type string // "cleartext" | "password"
Value string // the operator-entered value (sent over mTLS; never logged)
Value string // the operator-entered value (never logged)
}
// configRequest is the JSON body POSTed to the service's /config endpoint.
@@ -79,10 +64,9 @@ type configRequest struct {
Action string `json:"action,omitempty"`
}
// DeliverConfigValues delivers the given config pairs to the running service
// over the mTLS peer edge. If the service is not running or unreachable, the
// error is logged but not returned — the values will take effect at next start
// via injectConfigSecrets.
// DeliverConfigValues delivers the given config pairs to the running service.
// If the service is not running or unreachable, the error is logged but not
// returned — the values will take effect at next start via injectConfigSecrets.
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
if len(items) == 0 {
return
@@ -114,9 +98,10 @@ func DeliverConfigClear(ctx context.Context, serviceName, field string) {
}
// postConfig performs one best-effort POST of body to the running service's
// /config endpoint. NotFound/unreachable cases are logged at debug (the write
// still applies at next start); transport errors carry the field name only,
// never a value.
// /config endpoint on the single :8080 listener, authenticated with a freshly
// minted ES256 config token. NotFound/unreachable cases are logged at debug
// (the write still applies at next start); transport errors carry the field
// name only, never a value.
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
dc, err := docker.NewDockerClient()
if err != nil {
@@ -137,30 +122,22 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
return
}
// /config is served on the service's dedicated Maestro peer listener
// (always mTLS, gated to CN=maestro), NOT the RN-facing flat edge. Maestro
// presents its own identity and targets the peer port; dev mode does not
// downgrade this edge.
port := peerPort(inspectData.Config.Labels)
transport, err := authmgmt.MaestroTransport()
token, err := authmgmt.ConfigToken(serviceName)
if err != nil {
logger.Warn("Config delivery to %s/%s: cannot build Maestro peer transport: %v", serviceName, field, err)
logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err)
return
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
client := &http.Client{Timeout: 5 * time.Second}
url := fmt.Sprintf("https://%s:%s/config", network.IPAddress.Address, port)
url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
-39
View File
@@ -1,39 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import "testing"
func TestPeerPort(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want string
}{
{"nil labels fall back to default", nil, defaultPeerPort},
{"empty labels fall back to default", map[string]string{}, defaultPeerPort},
{"label present wins", map[string]string{"org.dcntrlized.peer_port": "9000"}, "9000"},
{"empty label value falls back to default", map[string]string{"org.dcntrlized.peer_port": ""}, defaultPeerPort},
{"unrelated labels fall back to default", map[string]string{"org.dcntrlized.port": "8080"}, defaultPeerPort},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := peerPort(tt.labels); got != tt.want {
t.Fatalf("peerPort(%v) = %q, want %q", tt.labels, got, tt.want)
}
})
}
}
-11
View File
@@ -20,7 +20,6 @@ import (
"fmt"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
@@ -105,16 +104,6 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error {
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
}
// For the shared database, reconcile the CONNECT authorization against the
// peers Maestro manages (M2 §4): per-role CONNECT grants and, in mTLS mode,
// PUBLIC revoked. Best-effort — the database advertises infra-relevant
// problems as warnings, never as boot failures.
if serviceName == DatabaseServiceName {
if err := dbctl.New(dc).SyncAuthorization(ctx); err != nil {
logger.Warn("Shared database authorization sync failed: %v", err)
}
}
return nil
}
-331
View File
@@ -1,331 +0,0 @@
// 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 dbctl administers the shared database's authorization (M2 §4).
//
// The database image enables instance-CA mTLS when Maestro injects an identity
// (server cert + client-CA + hostssl cert / hostnossl reject rules), but the
// auth rules alone only prove *who* authenticates. Authorization lives here:
// the stock PUBLIC CONNECT grant is revoked and each installed peer gets a
// LOGIN role with CONNECT, so a peer that holds no role (or a revoked one)
// cannot even open a session. The admin path uses the postgres superuser over
// the in-container unix socket (trust), which deliberately bypasses the mTLS
// enforcement — Maestro owns the database and drives its lifecycle.
package dbctl
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/pkg/stdcopy"
)
const (
// DBContainerName is the canonical shared database container (image, DNS).
DBContainerName = "dcntrlized-db"
// DBName is the shared database name inside the instance.
DBName = "dcntrlized"
// AdminRole is Maestro's own database role (CN=maestro bootstrap in the
// image). It is granted like any peer so its existence is guaranteed.
AdminRole = "maestro"
// identityMountTarget is the container path of the injected leaf cert. Its
// presence in the mount list marks the database as mTLS-armed.
identityMountTarget = "/dcntrlized/pki/tls.crt"
)
// Client reconciles the shared database's CONNECT authorization state.
type Client struct {
dc *docker.DockerClient
}
// New wraps a Docker client.
func New(dc *docker.DockerClient) *Client {
return &Client{dc: dc}
}
// SyncAuthorization ensures every installed peer holds a LOGIN role with
// CONNECT on the shared database, and — when the database runs in mTLS mode —
// revokes the stock PUBLIC grant. It is safe to call on every start: role
// creation and grants are idempotent, and the PUBLIC revoke is a no-op on the
// second run. When the database is not running it returns an error; callers
// treat it as best-effort.
func (c *Client) SyncAuthorization(ctx context.Context) error {
id := c.dbContainerID(ctx)
if id == "" {
return fmt.Errorf("shared database %s is not running", DBContainerName)
}
if err := c.waitReady(ctx, id); err != nil {
return err
}
roles, err := peerRoles()
if err != nil {
return err
}
if len(roles) == 0 {
return nil
}
script := "SET client_min_messages TO warning;\n"
have := c.existingRoles(ctx, id)
for _, role := range roles {
if !have[role] {
script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role))
}
script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role))
// The peer connects and migrates under its own role (its cert CN),
// not the shared superuser: it may create tables in public (PG15+
// revoked that from PUBLIC) and must reach the tables it already
// owns or shares across every user schema (e.g. auth's own schema).
script += fmt.Sprintf("GRANT CREATE ON SCHEMA public TO %s;\n", quoteIdent(role))
script += grantExistingScript(role)
}
if c.isArmed(ctx, id) {
script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName))
}
out, code, err := c.execPSQL(ctx, id, DBName, script)
if err != nil {
return fmt.Errorf("apply database authorization: %w", err)
}
if code != 0 {
return fmt.Errorf("apply database authorization failed (exit %d): %s", code, out)
}
logger.Info("Reconciled shared database authorization for %d peer(s): %s", len(roles), strings.Join(roles, ", "))
return nil
}
// RevokeForService removes a peer's CONNECT grant when it is uninstalled.
// Best-effort: the database may already be gone.
func (c *Client) RevokeForService(ctx context.Context, serviceName string) {
if serviceName == "" {
return
}
id := c.dbContainerID(ctx)
if id == "" {
return
}
script := fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM %s;", quoteIdent(DBName), quoteIdent(serviceName))
out, code, err := c.execPSQL(ctx, id, DBName, script)
if err != nil || code != 0 {
logger.Warn("Failed to revoke database CONNECT for %s: %v (exit %d): %s", serviceName, err, code, out)
return
}
logger.Info("Revoked database CONNECT for %s", serviceName)
}
// peerRoles returns the guaranteed connectors: Maestro plus every installed
// service (identified by its install dir name, which is also its cert CN).
func peerRoles() ([]string, error) {
roles := []string{AdminRole}
root, err := dataRoot()
if err != nil {
return nil, fmt.Errorf("resolve data root: %w", err)
}
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return roles, nil
}
return nil, fmt.Errorf("list data root: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == DBContainerName {
continue
}
if _, err := os.Stat(filepath.Join(root, entry.Name(), "metadata.json")); err != nil {
continue
}
roles = append(roles, entry.Name())
}
return roles, nil
}
// dataRoot resolves the directory storing installed container metadata, with
// the same precedence as lib/containers.DataRoot (kept local to avoid an
// import cycle — lib/containers imports dbctl).
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
}
// grantExistingScript returns the DO block granting a role USAGE plus ALL
// PRIVILEGES on tables and sequences in every user schema. Existing volumes
// hold tables created by the postgres superuser (and by other services'
// earlier migrations); without these grants a per-service role could connect
// but not read or migrate anything.
func grantExistingScript(role string) string {
return fmt.Sprintf(`DO $dcntrlized_grants$ DECLARE s text; BEGIN
FOR s IN SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg_%%' AND nspname <> 'information_schema' LOOP
EXECUTE format('GRANT USAGE ON SCHEMA %%I TO %%I', s, %s);
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %%I TO %%I', s, %s);
EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %%I TO %%I', s, %s);
END LOOP; END $dcntrlized_grants$;
`, quoteLiteral(role), quoteLiteral(role), quoteLiteral(role))
}
// quoteLiteral renders a PostgreSQL string literal safe for interpolation.
func quoteLiteral(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// quoteIdent renders a PostgreSQL identifier safe for interpolation.
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// dbContainerID resolves the running canonical database container, or "".
func (c *Client) dbContainerID(ctx context.Context) string {
filter := filters.NewArgs()
filter.Add("label", "org.dcntrlized.service="+DBContainerName)
containers, err := c.dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
if err != nil {
logger.Warn("Failed to list database containers: %v", err)
return ""
}
for _, cc := range containers {
if cc.State != "running" {
continue
}
for _, n := range cc.Names {
if strings.TrimPrefix(n, "/") == DBContainerName {
return cc.ID
}
}
}
return ""
}
// Armed reports whether the running shared database received the injected
// instance-CA identity (i.e. it enforces mTLS on its TCP edge). Callers use
// this to decide which connection URL peers get: verify-full mTLS DSNs on an
// armed database, plaintext defaults otherwise.
func (c *Client) Armed(ctx context.Context) bool {
id := c.dbContainerID(ctx)
if id == "" {
return false
}
return c.isArmed(ctx, id)
}
// isArmed reports whether the database container received the injected
// instance-CA identity (i.e. it enforces mTLS on its TCP edge).
func (c *Client) isArmed(ctx context.Context, id string) bool {
inspect, err := c.dc.ContainerInspect(ctx, id)
if err != nil {
return false
}
for _, m := range inspect.Mounts {
if filepath.Clean(m.Destination) == identityMountTarget {
return true
}
}
return false
}
// waitReady polls the database until an admin statement executes or the
// deadline is reached, tolerating the first-boot delay after compose up.
func (c *Client) waitReady(ctx context.Context, id string) error {
const attempts = 30
for i := 0; i < attempts; i++ {
_, code, err := c.execPSQL(ctx, id, "postgres", "SELECT 1")
if err == nil && code == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
return fmt.Errorf("database %s did not become ready in time", DBContainerName)
}
// existingRoles returns the set of roles currently defined in the database.
func (c *Client) existingRoles(ctx context.Context, id string) map[string]bool {
have := map[string]bool{}
out, code, err := c.execPSQL(ctx, id, "postgres", "SELECT rolname FROM pg_catalog.pg_roles")
if err != nil || code != 0 {
return have
}
for _, line := range strings.Split(out, "\n") {
if name := strings.TrimSpace(line); name != "" {
have[name] = true
}
}
return have
}
// execPSQL runs a psql statement over the in-container unix socket as the
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
// administers the database). It returns trimmed stdout and the exit code.
// execPSQL runs a psql statement over the in-container unix socket as the
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
// administers the database), targeting the named database. It returns trimmed
// stdout and the exit code.
func (c *Client) execPSQL(ctx context.Context, id, dbName, sql string) (string, int, error) {
created, err := c.dc.ContainerExecCreate(ctx, id, container.ExecOptions{
User: "postgres",
AttachStdout: true,
Cmd: []string{"psql", "-U", "postgres", "-d", dbName, "-v", "ON_ERROR_STOP=1", "-tA", "-c", sql},
})
if err != nil {
return "", -1, fmt.Errorf("create psql exec: %w", err)
}
resp, err := c.dc.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
if err != nil {
return "", -1, fmt.Errorf("attach psql exec: %w", err)
}
defer resp.Close()
var stdout, stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil {
return "", -1, fmt.Errorf("read psql output: %w", err)
}
for i := 0; i < 50; i++ {
inspect, err := c.dc.ContainerExecInspect(ctx, created.ID)
if err != nil {
return "", -1, fmt.Errorf("inspect psql exec: %w", err)
}
if !inspect.Running {
return strings.TrimSpace(stdout.String()), inspect.ExitCode, nil
}
time.Sleep(100 * time.Millisecond)
}
return "", -1, fmt.Errorf("psql exec did not finish in time")
}
-84
View File
@@ -1,84 +0,0 @@
// 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 dbctl
import (
"context"
"os"
"path/filepath"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
)
// TestSyncAuthorizationIntegration exercises the real exec path against a
// running dcntrlized-db container.
//
// 1. docker run -d --name dcntrlized-db -e POSTGRES_DB=dcntrlized \
// -e POSTGRES_USER=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
// -v $PWD/PKI/ca.crt:/dcntrlized/pki/ca.crt:ro \
// -v $PWD/PKI/tls.crt:/dcntrlized/pki/tls.crt:ro \
// -v $PWD/PKI/tls.key:/dcntrlized/pki/tls.key:ro \
// -v dctl-vol:/var/lib/postgresql/data dcntrlized-db
// 2. seed the data root with install dirs for the peers
// 3. DBCtl_INTEGRATION=1 go test ./lib/db -run TestSyncAuthorizationIntegration -v
func TestSyncAuthorizationIntegration(t *testing.T) {
if os.Getenv("DBCtl_INTEGRATION") != "1" {
t.Skip("set DBCtl_INTEGRATION=1 to run against a live database container")
}
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
for _, name := range []string{"keepnote", "dcntrlized-auth"} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, name, "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
dc, err := docker.NewDockerClient()
if err != nil {
t.Fatal(err)
}
defer dc.Close()
c := New(dc)
if err := c.SyncAuthorization(context.Background()); err != nil {
t.Fatalf("SyncAuthorization: %v", err)
}
id := c.dbContainerID(context.Background())
if id == "" {
t.Fatal("database container not found")
}
assert := func(t *testing.T, sql, contains string) {
t.Helper()
out, code, err := c.execPSQL(context.Background(), id, "postgres", sql)
if err != nil || code != 0 {
t.Fatalf("check %q failed (exit %d, err %v): %s", sql, code, err, out)
}
if want := contains; want != "" && out != want {
t.Fatalf("%s => %q, want %q", sql, out, want)
}
}
// Roles exist and hold CONNECT.
assert(t, `SELECT rolname FROM pg_catalog.pg_roles WHERE rolname IN ('maestro','keepnote','dcntrlized-auth') ORDER BY 1`, "dcntrlized-auth\nkeepnote\nmaestro")
assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
// Armed database: PUBLIC grant removed.
assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
}
-78
View File
@@ -1,78 +0,0 @@
// 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 dbctl
import (
"os"
"path/filepath"
"sort"
"testing"
)
func TestQuoteIdent(t *testing.T) {
cases := map[string]string{
"keepnote": `"keepnote"`,
"dcntrlized-db": `"dcntrlized-db"`,
`we"ird`: `"we""ird"`,
}
for in, want := range cases {
if got := quoteIdent(in); got != want {
t.Errorf("quoteIdent(%q) = %s, want %s", in, got, want)
}
}
}
func TestPeerRoles(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
// Installed peers (metadata.json present).
for _, name := range []string{"keepnote", "dcntrlized-auth", "calendar"} {
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
// The database itself must never be granted a role.
if err := os.MkdirAll(filepath.Join(root, DBContainerName), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, DBContainerName, "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
// A non-install directory (no metadata.json) is skipped.
if err := os.MkdirAll(filepath.Join(root, "scratch"), 0o755); err != nil {
t.Fatal(err)
}
roles, err := peerRoles()
if err != nil {
t.Fatalf("peerRoles: %v", err)
}
sort.Strings(roles)
want := []string{"calendar", "dcntrlized-auth", "keepnote", "maestro"}
if len(roles) != len(want) {
t.Fatalf("peerRoles = %v, want %v", roles, want)
}
for i := range want {
if roles[i] != want[i] {
t.Fatalf("peerRoles = %v, want %v", roles, want)
}
}
}
-60
View File
@@ -1,60 +0,0 @@
// 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 devboot provisions the dev fleet the DCNTRLIZED_DEV way: Maestro
// issues the identities its docker-compose.dev/debug.yaml peers bind-mount at
// /dcntrlized/pki. There is no approval shortcut — a satellite's first mTLS
// contact records a pending authorization and is refused (403) until the owner
// approves it via /api/peers, exactly the real trust-store flow.
package devboot
import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
)
// Infra are the shared services whose identities the dev composes mount. They
// are peers/attesters themselves, not approval candidates (auth is the
// trust-store owner, Maestro uses the internal RequireMaestro endpoints).
var Infra = []string{"dcntrlized-auth"}
// Fleet lists the dev-mode satellite identities Maestro issues. Their CNs must
// match the service names used in the dev composes' pki mounts.
var Fleet = []string{"keepnote", "calendar", "testservice"}
// EnsureIdentities provisions the instance CA identities the dev composes
// bind-mount at /dcntrlized/pki, so compose-managed services come up with a
// valid mTLS identity before they ever dial the auth service. Run before the
// shared infrastructure is ensured, because dcntrlized-auth's mTLS listener
// refuses to start without its identity.
func EnsureIdentities() {
dataRoot, err := containers.DataRoot()
if err != nil {
logger.Warn("devboot: resolve data root: %v", err)
return
}
ca, err := pki.EnsureCA(dataRoot)
if err != nil {
logger.Warn("devboot: ensure instance CA: %v", err)
return
}
for _, name := range append(append([]string{}, Infra...), Fleet...) {
if _, err := pki.EnsureServiceIdentity(ca, name); err != nil {
logger.Warn("devboot: ensure identity for %s: %v", name, err)
}
}
logger.Info("devboot: ensured dev fleet identities (%d infra + %d satellites)", len(Infra), len(Fleet))
}
+3 -7
View File
@@ -93,18 +93,14 @@ func ListContainers(dc *DockerClient) ([]string, error) {
// fetchManifestWithRetry polls the container's manifest endpoint until it succeeds or max retries are reached.
// Could probably be simplified to (ctx, container) with maxRetries and retryDelay hardcoded, low priority TODO
func FetchManifestWithRetry(ctx context.Context, host, port string, maxRetries int, retryDelay time.Duration) (*types.Manifest, error) {
// The service peer edge is mutual TLS (M2 §2), same as RunAction: Maestro's
// identity cert, https scheme, with the plaintext dev escape hatch.
// Rev 9: every service peer edge is plaintext HTTP on the single :8080
// listener — no client cert, no TLS.
url := fmt.Sprintf("%s://%s:%s/_manifest", authmgmt.Scheme(), host, port)
var resp *http.Response
var err error
// Create a client that respects context deadlines/cancellation
transport, terr := authmgmt.PeerTransport()
if terr != nil {
return nil, fmt.Errorf("build peer transport: %w", terr)
}
client := &http.Client{Timeout: 2 * time.Second, Transport: transport}
client := &http.Client{Timeout: 2 * time.Second}
for range maxRetries {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+3 -2
View File
@@ -16,8 +16,9 @@
// Package env centralizes environment-derived configuration. DCNTRLIZED_DEV is
// the single canonical dev-mode flag, injected by every docker-compose.dev.yaml
// and docker-compose.debug.yaml. It selects dev-only endpoints and behaviors
// (registry URL, plaintext satellite peer edges) — it never downgrades the
// transport security of the control-plane edges, which run mTLS in dev too.
// (registry URL, debug selectors). After rev 9 the fleet-wide internal
// transport is plaintext HTTP on a single :8080 listener in every mode, so dev
// mode no longer downgrades anything.
package env
import "os"
-69
View File
@@ -1,69 +0,0 @@
// 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 federation is a defensive reader for the remote-instance trust
// store that the multi-registry & instance federation theme will own. The
// hub app's public profile (hub-app theme M1) surfaces authorized peers from
// it when present; until that theme lands, the file simply does not exist and
// every read returns nil.
package federation
import (
"encoding/json"
"os"
"path/filepath"
)
// RemoteInstance mirrors the planned trust-store row: a peer this instance
// trusts, addressed by URL and identified by its CA fingerprint.
type RemoteInstance struct {
Name string `json:"name"`
URL string `json:"url"`
Fingerprint string `json:"fingerprint"`
Status string `json:"status"`
Scopes []string `json:"scopes,omitempty"`
}
// Path returns the trust-store location for the given data root.
func Path(dataRoot string) string {
return filepath.Join(dataRoot, "federation", "instances.json")
}
// List reads the authorized remote instances. A missing store yields
// (nil, nil): callers distinguish "absent" (nil → JSON null) from
// "present but empty" (non-nil, len 0 → JSON []).
func List(dataRoot string) ([]RemoteInstance, error) {
data, err := os.ReadFile(Path(dataRoot))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
// Defensive parse: accept both a bare array and the wrapped shape the
// federation theme is expected to persist.
var direct []RemoteInstance
if json.Unmarshal(data, &direct) == nil {
return direct, nil
}
var wrapped struct {
Instances []RemoteInstance `json:"instances"`
}
if err := json.Unmarshal(data, &wrapped); err != nil {
return nil, err
}
return wrapped.Instances, nil
}
+60 -6
View File
@@ -15,16 +15,23 @@
// Package instance holds the instance's public identity: a small, self-
// contained file at the data root that answers "who am I" for the hub app's
// public profile (hub-app theme M1) and, later, federation attestation.
// public profile (hub-app theme M1). Rev 9 removed federation, so the
// fingerprint here is informational only (it is no longer a trust anchor).
package instance
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// keyFileName is the persisted random instance key backing Fingerprint.
const keyFileName = "instance.key"
// Identity is the instance's user-visible self-description. Name falls back to
// DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent;
// Title and Description are optional and only ever come from the file.
@@ -39,6 +46,55 @@ func Path(dataRoot string) string {
return filepath.Join(dataRoot, "instance.json")
}
// Fingerprint returns the instance's stable app-level identity fingerprint in
// the same "sha256:ab:cd:…" shape the CA fingerprint used to have. Rev 9
// removed the PKI layer, so the value is derived from a persisted 32-byte
// random key at <dataRoot>/instance.key (created on first use, chmod 600) —
// not from any certificate. It is unauthenticated by design; a consumer can
// still compare it to detect a changed peer. Returns "" when the key cannot be
// read or created (the public profile then omits the field).
func Fingerprint(dataRoot string) string {
key, err := loadOrCreateKey(dataRoot)
if err != nil {
return ""
}
sum := sha256.Sum256(key)
hexStr := hex.EncodeToString(sum[:])
b := make([]byte, 0, len(hexStr)+len(hexStr)/2+7)
b = append(b, "sha256:"...)
for i := 0; i < len(hexStr); i += 2 {
if i > 0 {
b = append(b, ':')
}
b = append(b, hexStr[i:i+2]...)
}
return string(b)
}
// loadOrCreateKey reads <dataRoot>/instance.key, generating it atomically on
// first use.
func loadOrCreateKey(dataRoot string) ([]byte, error) {
path := filepath.Join(dataRoot, keyFileName)
if data, err := os.ReadFile(path); err == nil && len(data) == 32 {
return data, nil
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate instance key: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, key, 0o600); err != nil {
return nil, fmt.Errorf("write instance key: %w", err)
}
if err := os.Chmod(tmp, 0o600); err != nil {
return nil, fmt.Errorf("chmod instance key: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return nil, fmt.Errorf("replace instance key: %w", err)
}
return key, nil
}
// Load resolves the instance identity: instance.json when present, else
// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design —
// no daemon state, so an edited file shows up on the next request.
@@ -61,9 +117,8 @@ func Load(dataRoot string) Identity {
}
// Save atomically writes the identity file (tmp + rename), chmod 600 like the
// rest of Maestro's file stores. The owner-gated edit endpoint that uses this
// lands with the federation theme; the helper exists so both callers share one
// write path.
// rest of Maestro's file stores. The helper exists so the create and edit
// paths share one write path.
func Save(dataRoot string, id Identity) error {
if id.Name == "" {
return fmt.Errorf("instance name must not be empty")
@@ -90,8 +145,7 @@ func defaultIdentity() Identity {
if name := os.Getenv("DCNTRLIZED_INSTANCE_NAME"); name != "" {
return Identity{Name: name}
}
// The domain the instance announces as is its natural public name;
// federation attestation will reuse it.
// The domain the instance announces as is its natural public name.
if domain := os.Getenv("DCNTRLIZED_INSTANCE_DOMAIN"); domain != "" {
return Identity{Name: domain}
}
-167
View File
@@ -1,167 +0,0 @@
// 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 pki
import (
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
)
// 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"`
}
// RevokedSerialsDir returns the directory holding revoked certificates.
func RevokedSerialsDir(dataRoot string) string {
return filepath.Join(Dir(dataRoot), revokedDir)
}
// Revoke revokes a service's identity certificate: the leaf certificate is
// recorded as revoked and the current CRL is regenerated, then the service's
// identity files are removed. Peers stop accepting the cert within one CRL
// freshness window (security theme M3 §2).
func Revoke(dataRoot, serviceName string) error {
ca, err := EnsureCA(dataRoot)
if err != nil {
return fmt.Errorf("load CA for revocation: %w", err)
}
meta, metaErr := readMeta(ServiceDir(dataRoot, serviceName))
if metaErr == nil && meta.Serial != "" {
serial := new(big.Int)
if _, ok := serial.SetString(meta.Serial, 16); ok {
if err := recordRevoked(ca, serviceName, serial, "uninstall"); err != nil {
return err
}
}
}
// Remove the identity files so a revoked service cannot be restarted with
// a valid certificate (it will get a fresh one on reinstall).
if err := os.RemoveAll(ServiceDir(dataRoot, serviceName)); err != nil {
return fmt.Errorf("remove identity for %s: %w", serviceName, err)
}
return nil
}
// recordRevoked persists a revoked certificate and regenerates the CRL.
func recordRevoked(ca *CA, serviceName string, serial *big.Int, reason string) error {
dir := RevokedSerialsDir(ca.DataRoot)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create revoked dir: %w", err)
}
revoked := RevokedCert{
Serial: serial.Text(16),
Service: serviceName,
RevokedAt: now(),
Reason: reason,
}
if err := writeFile(filepath.Join(dir, serial.Text(16)+".json"), marshalOrDie(revoked), 0o600); err != nil {
return fmt.Errorf("record revoked cert: %w", err)
}
return regenerateCRL(ca)
}
// LoadRevokedSerials returns the serials currently revoked for this instance.
func LoadRevokedSerials(dataRoot string) ([]*big.Int, error) {
dir := RevokedSerialsDir(dataRoot)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var serials []*big.Int
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
serial := new(big.Int)
if _, ok := serial.SetString(name, 16); ok {
serials = append(serials, serial)
}
}
return serials, nil
}
// regenerateCRL writes crl.pem listing every currently-revoked serial. Peers
// re-fetch it with a short freshness window (security theme M3).
func regenerateCRL(ca *CA) error {
serials, err := LoadRevokedSerials(ca.DataRoot)
if err != nil {
return fmt.Errorf("load revoked serials: %w", err)
}
template := &x509.RevocationList{
Number: mustSerial(),
ThisUpdate: now(),
NextUpdate: now().Add(24 * time.Hour),
RevokedCertificates: revokedEntries(serials),
}
der, err := x509.CreateRevocationList(rand.Reader, template, ca.Cert, ca.PrivateKey)
if err != nil {
return fmt.Errorf("create CRL: %w", err)
}
if err := writeFile(CRLPath(ca.DataRoot), pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), 0o644); err != nil {
return fmt.Errorf("write CRL: %w", err)
}
return nil
}
func revokedEntries(serials []*big.Int) []pkix.RevokedCertificate {
entries := make([]pkix.RevokedCertificate, 0, len(serials))
for _, s := range serials {
entries = append(entries, pkix.RevokedCertificate{
SerialNumber: s,
RevocationTime: now(),
})
}
return entries
}
func mustSerial() *big.Int {
serial, err := randomSerial()
if err != nil {
panic(fmt.Sprintf("pki: failed to generate CRL number: %v", err))
}
return serial
}
func marshalOrDie(v any) []byte {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
panic(fmt.Sprintf("pki: failed to marshal: %v", err))
}
return data
}
-139
View File
@@ -1,139 +0,0 @@
// 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 pki
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"time"
)
// day is a shorthand so certificate validity windows read cleanly.
const day = 24 * time.Hour
// now returns the current time in UTC (kept as a function so tests can
// override it if they ever need deterministic timestamps).
func now() time.Time {
return time.Now().UTC()
}
func writeFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
// randomSerial returns a cryptographically random 128-bit serial number, as
// required for CA-issued certificates.
func randomSerial() (*big.Int, error) {
limit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, limit)
if err != nil {
return nil, fmt.Errorf("generate serial: %w", err)
}
return serial, nil
}
func pkixName(cn string) pkix.Name {
return pkix.Name{CommonName: cn}
}
func encodeKeyPEM(key *ecdsa.PrivateKey) []byte {
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
}
func parseCertPEM(data []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
func parseKeyPEM(data []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block found")
}
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
if ecdsaKey, ok := key.(*ecdsa.PrivateKey); ok {
return ecdsaKey, nil
}
}
return nil, errors.New("not a valid ECDSA private key")
}
// newLeafKey generates an ECDSA P-256 key for a service identity.
func newLeafKey() (*ecdsa.PrivateKey, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
// Fingerprint returns the SHA256 fingerprint of a certificate's public key in
// the OpenSSH-style format (sha256:ab:cd:…). It is the value satellites are
// identified by in the authorized-keys trust store.
func Fingerprint(cert *x509.Certificate) string {
return FingerprintDER(cert.RawSubjectPublicKeyInfo)
}
// FingerprintDER returns the SHA256 fingerprint of a DER SubjectPublicKeyInfo.
func FingerprintDER(spki []byte) string {
sum := sha256.Sum256(spki)
hexStr := hex.EncodeToString(sum[:])
b := make([]byte, 0, len(hexStr)+len(hexStr)/2+7)
b = append(b, "sha256:"...)
for i := 0; i < len(hexStr); i += 2 {
if i > 0 {
b = append(b, ':')
}
b = append(b, hexStr[i:i+2]...)
}
return string(b)
}
// CAFingerprint returns the instance CA's public-key fingerprint by reading
// the certificate from disk on demand (hub-app M1's public profile surfaces
// it as the instance identity). Empty string when no CA exists yet — dev-mode
// static services never provision one.
func CAFingerprint(dataRoot string) string {
pemBytes, err := os.ReadFile(CertPath(dataRoot))
if err != nil {
return ""
}
block, _ := pem.Decode(pemBytes)
if block == nil {
return ""
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return ""
}
return Fingerprint(cert)
}
-213
View File
@@ -1,213 +0,0 @@
// 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 pki
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// ServiceMeta records the issuance details of a service identity so Maestro
// can make rotation/revocation decisions without parsing the certificate.
type ServiceMeta struct {
Service string `json:"service"`
Serial string `json:"serial"`
IssuedAt time.Time `json:"issued_at"`
NotAfter time.Time `json:"not_after"`
Fingerprint string `json:"fingerprint"`
}
// Identity is a service's leaf certificate issued by the instance CA. It
// 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
}
// ServiceDir returns the identity directory for a service.
func ServiceDir(dataRoot, serviceName string) string {
return filepath.Join(Dir(dataRoot), servicesDir, serviceName)
}
// ServiceCertPath returns the leaf certificate path for a service.
func ServiceCertPath(dataRoot, serviceName string) string {
return filepath.Join(ServiceDir(dataRoot, serviceName), "tls.crt")
}
// ServiceKeyPath returns the leaf private key path for a service.
func ServiceKeyPath(dataRoot, serviceName string) string {
return filepath.Join(ServiceDir(dataRoot, serviceName), "tls.key")
}
// HasServiceIdentity reports whether the service already holds an identity.
func HasServiceIdentity(dataRoot, serviceName string) bool {
_, err := os.Stat(ServiceCertPath(dataRoot, serviceName))
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.
func EnsureServiceIdentity(ca *CA, serviceName string) (*Identity, error) {
dataRoot := ca.DataRoot
dir := ServiceDir(dataRoot, serviceName)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create identity dir for %s: %w", serviceName, err)
}
certPath := ServiceCertPath(dataRoot, serviceName)
keyPath := ServiceKeyPath(dataRoot, serviceName)
meta, metaErr := readMeta(dir)
certData, certErr := os.ReadFile(certPath)
keyData, keyErr := os.ReadFile(keyPath)
if metaErr == nil && certErr == nil && keyErr == nil {
cert, err := parseCertPEM(certData)
if err != nil {
return nil, fmt.Errorf("parse identity cert for %s: %w", serviceName, err)
}
key, err := parseKeyPEM(keyData)
if err != nil {
return nil, fmt.Errorf("parse identity key for %s: %w", serviceName, err)
}
// Refresh when the identity is expiring or already expired so
// long-running installs never go dark (M1 §2: short-lived, refreshed).
// meta.NotAfter is the issuance record; the cert's own NotAfter would
// require a full parse+verify to be trustworthy, and the two can drift.
if time.Until(meta.NotAfter) > 30*day {
return &Identity{
Service: serviceName,
Cert: cert,
Key: key,
CertPath: certPath,
KeyPath: keyPath,
Meta: meta,
}, nil
}
logRotate(serviceName)
}
return issueIdentity(ca, serviceName)
}
// issueIdentity generates a fresh keypair + CA-signed leaf and persists it.
func issueIdentity(ca *CA, serviceName string) (*Identity, error) {
key, err := newLeafKey()
if err != nil {
return nil, err
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
notBefore := now().Add(-5 * time.Minute)
notAfter := now().Add(DefaultCertValidity * time.Second)
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkixName(serviceName),
DNSNames: []string{serviceName},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.PrivateKey)
if err != nil {
return nil, fmt.Errorf("create identity cert for %s: %w", serviceName, err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse issued cert for %s: %w", serviceName, err)
}
dir := ServiceDir(ca.DataRoot, serviceName)
certPath := ServiceCertPath(ca.DataRoot, serviceName)
keyPath := ServiceKeyPath(ca.DataRoot, serviceName)
meta := ServiceMeta{
Service: serviceName,
Serial: serial.Text(16),
IssuedAt: notBefore,
NotAfter: notAfter,
Fingerprint: Fingerprint(cert),
}
metaData, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return nil, err
}
// Key first so a crash mid-write never leaves a key without its cert
// (the reverse would make the key useless but harmless; this ordering
// keeps EnsureServiceIdentity self-healing in both cases).
if err := writeFile(keyPath, encodeKeyPEM(key), 0o600); err != nil {
return nil, fmt.Errorf("write identity key for %s: %w", serviceName, err)
}
if err := writeFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return nil, fmt.Errorf("write identity cert for %s: %w", serviceName, err)
}
if err := writeFile(filepath.Join(dir, "meta.json"), metaData, 0o644); err != nil {
return nil, fmt.Errorf("write identity meta for %s: %w", serviceName, err)
}
return &Identity{
Service: serviceName,
Cert: cert,
Key: key,
CertPath: certPath,
KeyPath: keyPath,
Meta: meta,
}, nil
}
func readMeta(dir string) (ServiceMeta, error) {
var meta ServiceMeta
data, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return meta, err
}
err = json.Unmarshal(data, &meta)
return meta, err
}
// logRotate is a tiny indirection so the issue path can log a rotation without
// cluttering call sites.
func logRotate(serviceName string) {
logger.Info("pki: refreshing expiring identity for %s", serviceName)
}
-181
View File
@@ -1,181 +0,0 @@
// 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 pki is Maestro's instance CA (security theme D1). It provisions the
// root CA on first run, issues per-service identity certificates at install,
// and revokes them at uninstall (CRL). The CA is the trust anchor for every
// peer edge on dcntrlized_net: mTLS handshakes verify leaf certs against it,
// and the auth service's key attestation is signed by it.
package pki
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
)
// File layout under <data root>/pki:
//
// ca.crt -- self-signed instance CA certificate (public)
// ca.key -- CA private key (chmod 600, never leaves the host)
// crl.pem -- current certificate revocation list
// services/<name>/tls.crt -- service leaf certificate
// services/<name>/tls.key -- service leaf private key
// services/<name>/meta.json -- serial/validity/fingerprint record
// revoked/<serial>.pem -- revoked certificates (CRL source)
const (
dirName = "pki"
caCertFile = "ca.crt"
caKeyFile = "ca.key"
crlFile = "crl.pem"
servicesDir = "services"
revokedDir = "revoked"
)
// DefaultCertValidity is how long issued identity certificates are valid.
// Leaves are short-lived and refreshed on install/update (security theme M1).
const DefaultCertValidity = 90 * 24 * 3600 // 90 days in seconds
// CA is the instance trust anchor: a self-signed root certificate plus its
// private key. The private key lives only on the host (the maestro data dir).
type CA struct {
Cert *x509.Certificate
PrivateKey *ecdsa.PrivateKey
CertPEM []byte
KeyPEM []byte
DataRoot string
}
// Dir returns the pki directory for the given data root.
func Dir(dataRoot string) string {
return filepath.Join(dataRoot, dirName)
}
// CertPath returns the path of the CA certificate file.
func CertPath(dataRoot string) string {
return filepath.Join(Dir(dataRoot), caCertFile)
}
// KeyPath returns the path of the CA private key file.
func KeyPath(dataRoot string) string {
return filepath.Join(Dir(dataRoot), caKeyFile)
}
// CRLPath returns the path of the current CRL file.
func CRLPath(dataRoot string) string {
return filepath.Join(Dir(dataRoot), crlFile)
}
// EnsureCA loads the instance CA, generating it on first run. Idempotent:
// subsequent calls reuse the existing CA. The CA is provisioned from the same
// infra-bootstrap path as the network and the shared DB.
func EnsureCA(dataRoot string) (*CA, error) {
dir := Dir(dataRoot)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create pki dir: %w", err)
}
caPEM, err := os.ReadFile(CertPath(dataRoot))
keyPEM, keyErr := os.ReadFile(KeyPath(dataRoot))
if err == nil && keyErr == nil {
ca, loadErr := loadCA(caPEM, keyPEM, dataRoot)
if loadErr != nil {
return nil, loadErr
}
return ca, nil
}
if (err == nil) != (keyErr == nil) {
return nil, fmt.Errorf("CA state is inconsistent: only one of ca.crt/ca.key exists; restore from backup or delete the pki dir")
}
ca, err := generateCA(dataRoot)
if err != nil {
return nil, err
}
return ca, nil
}
// generateCA creates a fresh self-signed root CA and persists it.
func generateCA(dataRoot string) (*CA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate CA key: %w", err)
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkixName("Dcntrlized Instance CA"),
NotBefore: now().Add(-5 * time.Minute),
NotAfter: now().Add(10 * 365 * day),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("create CA certificate: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse CA certificate: %w", err)
}
ca := &CA{
Cert: cert,
PrivateKey: key,
CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
KeyPEM: encodeKeyPEM(key),
DataRoot: dataRoot,
}
if err := writeFile(CertPath(dataRoot), ca.CertPEM, 0o644); err != nil {
return nil, fmt.Errorf("write CA certificate: %w", err)
}
if err := writeFile(KeyPath(dataRoot), ca.KeyPEM, 0o600); err != nil {
return nil, fmt.Errorf("write CA key: %w", err)
}
return ca, nil
}
func loadCA(caPEM, keyPEM []byte, dataRoot string) (*CA, error) {
cert, err := parseCertPEM(caPEM)
if err != nil {
return nil, fmt.Errorf("parse CA certificate: %w", err)
}
key, err := parseKeyPEM(keyPEM)
if err != nil {
return nil, fmt.Errorf("parse CA key: %w", err)
}
return &CA{
Cert: cert,
PrivateKey: key,
CertPEM: caPEM,
KeyPEM: keyPEM,
DataRoot: dataRoot,
}, nil
}
-166
View File
@@ -1,166 +0,0 @@
// 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 pki
import (
"crypto/x509"
"os"
"path/filepath"
"testing"
"time"
)
func TestEnsureCAIdempotent(t *testing.T) {
root := t.TempDir()
ca1, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
if !ca1.Cert.IsCA {
t.Fatal("root CA must have IsCA set")
}
if fi, err := os.Stat(KeyPath(root)); err != nil || fi.Mode().Perm() != 0o600 {
t.Fatalf("CA key should be chmod 600 (mode %v, err %v)", fi.Mode().Perm(), err)
}
ca2, err := EnsureCA(root)
if err != nil {
t.Fatalf("second EnsureCA: %v", err)
}
if ca1.Cert.SerialNumber.Cmp(ca2.Cert.SerialNumber) != 0 {
t.Fatal("EnsureCA must reuse the existing CA, not regenerate")
}
}
func TestServiceIdentityAndValidation(t *testing.T) {
root := t.TempDir()
ca, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
id, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity: %v", err)
}
// Leaf is signed by the CA, carries the right name, and is a valid
// server+client cert.
pool := x509.NewCertPool()
pool.AddCert(ca.Cert)
if _, err := id.Cert.Verify(x509.VerifyOptions{
DNSName: "keepnote",
Roots: pool,
KeyUsages: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
},
}); err != nil {
t.Fatalf("leaf cert does not verify against CA: %v", err)
}
if id.Cert.Subject.CommonName != "keepnote" {
t.Fatalf("leaf CN = %q, want keepnote", id.Cert.Subject.CommonName)
}
if id.Meta.Fingerprint == "" {
t.Fatal("meta must record a fingerprint")
}
if time.Until(id.Cert.NotAfter) > DefaultCertValidity*time.Second {
t.Fatal("leaf validity exceeds the configured default")
}
// Reload path returns the same identity (no reissue).
id2, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("second EnsureServiceIdentity: %v", err)
}
if id2.Meta.Serial != id.Meta.Serial {
t.Fatal("EnsureServiceIdentity must reuse an existing identity")
}
}
func TestRevokePublishesCRLAndRemovesIdentity(t *testing.T) {
root := t.TempDir()
ca, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
id, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity: %v", err)
}
if err := Revoke(root, "keepnote"); err != nil {
t.Fatalf("Revoke: %v", err)
}
// The service identity is gone.
if HasServiceIdentity(root, "keepnote") {
t.Fatal("revoked service must not hold an identity anymore")
}
// The CRL lists the revoked serial.
serials, err := LoadRevokedSerials(root)
if err != nil {
t.Fatalf("LoadRevokedSerials: %v", err)
}
found := false
for _, s := range serials {
if s.Text(16) == id.Meta.Serial {
found = true
}
}
if !found {
t.Fatal("revoked serial not present in CRL source")
}
if _, err := os.Stat(CRLPath(root)); err != nil {
t.Fatalf("crl.pem not written: %v", err)
}
}
func TestEnsureServiceIdentityRefreshesExpiring(t *testing.T) {
root := t.TempDir()
ca, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
id, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity: %v", err)
}
// Backdate the cert so it is expiring within the refresh window.
metaPath := filepath.Join(ServiceDir(root, "keepnote"), "meta.json")
meta, err := readMeta(filepath.Join(ServiceDir(root, "keepnote")))
if err != nil {
t.Fatalf("readMeta: %v", err)
}
meta.NotAfter = now().Add(10 * 24 * time.Hour)
if err := writeFile(metaPath, marshalOrDie(meta), 0o644); err != nil {
t.Fatalf("write backdated meta: %v", err)
}
id2, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity after backdate: %v", err)
}
if id2.Meta.Serial == id.Meta.Serial {
t.Fatal("expiring identity must be rotated to a new serial")
}
}
+80 -33
View File
@@ -26,17 +26,16 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
handlerscatalog "git.dcntrlized.org/Dcntrlized/Maestro/handlers/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/config"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/dataplane"
handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/weblinks"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
)
@@ -114,19 +113,9 @@ func main() {
log.Fatalf("Failed to init docker client: %v", err)
}
defer dc.Close()
// Provision the instance CA on first run (security theme M1). The CA is
// the trust anchor for every peer edge on dcntrlized_net; services get
// their identity certs from it at start/install.
if _, err := pki.EnsureCA(dataRoot); err != nil {
log.Fatalf("Failed to provision instance CA: %v", err)
}
// Dev mode: provision the identities the docker-compose.dev/debug.yaml
// peers bind-mount at /dcntrlized/pki, BEFORE the shared infrastructure
// boots — dcntrlized-auth's mTLS listener refuses to start without its
// identity. Must run before EnsureDatabaseRunning/EnsureAuthRunning.
if env.IsDev() {
devboot.EnsureIdentities()
}
// Rev 9: the PKI identity layer is removed fleet-wide. There is no
// instance CA, no devboot identity provisioning and no mTLS anywhere;
// every internal edge is plaintext HTTP on dcntrlized_net.
if err := cache.LoadInstalledContainers(); err != nil {
log.Fatalf("Failed to load installed containers: %v", err)
}
@@ -140,9 +129,10 @@ func main() {
if err := libcontainers.EnsureAuthRunning(ctx); err != nil {
log.Printf("Warning: failed to ensure shared auth service: %v", err)
}
// No dev pre-approval: the fleet's first mTLS contact records a pending
// authorization and is refused (403) until the owner approves it via
// /api/peers — dev runs the real trust-store flow.
// Rev 9: no trust handshake happens at contact time. Containers are
// tracked by label and their manifests are fetched over plaintext HTTP;
// the only key check left is the app-level AUTH_PINNED_FINGERPRINT
// comparison (M2 §5).
cache.ReconcileContainers(ctx, dc)
go cache.WatchContainers(ctx, dc)
}
@@ -164,12 +154,26 @@ func main() {
// Instance public profile (hub-app theme M1): the one additional
// unauthenticated surface — deliberately allowlisted around the owner
// gate (cross-theme contract, hub-app D2). Exact path only; sub-paths
// like /api/instance/attestation (federation) are registered separately.
// gate (cross-theme contract, hub-app D2). Exact path only. Rev 9 removed
// the federation attestation sub-path (/api/instance/attestation) with the
// PKI layer.
mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot))
// App catalog (app-catalog theme): the operator-authored Android app
// distribution. Owner-gated control surface plus the public artifact/icon
// endpoints clients download from. The public reads sit outside the gate,
// like /api/instance, and fail closed (404) when
// DCNTRLIZED_PUBLIC_PROFILE=false.
catalogHandler := handlerscatalog.NewHandler(dataRoot)
mux.Handle("/api/catalog/apps", gate(http.HandlerFunc(catalogHandler.Apps)))
mux.Handle("/api/catalog/apps/{id}", gate(http.HandlerFunc(catalogHandler.App)))
mux.Handle("/api/catalog/apps/{id}/artifact", gate(http.HandlerFunc(catalogHandler.Artifact)))
mux.Handle("/api/catalog/apps/{id}/icon", gate(http.HandlerFunc(catalogHandler.Icon)))
mux.Handle("/api/catalog/artifacts/{id}/{filename}", http.HandlerFunc(catalogHandler.Download))
mux.Handle("/api/catalog/icons/{id}", http.HandlerFunc(catalogHandler.IconBytes))
mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS)))
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache)))
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache, dataRoot)))
mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))
mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache)))
mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(cache)))
@@ -177,16 +181,42 @@ func main() {
mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction)))
mux.Handle("/api/registry", gate(http.HandlerFunc(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.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List)))
mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation)))
mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve)))
mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke)))
mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject)))
mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide)))
// Service data plane (user ruling, 2026-10-18): every request to a service is
// expected to go through Maestro, so an instance only needs ONE open port.
// Reachable: /api/{service}/action/{action}, /api/{service}/auth/{path...} and
// /api/{service}/ws[/{path...}] — one allowlist, one passthrough, HTTP or
// WebSocket depending on the request.
//
// DELIBERATELY NOT BEHIND `gate`, and this is the second time it matters.
// History: the action route shipped ungated in 0accff3 ("real init"), survived
// eb01569 and d24fe0f, and was wrapped in the owner-session gate by 2ffdf77
// ("feat(auth): instance-owner gate"). That commit treated a SERVICE
// data-plane route as console traffic. It never was: this surface exists to
// reach services, and its callers are apps and phones, which hold no Maestro
// owner session — that cookie is the instance operator's and an app must never
// carry one. The service is the gate: it validates whatever token it issues
// (via dcntrlized-auth) and answers 401 itself. A future reader must not
// "restore" an owner gate here; doing so reproduces the 2026-10 regression
// that made every phone unable to act through its own instance.
//
// /api/ws above stays owner-gated: that is Maestro's own dashboard broadcast,
// not service traffic. /api/actions/{containerId}/{actionName} above also
// stays as it is (the legacy owner-gated console path) — the data plane is a
// separate, ungated surface.
//
// Why one "/api/" fallback and not a pattern per shape: registering
// /api/{service}/action/{actionName} (or /api/{service}/auth/{path...})
// PANICS at boot next to /api/actions/{containerId}/{actionName} — both match
// /api/actions/action/<x> and neither is more specific. Build and vet cannot
// see it (the mux is assembled here), so it is pinned by
// handlers/dataplane's TestProductionPatternSetDoesNotPanic and
// TestNaivePatternSetPanics.
dataplane.Register(mux)
// Rev 9 retires the peer/federation trust surfaces: /api/peers* (the auth
// service's trust store) and /api/instances (the TOFU-pinned remote-instance
// store) are gone, along with the console's TrustedServices UI. There is no
// peer identity to authorize on a plaintext network.
// Service Config & Secrets (theme M1): owner-gated per-service config and
// secret values. The manifest declares the schema; the encrypted store
@@ -196,6 +226,23 @@ func main() {
mux.Handle("/api/services/{service}/config", gate(http.HandlerFunc(configHandler.List)))
mux.Handle("/api/services/{service}/config/{key}", gate(http.HandlerFunc(configHandler.Value)))
// Web App URL sources (contract rev 8.2 §16.2): the owner-controlled
// base-domain template and per-service overrides that Maestro layers
// between the manifest's own web.url and the derived request-host fallback.
webLinksHandler := weblinks.NewHandler(dataRoot)
mux.Handle("/api/web-links", gate(http.HandlerFunc(webLinksHandler.Root)))
// Public hub web app (browser-edge contract §10.5, RC16): a separate,
// unauthenticated SPA bundle served at /hub/ OUTSIDE the owner gate. It
// reads only the public GET /api/instance profile. The operator console at
// "/" and every owner-gated /api/* route stay exactly as they were; no
// owner-only route is moved, weakened or exposed here.
hubHandler, err := newHubHandler()
if err != nil {
log.Fatalf("Failed to parse embedded hub subtree: %v", err)
}
mux.Handle("/hub/", http.StripPrefix("/hub/", hubHandler))
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
if err != nil {
log.Fatalf("Failed to parse embedded frontend subtree: %v", err)