[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:
@@ -18,7 +18,6 @@
|
|||||||
import { useMemo, useState } from 'preact/hooks'
|
import { useMemo, useState } from 'preact/hooks'
|
||||||
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
||||||
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
||||||
import { useTrustStore } from '@lib/hooks/useTrustStore'
|
|
||||||
import { useAuth } from '@lib/hooks/useAuth'
|
import { useAuth } from '@lib/hooks/useAuth'
|
||||||
import { mergeServices } from '@lib/domain/merge'
|
import { mergeServices } from '@lib/domain/merge'
|
||||||
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
||||||
@@ -41,7 +40,6 @@ export function App() {
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [filter, setFilter] = useState<ServiceFilter>('all')
|
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const trust = useTrustStore(settingsOpen, authed)
|
|
||||||
|
|
||||||
const services = useMemo(() => {
|
const services = useMemo(() => {
|
||||||
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
||||||
@@ -87,11 +85,8 @@ export function App() {
|
|||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
void trust.refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
|
|
||||||
|
|
||||||
if (auth.gate === 'loading') {
|
if (auth.gate === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
|
<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}
|
installed={counts.installed}
|
||||||
updates={counts.updates}
|
updates={counts.updates}
|
||||||
connection={status}
|
connection={status}
|
||||||
pendingTrust={pendingTrust}
|
pendingTrust={0}
|
||||||
onShowUpdates={showUpdates}
|
onShowUpdates={showUpdates}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
@@ -155,8 +150,6 @@ export function App() {
|
|||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={setSettingsOpen}
|
||||||
trust={trust.load}
|
|
||||||
onRefreshTrust={trust.refresh}
|
|
||||||
username={auth.username}
|
username={auth.username}
|
||||||
onSignOut={() => {
|
onSignOut={() => {
|
||||||
setSettingsOpen(false)
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -7,7 +7,9 @@ require (
|
|||||||
github.com/docker/cli v28.5.1+incompatible
|
github.com/docker/cli v28.5.1+incompatible
|
||||||
github.com/docker/compose/v2 v2.40.3
|
github.com/docker/compose/v2 v2.40.3
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -60,7 +62,6 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // 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/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/gnostic-models v0.6.8 // indirect
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // 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/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // 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/net v0.55.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+47
-187
@@ -23,8 +23,6 @@ package authmgmt
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,26 +30,27 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
"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
|
// AuthServiceURL is the default DNS name of the shared auth service on the
|
||||||
// dcntrlized network. The auth service listens with ClientAuth=RequireAndVerify
|
// dcntrlized network. After rev 9 the fleet-wide internal transport is
|
||||||
// trusting the instance CA (M2 §1).
|
// plaintext HTTP on the single :8080 listener; there is no mTLS anywhere.
|
||||||
const AuthServiceURL = "https://dcntrlized-auth:8080"
|
const AuthServiceURL = "http://dcntrlized-auth:8080"
|
||||||
|
|
||||||
// Client talks to the auth service over mTLS using Maestro's own identity
|
// Client talks to the auth service over plaintext HTTP. It no longer presents
|
||||||
// certificate (CN=maestro), which the auth service's RequireMaestro gate
|
// any client certificate: the peer gate was removed fleet-wide in rev 9.
|
||||||
// recognizes as the orchestrator.
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attestation is the auth service's public signing key statement (D3): the key
|
// 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 {
|
type Attestation struct {
|
||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
@@ -59,44 +58,9 @@ type Attestation struct {
|
|||||||
IssuedAt string `json:"issued_at"`
|
IssuedAt string `json:"issued_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizedKey is an approved peer row of the auth service's trust store.
|
// New builds the plaintext management client. There is no identity to
|
||||||
type AuthorizedKey struct {
|
// provision and no transport to configure after the PKI removal.
|
||||||
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).
|
|
||||||
func New() (*Client, error) {
|
func New() (*Client, error) {
|
||||||
transport, err := maestroTransport()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseURL := ""
|
baseURL := ""
|
||||||
if env.IsDev() {
|
if env.IsDev() {
|
||||||
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
|
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
|
||||||
@@ -111,84 +75,52 @@ func New() (*Client, error) {
|
|||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Transport: transport,
|
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maestroTransport builds the mutual-TLS transport Maestro uses as a peer: its
|
// Scheme returns the scheme every satellite peer edge uses after rev 9:
|
||||||
// own identity certificate (CN=maestro) signed by the instance CA, trusting the
|
// always "http" (plaintext on the single :8080 listener). Kept as a function
|
||||||
// CA as root. This is the client side of every Maestro peer edge.
|
// so the call sites (manifest fetch, action routing) do not hard-code it.
|
||||||
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.
|
|
||||||
func Scheme() string {
|
func Scheme() string {
|
||||||
if env.IsDev() {
|
return "http"
|
||||||
return "http"
|
|
||||||
}
|
|
||||||
return "https"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerTransport returns the transport Maestro uses for the satellite peer
|
// ConfigToken mints a fresh, single-use ES256 config token for one service
|
||||||
// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners),
|
// (contract rev 9 §17.3.6). It loads (or creates) Maestro's DEDICATED
|
||||||
// mutual TLS with Maestro's identity in production.
|
// config-signing keypair from the data root and returns the compact JWS. The
|
||||||
func PeerTransport() (*http.Transport, error) {
|
// service holds only the public half (DCNTRLIZED_CONFIG_PUBKEY) and verifies;
|
||||||
if env.IsDev() {
|
// Maestro only ever mints. Wire format is frozen in §17.3.3.
|
||||||
return &http.Transport{}, nil
|
func ConfigToken(serviceName string) (string, error) {
|
||||||
|
root, err := dataRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
return maestroTransport()
|
signer, err := configsign.LoadOrCreate(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return signer.Mint(serviceName, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro)
|
// dataRoot resolves the directory holding Maestro's private key material
|
||||||
// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges
|
// (DCNTRLIZED_INSTALL_DIR → XDG → HOME), mirroring the containers package's
|
||||||
// that must prove the caller is the orchestrator (e.g. /config delivery, which
|
// resolution. Kept local to avoid an import cycle: containers imports authmgmt.
|
||||||
// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV,
|
func dataRoot() (string, error) {
|
||||||
// since those services keep an always-mTLS peer listener.
|
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||||
func MaestroTransport() (*http.Transport, error) {
|
return dir, nil
|
||||||
return maestroTransport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrustState returns the pending authorizations and the approved trust store.
|
|
||||||
func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
|
|
||||||
var state TrustState
|
|
||||||
if err := c.do(ctx, http.MethodGet, "/internal/pending", nil, &state); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return &state, nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attestation returns the auth service's public key statement.
|
// 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
|
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 {
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
var payload []byte
|
var payload []byte
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -258,46 +161,3 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,12 +16,13 @@
|
|||||||
// Package containers implements Maestro's runtime write-back delivery for the
|
// Package containers implements Maestro's runtime write-back delivery for the
|
||||||
// Service Config & Secrets theme (D6). When the operator sets or clears a
|
// Service Config & Secrets theme (D6). When the operator sets or clears a
|
||||||
// config value via the Settings panel, Maestro delivers it to the running
|
// 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
|
// service's POST /config endpoint on the service's single plaintext :8080
|
||||||
// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at
|
// listener, authenticated with a short-lived, single-use ES256 config token
|
||||||
// the service's dedicated peer listener port (label org.dcntrlized.peer_port,
|
// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17).
|
||||||
// default 9443), which the service gates to CN=maestro. A set carries
|
// There is no mTLS and no peer port: the fleet-wide internal transport is
|
||||||
// {field, value, type}; a clear carries {field, action:"clear"} — the service
|
// plaintext HTTP. A set carries {field, value, type}; a clear carries
|
||||||
// owns the schema and branches on what it receives.
|
// {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
|
// 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
|
// 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"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// peerPortLabel is the compose label a service declares to advertise the
|
// configPort is the single port every service serves its API, /config and web
|
||||||
// internal port of its always-mTLS Maestro peer listener.
|
// app on after rev 9. There is no peer listener and no compose label.
|
||||||
const peerPortLabel = "org.dcntrlized.peer_port"
|
const configPort = "8080"
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigDelivery is one key-value pair to deliver to a running service.
|
// ConfigDelivery is one key-value pair to deliver to a running service.
|
||||||
type ConfigDelivery struct {
|
type ConfigDelivery struct {
|
||||||
Field string // manifest config field name
|
Field string // manifest config field name
|
||||||
Key string // container env var name
|
Key string // container env var name
|
||||||
Type string // "cleartext" | "password"
|
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.
|
// configRequest is the JSON body POSTed to the service's /config endpoint.
|
||||||
@@ -79,10 +64,9 @@ type configRequest struct {
|
|||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliverConfigValues delivers the given config pairs to the running service
|
// DeliverConfigValues delivers the given config pairs to the running service.
|
||||||
// over the mTLS peer edge. If the service is not running or unreachable, the
|
// If the service is not running or unreachable, the error is logged but not
|
||||||
// error is logged but not returned — the values will take effect at next start
|
// returned — the values will take effect at next start via injectConfigSecrets.
|
||||||
// via injectConfigSecrets.
|
|
||||||
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
|
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return
|
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
|
// postConfig performs one best-effort POST of body to the running service's
|
||||||
// /config endpoint. NotFound/unreachable cases are logged at debug (the write
|
// /config endpoint on the single :8080 listener, authenticated with a freshly
|
||||||
// still applies at next start); transport errors carry the field name only,
|
// minted ES256 config token. NotFound/unreachable cases are logged at debug
|
||||||
// never a value.
|
// (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) {
|
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
|
||||||
dc, err := docker.NewDockerClient()
|
dc, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,30 +122,22 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// /config is served on the service's dedicated Maestro peer listener
|
token, err := authmgmt.ConfigToken(serviceName)
|
||||||
// (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()
|
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
Transport: transport,
|
|
||||||
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
|
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
|
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 {
|
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
|
||||||
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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.
|
// 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
|
// 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) {
|
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
|
// Rev 9: every service peer edge is plaintext HTTP on the single :8080
|
||||||
// identity cert, https scheme, with the plaintext dev escape hatch.
|
// listener — no client cert, no TLS.
|
||||||
url := fmt.Sprintf("%s://%s:%s/_manifest", authmgmt.Scheme(), host, port)
|
url := fmt.Sprintf("%s://%s:%s/_manifest", authmgmt.Scheme(), host, port)
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Create a client that respects context deadlines/cancellation
|
// Create a client that respects context deadlines/cancellation
|
||||||
transport, terr := authmgmt.PeerTransport()
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
if terr != nil {
|
|
||||||
return nil, fmt.Errorf("build peer transport: %w", terr)
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 2 * time.Second, Transport: transport}
|
|
||||||
|
|
||||||
for range maxRetries {
|
for range maxRetries {
|
||||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
|||||||
Vendored
+3
-2
@@ -16,8 +16,9 @@
|
|||||||
// Package env centralizes environment-derived configuration. DCNTRLIZED_DEV is
|
// Package env centralizes environment-derived configuration. DCNTRLIZED_DEV is
|
||||||
// the single canonical dev-mode flag, injected by every docker-compose.dev.yaml
|
// 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
|
// and docker-compose.debug.yaml. It selects dev-only endpoints and behaviors
|
||||||
// (registry URL, plaintext satellite peer edges) — it never downgrades the
|
// (registry URL, debug selectors). After rev 9 the fleet-wide internal
|
||||||
// transport security of the control-plane edges, which run mTLS in dev too.
|
// transport is plaintext HTTP on a single :8080 listener in every mode, so dev
|
||||||
|
// mode no longer downgrades anything.
|
||||||
package env
|
package env
|
||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -15,16 +15,23 @@
|
|||||||
|
|
||||||
// Package instance holds the instance's public identity: a small, self-
|
// 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
|
// 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
|
package instance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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
|
// 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;
|
// DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent;
|
||||||
// Title and Description are optional and only ever come from the file.
|
// 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")
|
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
|
// Load resolves the instance identity: instance.json when present, else
|
||||||
// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design —
|
// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design —
|
||||||
// no daemon state, so an edited file shows up on the next request.
|
// 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
|
// 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
|
// rest of Maestro's file stores. The helper exists so the create and edit
|
||||||
// lands with the federation theme; the helper exists so both callers share one
|
// paths share one write path.
|
||||||
// write path.
|
|
||||||
func Save(dataRoot string, id Identity) error {
|
func Save(dataRoot string, id Identity) error {
|
||||||
if id.Name == "" {
|
if id.Name == "" {
|
||||||
return fmt.Errorf("instance name must not be empty")
|
return fmt.Errorf("instance name must not be empty")
|
||||||
@@ -90,8 +145,7 @@ func defaultIdentity() Identity {
|
|||||||
if name := os.Getenv("DCNTRLIZED_INSTANCE_NAME"); name != "" {
|
if name := os.Getenv("DCNTRLIZED_INSTANCE_NAME"); name != "" {
|
||||||
return Identity{Name: name}
|
return Identity{Name: name}
|
||||||
}
|
}
|
||||||
// The domain the instance announces as is its natural public name;
|
// The domain the instance announces as is its natural public name.
|
||||||
// federation attestation will reuse it.
|
|
||||||
if domain := os.Getenv("DCNTRLIZED_INSTANCE_DOMAIN"); domain != "" {
|
if domain := os.Getenv("DCNTRLIZED_INSTANCE_DOMAIN"); domain != "" {
|
||||||
return Identity{Name: domain}
|
return Identity{Name: domain}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ func TestDefaultIdentityFallbacks(t *testing.T) {
|
|||||||
|
|
||||||
func TestLooksLikeContainerID(t *testing.T) {
|
func TestLooksLikeContainerID(t *testing.T) {
|
||||||
cases := map[string]bool{
|
cases := map[string]bool{
|
||||||
"6079761d6e4d": true,
|
"6079761d6e4d": true,
|
||||||
"ABCDEF0123456789abcdef0123456789": true,
|
"ABCDEF0123456789abcdef0123456789": true,
|
||||||
"archlinux": false,
|
"archlinux": false,
|
||||||
"maestro-1": false,
|
"maestro-1": false,
|
||||||
"abc": false,
|
"abc": false,
|
||||||
"": false,
|
"": false,
|
||||||
}
|
}
|
||||||
for s, want := range cases {
|
for s, want := range cases {
|
||||||
if got := looksLikeContainerID(s); got != want {
|
if got := looksLikeContainerID(s); got != want {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -26,17 +26,16 @@ import (
|
|||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
|
"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/config"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/dataplane"
|
||||||
handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance"
|
handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
|
|
||||||
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
|
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/weblinks"
|
||||||
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
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/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,19 +113,9 @@ func main() {
|
|||||||
log.Fatalf("Failed to init docker client: %v", err)
|
log.Fatalf("Failed to init docker client: %v", err)
|
||||||
}
|
}
|
||||||
defer dc.Close()
|
defer dc.Close()
|
||||||
// Provision the instance CA on first run (security theme M1). The CA is
|
// Rev 9: the PKI identity layer is removed fleet-wide. There is no
|
||||||
// the trust anchor for every peer edge on dcntrlized_net; services get
|
// instance CA, no devboot identity provisioning and no mTLS anywhere;
|
||||||
// their identity certs from it at start/install.
|
// every internal edge is plaintext HTTP on dcntrlized_net.
|
||||||
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()
|
|
||||||
}
|
|
||||||
if err := cache.LoadInstalledContainers(); err != nil {
|
if err := cache.LoadInstalledContainers(); err != nil {
|
||||||
log.Fatalf("Failed to load installed containers: %v", err)
|
log.Fatalf("Failed to load installed containers: %v", err)
|
||||||
}
|
}
|
||||||
@@ -140,9 +129,10 @@ func main() {
|
|||||||
if err := libcontainers.EnsureAuthRunning(ctx); err != nil {
|
if err := libcontainers.EnsureAuthRunning(ctx); err != nil {
|
||||||
log.Printf("Warning: failed to ensure shared auth service: %v", err)
|
log.Printf("Warning: failed to ensure shared auth service: %v", err)
|
||||||
}
|
}
|
||||||
// No dev pre-approval: the fleet's first mTLS contact records a pending
|
// Rev 9: no trust handshake happens at contact time. Containers are
|
||||||
// authorization and is refused (403) until the owner approves it via
|
// tracked by label and their manifests are fetched over plaintext HTTP;
|
||||||
// /api/peers — dev runs the real trust-store flow.
|
// the only key check left is the app-level AUTH_PINNED_FINGERPRINT
|
||||||
|
// comparison (M2 §5).
|
||||||
cache.ReconcileContainers(ctx, dc)
|
cache.ReconcileContainers(ctx, dc)
|
||||||
go cache.WatchContainers(ctx, dc)
|
go cache.WatchContainers(ctx, dc)
|
||||||
}
|
}
|
||||||
@@ -164,12 +154,26 @@ func main() {
|
|||||||
|
|
||||||
// Instance public profile (hub-app theme M1): the one additional
|
// Instance public profile (hub-app theme M1): the one additional
|
||||||
// unauthenticated surface — deliberately allowlisted around the owner
|
// unauthenticated surface — deliberately allowlisted around the owner
|
||||||
// gate (cross-theme contract, hub-app D2). Exact path only; sub-paths
|
// gate (cross-theme contract, hub-app D2). Exact path only. Rev 9 removed
|
||||||
// like /api/instance/attestation (federation) are registered separately.
|
// the federation attestation sub-path (/api/instance/attestation) with the
|
||||||
|
// PKI layer.
|
||||||
mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot))
|
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/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/install", gate(containers.InstallContainerHandler(cache)))
|
||||||
mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache)))
|
mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache)))
|
||||||
mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(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/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction)))
|
||||||
mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry)))
|
mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry)))
|
||||||
|
|
||||||
// Trusted-services surface (security theme M2 §3): pending first-contact
|
// Service data plane (user ruling, 2026-10-18): every request to a service is
|
||||||
// authorizations and the owner's approve/revoke actions, forwarded to the
|
// expected to go through Maestro, so an instance only needs ONE open port.
|
||||||
// auth service's own trust store.
|
// Reachable: /api/{service}/action/{action}, /api/{service}/auth/{path...} and
|
||||||
peersHandler := peers.NewHandler()
|
// /api/{service}/ws[/{path...}] — one allowlist, one passthrough, HTTP or
|
||||||
mux.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List)))
|
// WebSocket depending on the request.
|
||||||
mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation)))
|
//
|
||||||
mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve)))
|
// DELIBERATELY NOT BEHIND `gate`, and this is the second time it matters.
|
||||||
mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke)))
|
// History: the action route shipped ungated in 0accff3 ("real init"), survived
|
||||||
mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject)))
|
// eb01569 and d24fe0f, and was wrapped in the owner-session gate by 2ffdf77
|
||||||
mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide)))
|
// ("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
|
// Service Config & Secrets (theme M1): owner-gated per-service config and
|
||||||
// secret values. The manifest declares the schema; the encrypted store
|
// 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", gate(http.HandlerFunc(configHandler.List)))
|
||||||
mux.Handle("/api/services/{service}/config/{key}", gate(http.HandlerFunc(configHandler.Value)))
|
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")
|
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to parse embedded frontend subtree: %v", err)
|
log.Fatalf("Failed to parse embedded frontend subtree: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user