feat(auth): instance-owner gate — owner store, JWT sessions, setup/login UI (onboarding M1+M2)

lib/owner: argon2id credential store, HS256 access/refresh pair with kid,
hashed-jti session store, throttler (5 free failures then 4^n backoff).
handlers/auth: /api/auth/* + RequireSession/CSRF/GateWebSocket middleware;
every /api/* route is now gated (428 in setup mode). --reset-owner recovery.
Frontend: AuthScreen (setup/login), useAuth gate hook, gated data hooks +
single-flight silent refresh, Owner account section in settings.
spec.yaml documents the full surface.
This commit is contained in:
2026-08-22 22:58:18 +01:00
parent cf51a6be94
commit 2ffdf77905
24 changed files with 2850 additions and 37 deletions
+182 -2
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: Dcntrlized Maestro API
version: 1.0.0
description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them.
description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF).
components:
schemas:
@@ -29,6 +29,65 @@ components:
message:
type: string
ApiError:
type: object
description: The uniform API error shape (onboarding theme M1).
properties:
error:
type: object
properties:
code:
type: string
example: "ERR_UNAUTHENTICATED"
AuthStatus:
type: object
description: The instance-owner gate state; the SPA bootstraps from it.
properties:
setup_required:
type: boolean
description: True until the first owner account exists.
authenticated:
type: boolean
username:
type: string
Credentials:
type: object
required: [username, password]
properties:
username:
type: string
description: "3-64 chars: lowercase letters, digits and . _ - ; must start alphanumeric."
password:
type: string
minLength: 10
SessionResponse:
type: object
description: >-
Login/setup/rotation result. The access and refresh tokens ride as
HttpOnly SameSite=Lax cookies (maestro_access / maestro_refresh).
properties:
username:
type: string
role:
type: string
example: "admin"
access_ttl_s:
type: integer
example: 900
PasswordChange:
type: object
required: [current_password, new_password]
properties:
current_password:
type: string
new_password:
type: string
minLength: 10
PendingAuthorization:
type: object
description: A first-contact authorization request awaiting the instance owner (M2 §3).
@@ -447,14 +506,135 @@ paths:
'502':
description: Failed to connect to upstream registry
/api/auth/status:
get:
summary: Instance-owner gate state (public)
tags: ["auth"]
description: >-
The SPA bootstraps from this endpoint. All other /api/* routes are
gated by the instance-owner session (onboarding theme M1): in setup
mode they answer 428 ERR_SETUP_REQUIRED, otherwise 401
ERR_UNAUTHENTICATED without a valid session.
responses:
'200':
description: Gate state
content:
application/json:
schema:
$ref: '#/components/schemas/AuthStatus'
'500':
description: Internal server error
/api/auth/setup:
post:
summary: Create the first owner account (admin)
tags: ["auth"]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Credentials'
responses:
'200':
description: Owner created; session cookies issued
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'400':
description: Invalid credentials (ERR_INVALID_REQUEST / ERR_INVALID_CREDENTIALS)
'409':
description: Setup already completed (ERR_SETUP_COMPLETED)
'429':
description: Rate limited (ERR_RATE_LIMITED)
/api/auth/login:
post:
summary: Log in as the instance owner
tags: ["auth"]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Credentials'
responses:
'200':
description: Session cookies issued
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'400':
description: Invalid JSON payload
'401':
description: Unknown user or wrong password (ERR_INVALID_CREDENTIALS)
'429':
description: Rate limited (ERR_RATE_LIMITED) with Retry-After
/api/auth/refresh:
post:
summary: Rotate the access/refresh pair
tags: ["auth"]
description: >-
Uses the maestro_refresh cookie. Reuse of an already-rotated jti is a
theft signal and revokes every session of the user
(ERR_SESSION_REVOKED).
responses:
'200':
description: Fresh pair issued
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'401':
description: Missing/invalid/replayed refresh cookie
/api/auth/logout:
post:
summary: Revoke the current refresh token and clear cookies
tags: ["auth"]
responses:
'200':
description: Logged out (idempotent, bodyless-safe)
/api/auth/change-password:
post:
summary: Change the owner's password (session required)
tags: ["auth"]
description: Revokes every existing session of the user on success.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordChange'
responses:
'200':
description: Password changed; all sessions revoked
'400':
description: Invalid payload or wrong current password
'401':
description: Not authenticated
'429':
description: Rate limited (ERR_RATE_LIMITED)
/api/ws:
get:
summary: WebSocket endpoint for live container events
tags: ["containers"]
description: Establishes a WebSocket connection that streams container lifecycle events as JSON.
description: >-
Establishes a WebSocket connection that streams container lifecycle
events as JSON. The upgrade is gated by the instance-owner session;
anonymous handshakes close with 401 (428 in setup mode).
responses:
'101':
description: Switching Protocols - WebSocket upgrade
'401':
description: Not authenticated
'428':
description: Setup required
/api/peers:
get:
+28 -3
View File
@@ -19,9 +19,11 @@ import { useMemo, useState } from 'preact/hooks'
import { useContainerStore } from '@lib/hooks/useContainerStore'
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
import { useTrustStore } from '@lib/hooks/useTrustStore'
import { useAuth } from '@lib/hooks/useAuth'
import { mergeServices } from '@lib/domain/merge'
import { filterServices, serviceCounts } from '@lib/domain/filter'
import type { DatabaseStatus, ServiceFilter } from '@lib/types'
import { AuthScreen } from '@components/auth/auth-screen'
import { Header } from '@components/layout/header'
import { Toolbar } from '@components/layout/toolbar'
import { ServiceGrid } from '@components/service/service-grid'
@@ -32,12 +34,14 @@ import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout
const DATABASE_NAME = 'dcntrlized-db'
export function App() {
const { containers, status } = useContainerStore()
const registry = useMasterRegistry()
const auth = useAuth()
const authed = auth.gate === 'ready'
const { containers, status } = useContainerStore(authed)
const registry = useMasterRegistry(authed)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<ServiceFilter>('all')
const [settingsOpen, setSettingsOpen] = useState(false)
const trust = useTrustStore(settingsOpen)
const trust = useTrustStore(settingsOpen, authed)
const services = useMemo(() => {
const items = registry.state.kind === 'ok' ? registry.state.items : []
@@ -88,6 +92,22 @@ export function App() {
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
if (auth.gate === 'loading') {
return (
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
<LoadingState />
</div>
)
}
if (auth.gate === 'setup' || auth.gate === 'login') {
return (
<div data-slot="app" className="bg-background text-foreground antialiased">
<AuthScreen mode={auth.gate} onAuthenticated={auth.markReady} />
</div>
)
}
return (
<div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
<Header
@@ -137,6 +157,11 @@ export function App() {
onOpenChange={setSettingsOpen}
trust={trust.load}
onRefreshTrust={trust.refresh}
username={auth.username}
onSignOut={() => {
setSettingsOpen(false)
void auth.signOut()
}}
/>
<Toaster />
@@ -0,0 +1,149 @@
/**
* 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 { useState } from 'preact/hooks'
import { BrandMark } from '@components/brand-mark'
import { Button } from '@components/ui/button'
import { Input } from '@components/ui/input'
import { loginOwner, setupOwner } from '@lib/api/auth'
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{2,63}$/
interface AuthScreenProps {
mode: 'setup' | 'login'
onAuthenticated: (username: string) => void
}
/** The instance-owner gate screen: first-run setup wizard and login. */
export function AuthScreen({ mode, onAuthenticated }: AuthScreenProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const submit = async (event: Event) => {
event.preventDefault()
setError(null)
if (!USERNAME_PATTERN.test(username)) {
setError('Username must be 3-64 characters: lowercase letters, digits and . _ -')
return
}
if (password.length < 10) {
setError('Password must be at least 10 characters.')
return
}
if (mode === 'setup' && password !== confirm) {
setError('Passwords do not match.')
return
}
setBusy(true)
try {
const session =
mode === 'setup'
? await setupOwner(username, password)
: await loginOwner(username, password)
onAuthenticated(session.username)
} catch (err) {
const status = err instanceof Error && 'status' in err ? Number(err.status) : 0
if (status === 409) {
setError('This instance already has an owner. Log in instead.')
} else if (status === 429) {
setError('Too many attempts. Wait a moment and try again.')
} else {
setError(mode === 'setup' ? 'Could not create the account.' : 'Wrong username or password.')
}
} finally {
setBusy(false)
}
}
return (
<div className="grid min-h-dvh place-items-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3 text-center">
<BrandMark className="size-12" />
<h1 className="text-lg font-semibold tracking-tight text-foreground">
{mode === 'setup' ? 'Set up your Dcntrlized node' : 'Sign in to Maestro'}
</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
{mode === 'setup'
? 'Create the owner account for this instance. It stays on your hardware.'
: 'Use the owner account you created for this instance.'}
</p>
</div>
<form
onSubmit={submit}
className="grid gap-4 rounded-xl border border-border bg-card p-6 shadow-sm"
aria-label={mode === 'setup' ? 'Create owner account' : 'Log in'}
>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Username</span>
<Input
value={username}
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
autoComplete="username"
autoCapitalize="none"
spellcheck={false}
required
autoFocus
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Password</span>
<Input
type="password"
value={password}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
autoComplete={mode === 'setup' ? 'new-password' : 'current-password'}
minLength={10}
required
/>
</label>
{mode === 'setup' && (
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Confirm password</span>
<Input
type="password"
value={confirm}
onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}
autoComplete="new-password"
minLength={10}
required
/>
</label>
)}
{error && (
<p role="alert" className="text-xs font-medium text-destructive">
{error}
</p>
)}
<Button type="submit" isDisabled={busy} className="mt-1 w-full">
{busy ? 'Working…' : mode === 'setup' ? 'Create account' : 'Sign in'}
</Button>
</form>
</div>
</div>
)
}
@@ -0,0 +1,137 @@
/**
* 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 { useState } from 'preact/hooks'
import { KeyRound, LogOut } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@components/ui/button'
import { Input } from '@components/ui/input'
import { changePassword } from '@lib/api/auth'
interface AccountSectionProps {
username: string
onSignOut: () => void
}
function describeError(error: unknown): string {
const status = error instanceof Error && 'status' in error ? Number(error.status) : 0
if (status === 429) return 'Too many attempts. Wait a moment and try again.'
if (status === 400 || status === 401) return 'Current password is wrong.'
if (status === 403) return 'Your session was rejected — sign in again.'
return 'Could not change the password.'
}
/** Owner account controls inside the settings dialog: password + logout. */
export function AccountSection({ username, onSignOut }: AccountSectionProps) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const submit = async (event: Event) => {
event.preventDefault()
setError(null)
if (next.length < 10) {
setError('New password must be at least 10 characters.')
return
}
if (next !== confirm) {
setError('Passwords do not match.')
return
}
setBusy(true)
try {
await changePassword(current, next)
toast.success('Password changed', {
description: 'All sessions were signed out — sign in with the new password.',
})
onSignOut()
} catch (err) {
setError(describeError(err))
} finally {
setBusy(false)
}
}
return (
<section data-slot="settings-section-account" aria-labelledby="settings-account-title" className="min-w-0">
<h3
id="settings-account-title"
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"
>
<KeyRound className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
Owner account
</h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Signed in as{' '}
<span className="font-medium text-foreground">{username}</span>. Changing the password signs
out every session, including this one.
</p>
<div className="mt-4 grid gap-4">
<form onSubmit={submit} className="grid gap-3" aria-label="Change password">
<Input
type="password"
placeholder="Current password"
value={current}
onInput={(e) => setCurrent((e.target as HTMLInputElement).value)}
autoComplete="current-password"
required
/>
<div className="grid gap-3 sm:grid-cols-2">
<Input
type="password"
placeholder="New password"
value={next}
onInput={(e) => setNext((e.target as HTMLInputElement).value)}
autoComplete="new-password"
minLength={10}
required
/>
<Input
type="password"
placeholder="Confirm new password"
value={confirm}
onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}
autoComplete="new-password"
minLength={10}
required
/>
</div>
{error && (
<p role="alert" className="text-xs font-medium text-destructive">
{error}
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<Button type="submit" variant="secondary" size="sm" isDisabled={busy}>
{busy ? 'Changing…' : 'Change password'}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => void onSignOut()}>
<LogOut aria-hidden="true" data-icon="inline-start" />
Sign out
</Button>
</div>
</form>
</div>
</section>
)
}
@@ -18,6 +18,7 @@
import { Settings, ShieldCheck, X } from 'lucide-react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
import { Button } from '@components/ui/button'
import { AccountSection } from '@components/settings/account-section'
import { TrustedServicesSection } from '@components/service/trusted-services'
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
@@ -26,9 +27,18 @@ interface SettingsDialogProps {
onOpenChange: (open: boolean) => void
trust: TrustLoadState
onRefreshTrust: () => void
username: string
onSignOut: () => void
}
export function SettingsDialog({ open, onOpenChange, trust, onRefreshTrust }: SettingsDialogProps) {
export function SettingsDialog({
open,
onOpenChange,
trust,
onRefreshTrust,
username,
onSignOut,
}: SettingsDialogProps) {
return (
<ModalOverlay
isOpen={open}
@@ -63,6 +73,8 @@ export function SettingsDialog({ open, onOpenChange, trust, onRefreshTrust }: Se
</header>
<div className="grid min-w-0 gap-6 px-5 py-5">
<AccountSection username={username} onSignOut={onSignOut} />
<section data-slot="settings-section-trusted" aria-labelledby="settings-trusted-title" className="min-w-0">
<h3
id="settings-trusted-title"
+52
View File
@@ -0,0 +1,52 @@
/**
* 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 { AuthStatus, SessionResponse } from '../types'
/** Fetches the instance-owner gate state; the SPA bootstraps from it. */
export function fetchAuthStatus(): Promise<AuthStatus> {
return get<AuthStatus>('/api/auth/status')
}
/** Creates the first owner account and starts its session. */
export function setupOwner(username: string, password: string): Promise<SessionResponse> {
return post<SessionResponse>('/api/auth/setup', { username, password })
}
/** Logs in as the instance owner. */
export function loginOwner(username: string, password: string): Promise<SessionResponse> {
return post<SessionResponse>('/api/auth/login', { username, password })
}
/** Rotates the access/refresh pair using the refresh cookie. */
export function rotateSession(): Promise<SessionResponse> {
return post<SessionResponse>('/api/auth/refresh')
}
/** Revokes the current refresh token and clears both cookies. */
export function logoutOwner(): Promise<void> {
return post<void>('/api/auth/logout')
}
/** Changes the owner's password; every existing session is revoked. */
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return post<void>('/api/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
})
}
+30 -1
View File
@@ -35,8 +35,37 @@ async function readJson<T>(res: Response): Promise<T> {
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
/** In-flight session rotation shared by concurrent 401s (no refresh storms). */
let refreshing: Promise<boolean> | null = null
function refreshSession(): Promise<boolean> {
refreshing ??= (async () => {
try {
const res = await fetch('/api/auth/refresh', { method: 'POST' })
return res.ok
} catch {
return false
} finally {
refreshing = null
}
})()
return refreshing
}
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
const res = await fetch(path, init)
if (!path.startsWith('/api/auth/')) {
// Silent session recovery: rotate once and replay the original call.
if (res.status === 401 && !retried) {
if (await refreshSession()) {
return request<T>(path, init, true)
}
}
// Surface gate loss to the app shell so it drops back to login/setup.
if (res.status === 401 || res.status === 428) {
window.dispatchEvent(new CustomEvent('maestro:gate-lost', { detail: res.status }))
}
}
if (!res.ok) {
throw new ApiError(`Request failed (${res.status})`, res.status)
}
+82
View File
@@ -0,0 +1,82 @@
/**
* 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 { fetchAuthStatus, logoutOwner, rotateSession } from '@lib/api/auth'
/** Which screen the app shell should render. */
export type AuthGate = 'loading' | 'setup' | 'login' | 'ready'
/**
* Tracks the instance-owner gate: bootstraps from /api/auth/status, silently
* recovers an expired access token through refresh rotation, and listens for
* gate-loss events emitted by the API client (401/428 on gated routes).
*/
export function useAuth() {
const [gate, setGate] = useState<AuthGate>('loading')
const [username, setUsername] = useState('')
const sync = useCallback(async () => {
try {
const status = await fetchAuthStatus()
if (status.setup_required) {
setGate('setup')
return
}
if (status.authenticated && status.username) {
setUsername(status.username)
setGate('ready')
return
}
// Access token expired but a valid refresh cookie may survive — try to
// resume the session before showing the login form.
try {
const session = await rotateSession()
setUsername(session.username)
setGate('ready')
} catch {
setGate('login')
}
} catch {
setGate('login')
}
}, [])
useEffect(() => {
void sync()
const onGateLost = () => void sync()
window.addEventListener('maestro:gate-lost', onGateLost)
return () => window.removeEventListener('maestro:gate-lost', onGateLost)
}, [sync])
const markReady = useCallback((user: string) => {
setUsername(user)
setGate('ready')
}, [])
const signOut = useCallback(async () => {
try {
await logoutOwner()
} finally {
setGate('login')
setUsername('')
}
}, [])
return { gate, username, markReady, signOut }
}
@@ -17,20 +17,25 @@
import { useEffect, useState } from 'preact/hooks'
import { store, type ContainerStoreState } from '../ws/registry'
import { connectWebSocket } from '../ws/client'
import { connectWebSocket, disconnectWebSocket } from '../ws/client'
/**
* Subscribes the component to the live container store and owns the single
* WebSocket connection for the app.
* WebSocket connection for the app. While `enabled` is false (owner not yet
* authenticated) no connection is attempted and any existing one is torn down.
*/
export function useContainerStore(): ContainerStoreState {
export function useContainerStore(enabled = true): ContainerStoreState {
const [state, setState] = useState<ContainerStoreState>(() => store.getState())
useEffect(() => {
if (!enabled) {
disconnectWebSocket()
return
}
connectWebSocket()
const unsubscribe = store.subscribe(() => setState(store.getState()))
return unsubscribe
}, [])
}, [enabled])
return state
}
@@ -24,7 +24,7 @@ export type RegistryLoadState =
| { kind: 'ok'; items: RegistryItem[] }
| { kind: 'error'; message: string }
export function useMasterRegistry() {
export function useMasterRegistry(enabled = true) {
const [state, setState] = useState<RegistryLoadState>({ kind: 'loading' })
const load = useCallback(async () => {
@@ -38,8 +38,9 @@ export function useMasterRegistry() {
}, [])
useEffect(() => {
if (!enabled) return
void load()
}, [load])
}, [enabled, load])
return { state, retry: load }
}
+5 -3
View File
@@ -36,7 +36,7 @@ function errorMessage(error: unknown): string {
* mutation never flashes a loading spinner over live rows; mutations also
* trigger an immediate refresh through the same hook.
*/
export function useTrustStore(open: boolean) {
export function useTrustStore(open: boolean, enabled = true) {
const [load, setLoad] = useState<TrustLoadState>({ kind: 'loading' })
const refresh = useCallback(async () => {
@@ -49,15 +49,17 @@ export function useTrustStore(open: boolean) {
}, [])
useEffect(() => {
if (!enabled) return
void refresh()
}, [refresh])
}, [enabled, refresh])
useEffect(() => {
if (!enabled) return
const interval = window.setInterval(() => {
void refresh()
}, open ? 10_000 : 120_000)
return () => window.clearInterval(interval)
}, [open, refresh])
}, [open, enabled, refresh])
return { load, refresh }
}
+14
View File
@@ -134,3 +134,17 @@ export interface PeerAttestation {
kid: string
issued_at: string
}
/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */
export interface AuthStatus {
setup_required: boolean
authenticated: boolean
username: string
}
/** Login/setup/rotation result; tokens ride as HttpOnly cookies. */
export interface SessionResponse {
username: string
role: string
access_ttl_s: number
}
+18 -2
View File
@@ -18,12 +18,17 @@
import { store } from './registry'
import type { Container } from '../types'
const URL = 'ws://127.0.0.1:8080/api/ws'
const RECONNECT_DELAY = 3000
let socket: WebSocket | null = null
let reconnectTimer: number | undefined
/** Derives the same-origin WebSocket URL (wss:// behind a TLS proxy). */
function wsURL(): string {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${scheme}//${window.location.host}/api/ws`
}
export function connectWebSocket(): void {
if (
socket &&
@@ -32,7 +37,7 @@ export function connectWebSocket(): void {
return
}
const ws = new WebSocket(URL)
const ws = new WebSocket(wsURL())
socket = ws
store.setStatus('connecting')
@@ -57,6 +62,17 @@ export function connectWebSocket(): void {
}
}
/** Tears down the socket and stops the reconnect loop (auth gate lost). */
export function disconnectWebSocket(): void {
window.clearTimeout(reconnectTimer)
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
store.setStatus('closed')
}
function scheduleReconnect(): void {
window.clearTimeout(reconnectTimer)
reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY)
+386
View File
@@ -0,0 +1,386 @@
// 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 auth implements Maestro's instance-owner gate (onboarding theme M1):
// /api/auth/* endpoints plus the RequireSession middleware that guards every
// other /api/* route and the WebSocket handshake. Deliberately independent of
// the fleet — the owner store lives on the local data root.
package auth
import (
"context"
"encoding/json"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
)
const (
accessCookie = "maestro_access"
refreshCookie = "maestro_refresh"
)
// errorBody is the uniform API error shape.
type errorBody struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
func writeError(w http.ResponseWriter, status int, code string) {
var body errorBody
body.Error.Code = code
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// rateLimited emits the 429 with a Retry-After hint from the throttler.
func (s *Service) rateLimited(w http.ResponseWriter, identifier string) {
if wait := s.throttle.RetryAfter(identifier); wait > 0 {
w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
}
writeError(w, http.StatusTooManyRequests, "ERR_RATE_LIMITED")
}
// Service wires the owner gate together.
type Service struct {
owners owner.Store
tokens *owner.TokenService
sessions *owner.FileSessionStore
throttle *owner.Throttler
secureCookies bool
}
// New builds the auth service from its stores.
func New(owners owner.Store, tokens *owner.TokenService, sessions *owner.FileSessionStore) *Service {
return &Service{
owners: owners,
tokens: tokens,
sessions: sessions,
throttle: owner.NewThrottler(),
secureCookies: os.Getenv("COOKIE_SECURE") == "true",
}
}
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
type passwordChange struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// SetupRequired reports whether the instance still needs its first owner.
func (s *Service) SetupRequired(ctx context.Context) bool {
count, err := s.owners.Count(ctx)
return err != nil || count == 0
}
// Status serves GET /api/auth/status — public; the SPA bootstraps from it.
func (s *Service) Status(w http.ResponseWriter, r *http.Request) {
setupRequired := s.SetupRequired(r.Context())
username := ""
if !setupRequired {
if c, err := r.Cookie(accessCookie); err == nil {
if sub, _, err := s.tokens.VerifyAccess(c.Value); err == nil {
username = sub
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"setup_required": setupRequired,
"authenticated": username != "",
"username": username,
})
}
// Setup serves POST /api/auth/setup — creates the first owner (admin). In
// setup mode only this and status respond anywhere in /api.
func (s *Service) Setup(w http.ResponseWriter, r *http.Request) {
var creds credentials
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
return
}
if count, _ := s.owners.Count(r.Context()); count > 0 {
writeError(w, http.StatusConflict, "ERR_SETUP_COMPLETED")
return
}
if !s.throttle.Allow(creds.Username) {
s.rateLimited(w, creds.Username)
return
}
o, err := s.owners.Create(r.Context(), creds.Username, creds.Password, true)
if err != nil {
s.throttle.Fail(creds.Username)
writeError(w, http.StatusBadRequest, "ERR_INVALID_CREDENTIALS")
return
}
logger.Info("Owner account created: %s", o.Username)
s.issueSession(w, r, o.Username, o.Role())
}
// Login serves POST /api/auth/login.
func (s *Service) Login(w http.ResponseWriter, r *http.Request) {
var creds credentials
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
return
}
if !s.throttle.Allow(creds.Username) {
s.rateLimited(w, creds.Username)
return
}
o, err := s.owners.Verify(r.Context(), creds.Username, creds.Password)
if err != nil || o == nil {
s.throttle.Fail(creds.Username)
writeError(w, http.StatusUnauthorized, "ERR_INVALID_CREDENTIALS")
return
}
s.throttle.Reset(creds.Username)
s.issueSession(w, r, o.Username, o.Role())
}
// issueSession mints the pair, persists the refresh row and sets both cookies.
func (s *Service) issueSession(w http.ResponseWriter, r *http.Request, username, role string) {
pair, err := s.tokens.MintPair(username, role)
if err != nil {
logger.Error("Failed to mint session pair: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
if err := s.sessions.Store(username, pair.RefreshJTI, time.Now().Add(pair.RefreshTTL)); err != nil {
logger.Error("Failed to persist session: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
http.SetCookie(w, s.cookie(accessCookie, pair.Access, pair.AccessTTL))
http.SetCookie(w, s.cookie(refreshCookie, pair.Refresh, pair.RefreshTTL))
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": pair.Username,
"role": pair.Role,
"access_ttl_s": int(pair.AccessTTL.Seconds()),
})
}
func (s *Service) cookie(name, value string, ttl time.Duration) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.secureCookies,
MaxAge: int(ttl.Seconds()),
}
}
// Refresh serves POST /api/auth/refresh — rotates the pair. Reuse of an
// already-rotated (or unknown/revoked) jti revokes every session of the user.
func (s *Service) Refresh(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(refreshCookie)
if err != nil || c.Value == "" {
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
sub, jti, err := s.tokens.VerifyRefresh(c.Value)
if err != nil {
clearCookies(w)
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
if !s.sessions.Valid(jti) {
// Reuse of a rotated/revoked jti is a theft signal: burn everything.
_ = s.sessions.RevokeAllForUser(sub)
clearCookies(w)
writeError(w, http.StatusUnauthorized, "ERR_SESSION_REVOKED")
return
}
// The owner's role may have changed since login; re-read it.
role := "member"
if o, err := s.owners.Get(r.Context(), sub); err == nil && o != nil {
role = o.Role()
}
pair, err := s.tokens.MintPair(sub, role)
if err != nil {
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
if err := s.sessions.Store(sub, pair.RefreshJTI, time.Now().Add(pair.RefreshTTL)); err != nil {
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
if err := s.sessions.Revoke(jti); err != nil {
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
http.SetCookie(w, s.cookie(accessCookie, pair.Access, pair.AccessTTL))
http.SetCookie(w, s.cookie(refreshCookie, pair.Refresh, pair.RefreshTTL))
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": sub,
"role": role,
"access_ttl_s": int(pair.AccessTTL.Seconds()),
})
}
// Logout serves POST /api/auth/logout — revokes the refresh jti, clears both.
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" {
if _, jti, err := s.tokens.VerifyRefresh(c.Value); err == nil {
if err := s.sessions.Revoke(jti); err != nil {
logger.Warn("Logout revoke failed: %v", err)
}
}
}
clearCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "logged-out"})
}
func clearCookies(w http.ResponseWriter) {
for _, name := range []string{accessCookie, refreshCookie} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
}
// ChangePassword serves POST /api/auth/change-password — verifies the current
// password, rehashes, then rotates all of the user's sessions.
func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) {
sub := r.Header.Get("X-Maestro-User")
if sub == "" {
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
var change passwordChange
if err := json.NewDecoder(r.Body).Decode(&change); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
return
}
store, ok := s.owners.(*owner.FileOwnerStore)
if !ok {
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
if !s.throttle.Allow(sub) {
s.rateLimited(w, sub)
return
}
if _, err := store.ChangePassword(r.Context(), sub, change.CurrentPassword, change.NewPassword); err != nil {
s.throttle.Fail(sub)
writeError(w, http.StatusBadRequest, "ERR_INVALID_CREDENTIALS")
return
}
// Every existing session dies: the password changed under them.
if err := s.sessions.RevokeAllForUser(sub); err != nil {
logger.Warn("Session revoke after password change failed: %v", err)
}
clearCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "password-changed"})
}
// RequireSession gates every /api/* route except the /api/auth/* endpoints.
// Setup mode short-circuits everything else with 428; unauthenticated calls
// get the uniform 401; mutating requests pass the CSRF origin check.
func (s *Service) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.SetupRequired(r.Context()) {
writeError(w, http.StatusPreconditionRequired, "ERR_SETUP_REQUIRED")
return
}
c, err := r.Cookie(accessCookie)
if err != nil || c.Value == "" {
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
sub, _, err := s.tokens.VerifyAccess(c.Value)
if err != nil {
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
r.Header.Set("X-Maestro-User", sub)
if mutating(r.Method) && !sameOriginOK(r) {
writeError(w, http.StatusForbidden, "ERR_CSRF")
return
}
next.ServeHTTP(w, r)
})
}
// GateWebSocket validates the access cookie before an upgrade is attempted;
// rejected handshakes close with 401 instead of switching protocols.
func (s *Service) GateWebSocket(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.SetupRequired(r.Context()) {
writeError(w, http.StatusPreconditionRequired, "ERR_SETUP_REQUIRED")
return
}
c, err := r.Cookie(accessCookie)
if err != nil || c.Value == "" {
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
if _, _, err := s.tokens.VerifyAccess(c.Value); err != nil {
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
next.ServeHTTP(w, r)
})
}
func mutating(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
}
return false
}
// sameOriginOK enforces D5's second line: on mutating requests, when Origin or
// Referer is present, its host must equal the request host. Non-browser
// clients omit both and pass (cookies are SameSite=Lax regardless).
func sameOriginOK(r *http.Request) bool {
header := r.Header.Get("Origin")
if header == "" {
header = r.Header.Get("Referer")
}
if header == "" {
return true
}
u, err := url.Parse(header)
if err != nil || u.Host == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
+396
View File
@@ -0,0 +1,396 @@
// 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 auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
)
// statusBody mirrors GET /api/auth/status's snake_case response.
type statusBody struct {
SetupRequired bool `json:"setup_required"`
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
}
func newTestServer(t *testing.T) (*Service, http.Handler) {
t.Helper()
dir := t.TempDir()
owners, err := owner.NewFileOwnerStore(dir)
if err != nil {
t.Fatal(err)
}
tokens, err := owner.NewTokenService(dir)
if err != nil {
t.Fatal(err)
}
sessions, err := owner.NewFileSessionStore(dir)
if err != nil {
t.Fatal(err)
}
s := New(owners, tokens, sessions)
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/status", s.Status)
mux.HandleFunc("/api/auth/setup", s.Setup)
mux.HandleFunc("/api/auth/login", s.Login)
mux.HandleFunc("/api/auth/refresh", s.Refresh)
mux.HandleFunc("/api/auth/logout", s.Logout)
mux.Handle("/api/auth/change-password", s.RequireSession(http.HandlerFunc(s.ChangePassword)))
mux.Handle("/api/gated", s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
return s, mux
}
func do(t *testing.T, mux http.Handler, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, target, nil)
} else {
req = httptest.NewRequest(method, target, strings.NewReader(body))
}
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func cookieValue(t *testing.T, rec *httptest.ResponseRecorder, name string) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == name && c.Value != "" {
return c.Value
}
}
t.Fatalf("cookie %q not set", name)
return ""
}
func TestStatusSetupModeAndAfterSetup(t *testing.T) {
s, h := newTestServer(t)
rec := do(t, h, http.MethodGet, "/api/auth/status", "", nil)
if rec.Code != 200 {
t.Fatalf("status pre-setup: %d", rec.Code)
}
var st statusBody
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
t.Fatal(err)
}
if !st.SetupRequired || st.Authenticated {
t.Fatalf("pre-setup status wrong: %+v", st)
}
// Gated route in setup mode → 428 ERR_SETUP_REQUIRED.
gate := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
rec2 := httptest.NewRecorder()
gate.ServeHTTP(rec2, req)
if rec2.Code != 428 {
t.Fatalf("gated pre-setup = %d, want 428", rec2.Code)
}
var e errorBody
if err := json.Unmarshal(rec2.Body.Bytes(), &e); err == nil && e.Error.Code != "ERR_SETUP_REQUIRED" {
t.Fatalf("code = %q", e.Error.Code)
}
// Run setup through the API, then log in to obtain a session.
setupRec := do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"setup-password"}`, nil)
if setupRec.Code != 200 {
t.Fatalf("post-gate setup = %d", setupRec.Code)
}
loginRec := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"setup-password"}`, nil)
if loginRec.Code != 200 {
t.Fatalf("post-setup login = %d", loginRec.Code)
}
rec3 := do(t, h, http.MethodGet, "/api/auth/status", "", map[string]string{
"Cookie": "maestro_access=" + cookieValue(t, loginRec, accessCookie),
})
var st2 statusBody
if err := json.Unmarshal(rec3.Body.Bytes(), &st2); err != nil {
t.Fatal(err)
}
if st2.SetupRequired || !st2.Authenticated || st2.Username != "admin" {
t.Fatalf("post-setup status: %+v", st2)
}
}
func TestSetupFlow(t *testing.T) {
_, h := newTestServer(t)
rec := do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
if rec.Code != 200 {
t.Fatalf("setup = %d: %s", rec.Code, rec.Body.String())
}
if c := cookieValue(t, rec, accessCookie); c == "" {
t.Fatal("no session after setup")
}
// Second setup attempt → 409.
rec2 := do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"evil","password":"hijack-attempt"}`, nil)
if rec2.Code != http.StatusConflict {
t.Fatalf("second setup = %d, want 409", rec2.Code)
}
}
func TestLoginLogoutAndGatedAccess(t *testing.T) {
s, h := newTestServer(t)
do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
// Bad password.
rec := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"wrong-password"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad login = %d", rec.Code)
}
// Good login.
rec = do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"first-owner-pass"}`, nil)
if rec.Code != 200 {
t.Fatalf("login = %d: %s", rec.Code, rec.Body.String())
}
var lr struct {
Username string `json:"username"`
Role string `json:"role"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil || lr.Role != "admin" || lr.Username != "admin" {
t.Fatalf("login response: %s", rec.Body.String())
}
access := cookieValue(t, rec, accessCookie)
refresh := cookieValue(t, rec, refreshCookie)
for _, c := range rec.Result().Cookies() {
if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode {
t.Fatalf("cookie %s not HttpOnly/Lax", c.Name)
}
}
// Gated access with the access token.
gate := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(r.Header.Get("X-Maestro-User")))
}))
req := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
rec2 := httptest.NewRecorder()
gate.ServeHTTP(rec2, req)
if rec2.Code != 200 || rec2.Body.String() != "admin" {
t.Fatalf("gated with token = %d %q", rec2.Code, rec2.Body.String())
}
// Logout revokes and clears both cookies.
rec3 := do(t, h, http.MethodPost, "/api/auth/logout",
"", map[string]string{"Cookie": "maestro_refresh=" + refresh})
if rec3.Code != 200 {
t.Fatalf("logout = %d", rec3.Code)
}
for _, c := range rec3.Result().Cookies() {
if c.MaxAge >= 0 {
t.Fatalf("cookie %s not cleared (max-age=%d)", c.Name, c.MaxAge)
}
}
// Refresh token is dead post-logout.
rec4 := do(t, h, http.MethodPost, "/api/auth/refresh",
"", map[string]string{"Cookie": "maestro_refresh=" + refresh})
if rec4.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout = %d", rec4.Code)
}
}
func TestRefreshRotationAndReuseDetection(t *testing.T) {
_, h := newTestServer(t)
setupRec := do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
firstRefresh := cookieValue(t, setupRec, refreshCookie)
// Rotate.
rec := do(t, h, http.MethodPost, "/api/auth/refresh",
"", map[string]string{"Cookie": "maestro_refresh=" + firstRefresh})
if rec.Code != 200 {
t.Fatalf("refresh = %d: %s", rec.Code, rec.Body.String())
}
secondRefresh := cookieValue(t, rec, refreshCookie)
if secondRefresh == firstRefresh {
t.Fatal("refresh token not rotated")
}
// Replay of the old token → 401 AND kill-switch revokes everything,
// including the session minted during rotation.
replay := do(t, h, http.MethodPost, "/api/auth/refresh",
"", map[string]string{"Cookie": "maestro_refresh=" + firstRefresh})
if replay.Code != http.StatusUnauthorized {
t.Fatalf("replay = %d, want 401", replay.Code)
}
rec2 := do(t, h, http.MethodPost, "/api/auth/refresh",
"", map[string]string{"Cookie": "maestro_refresh=" + secondRefresh})
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("post-replay rotation = %d, want 401 (kill switch)", rec2.Code)
}
}
func TestCSRFOriginCheck(t *testing.T) {
s, h := newTestServer(t)
do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
loginRec := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"first-owner-pass"}`, nil)
access := cookieValue(t, loginRec, accessCookie)
handler := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
// POST from a foreign origin without cookies → still 401 (auth first).
req := httptest.NewRequest(http.MethodPost, "/api/containers/install", nil)
req.Header.Set("Origin", "https://evil.example")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated foreign POST = %d, want 401", rec.Code)
}
// Authenticated but foreign origin → 403.
req = httptest.NewRequest(http.MethodDelete, "/api/containers/uninstall", nil)
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
req.Header.Set("Origin", "https://evil.example")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("foreign-origin POST = %d, want 403", rec.Code)
}
// Same host, no Origin header (curl-style) → allowed.
req = httptest.NewRequest(http.MethodPost, "/api/x", nil)
req.Host = "localhost:8080"
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("same-host POST = %d, want 200", rec.Code)
}
// GET is exempt.
req = httptest.NewRequest(http.MethodGet, "/api/x", nil)
req.Header.Set("Origin", "https://evil.example")
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("GET exempt = %d, want 200", rec.Code)
}
}
func TestThrottleLockout(t *testing.T) {
_, h := newTestServer(t)
do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
for i := 0; i < 6; i++ { // 5 free + 1 that crosses the throttle threshold
rec := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"wrong-password"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d = %d", i+1, rec.Code)
}
}
rec := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"first-owner-pass"}`, nil)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("locked-out login = %d, want 429", rec.Code)
}
if got := rec.Header().Get("Retry-After"); got == "" {
t.Fatal("no Retry-After header on 429")
}
}
func TestChangePasswordRevokesSessions(t *testing.T) {
_, h := newTestServer(t)
do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
loginRec := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"first-owner-pass"}`, nil)
access := cookieValue(t, loginRec, accessCookie)
refresh := cookieValue(t, loginRec, refreshCookie)
// Missing current password → 401.
rec := do(t, h, http.MethodPost, "/api/auth/change-password",
`{"new_password":"rotated-password"}`,
map[string]string{"Cookie": "maestro_access=" + access})
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
t.Fatalf("missing currentPassword = %d", rec.Code)
}
rec = do(t, h, http.MethodPost, "/api/auth/change-password",
`{"current_password":"first-owner-pass","new_password":"rotated-password"}`,
map[string]string{"Cookie": "maestro_access=" + access})
if rec.Code != 200 {
t.Fatalf("change-password = %d: %s", rec.Code, rec.Body.String())
}
// All sessions revoked — including the caller's.
rec2 := do(t, h, http.MethodPost, "/api/auth/refresh",
"", map[string]string{"Cookie": "maestro_refresh=" + refresh})
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("refresh after password change = %d", rec2.Code)
}
// Old password dead, new one works.
rec3 := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"first-owner-pass"}`, nil)
if rec3.Code != http.StatusUnauthorized {
t.Fatalf("old password login = %d", rec3.Code)
}
rec4 := do(t, h, http.MethodPost, "/api/auth/login",
`{"username":"admin","password":"rotated-password"}`, nil)
if rec4.Code != 200 {
t.Fatalf("new password login = %d: %s", rec4.Code, rec4.Body.String())
}
}
func TestGateWebSocketRejectsAnonymousUpgrade(t *testing.T) {
s, h := newTestServer(t)
do(t, h, http.MethodPost, "/api/auth/setup",
`{"username":"admin","password":"first-owner-pass"}`, nil)
handler := s.GateWebSocket(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("upgrade handler reached without credentials")
}))
req := httptest.NewRequest(http.MethodGet, "/api/ws", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous ws = %d, want 401", rec.Code)
}
}
+1 -1
View File
@@ -81,4 +81,4 @@ func TestSyncAuthorizationIntegration(t *testing.T) {
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")
}
}
+274
View File
@@ -0,0 +1,274 @@
// 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 owner is Maestro's self-contained instance-owner identity store
// (onboarding theme M1, D1/D2): argon2id password hashes in a JSON file at the
// data root, deliberately independent of the fleet database and auth service —
// logging into the orchestrator must work exactly when everything else is
// down. The Store interface keeps the middleware swappable for a DB- or
// LDAP-backed implementation later.
package owner
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
)
// Owner is one dashboard user. The setup wizard creates the first admin;
// further owners seed through the same store/API path (no management UI yet).
type Owner struct {
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Role renders the JWT role claim: admins gatekeep trust decisions.
func (o *Owner) Role() string {
if o.IsAdmin {
return "admin"
}
return "member"
}
// Store persists owners. FileOwnerStore is the on-disk implementation; a
// DB-backed store can slot in later without touching the middleware.
type Store interface {
Count(ctx context.Context) (int, error)
Verify(ctx context.Context, username, password string) (*Owner, error)
Create(ctx context.Context, username, password string, isAdmin bool) (*Owner, error)
// Get looks a user up without credentials (role refresh during token
// rotation).
Get(ctx context.Context, username string) (*Owner, error)
}
var (
usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`)
// Mirrors the dcntrlized-auth conventions.
MinPasswordLen = 10
ErrInvalidUsername = fmt.Errorf("username must match %s", usernameRe.String())
ErrWeakPassword = fmt.Errorf("password must be at least %d characters", MinPasswordLen)
)
// ValidateCredentials enforces the shared-auth conventions: lowercase
// usernames of 3–64 chars, passwords of at least MinPasswordLen chars.
func ValidateCredentials(username, password string) error {
if !usernameRe.MatchString(username) {
return ErrInvalidUsername
}
if len(password) < MinPasswordLen {
return ErrWeakPassword
}
return nil
}
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
)
// HashPassword derives an argon2id PHC-style hash string.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("read salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key)), nil
}
// VerifyPassword compares a password against a PHC-style argon2id hash in
// constant time.
func VerifyPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
// "", "argon2id", "v=19", "m=…,t=…,p=…", salt, key
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// ChangePassword rehashes the owner's password and bumps updated_at.
func (s *FileOwnerStore) ChangePassword(ctx context.Context, username, currentPassword, newPassword string) (*Owner, error) {
s.mu.Lock()
defer s.mu.Unlock()
file, owners, err := s.read()
if err != nil {
return nil, err
}
for i := range owners.Owners {
o := &owners.Owners[i]
if o.Username != username {
continue
}
if !VerifyPassword(o.PasswordHash, currentPassword) {
return nil, os.ErrPermission
}
hash, err := HashPassword(newPassword)
if err != nil {
return nil, err
}
o.PasswordHash = hash
o.UpdatedAt = time.Now().UTC()
return o, s.write(file, owners)
}
return nil, os.ErrNotExist
}
type ownerFile struct {
Version int `json:"version"`
Owners []Owner `json:"owners"`
}
// FileOwnerStore persists owners to <dataRoot>/maestro/owners.json with
// atomic writes and chmod 600.
type FileOwnerStore struct {
path string
mu sync.Mutex
}
// NewFileOwnerStore targets dir/owners.json (dir = <dataRoot>/maestro).
func NewFileOwnerStore(dir string) (*FileOwnerStore, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create owner store directory: %w", err)
}
return &FileOwnerStore{path: filepath.Join(dir, "owners.json")}, nil
}
func (s *FileOwnerStore) read() (string, *ownerFile, error) {
file := ownerFile{Version: 1}
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return s.path, &file, nil
}
if err != nil {
return s.path, nil, fmt.Errorf("read owners file: %w", err)
}
if err := json.Unmarshal(data, &file); err != nil {
return s.path, nil, fmt.Errorf("parse owners file: %w", err)
}
return s.path, &file, nil
}
// write atomically replaces the owners file; the mutex serializes writers and
// concurrent setups end up idempotent through Count's check in the caller.
func (s *FileOwnerStore) write(path string, owners *ownerFile) error {
data, err := json.MarshalIndent(owners, "", " ")
if err != nil {
return fmt.Errorf("encode owners file: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write owners file: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace owners file: %w", err)
}
return nil
}
func (s *FileOwnerStore) Count(_ context.Context) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, owners, err := s.read()
if err != nil {
return 0, err
}
return len(owners.Owners), nil
}
func (s *FileOwnerStore) Create(_ context.Context, username, password string, isAdmin bool) (*Owner, error) {
if err := ValidateCredentials(username, password); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
file, owners, err := s.read()
if err != nil {
return nil, err
}
for _, o := range owners.Owners {
if o.Username == username {
return nil, os.ErrExist
}
}
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
now := time.Now().UTC()
owner := Owner{Username: username, PasswordHash: hash, IsAdmin: isAdmin, CreatedAt: now, UpdatedAt: now}
owners.Owners = append(owners.Owners, owner)
if err := s.write(file, owners); err != nil {
return nil, err
}
return &owner, nil
}
func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) {
o, err := s.Get(context.Background(), username)
if err != nil || o == nil {
return nil, os.ErrNotExist
}
if VerifyPassword(o.PasswordHash, password) {
return o, nil
}
return nil, os.ErrNotExist
}
func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, owners, err := s.read()
if err != nil {
return nil, err
}
for _, o := range owners.Owners {
if o.Username == username {
return &o, nil
}
}
return nil, os.ErrNotExist
}
+168
View File
@@ -0,0 +1,168 @@
// 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 owner
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func newTestStore(t *testing.T) *FileOwnerStore {
t.Helper()
s, err := NewFileOwnerStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") {
t.Fatalf("hash missing argon2id PHC prefix: %s", hash)
}
if !VerifyPassword(hash, "correct horse battery") {
t.Fatal("correct password rejected")
}
if VerifyPassword(hash, "wrong password!") {
t.Fatal("wrong password accepted")
}
if VerifyPassword("garbage", "x") {
t.Fatal("garbage hash accepted")
}
}
func TestValidateCredentials(t *testing.T) {
good := [][2]string{{"admin", "longenough123"}, {"a.b-c_d1", "0123456789"}}
for _, c := range good {
if err := ValidateCredentials(c[0], c[1]); err != nil {
t.Errorf("%q/%q rejected: %v", c[0], c[1], err)
}
}
bad := [][2]string{
{"Admin", "0123456789"}, // uppercase
{"-lead", "0123456789"}, // bad first char
{"ab", "0123456789"}, // too short
{"okname", "short"}, // weak password
}
for _, c := range bad {
if err := ValidateCredentials(c[0], c[1]); err == nil {
t.Errorf("%q/%q accepted", c[0], c[1])
}
}
}
func TestStoreRoundtripAndMultiUser(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if n, _ := s.Count(ctx); n != 0 {
t.Fatalf("fresh store Count = %d", n)
}
first, err := s.Create(ctx, "admin", "first-owner-pass", true)
if err != nil {
t.Fatal(err)
}
if !first.IsAdmin || first.Role() != "admin" {
t.Fatalf("first owner should be admin, got %+v", first)
}
second, err := s.Create(ctx, "member", "second-owner-pass", false)
if err != nil {
t.Fatal(err)
}
if second.Role() != "member" {
t.Fatalf("second owner role = %q", second.Role())
}
if n, _ := s.Count(ctx); n != 2 {
t.Fatalf("Count = %d, want 2", n)
}
if got, err := s.Verify(ctx, "admin", "first-owner-pass"); err != nil || got.Username != "admin" {
t.Fatalf("Verify admin: %v %+v", err, got)
}
if _, err := s.Verify(ctx, "admin", "wrong"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("wrong password: %v", err)
}
if _, err := s.Create(ctx, "admin", "whatever-123", false); !errors.Is(err, os.ErrExist) {
t.Fatalf("duplicate create: %v", err)
}
changed, err := s.ChangePassword(ctx, "admin", "first-owner-pass", "rotated-password")
if err != nil {
t.Fatal(err)
}
if changed.UpdatedAt.Equal(changed.CreatedAt) {
t.Fatal("updated_at not bumped")
}
if _, err := s.Verify(ctx, "admin", "first-owner-pass"); err == nil {
t.Fatal("old password still valid")
}
if _, err := s.Verify(ctx, "admin", "rotated-password"); err != nil {
t.Fatalf("new password rejected: %v", err)
}
}
func TestConcurrentCreateIsIdempotent(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
var wg sync.WaitGroup
errs := make(chan error, 16)
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := s.Create(ctx, "wizard", "setup-password", true); err != nil && !errors.Is(err, os.ErrExist) {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("unexpected error: %v", err)
}
if n, _ := s.Count(ctx); n != 1 {
t.Fatalf("Count after concurrent setup = %d, want 1", n)
}
}
func TestOwnerFilePermissions(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
s, err := NewFileOwnerStore(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s.Create(ctx, "admin", "perm-check-pass", true); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(dir, "owners.json"))
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("owners.json mode = %o, want 600", perm)
}
}
+171
View File
@@ -0,0 +1,171 @@
// 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 owner
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Session is one persisted refresh-token record. Only the SHA-256 hash of the
// jti is stored — never the raw value, mirroring the auth service's
// token_hash convention.
type Session struct {
Username string `json:"username"`
JTIHash string `json:"jti_hash"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type sessionFile struct {
Version int `json:"version"`
Sessions []Session `json:"sessions"`
}
// HashJTI renders the stored digest of a raw refresh jti.
func HashJTI(jti string) string {
sum := sha256.Sum256([]byte(jti))
return hex.EncodeToString(sum[:])
}
// FileSessionStore persists refresh-token state to
// <dataRoot>/maestro/sessions.json so sessions survive restarts, can be
// revoked on logout, and support rotation + reuse detection.
type FileSessionStore struct {
path string
mu sync.Mutex
}
// NewFileSessionStore targets dir/sessions.json.
func NewFileSessionStore(dir string) (*FileSessionStore, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create session store directory: %w", err)
}
return &FileSessionStore{path: filepath.Join(dir, "sessions.json")}, nil
}
func (s *FileSessionStore) load() (*sessionFile, error) {
file := sessionFile{Version: 1}
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return &file, nil
}
if err != nil {
return nil, fmt.Errorf("read sessions file: %w", err)
}
if err := json.Unmarshal(data, &file); err != nil {
return nil, fmt.Errorf("parse sessions file: %w", err)
}
return &file, nil
}
// save atomically replaces the file after pruning expired/revoked rows.
func (s *FileSessionStore) save(file *sessionFile) error {
now := time.Now()
live := file.Sessions[:0]
for _, s := range file.Sessions {
if s.RevokedAt == nil && now.Before(s.ExpiresAt) {
live = append(live, s)
}
}
file.Sessions = live
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode sessions file: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write sessions file: %w", err)
}
return os.Rename(tmp, s.path)
}
// Store records a freshly minted refresh token.
func (s *FileSessionStore) Store(username, jti string, expiresAt time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.load()
if err != nil {
return err
}
file.Sessions = append(file.Sessions, Session{
Username: username,
JTIHash: HashJTI(jti),
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt.UTC(),
})
return s.save(file)
}
// Valid reports whether the jti maps to a live (stored, unrevoked, unexpired)
// session row.
func (s *FileSessionStore) Valid(jti string) bool {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.load()
if err != nil {
return false
}
hash := HashJTI(jti)
now := time.Now()
for _, sess := range file.Sessions {
if sess.JTIHash == hash {
return sess.RevokedAt == nil && now.Before(sess.ExpiresAt)
}
}
return false
}
// Revoke marks the jti's row revoked (logout / rotation).
func (s *FileSessionStore) Revoke(jti string) error {
return s.revokeWhere(func(sess Session) bool { return sess.JTIHash == HashJTI(jti) })
}
// RevokeAllForUser kills every live session row of the user — the response to
// refresh-token reuse (theft signal).
func (s *FileSessionStore) RevokeAllForUser(username string) error {
return s.revokeWhere(func(sess Session) bool { return sess.Username == username })
}
func (s *FileSessionStore) revokeWhere(match func(Session) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
file, err := s.load()
if err != nil {
return err
}
now := time.Now().UTC()
changed := false
for i := range file.Sessions {
if match(file.Sessions[i]) && file.Sessions[i].RevokedAt == nil {
t := now
file.Sessions[i].RevokedAt = &t
changed = true
}
}
if !changed {
return nil
}
return s.save(file)
}
+111
View File
@@ -0,0 +1,111 @@
// 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 owner
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
)
func TestSessionsStoreValidRevoke(t *testing.T) {
s, err := NewFileSessionStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
jti := "jti-1"
if err := s.Store("admin", jti, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if !s.Valid(jti) {
t.Fatal("fresh session invalid")
}
if s.Valid("missing") {
t.Fatal("unknown session valid")
}
if err := s.Revoke(jti); err != nil {
t.Fatal(err)
}
if s.Valid(jti) {
t.Fatal("revoked session still valid")
}
// Re-revoke must not fail (logout idempotence).
if err := s.Revoke(jti); err != nil {
t.Fatalf("double revoke: %v", err)
}
}
func TestRevokeAllForUser(t *testing.T) {
s, _ := NewFileSessionStore(t.TempDir())
for _, j := range []string{"a", "b", "c"} {
if err := s.Store("admin", j, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
}
if err := s.Store("other", "d", time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := s.RevokeAllForUser("admin"); err != nil {
t.Fatal(err)
}
for _, j := range []string{"a", "b", "c"} {
if s.Valid(j) {
t.Fatalf("session %q survived revoke-all", j)
}
}
if !s.Valid("d") {
t.Fatal("other user's session revoked")
}
}
func TestExpiredSessionsPrunedOnSave(t *testing.T) {
dir := t.TempDir()
s, _ := NewFileSessionStore(dir)
// Expired session: stored but never valid.
expired := sha256.Sum256([]byte("expired"))
jti := hex.EncodeToString(expired[:])
if err := s.Store("admin", jti, time.Now().Add(-time.Minute)); err != nil {
t.Fatal(err)
}
if s.Valid(jti) {
t.Fatal("expired session valid")
}
// Prune happens on save: force a write and confirm the expired entry
// disappears from disk.
if err := s.Store("admin", "fresh", time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "sessions.json"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte(HashJTI(jti))) {
t.Fatal("expired session not pruned from disk")
}
if !bytes.Contains(data, []byte(HashJTI("fresh"))) {
t.Fatal("live session missing from disk")
}
}
+118
View File
@@ -0,0 +1,118 @@
// 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 owner
import (
"sync"
"time"
)
// In-process throttling for login, setup and password change, keyed by
// username — mirrors dcntrlized-auth's handlers/auth/throttle.go: the first
// throttleMaxAttempts failures inside the window pass freely (humans mistype),
// beyond that each further failure multiplies the wait (5s×4^n, capped at
// 24h). Maestro is single-instance, so process-local state suffices.
const (
throttleMaxAttempts = 5
throttleWindow = 15 * time.Minute
throttleBackoffBase = 5 * time.Second
throttleMultiplier = 4
throttleMaxWait = 24 * time.Hour
)
type throttleAttempt struct {
count int
last time.Time
}
type Throttler struct {
mu sync.Mutex
state map[string]*throttleAttempt
nowFn func() time.Time
}
// NewThrottler builds an empty throttler.
func NewThrottler() *Throttler {
return &Throttler{state: map[string]*throttleAttempt{}, nowFn: time.Now}
}
func backoffFor(count int) time.Duration {
delay := throttleBackoffBase
for i := 1; i < count-throttleMaxAttempts && delay < throttleMaxWait; i++ {
delay *= throttleMultiplier
}
if delay > throttleMaxWait {
delay = throttleMaxWait
}
return delay
}
// Allow reports whether an attempt for identifier may proceed right now.
func (t *Throttler) Allow(identifier string) bool {
t.mu.Lock()
defer t.mu.Unlock()
a, ok := t.state[identifier]
if !ok || a.count <= throttleMaxAttempts {
return true
}
if t.nowFn().Sub(a.last) > throttleWindow {
delete(t.state, identifier)
return true
}
return t.nowFn().Sub(a.last) >= backoffFor(a.count)
}
// Fail records a failed attempt.
func (t *Throttler) Fail(identifier string) {
t.mu.Lock()
defer t.mu.Unlock()
a, ok := t.state[identifier]
now := t.nowFn()
if !ok || now.Sub(a.last) > throttleWindow {
t.state[identifier] = &throttleAttempt{count: 1, last: now}
return
}
a.count++
a.last = now
}
// Reset clears the identifier after a success.
func (t *Throttler) Reset(identifier string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.state, identifier)
}
// RetryAfter reports how long the identifier must wait before the next
// attempt is permitted; 0 when attempts are currently allowed. Used for the
// 429 Retry-After header.
func (t *Throttler) RetryAfter(identifier string) time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
a, ok := t.state[identifier]
if !ok || a.count <= throttleMaxAttempts {
return 0
}
now := t.nowFn()
if now.Sub(a.last) > throttleWindow {
return 0
}
if wait := backoffFor(a.count) - now.Sub(a.last); wait > 0 {
return wait
}
return 0
}
+207
View File
@@ -0,0 +1,207 @@
// 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 owner
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
// AccessTokenTTL is the stateless access-token lifetime.
AccessTokenTTL = 15 * time.Minute
// RefreshTokenTTL is the persisted refresh-token lifetime.
RefreshTokenTTL = 7 * 24 * time.Hour
// Issuer tags every minted token; verification rejects others.
Issuer = "maestro"
claimType = "type"
claimKid = "kid"
claimJti = "jti"
typeAccess = "access"
typeRefresh = "refresh"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrWrongType = errors.New("token has the wrong type")
)
// TokenPair is one login/rotation result: the cookie payloads plus the refresh
// jti (its raw value lives only in the cookies and the caller's response).
type TokenPair struct {
Access string
Refresh string
RefreshJTI string
AccessTTL time.Duration
RefreshTTL time.Duration
Username string
Role string
}
// TokenService mints and verifies the HS256 access/refresh session pair. The
// signing secret is a per-instance random key on disk (`session.key`, chmod
// 600); kid = sha256(secret)[:16] so a rotated secret keeps old access tokens
// valid through their remaining TTL (grace window).
type TokenService struct {
dir string
key []byte
kid string
}
// NewTokenService loads or creates dir/session.key.
func NewTokenService(dir string) (*TokenService, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create token directory: %w", err)
}
path := filepath.Join(dir, "session.key")
key, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
key = make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate session key: %w", err)
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, fmt.Errorf("persist session key: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("read session key: %w", err)
} else if len(key) < 32 {
return nil, fmt.Errorf("session key at %s is too short", path)
}
sum := sha256.Sum256(key)
return &TokenService{dir: dir, key: key, kid: hex.EncodeToString(sum[:])[:16]}, nil
}
// Kid returns the active signing-key identifier.
func (t *TokenService) Kid() string { return t.kid }
func (t *TokenService) claims(sub, typ, role string, ttl time.Duration, jti string) jwt.MapClaims {
now := time.Now()
c := jwt.MapClaims{
"sub": sub,
"iss": Issuer,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
claimType: typ,
claimKid: t.kid,
}
if role != "" {
c["role"] = role
}
if jti != "" {
c[claimJti] = jti
}
return c
}
func (t *TokenService) sign(c jwt.MapClaims) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(t.key)
}
// MintPair issues a fresh access/refresh pair for the owner. The refresh jti
// must be recorded by the session store before the cookie is served.
func (t *TokenService) MintPair(username, role string) (*TokenPair, error) {
jti := make([]byte, 16)
if _, err := rand.Read(jti); err != nil {
return nil, fmt.Errorf("generate jti: %w", err)
}
raw := hex.EncodeToString(jti)
access, err := t.sign(t.claims(username, typeAccess, role, AccessTokenTTL, ""))
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
refresh, err := t.sign(t.claims(username, typeRefresh, "", RefreshTokenTTL, raw))
if err != nil {
return nil, fmt.Errorf("sign refresh token: %w", err)
}
return &TokenPair{
Access: access, Refresh: refresh, RefreshJTI: raw,
AccessTTL: AccessTokenTTL, RefreshTTL: RefreshTokenTTL,
Username: username, Role: role,
}, nil
}
// parse validates signature (constant-time HMAC compare inside the library),
// issuer and expiry, returning the claims.
func (t *TokenService) parse(token string) (jwt.MapClaims, error) {
parsed, err := jwt.Parse(token, func(tk *jwt.Token) (interface{}, error) {
if _, ok := tk.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return t.key, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(Issuer), jwt.WithExpirationRequired())
if err != nil || !parsed.Valid {
return nil, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
if kid, _ := claims[claimKid].(string); kid != "" && kid != t.kid && !hmac.Equal([]byte(kid), []byte(t.kid)) {
// A different kid means an unknown secret generation — refuse. The
// same-kid path is the normal case; empty kid predates kid tagging.
return nil, ErrInvalidToken
}
return claims, nil
}
// VerifyAccess validates an access token and returns the username + role.
func (t *TokenService) VerifyAccess(token string) (username, role string, err error) {
claims, err := t.parse(token)
if err != nil {
return "", "", err
}
if typ, _ := claims[claimType].(string); typ != typeAccess {
return "", "", ErrWrongType
}
sub, _ := claims["sub"].(string)
role, _ = claims["role"].(string)
if sub == "" {
return "", "", ErrInvalidToken
}
return sub, role, nil
}
// VerifyRefresh validates a refresh token and returns username + jti.
func (t *TokenService) VerifyRefresh(token string) (username, jti string, err error) {
claims, err := t.parse(token)
if err != nil {
return "", "", err
}
if typ, _ := claims[claimType].(string); typ != typeRefresh {
return "", "", ErrWrongType
}
sub, _ := claims["sub"].(string)
jti, _ = claims[claimJti].(string)
if sub == "" || jti == "" {
return "", "", ErrInvalidToken
}
return sub, jti, nil
}
+224
View File
@@ -0,0 +1,224 @@
// 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 owner
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func newTestTokenService(t *testing.T) *TokenService {
t.Helper()
s, err := NewTokenService(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
func TestMintAndVerifyPair(t *testing.T) {
s := newTestTokenService(t)
pair, err := s.MintPair("admin", "admin")
if err != nil {
t.Fatal(err)
}
if pair.Access == "" || pair.Refresh == "" || pair.RefreshJTI == "" {
t.Fatal("empty field in pair")
}
if pair.Username != "admin" || pair.Role != "admin" {
t.Fatalf("pair identity wrong: %+v", pair)
}
if pair.AccessTTL != AccessTokenTTL || pair.RefreshTTL != RefreshTokenTTL {
t.Fatalf("pair TTLs wrong: %+v", pair)
}
for _, tok := range []string{pair.Access, pair.Refresh} {
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("token not JWS compact: %q", tok)
}
}
username, role, err := s.VerifyAccess(pair.Access)
if err != nil {
t.Fatalf("VerifyAccess: %v", err)
}
if username != "admin" || role != "admin" {
t.Fatalf("access claims: %q/%q", username, role)
}
rUser, rJTI, err := s.VerifyRefresh(pair.Refresh)
if err != nil {
t.Fatalf("VerifyRefresh: %v", err)
}
if rUser != "admin" || rJTI != pair.RefreshJTI {
t.Fatalf("refresh claims: %q/%q", rUser, rJTI)
}
}
func TestTokenTypeIsEnforced(t *testing.T) {
s := newTestTokenService(t)
pair, _ := s.MintPair("admin", "admin")
if _, _, err := s.VerifyAccess(pair.Refresh); err == nil {
t.Fatal("refresh token accepted as access")
}
if _, _, err := s.VerifyRefresh(pair.Access); err == nil {
t.Fatal("access token accepted as refresh")
}
}
func craftToken(t *testing.T, s *TokenService, claims jwt.MapClaims) string {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.key)
if err != nil {
t.Fatal(err)
}
return tok
}
func TestExpiredTokensRejected(t *testing.T) {
s := newTestTokenService(t)
mk := func(typ string, ttl time.Duration) string {
now := time.Now()
return craftToken(t, s, jwt.MapClaims{
"iss": Issuer, "sub": "admin", "type": typ, "kid": s.kid,
"iat": now.Add(-2 * ttl).Unix(), "exp": now.Add(-ttl).Unix(),
"jti": "expired-" + typ,
})
}
if _, _, err := s.VerifyAccess(mk(typeAccess, AccessTokenTTL)); err == nil {
t.Fatal("expired access accepted")
}
if _, _, err := s.VerifyRefresh(mk(typeRefresh, RefreshTokenTTL)); err == nil {
t.Fatal("expired refresh accepted")
}
}
func TestForgedTokensRejected(t *testing.T) {
s := newTestTokenService(t)
pair, _ := s.MintPair("admin", "admin")
// Signed by a different key.
other := newTestTokenService(t)
forged, _ := other.MintPair("admin", "admin")
if _, _, err := s.VerifyAccess(forged.Access); err == nil {
t.Fatal("foreign-signed access accepted")
}
if _, _, err := s.VerifyRefresh(forged.Refresh); err == nil {
t.Fatal("foreign-signed refresh accepted")
}
// Tampered payload.
if _, _, err := s.VerifyAccess(strings.TrimSuffix(pair.Access, "x") + "y"); err == nil {
t.Fatal("tampered token accepted")
}
// Wrong issuer but right key.
now := time.Now()
badIss := craftToken(t, s, jwt.MapClaims{
"iss": "someone-else", "sub": "admin", "type": typeAccess, "kid": s.kid,
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
})
if _, _, err := s.VerifyAccess(badIss); err == nil {
t.Fatal("wrong-issuer token accepted")
}
// Unknown kid (foreign key generation).
unknownKid := craftToken(t, s, jwt.MapClaims{
"iss": Issuer, "sub": "admin", "type": typeAccess, "kid": "deadbeefdeadbeef",
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
})
if _, _, err := s.VerifyAccess(unknownKid); err == nil {
t.Fatal("unknown-kid token accepted")
}
}
func TestKeyPersistsAcrossRestarts(t *testing.T) {
dir := t.TempDir()
s1, err := NewTokenService(dir)
if err != nil {
t.Fatal(err)
}
pair, err := s1.MintPair("admin", "admin")
if err != nil {
t.Fatal(err)
}
s2, err := NewTokenService(dir)
if err != nil {
t.Fatal(err)
}
if s1.Kid() != s2.Kid() {
t.Fatalf("kid changed across restarts: %s vs %s", s1.Kid(), s2.Kid())
}
if _, _, err := s2.VerifyAccess(pair.Access); err != nil {
t.Fatalf("token invalid after restart: %v", err)
}
}
func TestThrottlerBackoffAndReset(t *testing.T) {
th := NewThrottler()
id := "admin"
if !th.Allow(id) {
t.Fatal("fresh identifier blocked")
}
if th.RetryAfter(id) != 0 {
t.Fatal("RetryAfter nonzero without failures")
}
// The first five failures stay within the free allowance.
for i := 0; i < throttleMaxAttempts; i++ {
th.Fail(id)
}
if !th.Allow(id) {
t.Fatal("attempt inside allowance blocked")
}
if th.RetryAfter(id) != 0 {
t.Fatal("RetryAfter nonzero inside allowance")
}
// The next failure crosses the threshold: backoff starts at base…
th.Fail(id)
if th.Allow(id) {
t.Fatal("locked-out identifier allowed immediately")
}
if wait := th.RetryAfter(id); wait <= 0 || wait > throttleBackoffBase {
t.Fatalf("RetryAfter = %v, want (0,%v]", wait, throttleBackoffBase)
}
// …and escalates multiplicatively with further failures.
th.Fail(id)
if wait := th.RetryAfter(id); wait <= throttleBackoffBase || wait > throttleBackoffBase*throttleMultiplier {
t.Fatalf("RetryAfter = %v, want escalated", wait)
}
// Success clears state.
th.Reset(id)
if !th.Allow(id) || th.RetryAfter(id) != 0 {
t.Fatal("reset did not clear throttler")
}
// Window expiry also clears state.
th.Fail(id)
th.nowFn = func() time.Time { return time.Now().Add(2 * throttleWindow) }
if !th.Allow(id) {
t.Fatal("identifier not forgiven after window")
}
}
+72 -18
View File
@@ -22,8 +22,10 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
@@ -31,18 +33,62 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
)
//go:embed all:frontend/dist
var embeddedFrontend embed.FS
// resetOwner implements --reset-owner: documented headless lockout recovery.
// It removes the owner store, signing key and sessions, then exits — the next
// boot runs the setup wizard again.
func resetOwner() {
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
dir := filepath.Join(dataRoot, "maestro")
for _, f := range []string{"owners.json", "session.key", "sessions.json"} {
if err := os.Remove(filepath.Join(dir, f)); err != nil && !os.IsNotExist(err) {
log.Fatalf("Failed to remove %s: %v", f, err)
}
}
log.Printf("Owner identity reset (%s); restart Maestro to run the setup wizard.", dir)
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "--reset-owner" {
resetOwner()
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cache := libcontainers.NewContainerCache()
// Instance-owner gate (onboarding theme M1): self-contained credential +
// session stores at the data root, independent of the fleet.
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
maestroDir := filepath.Join(dataRoot, "maestro")
ownerStore, err := owner.NewFileOwnerStore(maestroDir)
if err != nil {
log.Fatalf("Failed to open owner store: %v", err)
}
tokenService, err := owner.NewTokenService(maestroDir)
if err != nil {
log.Fatalf("Failed to initialize session signing key: %v", err)
}
sessionStore, err := owner.NewFileSessionStore(maestroDir)
if err != nil {
log.Fatalf("Failed to open session store: %v", err)
}
authService := auth.New(ownerStore, tokenService, sessionStore)
if os.Getenv("DEV_MODE_STATIC_SERVICES") == "true" {
// DEV MODE: Load from file, skip Docker client
cache.LoadDevServices("services.json")
@@ -59,10 +105,6 @@ func main() {
// Provision the instance CA on first run (security theme M1). The CA is
// the trust anchor for every peer edge on dcntrlized_net; services get
// their identity certs from it at start/install.
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
if _, err := pki.EnsureCA(dataRoot); err != nil {
log.Fatalf("Failed to provision instance CA: %v", err)
}
@@ -94,27 +136,39 @@ func main() {
}
wsHub := containers.NewWebSocketHub(cache)
gate := authService.RequireSession
mux := http.NewServeMux()
mux.HandleFunc("/api/containers", containers.NewListContainersHandler(cache))
mux.HandleFunc("/api/ws", wsHub.HandleWS)
mux.HandleFunc("/api/containers/install", containers.InstallContainerHandler(cache))
mux.HandleFunc("/api/containers/uninstall", containers.UninstallContainerHandler(cache))
mux.HandleFunc("/api/containers/start", containers.StartContainerHandler(cache))
mux.HandleFunc("/api/containers/stop", containers.StopContainerHandler(cache))
mux.HandleFunc("/api/actions/{containerId}/{actionName}", actions.RunAction)
mux.HandleFunc("/api/registry", handlers.GetRegistry)
// Owner gate endpoints (onboarding theme M1). Status/setup/login/refresh/
// logout stay public — they are how a session is obtained; change-password
// rides behind the session gate like every other route.
mux.HandleFunc("/api/auth/status", authService.Status)
mux.HandleFunc("/api/auth/setup", authService.Setup)
mux.HandleFunc("/api/auth/login", authService.Login)
mux.HandleFunc("/api/auth/refresh", authService.Refresh)
mux.HandleFunc("/api/auth/logout", authService.Logout)
mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword)))
mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS)))
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache)))
mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))
mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache)))
mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(cache)))
mux.Handle("/api/containers/stop", gate(containers.StopContainerHandler(cache)))
mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction)))
mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry)))
// Trusted-services surface (security theme M2 §3): pending first-contact
// authorizations and the owner's approve/revoke actions, forwarded to the
// auth service's own trust store.
peersHandler := peers.NewHandler()
mux.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)
mux.HandleFunc("/api/peers/reject", peersHandler.Reject)
mux.HandleFunc("/api/peers/hide", peersHandler.Hide)
mux.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List)))
mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation)))
mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve)))
mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke)))
mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject)))
mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide)))
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
if err != nil {