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