Compare commits

..
10 Commits
Author SHA1 Message Date
Admin 94d60482e6 [build](dev): publish Maestro on all interfaces for a LAN phone
The debug stack is the instance an app on the phone talks to, and Maestro is the
only port an app is ever given (the single data plane routes every service under
/api/{service}). Binding 127.0.0.1 makes the instance unreachable from a device
on the LAN — only USB with `adb reverse` could reach it — so the dev compose now
publishes on 0.0.0.0.
2026-10-19 14:52:56 +01:00
Admin ef4c7c584d [chore](maestro): drop the stale VS Code task and launch files
The per-repo task scheme is maintained in the workspace slices; these copies drifted and are no longer used.
2026-10-19 02:40:26 +01:00
Admin ea12039af8 [docs](maestro): specify rev 9, the data plane, web apps, and the catalog
Bring spec.yaml in line with the implemented surface: the retired PKI/federation routes, the ungated /api/{service} data plane and its WebSocket upgrade, the app catalog and public downloads, the hub SPA mount, and the web app link resolution and container fields.
2026-10-19 02:40:26 +01:00
Admin ad670f30ac [feat](maestro): serve the public hub app from an embedded bundle
The browser hub is a status/metadata reader for an instance, separate from the operator console and its owner cookies. Build it as its own Preact bundle at /hub/ outside the owner gate, embed it via //go:embed all:hub/dist (with a committed index placeholder so a clean checkout still builds), and apply a strict CSP because it shares the console origin. The Docker build stages the hub build alongside the console; dist assets remain ignored.
2026-10-19 02:40:26 +01:00
Admin b7fa29849d [fix](maestro): pair services by declared identity and show web/source chips
Image-name comparison split on the first colon, so every image on a registry with a port looked like a different service (or the same one); split on the last colon before the tag and strip the registry host when present. Pair a catalog entry with a container by org.dcntrlized.service first, then by repository name, and never with a registry-only placeholder. The card gains the resolved Web app chip and renders a source link only when the server validated it, with the raw value inert otherwise.
2026-10-19 02:40:26 +01:00
Admin 4038334c28 [feat](maestro): resolve web app links from owner-controlled sources
A service announces a web block only while its listener is enabled and serving, and the manifest own href is ignored. Resolve the URL server-side with the precedence manifest url > per-service operator override > global base-domain template > request host plus published port, validate every source at resolution time, and omit a block that has no usable target. The list endpoint resolves a fresh copy, so cached containers are never mutated and the console only ever opens a server-produced href.
2026-10-19 02:40:26 +01:00
Admin 786b7a3b5f [feat](maestro): publish an operator-authored app catalog
An instance distributes installable Android apps independently of the services it runs. Add <dataRoot>/catalog/apps.json (owner-authored, atomic, 0600) with hosted-or-linked artifacts, SHA-256 and optional icon; expose the owner-gated add/edit/delete/replace API plus session-free artifact and icon downloads, and add the apps section to the public profile. The catalog is deliberately independent of services and the registry, and registry listings gain the declared service identity and a validated source href so cards can be paired correctly.
2026-10-19 02:40:05 +01:00
Admin 13f6d14521 [feat](maestro): adopt externally managed services without owning them
The dashboard sees containers Maestro did not create (each repo dev debug compose), and treating them as installs made start/uninstall/config act on a project file that never created them. Mark them external, refuse start/update/uninstall with a message pointing at their own compose project, and let stop work at the container level. Inject runtime env (config-signing public key, plaintext shared-DB DSN) on the managed start path, and keep the external flag a property of the service so a debug sibling cannot flip a managed service.
2026-10-19 02:40:05 +01:00
Admin 7afd019215 [feat](maestro): proxy one data plane instead of per-service routes
Apps and phones reach services through the instance, so only one port per instance needs to be open and the service answers its own 401. Add handlers/dataplane: one allowlist (/api/{service}/action/{action}, /auth/{path...}, /ws[/...]) and one dispatcher that chooses HTTP or WebSocket by upgrade presence, outside the owner gate because callers hold no Maestro session. Both it and the legacy action proxy resolve through lib/serviceproxy, so the two edges cannot disagree about a service address.
2026-10-19 02:40:05 +01:00
Admin 17d5307110 [refactor](maestro): retire the PKI and federation identity layer
Contract rev 9 removes the instance CA, client certificates, the peer listener and the trust store fleet-wide. Internal traffic is plaintext HTTP on each service single :8080 listener, so there is no peer identity left to authorize: delete lib/pki, lib/federation, devboot, the per-service database role model and the /api/peers* and /api/instances surfaces, and drop the console Trusted services UI. Config delivery now rides a dedicated ES256 token from lib/configsign rather than mTLS, and the instance fingerprint is derived from a persisted random key and is informational only. main.go also wires the surfaces introduced by the later commits, which this change is what makes possible.
2026-10-19 02:39:15 +01:00
121 changed files with 14227 additions and 3066 deletions
+3
View File
@@ -2,6 +2,9 @@
src/frontend/node_modules/
src/frontend/dist/
src/frontend/.vite/
src/hub/node_modules/
src/hub/dist/
src/hub/.vite/
# Block compiled native Go binary targets and testing outputs
src/Maestro
-42
View File
@@ -1,42 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Frontend",
"type": "node",
"runtimeExecutable": "bun",
"cwd": "${workspaceFolder}/src/frontend",
"runtimeArgs": [
"run",
"dev"
],
"request": "launch",
"presentation": {
"hidden": false,
"group": "",
"order": 1
}
},
{
"name": "Debug API",
"type": "go",
"asRoot": false,
"debugAdapter": "dlv-dap",
"request": "launch",
"program": "${workspaceFolder}/src/main.go",
"cwd": "${workspaceFolder}/src"
},
],
"compounds": [
{
"name": "Debug All",
"configurations": [
"Debug API",
"Debug Frontend"
]
}
]
}
-68
View File
@@ -1,68 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Launch Backend",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml up -d --force-recreate",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Follow Backend Logs",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml logs -f",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Stop Backend",
"type": "shell",
"command": "docker compose -f docker-compose.debug.yaml down",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Build",
"dependsOn": [
"Build Server",
"Build Frontend"
],
"dependsOrder": "parallel",
"group": "build"
},
{
"label": "Build Server",
"hide": true,
"command": [
"go"
],
"args": [
"build"
],
"options": {
"cwd": "${workspaceFolder}/src"
}
},
{
"label": "Build Frontend",
"hide": true,
"command": [
"bun"
],
"args": [
"run",
"build"
],
"options": {
"cwd": "${workspaceFolder}/src/frontend"
}
}
]
}
+11
View File
@@ -22,6 +22,15 @@ RUN bun ci
COPY src/frontend/ ./
RUN bun run build
# Stage 1b: Build the PUBLIC hub SPA bundle (separate bundle, served at /hub/
# outside the owner gate; browser-edge contract §10.5).
FROM oven/bun:1.3.9-alpine AS hub-builder
WORKDIR /app
COPY src/hub/package.json src/hub/bun.lock ./
RUN bun ci
COPY src/hub/ ./
RUN bun run build
# Stage 2: Build the static Go binary
FROM golang:1.26-alpine AS go-builder
WORKDIR /app
@@ -31,6 +40,8 @@ RUN go mod download
COPY src/ .
# Copy the built UI into the frontend folder so go:embed finds it
COPY --from=frontend-builder /app/dist ./frontend/dist
# Copy the built hub SPA into hub/dist so go:embed all:hub/dist finds it
COPY --from=hub-builder /app/dist ./hub/dist
# Build a statically linked binary, stripping debug symbols (-s -w) for smaller size
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
RUN apk add --no-cache ca-certificates
+11
View File
@@ -26,6 +26,15 @@ RUN bun ci
COPY src/frontend/ ./
RUN bun run build
# Stage 1b: Build the PUBLIC hub SPA bundle (separate bundle, served at /hub/
# outside the owner gate; browser-edge contract §10.5).
FROM oven/bun:1.3.9-alpine AS hub-builder
WORKDIR /app
COPY src/hub/package.json src/hub/bun.lock ./
RUN bun ci
COPY src/hub/ ./
RUN bun run build
# Stage 2: Build the static Go binary
FROM golang:1.26-alpine AS go-builder
WORKDIR /app
@@ -34,6 +43,8 @@ RUN go mod download
COPY src/ .
# Copy the built UI into the frontend folder so go:embed finds it
COPY --from=frontend-builder /app/dist ./frontend/dist
# Copy the built hub SPA into hub/dist so go:embed all:hub/dist finds it
COPY --from=hub-builder /app/dist ./hub/dist
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
# Stage 3: Debug-final image (alpine base, not scratch, for shell access)
+5 -2
View File
@@ -3,8 +3,11 @@ services:
image: maestro
container_name: maestro
ports:
# Loopback-only default (dev is localhost anyway); see docker-compose.yaml
- "127.0.0.1:8080:8080"
# All interfaces: the debug stack is the instance a phone on the LAN
# talks to, and Maestro is the only port an app is ever given (the single
# data plane). Loopback-only makes the instance invisible to a device that
# is not using `adb reverse`. See docker-compose.yaml for the prod bind.
- "0.0.0.0:8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "${XDG_DATA_HOME:-${HOME}}/.dcntrlized:/data/.dcntrlized"
+905 -92
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "bun test tests",
"preview": "vite preview"
},
"dependencies": {
+1 -8
View File
@@ -18,7 +18,6 @@
import { useMemo, useState } from 'preact/hooks'
import { useContainerStore } from '@lib/hooks/useContainerStore'
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
import { useTrustStore } from '@lib/hooks/useTrustStore'
import { useAuth } from '@lib/hooks/useAuth'
import { mergeServices } from '@lib/domain/merge'
import { filterServices, serviceCounts } from '@lib/domain/filter'
@@ -41,7 +40,6 @@ export function App() {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<ServiceFilter>('all')
const [settingsOpen, setSettingsOpen] = useState(false)
const trust = useTrustStore(settingsOpen, authed)
const services = useMemo(() => {
const items = registry.state.kind === 'ok' ? registry.state.items : []
@@ -87,11 +85,8 @@ export function App() {
const openSettings = () => {
setSettingsOpen(true)
void trust.refresh()
}
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
if (auth.gate === 'loading') {
return (
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
@@ -114,7 +109,7 @@ export function App() {
installed={counts.installed}
updates={counts.updates}
connection={status}
pendingTrust={pendingTrust}
pendingTrust={0}
onShowUpdates={showUpdates}
onOpenSettings={openSettings}
/>
@@ -155,8 +150,6 @@ export function App() {
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
trust={trust.load}
onRefreshTrust={trust.refresh}
username={auth.username}
onSignOut={() => {
setSettingsOpen(false)
@@ -39,6 +39,7 @@ function dbService(item: RegistryItem): Service {
image: item.image,
registryImage: item.image,
source: item.source,
sourceHref: item.source_href,
registryUrl: item.url,
latestVersion: item.version,
hasUpdate: false,
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal } from 'lucide-react'
import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal, Globe } from 'lucide-react'
import { Button } from '@components/ui/button'
import { Card, CardContent, CardFooter } from '@components/ui/card'
import { statusMeta } from '@components/container/status'
@@ -25,6 +25,7 @@ import type { Service } from '@lib/types'
import { cn } from '@lib/utils'
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
import { DatabaseChip } from '@components/infra/database-chip'
import { sourceLink } from '@lib/domain/source-link'
import type { DatabaseState } from './service-grid'
interface ServiceCardProps {
@@ -47,9 +48,36 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
/**
* The "Web app" chip (contract rev 8.2 §16.5). `href` is resolved SERVER-SIDE
* and is the only thing this component may open — the console must never build
* a URL from service strings. The caller renders it only when `href` is
* non-empty, so a service without a usable web app shows no chip at all.
*/
function WebAppChip({ href, name }: { href: string; name: string }) {
return (
<a
data-slot="card-web"
href={href}
target="_blank"
rel="noopener noreferrer"
title={`Open the ${name} web app — ${href}`}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:border-border/90 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<Globe aria-hidden="true" className="size-3" />
Web app
<ExternalLink aria-hidden="true" className="size-3" />
</a>
)
}
export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
const meta = statusMeta(service.status)
const installed = service.origin !== 'remote'
// Externally managed containers (e.g. a repo's own debug compose) are not
// Maestro's to run: the console may stop them but must not start, uninstall,
// update or reconfigure them.
const managed = installed && !service.external
const running =
service.status.toLowerCase() === 'running' ||
service.status.toLowerCase() === 'starting' ||
@@ -60,6 +88,9 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
const shortContainerId = service.id.slice(0, 12)
const shortImage = service.image ? service.image.split('/').slice(-2).join('/') : service.name
// Registry `source` is only linked through its server-validated href; an
// unvalidated value renders as inert text (audit N7).
const source = sourceLink(service)
const copyId = useAsyncAction(async () => {
try {
@@ -193,6 +224,15 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{latest}
</span>
)}
{service.external && (
<span
data-slot="card-external"
title="Started outside Maestro — stop it here, start and manage it from its own compose project"
className="inline-flex shrink-0 items-center rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
>
external
</span>
)}
{running && installed && (
<button
data-slot="card-container-id"
@@ -206,26 +246,37 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
<Copy aria-hidden="true" className="size-3" />
</button>
)}
{service.source && (
{source?.kind === 'link' ? (
<a
data-slot="card-source"
href={service.source}
href={source.href}
target="_blank"
rel="noreferrer"
title={`Source — ${service.source}`}
title={`Source — ${source.href}`}
className="ml-auto inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
Source
<ExternalLink aria-hidden="true" className="size-3" />
</a>
)}
) : source?.kind === 'text' ? (
<span
data-slot="card-source"
title={`Source (not a valid http(s) URL, shown as text) — ${source.text}`}
className="ml-auto max-w-48 truncate text-xs font-medium text-muted-foreground"
>
{source.text}
</span>
) : null}
</div>
{service.capabilities.includes('database') && <DatabaseChip db={db} />}
<div className="flex flex-wrap items-center gap-2">
{service.capabilities.includes('database') && <DatabaseChip db={db} />}
{service.web?.href ? <WebAppChip href={service.web.href} name={service.name} /> : null}
</div>
</CardContent>
<CardFooter className="justify-end pt-4">
{installed && (
{managed && (
<Button
data-slot="card-uninstall"
variant="ghost"
@@ -239,7 +290,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
</Button>
)}
{installed && onOpenConfig && (
{managed && onOpenConfig && (
<Button
data-slot="card-config"
variant="ghost"
@@ -253,7 +304,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
</Button>
)}
{installed && service.hasUpdate && (
{managed && service.hasUpdate && (
<Button
data-slot="card-update-action"
size="sm"
@@ -277,7 +328,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{stop.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
Stop
</Button>
) : installed ? (
) : managed ? (
<Button
data-slot="card-start"
size="sm"
@@ -290,7 +341,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{start.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
Start
</Button>
) : (
) : installed ? null : (
<Button data-slot="card-install" className="" size="sm" isDisabled={busy} onPress={install.run}>
{install.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Download aria-hidden="true" className="size-3" />}
Install
@@ -1,302 +0,0 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BadgeCheck, Check, ChevronRight, EyeOff, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react'
import { Button } from '@components/ui/button'
import { Disclosure, DisclosurePanel, Heading } from 'react-aria-components'
import { toast } from 'sonner'
import { approvePeer, hidePeer, rejectPeer, revokePeer } from '@lib/api/peers'
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
import { cn } from '@lib/utils'
import type { PeerAuthorized, PeerPending } from '@lib/types'
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
function shortFingerprint(fp: string): string {
if (fp.length <= 16) return fp
return `${fp.slice(0, 16)}…`
}
function fmtTime(iso?: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString()
}
interface TrustedServicesSectionProps {
load: TrustLoadState
onRefresh: () => void
}
/**
* The trusted-services surface (security theme M2 §3): pending first-contact
* authorizations, the approved trust store, and revoked keys. Maestro is only
* the UI — the auth service owns the enforcement, so this panel is a
* convenience, not a security boundary. Rendered as a section inside the
* settings dialog.
*/
export function TrustedServicesSection({ load, onRefresh }: TrustedServicesSectionProps) {
if (load.kind === 'loading') {
return (
<div
data-slot="trusted-loading"
className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"
>
<LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
Loading trust state…
</div>
)
}
if (load.kind === 'error') {
return (
<div
data-slot="trusted-error"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2.5 text-sm"
>
<p className="min-w-0 text-muted-foreground">
Couldn't reach the auth service.
<span className="ml-1.5 text-destructive">{load.message}</span>
</p>
<Button size="xs" variant="outline" onPress={onRefresh}>
<RotateCcw aria-hidden="true" className="size-3" />
Retry
</Button>
</div>
)
}
const pending = load.state.pending ?? []
const authorized = load.state.authorized ?? []
const active = authorized.filter((k) => !k.revoked_at && !k.hidden_at)
const revoked = authorized.filter((k) => k.revoked_at && !k.hidden_at)
if (pending.length === 0 && active.length === 0 && revoked.length === 0) {
return (
<div
data-slot="trusted-empty"
className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-8 text-sm text-muted-foreground"
>
<ShieldCheck className="size-4 text-muted-foreground/60" aria-hidden="true" />
No services have asked to talk to the auth service yet.
</div>
)
}
return (
<div data-slot="trusted-section" className="grid min-w-0 gap-4">
{pending.length > 0 && (
<div data-slot="trusted-pending" className="grid min-w-0 gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-warning">
Requested access ({pending.length})
</p>
{pending.map((p) => (
<PendingRow key={`${p.service}:${p.fingerprint}`} entry={p} onDone={onRefresh} />
))}
</div>
)}
{active.length > 0 && (
<div data-slot="trusted-approved" className="grid min-w-0 gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-success">
Accepted ({active.length})
</p>
{active.map((k) => (
<AuthorizedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={onRefresh} />
))}
</div>
)}
{revoked.length > 0 && (
<Disclosure data-slot="trusted-revoked" defaultExpanded={false} className="group min-w-0">
<Heading className="m-0">
<Button
slot="trigger"
variant="ghost"
size="sm"
className="w-full justify-start gap-1.5 px-1 font-medium text-muted-foreground hover:text-foreground"
>
<ChevronRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-200 group-data-[expanded]:rotate-90"
/>
Revoked ({revoked.length})
</Button>
</Heading>
<DisclosurePanel className="mt-1 grid min-w-0 gap-2 px-1">
{revoked.map((k) => (
<RevokedRow key={`${k.service}:${k.fingerprint}`} entry={k} onDone={onRefresh} />
))}
</DisclosurePanel>
</Disclosure>
)}
</div>
)
}
function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void }) {
const approve = useAsyncAction(async () => {
try {
await approvePeer(entry.service, entry.fingerprint)
toast.success(`Authorized ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
onDone()
} catch (error) {
toast.error(`Failed to authorize ${entry.service}`, { description: errorMessage(error) })
}
})
const reject = useAsyncAction(async () => {
try {
await rejectPeer(entry.service, entry.fingerprint)
toast.success(`Rejected ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
onDone()
} catch (error) {
toast.error(`Failed to reject ${entry.service}`, { description: errorMessage(error) })
}
})
return (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium">
<span className="inline-block size-1.5 shrink-0 rounded-full bg-warning" aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">{entry.fingerprint}</p>
<p className="text-xs text-muted-foreground/70">First seen {fmtTime(entry.first_seen)}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="xs" variant="outline" onPress={() => void reject.run()} isDisabled={reject.pending}>
{reject.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Reject
</Button>
<Button size="xs" onPress={() => void approve.run()} isDisabled={approve.pending}>
{approve.pending ? <LoaderCircle className="animate-spin" /> : <Check />}
Accept
</Button>
</div>
</div>
)
}
function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) {
const revoke = useAsyncAction(async () => {
try {
await revokePeer(entry.service, entry.fingerprint)
toast.success(`Revoked ${entry.service}`, { description: shortFingerprint(entry.fingerprint) })
onDone()
} catch (error) {
toast.error(`Failed to revoke ${entry.service}`, { description: errorMessage(error) })
}
})
return (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium">
<BadgeCheck className="size-4 text-success" aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
{entry.fingerprint}
<span className="ml-2 text-muted-foreground/70">by {entry.approved_by}</span>
</p>
<p className="text-xs text-muted-foreground/70">Accepted {fmtTime(entry.approved_at)}</p>
</div>
<Button
size="xs"
variant="destructive"
onPress={() => void revoke.run()}
isDisabled={revoke.pending}
>
{revoke.pending ? <LoaderCircle className="animate-spin" /> : <X />}
Revoke
</Button>
</div>
)
}
function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) {
const reauthorize = useAsyncAction(async () => {
try {
await approvePeer(entry.service, entry.fingerprint)
toast.success(`Re-authorized ${entry.service}`, {
description: shortFingerprint(entry.fingerprint),
})
onDone()
} catch (error) {
toast.error(`Failed to re-authorize ${entry.service}`, { description: errorMessage(error) })
}
})
const hide = useAsyncAction(async () => {
try {
await hidePeer(entry.service, entry.fingerprint)
toast.success(`Hidden ${entry.service}`, {
description: `${shortFingerprint(entry.fingerprint)} removed from the dashboard`,
})
onDone()
} catch (error) {
toast.error(`Failed to hide ${entry.service}`, { description: errorMessage(error) })
}
})
return (
<div
className={cn(
'flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2'
)}
>
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<X className="size-4 text-muted-foreground/60" aria-hidden />
{entry.service}
</p>
<p className="truncate font-mono text-xs text-muted-foreground/80">
{entry.fingerprint}
<span className="ml-2 text-muted-foreground/60">by {entry.approved_by}</span>
</p>
<p className="text-xs text-muted-foreground/70">Revoked {fmtTime(entry.revoked_at)}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="icon-xs"
variant="ghost"
onPress={() => void hide.run()}
isDisabled={hide.pending}
aria-label={`Hide ${entry.service} permanently from the dashboard`}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
{hide.pending ? <LoaderCircle className="animate-spin" /> : <EyeOff />}
</Button>
<Button
size="xs"
variant="outline"
onPress={() => void reauthorize.run()}
isDisabled={reauthorize.pending}
>
{reauthorize.pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
Re-authorize
</Button>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -15,18 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Settings, ShieldCheck, X } from 'lucide-react'
import { Settings, Smartphone, 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'
import { AppCatalogSection } from '@components/settings/app-catalog-section'
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
trust: TrustLoadState
onRefreshTrust: () => void
username: string
onSignOut: () => void
}
@@ -34,8 +31,6 @@ interface SettingsDialogProps {
export function SettingsDialog({
open,
onOpenChange,
trust,
onRefreshTrust,
username,
onSignOut,
}: SettingsDialogProps) {
@@ -58,7 +53,7 @@ export function SettingsDialog({
Settings
</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Manage how this node trusts the services on its fleet.
This node’s owner account, the servers it trusts, and the apps it publishes.
</p>
</div>
<Button
@@ -75,22 +70,25 @@ export function SettingsDialog({
<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">
<section
data-slot="settings-section-app-catalog"
aria-labelledby="settings-app-catalog-title"
className="min-w-0"
>
<h3
id="settings-trusted-title"
id="settings-app-catalog-title"
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"
>
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
Trusted services
<Smartphone className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
App catalog
</h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH
authorized_keys, applied to the service fleet), reject drops a request without
authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores
one.
Apps this instance publishes for people to install on their phones. Clients
discover them here; each entry carries the artifact’s checksum so installs can be
verified.
</p>
<div className="mt-4 min-w-0">
<TrustedServicesSection load={trust} onRefresh={onRefreshTrust} />
<AppCatalogSection />
</div>
</section>
</div>
+162
View File
@@ -0,0 +1,162 @@
/**
* 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 { ApiError, del, get, post, put } from './client'
import type { CatalogApp, CatalogAppInput, CatalogAppPatch } from '@lib/types'
const BASE = '/api/catalog/apps'
export interface UploadOptions {
/** Byte-level progress, 0–100, reported by the browser's XHR upload stream. */
onProgress?: (percent: number) => void
signal?: AbortSignal
}
function readErrorCode(text: string): string | undefined {
try {
const body = JSON.parse(text) as { error?: { code?: unknown } }
return typeof body?.error?.code === 'string' ? body.error.code : undefined
} catch {
return undefined
}
}
function jsonField(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
/**
* Multipart over XMLHttpRequest instead of fetch: only XHR exposes the upload
* progress stream, and a 512 MiB artifact must never look frozen. Non-file
* metadata rides as flat snake_case form fields so the same handler can serve
* both the URL and the upload add paths. The uniform error envelope is decoded
* exactly the way the fetch client does it.
*/
function multipart<T>(
method: 'POST' | 'PUT',
path: string,
parts: object,
fileField: string,
file: File | null,
options: UploadOptions = {}
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, path)
xhr.withCredentials = true
xhr.responseType = 'text'
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && options.onProgress) {
options.onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
}
}
xhr.onload = () => {
const text = typeof xhr.response === 'string' ? xhr.response : ''
if (xhr.status >= 200 && xhr.status < 300) {
options.onProgress?.(100)
try {
resolve((text ? JSON.parse(text) : undefined) as T)
} catch {
resolve(undefined as T)
}
return
}
reject(new ApiError(`Request failed (${xhr.status})`, xhr.status, readErrorCode(text)))
}
xhr.onerror = () => reject(new ApiError('Could not reach this node’s API.', 0))
xhr.onabort = () => reject(new ApiError('Upload cancelled.', 0))
options.signal?.addEventListener('abort', () => xhr.abort(), { once: true })
const form = new FormData()
for (const [key, value] of Object.entries(parts)) {
if (value === undefined || value === null || value === '') continue
form.append(key, jsonField(value))
}
if (file) {
form.append(fileField, file, file.name)
// The record-specific field name and the generic `file` alias both
// travel, so the handler can read either without a client-side guess.
if (fileField !== 'file') form.append('file', file, file.name)
}
xhr.send(form)
})
}
/** GET /api/catalog/apps — an empty catalog serializes as `null`. */
export function fetchCatalogApps(): Promise<CatalogApp[] | null> {
return get<CatalogApp[] | null>(BASE)
}
/** POST /api/catalog/apps — add from an artifact URL (hosted fetch or link-only). */
export function addCatalogApp(input: CatalogAppInput): Promise<CatalogApp> {
return post<CatalogApp>(BASE, input)
}
/**
* POST /api/catalog/apps as multipart — add with the APK supplied directly.
* The record metadata travels as snake_case fields beside the artifact bytes.
*/
export function addCatalogAppWithFile(
input: CatalogAppInput,
file: File,
options?: UploadOptions
): Promise<CatalogApp> {
return multipart<CatalogApp>('POST', BASE, input, 'artifact', file, options)
}
/** PUT /api/catalog/apps/{id} — edit metadata; `package` is immutable. */
export function updateCatalogApp(id: string, patch: CatalogAppPatch): Promise<CatalogApp> {
return put<CatalogApp>(`${BASE}/${encodeURIComponent(id)}`, patch)
}
/** DELETE /api/catalog/apps/{id} — removes the entry and any hosted bytes. */
export function deleteCatalogApp(id: string): Promise<void> {
return del<void>(`${BASE}/${encodeURIComponent(id)}`)
}
/** PUT /api/catalog/apps/{id}/artifact — replace the APK from a local file. */
export function uploadCatalogArtifact(
id: string,
file: File,
options?: UploadOptions
): Promise<CatalogApp> {
return multipart<CatalogApp>(
'PUT',
`${BASE}/${encodeURIComponent(id)}/artifact`,
{},
'artifact',
file,
options
)
}
/** PUT /api/catalog/apps/{id}/icon — replace the icon from a local image. */
export function uploadCatalogIcon(
id: string,
file: File,
options?: UploadOptions
): Promise<CatalogApp> {
return multipart<CatalogApp>(
'PUT',
`${BASE}/${encodeURIComponent(id)}/icon`,
{},
'icon',
file,
options
)
}
+15 -2
View File
@@ -17,11 +17,24 @@
export class ApiError extends Error {
readonly status: number
/** The uniform `{"error":{"code":…}}` code, when the body carried one. */
readonly code?: string
constructor(message: string, status: number) {
constructor(message: string, status: number, code?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
/** Best-effort read of the uniform error envelope; never masks the HTTP status. */
async function readErrorCode(res: Response): Promise<string | undefined> {
try {
const body = (await res.json()) as { error?: { code?: unknown } }
return typeof body?.error?.code === 'string' ? body.error.code : undefined
} catch {
return undefined
}
}
@@ -67,7 +80,7 @@ async function request<T>(path: string, init?: RequestInit, retried = false): Pr
}
}
if (!res.ok) {
throw new ApiError(`Request failed (${res.status})`, res.status)
throw new ApiError(`Request failed (${res.status})`, res.status, await readErrorCode(res))
}
return readJson<T>(res)
}
-49
View File
@@ -1,49 +0,0 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { get, post } from './client'
import type { PeerAttestation, PeerTrustState } from '../types'
/** Fetches the pending + approved peer trust state (security theme M2 §3). */
export function fetchTrustState(): Promise<PeerTrustState> {
return get<PeerTrustState>('/api/peers')
}
/** Fetches the auth service's public signing key attestation. */
export function fetchAttestation(): Promise<PeerAttestation> {
return get<PeerAttestation>('/api/peers/attestation')
}
/** Approves a peer's fingerprint in the auth service's trust store. */
export function approvePeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/api/peers/approve', { service, fingerprint })
}
/** Revokes a peer's fingerprint (returns it to the no-op path immediately). */
export function revokePeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/api/peers/revoke', { service, fingerprint })
}
/** Rejects a pending first-contact request without authorizing it. */
export function rejectPeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/api/peers/reject', { service, fingerprint })
}
/** Permanently hides a revoked peer from the dashboard (row stays in the trust store). */
export function hidePeer(service: string, fingerprint: string): Promise<void> {
return post<void>('/api/peers/hide', { service, fingerprint })
}
+53 -8
View File
@@ -16,7 +16,7 @@
*/
import type { Container, RegistryItem, Service } from '../types'
import { baseImageOf, compareVersions, normalizeVersion, versionFromImage } from './version'
import { compareVersions, normalizeVersion, repoNameOf, versionFromImage } from './version'
/** Statuses that should win the merge when a name maps to several containers. */
const STATUS_PRIORITY: Record<string, number> = {
@@ -46,6 +46,25 @@ function isHiddenRegistry(r: RegistryItem): boolean {
return Boolean(r.hidden)
}
/** Statuses that count as up (mirrors the card's running predicate). */
function isRunning(status?: string): boolean {
const s = (status ?? '').toLowerCase()
return s === 'running' || s === 'starting' || s === 'restarting'
}
/**
* Containers Maestro did not install are transient dev processes, not services:
* a stopped one has no actions left (no start, no uninstall, no config), so it
* does not deserve a card. The grid shows only services Maestro knows are
* installed (an install record in the data dir) or that are still up, plus the
* remote catalog. Dropping these *before* the registry merge matters: the
* catalog entry then stays unmatched and comes back as an installable remote
* card instead of being absorbed by a dead container.
*/
function isStoppedExternal(c: Container): boolean {
return Boolean(c.external) && !isRunning(c.status)
}
function toService(c: Container): Service {
return {
id: c.id || c.name,
@@ -53,27 +72,45 @@ function toService(c: Container): Service {
status: c.status,
origin: 'local',
title: c.service_name || c.name,
serviceName: c.service_name,
external: c.external,
image: c.image,
version: c.version || versionFromImage(c.image),
hasUpdate: false,
capabilities: c.capabilities ?? [],
config: c.config,
web: c.web,
}
}
function findMatch(
local: Service[],
map: Map<string, Service>,
name: string,
registryImage: string
registryImage: string,
registryService?: string
): Service | undefined {
const byName = map.get(name)
if (byName) return byName
const base = baseImageOf(registryImage)
if (!base) return undefined
// The declared identity (org.dcntrlized.service) is the contract: pair the
// catalog entry with the container that declares the same service.
if (registryService) {
const target = registryService.toLowerCase()
for (const item of local) {
if ((item.serviceName ?? '').toLowerCase() === target) return item
}
}
for (const item of map.values()) {
if (baseImageOf(item.image) === base) return item
const repo = repoNameOf(registryImage)
if (!repo) return undefined
// Only container-backed services are candidates. Registry-only placeholders
// are deliberately excluded: matching against them let one catalog entry
// absorb another (two images on the same registry compared equal), so one
// service's metadata ended up rendered under another service's image.
for (const item of local) {
if (repoNameOf(item.image) === repo) return item
}
return undefined
}
@@ -87,7 +124,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
const map = new Map<string, Service>()
for (const c of containers) {
if (isMaestro(c.name) || isHiddenContainer(c)) continue
if (isMaestro(c.name) || isHiddenContainer(c) || isStoppedExternal(c)) continue
const key = c.name.toLowerCase()
const next = toService(c)
const existing = map.get(key)
@@ -96,17 +133,23 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
}
}
// Snapshot the container-backed services before any registry item is
// inserted, so registry items can only ever pair up with real containers.
const local = Array.from(map.values())
for (const r of registry) {
if (isMaestro(r.name) || isHiddenRegistry(r)) continue
const key = r.name.toLowerCase()
const existing = findMatch(map, key, r.image)
const existing = findMatch(local, map, key, r.image, r.service_name)
if (existing) {
existing.origin = 'both'
existing.registryImage = r.image
existing.registryUrl = r.url || r.source
existing.source = r.source || existing.source
existing.sourceHref = r.source_href || existing.sourceHref
existing.title = r.title || existing.title
existing.serviceName = r.service_name || existing.serviceName
existing.vendor = r.vendor || existing.vendor
existing.author = r.author || existing.author
existing.description = r.description || existing.description
@@ -124,9 +167,11 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
status: 'uninstalled',
origin: 'remote',
title: r.title || r.name,
serviceName: r.service_name,
image: r.image,
registryImage: r.image,
source: r.source,
sourceHref: r.source_href,
registryUrl: r.url,
description: r.description,
vendor: r.vendor,
@@ -0,0 +1,42 @@
/**
* 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/>.
*/
/**
* How a service card should render an image's `source`.
*
* `source` comes verbatim from the image's OCI annotations, so it is
* attacker-influenced for anyone who can push to the operator's registry.
* `sourceHref` is the server's allowlisted projection of it (the Go side runs
* `types.ValidWebBase` — http/https exactly, no userinfo, no control chars,
* non-empty host). The frontend therefore never builds an href from `source`:
* a value without a validated href is shown as inert text (audit finding N7).
*/
export type SourceLink =
| { kind: 'link'; href: string; text: string }
| { kind: 'text'; text: string }
| null
export function sourceLink(item: {
source?: string
sourceHref?: string
}): SourceLink {
const href = item.sourceHref?.trim() ?? ''
const text = item.source?.trim() ?? ''
if (href) return { kind: 'link', href, text: text || href }
if (text) return { kind: 'text', text }
return null
}
+38 -5
View File
@@ -23,16 +23,49 @@
* Everything here normalizes to a comparable X.Y.Z[-pre] string.
*/
/** Base image reference without registry host, tag, or digest. */
/**
* Repository path of an image reference: registry host, tag and digest removed
* (e.g. `192.168.122.183:3000/dcntrlized/keepnote:1` → `dcntrlized/keepnote`).
*
* The tag separator is the LAST colon — a colon before the last slash is part
* of a registry host with a port, not a tag. Splitting naively on the first
* colon would reduce every image on such a registry to the bare host and make
* unrelated services compare equal.
*/
export function baseImageOf(imageRef?: string): string {
if (!imageRef) return ''
return imageRef.split('@')[0].split(':')[0].toLowerCase()
const noDigest = imageRef.split('@')[0]
const slash = noDigest.lastIndexOf('/')
const colon = noDigest.lastIndexOf(':')
const repo = colon > slash ? noDigest.slice(0, colon) : noDigest
const parts = repo.split('/')
const host = parts[0]
if (parts.length > 1 && (host.includes('.') || host.includes(':') || host === 'localhost')) {
parts.shift()
}
return parts.join('/').toLowerCase()
}
/** The tag of an image reference, "" when it is `latest` or absent. */
/**
* Repository name (final path segment) of an image reference
* (e.g. `…/dcntrlized/keepnote:1` → `keepnote`). Used to pair a locally built
* image (`calendar:debug`) with its catalog entry regardless of registry path.
*/
export function repoNameOf(imageRef?: string): string {
const base = baseImageOf(imageRef)
if (!base) return ''
const slash = base.lastIndexOf('/')
return slash >= 0 ? base.slice(slash + 1) : base
}
/** The tag of an image reference, "" when untagged, digest-pinned, or `latest`. */
export function versionFromImage(imageRef?: string): string {
if (!imageRef) return ''
const tag = imageRef.split('@')[0].split(':').pop() ?? ''
if (!imageRef || imageRef.includes('@')) return ''
const slash = imageRef.lastIndexOf('/')
const colon = imageRef.lastIndexOf(':')
if (colon <= slash) return ''
const tag = imageRef.slice(colon + 1)
return tag === 'latest' ? '' : tag
}
@@ -0,0 +1,190 @@
/**
* 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, useRef, useState } from 'preact/hooks'
import {
addCatalogApp,
addCatalogAppWithFile,
deleteCatalogApp,
fetchCatalogApps,
updateCatalogApp,
uploadCatalogArtifact,
uploadCatalogIcon,
} from '@lib/api/catalog'
import type { CatalogApp, CatalogAppInput, CatalogAppPatch } from '@lib/types'
export type CatalogLoadState =
| { kind: 'loading' }
| { kind: 'ok'; apps: CatalogApp[] }
| { kind: 'error'; message: string }
/** In-flight transfer for one row, so a 512 MiB artifact never looks frozen. */
export interface CatalogUpload {
percent: number
label: string
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
/** The catalog serializes empty as `null`; artifacts may omit optional fields. */
function normalize(rows: CatalogApp[] | null | undefined): CatalogApp[] {
if (!Array.isArray(rows)) return []
return rows
}
/**
* Owns the operator's app catalog: the single-flight listing plus add, edit,
* delete and the two byte transfers. Mutations refetch from the server so the
* console never drifts from what clients can actually install; delete is
* applied optimistically first because the row disappears in the same turn.
* Uploads report byte progress per app id and can be cancelled, and the row
* stays interactive while the transfer runs.
*/
export function useCatalogApps() {
const [state, setState] = useState<CatalogLoadState>({ kind: 'loading' })
const [uploads, setUploads] = useState<Record<string, CatalogUpload>>({})
const inFlight = useRef<Promise<void> | null>(null)
const aborts = useRef<Record<string, AbortController>>({})
const load = useCallback((silent: boolean): Promise<void> => {
// Single-flight: concurrent open/refresh/mutation calls share one request.
if (inFlight.current) return inFlight.current
const run = (async () => {
try {
const apps = normalize(await fetchCatalogApps())
setState({ kind: 'ok', apps })
} catch (error) {
setState((prev) =>
silent && prev.kind === 'ok' ? prev : { kind: 'error', message: errorMessage(error) }
)
} finally {
inFlight.current = null
}
})()
inFlight.current = run
return run
}, [])
useEffect(() => {
void load(false)
}, [load])
/** Quiet refetch — replaces live rows without flashing a loading state. */
const refresh = useCallback(() => load(true), [load])
/** Loud refetch — returns to the skeleton/error surface. */
const reload = useCallback(() => load(false), [load])
const setUpload = useCallback((id: string, upload: CatalogUpload) => {
setUploads((prev) => ({ ...prev, [id]: upload }))
}, [])
const clearUpload = useCallback((id: string) => {
setUploads((prev) => {
if (!(id in prev)) return prev
const next = { ...prev }
delete next[id]
return next
})
}, [])
const mutate = useCallback(
async (action: () => Promise<unknown>) => {
await action()
await load(true)
},
[load]
)
const add = useCallback(
(input: CatalogAppInput, file?: File | null, onProgress?: (percent: number) => void) =>
mutate(() =>
file
? addCatalogAppWithFile(input, file, { onProgress })
: addCatalogApp(input)
),
[mutate]
)
const update = useCallback(
(id: string, patch: CatalogAppPatch) => mutate(() => updateCatalogApp(id, patch)),
[mutate]
)
const remove = useCallback(
async (id: string) => {
setState((prev) =>
prev.kind === 'ok' ? { kind: 'ok', apps: prev.apps.filter((app) => app.id !== id) } : prev
)
try {
await deleteCatalogApp(id)
} catch (error) {
await load(true)
throw error
}
await load(true)
},
[load]
)
const transfer = useCallback(
async (id: string, kind: 'artifact' | 'icon', file: File) => {
const controller = new AbortController()
aborts.current[id] = controller
const label = kind === 'icon' ? 'Uploading icon' : 'Uploading artifact'
setUpload(id, { percent: 0, label })
try {
const send = kind === 'icon' ? uploadCatalogIcon : uploadCatalogArtifact
await send(id, file, {
signal: controller.signal,
onProgress: (percent) => setUpload(id, { percent, label }),
})
await load(true)
} finally {
delete aborts.current[id]
clearUpload(id)
}
},
[clearUpload, load, setUpload]
)
const uploadArtifact = useCallback(
(id: string, file: File) => transfer(id, 'artifact', file),
[transfer]
)
const uploadIcon = useCallback((id: string, file: File) => transfer(id, 'icon', file), [transfer])
const cancelUpload = useCallback((id: string) => {
aborts.current[id]?.abort()
}, [])
return {
state,
uploads,
refresh,
reload,
add,
update,
remove,
uploadArtifact,
uploadIcon,
cancelUpload,
}
}
export type CatalogController = ReturnType<typeof useCatalogApps>
@@ -1,65 +0,0 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { useCallback, useEffect, useState } from 'preact/hooks'
import { fetchTrustState } from '@lib/api/peers'
import type { PeerTrustState } from '@lib/types'
export type TrustLoadState =
| { kind: 'loading' }
| { kind: 'ok'; state: PeerTrustState }
| { kind: 'error'; message: string }
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
/**
* Owns the peer trust state for the console. Fetches once on mount and then
* polls: every 10s while the settings dialog is open (the operator is watching
* the trust store), every 2 minutes otherwise (keeps the header pending badge
* fresh). Refreshes replace the last known state silently so an in-flight
* mutation never flashes a loading spinner over live rows; mutations also
* trigger an immediate refresh through the same hook.
*/
export function useTrustStore(open: boolean, enabled = true) {
const [load, setLoad] = useState<TrustLoadState>({ kind: 'loading' })
const refresh = useCallback(async () => {
try {
const state = await fetchTrustState()
setLoad({ kind: 'ok', state })
} catch (error) {
setLoad({ kind: 'error', message: errorMessage(error) })
}
}, [])
useEffect(() => {
if (!enabled) return
void refresh()
}, [enabled, refresh])
useEffect(() => {
if (!enabled) return
const interval = window.setInterval(() => {
void refresh()
}, open ? 10_000 : 120_000)
return () => window.clearInterval(interval)
}, [open, enabled, refresh])
return { load, refresh }
}
+116 -33
View File
@@ -42,6 +42,11 @@ export interface Container {
role?: string
/** Infrastructure services managed by Maestro but hidden from the grid. */
hidden?: boolean
/**
* Started outside Maestro (e.g. a repo's own docker-compose.debug.yaml).
* The console may stop it but must not start, uninstall or reconfigure it.
*/
external?: boolean
/** Capabilities declared by the service manifest (e.g. ["database"]). */
capabilities?: string[]
/** Whether the service consumes the shared auth service (manifest has_auth). */
@@ -50,6 +55,25 @@ export interface Container {
public_key?: string
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
config?: ServiceConfigDeclaration[]
/**
* The service's Web App capability (contract rev 8.2 §16.2). Present only
* while the service advertises a usable web app; `href` is resolved
* SERVER-SIDE and is the only thing the console may open. The frontend never
* builds a URL from service strings.
*/
web?: ServiceWeb
}
/**
* The `web` block carried on `GET /api/containers` (contract §16.3/§16.4).
* `href` is always server-resolved; an entry with no valid href is omitted
* entirely rather than sent with an empty href.
*/
export interface ServiceWeb {
enabled: boolean
port?: number
url?: string
href: string
}
/** A config field spec declared by a service manifest (Service Config & Secrets theme). */
@@ -82,6 +106,8 @@ export interface ServiceConfigEntry {
/** A remote catalog entry, as served by GET /api/registry. */
export interface RegistryItem {
name: string
/** The service identity declared by the image's org.dcntrlized.service label. */
service_name?: string
version: string
image: string
digest?: string
@@ -90,6 +116,12 @@ export interface RegistryItem {
author?: string
description?: string
source?: string
/**
* The source URL to render as a hyperlink — server-validated against the
* http/https allowlist (`lib/types.ValidWebBase`), empty when `source` is not
* a safe link. Never derive this from `source` in the frontend (audit N7).
*/
source_href?: string
revision?: string
licenses?: string
url?: string
@@ -97,6 +129,8 @@ export interface RegistryItem {
created_at?: string
/** Infrastructure images (e.g. the shared database) excluded from the grid. */
hidden?: boolean
/** The Web App capability announced by the image manifest, when known. */
web?: ServiceWeb
}
/** Merged view model: one card per service, local state and remote catalog fused. */
@@ -106,6 +140,10 @@ export interface Service {
status: string
origin: ContainerOrigin
title?: string
/** The service identity from the dcntrlized service label (registry matching). */
serviceName?: string
/** True when the container was started outside Maestro (see Container.external). */
external?: boolean
vendor?: string
author?: string
description?: string
@@ -113,6 +151,8 @@ export interface Service {
/** The image reference published in the registry (for install/update). */
registryImage?: string
source?: string
/** Server-validated href for `source`; empty means render `source` as text. */
sourceHref?: string
registryUrl?: string
version?: string
latestVersion?: string
@@ -121,6 +161,8 @@ export interface Service {
capabilities: string[]
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
config?: ServiceConfigDeclaration[]
/** The service's Web App capability, when advertised (see Container.web). */
web?: ServiceWeb
}
/** Live status of the shared database infra service. */
@@ -134,38 +176,6 @@ export interface FilterOption {
hint: string
}
/** A first-contact authorization request waiting for the instance owner (security theme M2 §3). */
export interface PeerPending {
service: string
fingerprint: string
first_seen: string
last_seen: string
}
/** An approved peer row of the auth service's trust store. */
export interface PeerAuthorized {
service: string
fingerprint: string
approved_by: string
approved_at: string
revoked_at?: string | null
hidden_at?: string | null
}
/** Combined pending + approved trust state served by GET /api/peers. */
export interface PeerTrustState {
pending: PeerPending[]
authorized: PeerAuthorized[]
}
/** The auth service's public signing key statement (GET /api/peers/attestation). */
export interface PeerAttestation {
public_key: string
fingerprint: string
kid: string
issued_at: string
}
/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */
export interface AuthStatus {
setup_required: boolean
@@ -200,7 +210,11 @@ export interface PublicService {
image: string
}
/** A remote instance this instance trusts (federation store row). */
/**
* A remote instance this instance trusts, as surfaced on the public profile.
* Kept for wire compatibility: rev 9 retired federation, so the array is
* always empty.
*/
export interface TrustedInstance {
name: string
url: string
@@ -215,3 +229,72 @@ export interface InstancePublicProfile {
services?: PublicService[] | null
trusted_instances?: TrustedInstance[] | null
}
/**
* How this instance serves a catalog app's artifact (app catalog A4):
* `hosted` = Maestro keeps a copy and serves it; `link` = only the checksum is
* stored and downloads redirect to the operator-supplied URL.
*/
export type CatalogArtifactKind = 'hosted' | 'link'
/** The artifact half of a catalog app record (GET /api/catalog/apps). */
export interface CatalogArtifact {
filename: string
size_bytes: number
/** Lowercase hex sha256 of the exact bytes; clients verify what they install. */
sha256: string
content_type?: string
kind: CatalogArtifactKind
/** Absolute, session-free download URL served by this instance. */
download_url: string
/** sha256 of the APK signing certificate, when the operator supplied one. */
signer_sha256?: string
}
/**
* One operator-authored app in this instance's catalog (app catalog A1/A2).
* The catalog is independent of the services the instance runs; `package` is
* immutable after creation so installs are never orphaned.
*/
export interface CatalogApp {
/** `a_` + first 12 hex of sha256(package); stable across edits. */
id: string
package: string
name: string
summary?: string
description?: string
category?: string
version_name: string
version_code?: number
min_android?: number
icon_url?: string
artifact: CatalogArtifact
source_url?: string
added_at: string
updated_at?: string
/** Owner identity; present on the owner-gated list, never on the public profile. */
added_by?: string
}
/** The writable shape of a catalog app. Only package/name/version_name are required. */
export interface CatalogAppInput {
package: string
name: string
version_name: string
/** Fetched and copied at add time unless `link_only` is set. */
artifact_url?: string
/** Skip the fetch and store only the checksum; requires `sha256`. */
link_only?: boolean
sha256?: string
summary?: string
description?: string
category?: string
version_code?: number
min_android?: number
icon_url?: string
signer_sha256?: string
source_url?: string
}
/** PUT /api/catalog/apps/{id} — package is immutable, so it never appears here. */
export type CatalogAppPatch = Omit<CatalogAppInput, 'package' | 'artifact_url' | 'link_only'>
+81
View File
@@ -0,0 +1,81 @@
/**
* 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 { expect, test } from 'bun:test'
import { sourceLink } from '../src/lib/domain/source-link'
// Audit finding N7: `source` arrives verbatim from the image's OCI
// annotations, so the console must never turn it into an href on trust alone.
// The href can only come from the server's allowlisted `source_href`.
test('a validated http(s) source renders as a link with the server href', () => {
const link = sourceLink({
source: 'https://github.com/Dcntrlized/Maestro',
sourceHref: 'https://github.com/Dcntrlized/Maestro',
})
expect(link).toEqual({
kind: 'link',
href: 'https://github.com/Dcntrlized/Maestro',
text: 'https://github.com/Dcntrlized/Maestro',
})
})
test('an invalid source never becomes an href — it renders as plain text', () => {
const hostile = [
'javascript:alert(1)',
'JAVASCRIPT:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'https://user:[email protected]/repo',
'github.com/Dcntrlized/Maestro',
'//example.com/repo',
'/repo',
'https://example.com/re po',
'https://example.com/repo\njavascript:alert(1)',
'ftp://example.com/repo',
]
for (const source of hostile) {
const link = sourceLink({ source })
expect(link).toEqual({ kind: 'text', text: source })
expect(link && 'href' in link).toBe(false)
}
})
test('an empty href is ignored even when the server sends one as whitespace', () => {
expect(sourceLink({ source: 'javascript:alert(1)', sourceHref: ' ' })).toEqual({
kind: 'text',
text: 'javascript:alert(1)',
})
expect(sourceLink({ source: 'javascript:alert(1)', sourceHref: '' })).toEqual({
kind: 'text',
text: 'javascript:alert(1)',
})
})
test('no source renders nothing', () => {
expect(sourceLink({})).toBeNull()
expect(sourceLink({ source: ' ' })).toBeNull()
})
test('a link without a display source still gets a title', () => {
expect(sourceLink({ sourceHref: 'https://example.com/repo' })).toEqual({
kind: 'link',
href: 'https://example.com/repo',
text: 'https://example.com/repo',
})
})
+2 -2
View File
@@ -7,7 +7,9 @@ require (
github.com/docker/cli v28.5.1+incompatible
github.com/docker/compose/v2 v2.40.3
github.com/docker/docker v28.5.2+incompatible
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.51.0
)
require (
@@ -60,7 +62,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -136,7 +137,6 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
+24 -45
View File
@@ -17,15 +17,14 @@ package actions
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
)
func RunAction(w http.ResponseWriter, r *http.Request) {
@@ -35,43 +34,31 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
logger.Debug("Received request to run action %s on container id %s", actionName, containerId)
// Get IP address of the container using containerId
dockerClient, err := docker.NewDockerClient()
// Resolve "where is this service" through the shared helper — the WebSocket
// data plane (/api/{service}/ws) resolves through the same code, so the two
// edges can never disagree about a service's address. The status codes and
// messages below are the pre-refactor ones: only the lookup moved.
baseURL, err := serviceproxy.Target(r.Context(), containerId)
if err != nil {
http.Error(w, "Failed to create Docker client", http.StatusInternalServerError)
return
}
inspectData, err := docker.ResolveContainer(context.Background(), dockerClient, containerId)
if err != nil {
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
return
}
network, _, err := docker.ExtractNetworkInfo(context.Background(), dockerClient, inspectData)
if err != nil {
http.Error(w, "Failed to extract network info", http.StatusInternalServerError)
return
}
// The service port can be declared via the org.dcntrlized.port label. Fall back to the
// first mapped guest port when it is not set.
port := inspectData.Config.Labels["org.dcntrlized.port"]
if port == "" && len(network.Ports) > 0 {
port = fmt.Sprintf("%d", network.Ports[0].Guest)
}
if port == "" {
http.Error(w, "Container has no configured service port", http.StatusInternalServerError)
// Same status (500) and same message text as before the refactor; the real
// cause now goes to the debug log instead of being folded into the message.
logger.Debug("Failed to resolve container %s: %v", containerId, err)
switch {
case errors.Is(err, serviceproxy.ErrNoPort):
http.Error(w, "Container has no configured service port", http.StatusInternalServerError)
case errors.Is(err, serviceproxy.ErrDockerClient):
http.Error(w, "Failed to create Docker client", http.StatusInternalServerError)
case errors.Is(err, serviceproxy.ErrNoNetwork):
http.Error(w, "Failed to extract network info", http.StatusInternalServerError)
default:
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
}
return
}
// Construct the URL for the action endpoint, preserving the query string.
// The service peer edge is mutual TLS (M2 §2): Maestro presents its own
// identity cert and the service's listener requires a CA-issued client
// cert, so only Maestro can drive actions (T4). In dev mode
// (DCNTRLIZED_DEV=true) the satellites keep a plaintext listener for the
// RN clients, so the action edge stays plaintext there too.
actionURL := fmt.Sprintf("%s://%s:%s/action/%s", authmgmt.Scheme(), network.IPAddress.Address, port, actionName)
// Rev 9: the service edge is plaintext HTTP on the single :8080 listener.
actionURL := fmt.Sprintf("%s/action/%s", baseURL, actionName)
if r.URL.RawQuery != "" {
actionURL += "?" + r.URL.RawQuery
}
@@ -99,16 +86,8 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
}
}
// Maestro's own identity (mTLS) as the peer; plaintext in dev mode.
transport, err := authmgmt.PeerTransport()
if err != nil {
logger.Debug("Failed to build peer transport for action %s on container %s: %v", actionName, containerId, err)
http.Error(w, "Failed to build peer transport", http.StatusInternalServerError)
return
}
// Forward the request to the container's action endpoint
resp, err := (&http.Client{Transport: transport}).Do(req)
// Forward the request to the container's action endpoint (plaintext).
resp, err := (&http.Client{}).Do(req)
if err != nil {
logger.Debug("Failed to forward action request %s to container %s: %v", actionName, containerId, err)
http.Error(w, "Failed to forward request to container", http.StatusInternalServerError)
+749
View File
@@ -0,0 +1,749 @@
// 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 catalog serves Maestro's operator-authored app catalog
// (app-catalog theme): the owner-gated add/edit/delete/replace API and the
// public, session-free artifact/icon download endpoints. The catalog is
// independent of services and the registry (A1/A2).
package catalog
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// ownerFallback stamps added_by when the owner gate did not furnish a username
// (standalone wiring/tests).
const ownerFallback = "owner"
// Handler owns catalog store access and the artifact fetch for the
// /api/catalog endpoints.
type Handler struct {
store *catalog.Store
dataRoot string
client *http.Client
}
// NewHandler builds the catalog handler over the store at dataRoot. The fetch
// client follows redirects (Go default) with a 60s timeout; the 512 MiB cap is
// enforced while the copy streams to disk.
func NewHandler(dataRoot string) *Handler {
return &Handler{
store: catalog.NewStore(dataRoot),
dataRoot: dataRoot,
client: &http.Client{Timeout: 60 * time.Second},
}
}
// Apps dispatches GET (list) and POST (add) on /api/catalog/apps.
func (h *Handler) Apps(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.list(w, r)
case http.MethodPost:
h.add(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "GET or POST is required")
}
}
// App dispatches PUT (edit) and DELETE on /api/catalog/apps/{id}.
func (h *Handler) App(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
h.edit(w, r)
case http.MethodDelete:
h.remove(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT or DELETE is required")
}
}
// list serves GET /api/catalog/apps — every record with absolute URLs and the
// private added_by field. An empty catalog is [] (never null).
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
apps, err := h.store.List()
if err != nil {
logger.Error("catalog: list: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
base := catalog.RequestBaseURL(r)
out := make([]catalog.App, 0, len(apps))
for _, app := range apps {
out = append(out, app.WithBase(base))
}
writeJSON(w, http.StatusOK, out)
}
// addRequest is the POST /api/catalog/apps body.
type addRequest struct {
Package string `json:"package"`
Name string `json:"name"`
Summary string `json:"summary"`
Description string `json:"description"`
Category string `json:"category"`
VersionName string `json:"version_name"`
VersionCode int `json:"version_code"`
MinAndroid int `json:"min_android"`
IconURL string `json:"icon_url"`
SourceURL string `json:"source_url"`
ArtifactURL string `json:"artifact_url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SHA256 string `json:"sha256"`
SignerSHA256 string `json:"signer_sha256"`
SizeBytes int64 `json:"size_bytes"`
LinkOnly bool `json:"link_only"`
}
// add serves POST /api/catalog/apps — fetch-and-host an artifact from
// artifact_url, host an uploaded file (multipart `file` part), or record a
// link, then answer 201. A checksum mismatch stores nothing.
func (h *Handler) add(w http.ResponseWriter, r *http.Request) {
req, upload, uploadHeader, err := decodeAddRequest(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", err.Error())
return
}
if upload != nil {
defer upload.Close()
}
pkg := strings.TrimSpace(req.Package)
name := strings.TrimSpace(req.Name)
version := strings.TrimSpace(req.VersionName)
if pkg == "" || name == "" || version == "" {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package, name and version_name are required")
return
}
if strings.ContainsAny(pkg, " \t\r\n") {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package must not contain whitespace")
return
}
// The artifact source is either the uploaded multipart file or artifact_url.
artifactURL := ""
if upload == nil {
normalized, err := normalizeArtifactURL(req.ArtifactURL)
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact_url must be an absolute http(s) URL")
return
}
artifactURL = normalized
}
existing, err := h.store.GetByPackage(pkg)
if err != nil {
logger.Error("catalog: package lookup: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if existing != nil {
writeError(w, http.StatusConflict, "ERR_APP_EXISTS", "an app with that package already exists")
return
}
id := catalog.AppID(pkg)
filename := ""
if uploadHeader != nil {
filename = catalog.SafeFilename(uploadHeader.Filename)
}
if filename == "" {
filename = catalog.SafeFilename(req.Filename)
}
if filename == "" {
filename = catalog.FilenameFromURL(artifactURL)
}
if filename == "" {
filename = pkg + ".apk"
}
artifact, artifactRel, err := h.buildArtifact(id, filename, artifactURL, &req, upload, uploadHeader)
if err != nil {
writeArtifactError(w, err)
return
}
by := r.Header.Get("X-Maestro-User")
if by == "" {
by = ownerFallback
}
now := catalog.NowRFC3339()
app := catalog.App{
ID: id,
Package: pkg,
Name: name,
Summary: strings.TrimSpace(req.Summary),
Description: strings.TrimSpace(req.Description),
Category: strings.TrimSpace(req.Category),
VersionName: version,
VersionCode: req.VersionCode,
MinAndroid: req.MinAndroid,
IconURL: strings.TrimSpace(req.IconURL),
Artifact: artifact,
SourceURL: strings.TrimSpace(req.SourceURL),
AddedAt: now,
UpdatedAt: now,
AddedBy: by,
ArtifactPath: artifactRel,
}
if err := h.store.Upsert(app); err != nil {
logger.Error("catalog: persist app: %v", err)
_ = h.store.DeleteFiles(app)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
writeJSON(w, http.StatusCreated, app.WithBase(catalog.RequestBaseURL(r)))
}
// artifactError carries the HTTP mapping for a buildArtifact failure.
type artifactError struct {
status int
code string
msg string
}
func (e *artifactError) Error() string { return e.msg }
// buildArtifact hosts an uploaded file, fetches+hosts the artifact URL, or
// records a link. It returns the artifact sub-record plus the data-root-relative
// path of the hosted copy ("" for link entries). Nothing is persisted on a
// checksum mismatch or fetch failure.
func (h *Handler) buildArtifact(id, filename, artifactURL string, req *addRequest, upload multipart.File, uploadHeader *multipart.FileHeader) (catalog.Artifact, string, error) {
supplied := strings.ToLower(strings.TrimSpace(req.SHA256))
if upload != nil {
rel, size, sum, err := h.store.SaveArtifact(id, filename, upload, catalog.MaxArtifactBytes)
if err != nil {
if errors.Is(err, catalog.ErrTooLarge) {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit"}
}
logger.Warn("catalog: store uploaded artifact: %v", err)
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "could not store the uploaded artifact"}
}
if supplied != "" && !strings.EqualFold(supplied, sum) {
// Nothing is stored on mismatch: drop the freshly written copy.
_ = os.RemoveAll(filepath.Join(catalog.Dir(h.dataRoot), "artifacts", id))
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_CHECKSUM_MISMATCH", "sha256 does not match the uploaded artifact"}
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" && uploadHeader != nil {
contentType = strings.TrimSpace(uploadHeader.Header.Get("Content-Type"))
}
if contentType == "" || contentType == "application/octet-stream" {
contentType = detectFileContentType(h.store, rel)
}
if contentType == "" {
contentType = catalog.DefaultArtifactContentType
}
return catalog.Artifact{
Filename: filename,
SizeBytes: size,
SHA256: sum,
ContentType: contentType,
Kind: catalog.KindHosted,
}, rel, nil
}
if req.LinkOnly {
if supplied == "" {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "link_only requires sha256"}
}
if !isHexSHA256(supplied) {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "sha256 must be 64 hex characters"}
}
return catalog.Artifact{
Filename: filename,
SizeBytes: req.SizeBytes,
SHA256: supplied,
ContentType: firstNonEmpty(strings.TrimSpace(req.ContentType), catalog.DefaultArtifactContentType),
Kind: catalog.KindLink,
SourceURL: artifactURL,
}, "", nil
}
resp, err := h.client.Get(artifactURL)
if err != nil {
logger.Warn("catalog: fetch %s: %v", artifactURL, err)
return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not fetch artifact_url"}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logger.Warn("catalog: fetch %s: status %d", artifactURL, resp.StatusCode)
return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "artifact_url returned a non-success status"}
}
rel, size, sum, err := h.store.SaveArtifact(id, filename, resp.Body, catalog.MaxArtifactBytes)
if err != nil {
if errors.Is(err, catalog.ErrTooLarge) {
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit"}
}
logger.Warn("catalog: store artifact: %v", err)
return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not store the fetched artifact"}
}
if supplied != "" && !strings.EqualFold(supplied, sum) {
// Nothing is stored on mismatch: drop the freshly written copy.
_ = os.RemoveAll(filepath.Join(catalog.Dir(h.dataRoot), "artifacts", id))
return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_CHECKSUM_MISMATCH", "sha256 does not match the fetched artifact"}
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" {
contentType = strings.TrimSpace(resp.Header.Get("Content-Type"))
}
if contentType == "" {
contentType = catalog.DefaultArtifactContentType
}
return catalog.Artifact{
Filename: filename,
SizeBytes: size,
SHA256: sum,
ContentType: contentType,
Kind: catalog.KindHosted,
}, rel, nil
}
// decodeAddRequest reads POST /api/catalog/apps from either application/json or
// multipart/form-data (flat snake_case fields + an optional file part named
// "file"), so the owner UI can publish a new app from a local file in one step.
func decodeAddRequest(r *http.Request) (addRequest, multipart.File, *multipart.FileHeader, error) {
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
return addRequest{}, nil, nil, errors.New("invalid multipart body")
}
req := addRequest{
Package: formValue(r, "package"),
Name: formValue(r, "name"),
Summary: formValue(r, "summary"),
Description: formValue(r, "description"),
Category: formValue(r, "category"),
VersionName: formValue(r, "version_name"),
VersionCode: formInt(r, "version_code"),
MinAndroid: formInt(r, "min_android"),
IconURL: formValue(r, "icon_url"),
SourceURL: formValue(r, "source_url"),
ArtifactURL: formValue(r, "artifact_url"),
Filename: formValue(r, "filename"),
ContentType: formValue(r, "content_type"),
SHA256: formValue(r, "sha256"),
SignerSHA256: formValue(r, "signer_sha256"),
SizeBytes: formInt64(r, "size_bytes"),
LinkOnly: formBool(r, "link_only"),
}
file, header, err := r.FormFile("file")
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
return req, nil, nil, nil
}
return addRequest{}, nil, nil, errors.New("could not read the file part")
}
return req, file, header, nil
}
var req addRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return addRequest{}, nil, nil, errors.New("invalid JSON body")
}
return req, nil, nil, nil
}
func formValue(r *http.Request, key string) string {
if r.MultipartForm == nil {
return ""
}
if values := r.MultipartForm.Value[key]; len(values) > 0 {
return values[0]
}
return ""
}
func formInt(r *http.Request, key string) int {
value := strings.TrimSpace(formValue(r, key))
if value == "" {
return 0
}
n, _ := strconv.Atoi(value)
return n
}
func formInt64(r *http.Request, key string) int64 {
value := strings.TrimSpace(formValue(r, key))
if value == "" {
return 0
}
n, _ := strconv.ParseInt(value, 10, 64)
return n
}
func formBool(r *http.Request, key string) bool {
switch strings.ToLower(strings.TrimSpace(formValue(r, key))) {
case "true", "1", "on", "yes":
return true
}
return false
}
// editRequest is the PUT /api/catalog/apps/{id} body. Pointers distinguish
// "absent" from "explicitly cleared".
// editRequest is the PUT /api/catalog/apps/{id} body. The editable metadata is
// a full replacement: present fields are applied and optional fields that are
// omitted/null/empty are cleared. id and package are immutable.
type editRequest struct {
Package string `json:"package"`
Name string `json:"name"`
Summary string `json:"summary"`
Description string `json:"description"`
Category string `json:"category"`
VersionName string `json:"version_name"`
VersionCode int `json:"version_code"`
MinAndroid int `json:"min_android"`
IconURL string `json:"icon_url"`
SourceURL string `json:"source_url"`
SignerSHA256 string `json:"signer_sha256"`
}
// edit serves PUT /api/catalog/apps/{id} — metadata edit. package is immutable.
func (h *Handler) edit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
app, err := h.store.Get(id)
if err != nil {
logger.Error("catalog: get %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if app == nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
var req editRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "invalid JSON body")
return
}
if req.Package != "" && strings.TrimSpace(req.Package) != app.Package {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package is immutable")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "name must not be empty")
return
}
version := strings.TrimSpace(req.VersionName)
if version == "" {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "version_name must not be empty")
return
}
// Full replacement of the editable metadata: omitted optional fields
// decode to their zero value and therefore clear (README §Frozen request
// conventions).
updated := *app
updated.Name = name
updated.VersionName = version
updated.Summary = strings.TrimSpace(req.Summary)
updated.Description = strings.TrimSpace(req.Description)
updated.Category = strings.TrimSpace(req.Category)
updated.VersionCode = req.VersionCode
updated.MinAndroid = req.MinAndroid
updated.IconURL = strings.TrimSpace(req.IconURL)
updated.SourceURL = strings.TrimSpace(req.SourceURL)
updated.Artifact.SignerSHA256 = strings.TrimSpace(req.SignerSHA256)
updated.UpdatedAt = catalog.NowRFC3339()
if err := h.store.Upsert(updated); err != nil {
logger.Error("catalog: persist edit %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
}
// remove serves DELETE /api/catalog/apps/{id} — drops the record, the hosted
// bytes and the uploaded icon.
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
app, err := h.store.Get(id)
if err != nil {
logger.Error("catalog: get %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if app == nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
if err := h.store.Remove(id); err != nil {
logger.Error("catalog: remove %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
if err := h.store.DeleteFiles(*app); err != nil {
logger.Warn("catalog: delete files for %s: %v", id, err)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
}
// Artifact serves PUT /api/catalog/apps/{id}/artifact — replace hosted bytes
// from a multipart `file`, recomputing size + sha256.
func (h *Handler) Artifact(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT is required")
return
}
id := r.PathValue("id")
app, err := h.store.Get(id)
if err != nil {
logger.Error("catalog: get %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if app == nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "expected a multipart file upload")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "multipart field file is required")
return
}
defer file.Close()
filename := catalog.SafeFilename(header.Filename)
if filename == "" {
filename = catalog.SafeFilename(app.Artifact.Filename)
}
if filename == "" {
filename = app.Package + ".apk"
}
rel, size, sum, err := h.store.SaveArtifact(id, filename, file, catalog.MaxArtifactBytes)
if err != nil {
if errors.Is(err, catalog.ErrTooLarge) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit")
return
}
logger.Error("catalog: store uploaded artifact: %v", err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to store artifact")
return
}
updated := *app
updated.Artifact.Filename = filename
updated.Artifact.SizeBytes = size
updated.Artifact.SHA256 = sum
updated.Artifact.Kind = catalog.KindHosted
updated.Artifact.SourceURL = ""
updated.Artifact.ContentType = firstNonEmpty(
strings.TrimSpace(header.Header.Get("Content-Type")),
detectFileContentType(h.store, rel),
catalog.DefaultArtifactContentType,
)
updated.ArtifactPath = rel
updated.UpdatedAt = catalog.NowRFC3339()
if err := h.store.Upsert(updated); err != nil {
logger.Error("catalog: persist artifact %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
// Drop any previous copy that lived under a different filename.
if err := h.store.PruneArtifactDir(id, filename); err != nil {
logger.Warn("catalog: prune old artifact %s: %v", id, err)
}
writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
}
// Icon serves PUT /api/catalog/apps/{id}/icon — replace the uploaded icon
// (png/jpeg/webp, ≤512 KiB).
func (h *Handler) Icon(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT is required")
return
}
id := r.PathValue("id")
app, err := h.store.Get(id)
if err != nil {
logger.Error("catalog: get %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if app == nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
return
}
if err := r.ParseMultipartForm(2 << 20); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "expected a multipart file upload")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "multipart field file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, catalog.MaxIconBytes+1))
if err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "could not read icon upload")
return
}
if len(data) == 0 {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon file is empty")
return
}
if int64(len(data)) > catalog.MaxIconBytes {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon exceeds the 512 KiB limit")
return
}
contentType := http.DetectContentType(data)
if !allowedIconType(contentType) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon must be png, jpeg or webp")
return
}
rel, err := h.store.SaveIcon(id, data)
if err != nil {
logger.Error("catalog: store icon %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to store icon")
return
}
updated := *app
updated.IconPath = rel
updated.UpdatedAt = catalog.NowRFC3339()
if err := h.store.Upsert(updated); err != nil {
logger.Error("catalog: persist icon %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
return
}
writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
}
// --- helpers ---
func normalizeArtifactURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("artifact_url is required")
}
u, err := url.Parse(trimmed)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if u.Host == "" {
return "", errors.New("artifact_url must include a host")
}
return u.String(), nil
}
func isHexSHA256(s string) bool {
if len(s) != 64 {
return false
}
for _, r := range s {
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
return false
}
}
return true
}
func allowedIconType(contentType string) bool {
switch contentType {
case "image/png", "image/jpeg", "image/webp":
return true
}
return false
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func detectFileContentType(store *catalog.Store, rel string) string {
abs, err := store.ResolveArtifact(catalog.App{ArtifactPath: rel})
if err != nil {
return ""
}
f, err := os.Open(abs)
if err != nil {
return ""
}
defer f.Close()
buf := make([]byte, 512)
n, _ := f.Read(buf)
return http.DetectContentType(buf[:n])
}
// writeArtifactError maps a buildArtifact failure onto the error envelope.
func writeArtifactError(w http.ResponseWriter, err error) {
var ae *artifactError
if errors.As(err, &ae) {
writeError(w, ae.status, ae.code, ae.msg)
return
}
writeError(w, http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not fetch artifact_url")
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
} `json:"error"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+515
View File
@@ -0,0 +1,515 @@
// 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 catalog
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"os"
"path/filepath"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
)
func newTestHandler(t *testing.T) (*Handler, string) {
t.Helper()
root := t.TempDir()
return NewHandler(root), root
}
func seedHostedApp(t *testing.T, h *Handler, pkg, content string) catalog.App {
t.Helper()
id := catalog.AppID(pkg)
rel, size, sum, err := h.store.SaveArtifact(id, "app.apk", strings.NewReader(content), catalog.MaxArtifactBytes)
if err != nil {
t.Fatalf("seed artifact: %v", err)
}
now := catalog.NowRFC3339()
app := catalog.App{
ID: id, Package: pkg, Name: "Seed", VersionName: "1.0",
AddedAt: now, UpdatedAt: now, AddedBy: "owner",
Artifact: catalog.Artifact{
Filename: "app.apk", SizeBytes: size, SHA256: sum,
Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType,
},
ArtifactPath: rel,
}
if err := h.store.Upsert(app); err != nil {
t.Fatalf("seed app: %v", err)
}
return app
}
func doJSON(t *testing.T, h *Handler, method, target, body string, pathValues map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Maestro-User", "admin")
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
switch {
case strings.HasPrefix(target, "/api/catalog/apps") && strings.HasSuffix(target, "/artifact"):
h.Artifact(rec, req)
case strings.HasPrefix(target, "/api/catalog/apps") && strings.HasSuffix(target, "/icon"):
h.Icon(rec, req)
case target == "/api/catalog/apps":
h.Apps(rec, req)
default:
h.App(rec, req)
}
return rec
}
func multipartBody(t *testing.T, field, filename, contentType string, data []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
head := make(textproto.MIMEHeader)
head.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename))
head.Set("Content-Type", contentType)
part, err := mw.CreatePart(head)
if err != nil {
t.Fatalf("create part: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
return &buf, mw.FormDataContentType()
}
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode error envelope %q: %v", rec.Body.String(), err)
}
return body.Error.Code
}
func TestAddHostedFromURL(t *testing.T) {
payload := []byte("APK-CONTENT")
sum := sha256.Sum256(payload)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
_, _ = w.Write(payload)
}))
t.Cleanup(srv.Close)
h, root := newTestHandler(t)
body := fmt.Sprintf(`{"package":"com.example.app","name":"Example","summary":"summary","version_name":"1.2.3","version_code":123,"min_android":26,"artifact_url":%q,"sha256":%q}`,
srv.URL+"/example-1.2.3.apk", hex.EncodeToString(sum[:]))
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var app catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
t.Fatalf("decode app: %v", err)
}
wantID := catalog.AppID("com.example.app")
if app.ID != wantID {
t.Fatalf("id = %q, want %q", app.ID, wantID)
}
if app.Artifact.Kind != catalog.KindHosted || app.Artifact.SizeBytes != int64(len(payload)) {
t.Fatalf("artifact = %#v", app.Artifact)
}
if app.Artifact.SHA256 != hex.EncodeToString(sum[:]) || app.Artifact.Filename != "example-1.2.3.apk" {
t.Fatalf("artifact = %#v", app.Artifact)
}
wantURL := "http://example.com/api/catalog/artifacts/" + wantID + "/example-1.2.3.apk"
if app.Artifact.DownloadURL != wantURL {
t.Fatalf("download_url = %q, want %q", app.Artifact.DownloadURL, wantURL)
}
if app.AddedBy != "admin" {
t.Fatalf("added_by = %q", app.AddedBy)
}
stored, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", wantID, "example-1.2.3.apk"))
if err != nil || !bytes.Equal(stored, payload) {
t.Fatalf("hosted bytes = %q, err=%v", stored, err)
}
}
func TestListIncludesAddedByAndAbsoluteURLs(t *testing.T) {
h, _ := newTestHandler(t)
seedHostedApp(t, h, "com.example.list", "bytes")
rec := doJSON(t, h, http.MethodGet, "/api/catalog/apps", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var apps []catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &apps); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(apps) != 1 {
t.Fatalf("apps = %#v", apps)
}
if apps[0].AddedBy != "owner" {
t.Fatalf("owner list must include added_by, got %q", apps[0].AddedBy)
}
if !strings.HasPrefix(apps[0].Artifact.DownloadURL, "http://example.com/api/catalog/artifacts/") {
t.Fatalf("download_url not absolute: %q", apps[0].Artifact.DownloadURL)
}
// Empty catalog is [] not null.
empty, _ := newTestHandler(t)
rec = doJSON(t, empty, http.MethodGet, "/api/catalog/apps", "", nil)
if strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("empty catalog = %q, want []", rec.Body.String())
}
}
func TestAddChecksumMismatchStoresNothing(t *testing.T) {
payload := []byte("APK-CONTENT")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(payload)
}))
t.Cleanup(srv.Close)
h, root := newTestHandler(t)
body := fmt.Sprintf(`{"package":"com.example.bad","name":"Bad","version_name":"1.0","artifact_url":%q,"sha256":%q}`,
srv.URL+"/bad.apk", strings.Repeat("a", 64))
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_CHECKSUM_MISMATCH" {
t.Fatalf("status = %d code = %s body %s", rec.Code, errorCode(t, rec), rec.Body.String())
}
apps, _ := h.store.List()
if len(apps) != 0 {
t.Fatalf("checksum mismatch stored a record: %#v", apps)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", catalog.AppID("com.example.bad"))); !os.IsNotExist(err) {
t.Fatalf("checksum mismatch left artifact bytes behind: %v", err)
}
}
func TestAddLinkOnly(t *testing.T) {
h, _ := newTestHandler(t)
link := "https://downloads.example.org/keepnote/app.apk"
body := fmt.Sprintf(`{"package":"com.example.link","name":"Linked","version_name":"2.0","artifact_url":%q,"link_only":true,"sha256":%q,"size_bytes":42}`,
link, strings.Repeat("b", 64))
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var app catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
t.Fatalf("decode: %v", err)
}
if app.Artifact.Kind != catalog.KindLink || app.Artifact.SHA256 != strings.Repeat("b", 64) || app.Artifact.SizeBytes != 42 {
t.Fatalf("artifact = %#v", app.Artifact)
}
// link_only without a sha256 is invalid.
bad := fmt.Sprintf(`{"package":"com.example.link2","name":"NoSum","version_name":"1.0","artifact_url":%q,"link_only":true}`, link)
rec = doJSON(t, h, http.MethodPost, "/api/catalog/apps", bad, nil)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("missing sha256: status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
func TestAddDuplicatePackage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("data"))
}))
t.Cleanup(srv.Close)
h, _ := newTestHandler(t)
body := fmt.Sprintf(`{"package":"com.example.dup","name":"Dup","version_name":"1.0","artifact_url":%q}`, srv.URL+"/d.apk")
if rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil); rec.Code != http.StatusCreated {
t.Fatalf("first add status = %d", rec.Code)
}
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusConflict || errorCode(t, rec) != "ERR_APP_EXISTS" {
t.Fatalf("status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
func TestAddInvalidPayloadAndFetchFailure(t *testing.T) {
h, _ := newTestHandler(t)
rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", `{}`, nil)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("empty body: status = %d code = %s", rec.Code, errorCode(t, rec))
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
body := fmt.Sprintf(`{"package":"com.example.fetch","name":"Fetch","version_name":"1.0","artifact_url":%q}`, srv.URL+"/f.apk")
rec = doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
if rec.Code != http.StatusBadGateway || errorCode(t, rec) != "ERR_ARTIFACT_FETCH" {
t.Fatalf("fetch failure: status = %d code = %s", rec.Code, errorCode(t, rec))
}
apps, _ := h.store.List()
if len(apps) != 0 {
t.Fatalf("fetch failure stored a record: %#v", apps)
}
}
func TestEditMetadataFullReplacement(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.edit", "bytes")
if err := h.store.Upsert(func() catalog.App {
edited := app
edited.Summary = "old summary"
edited.Category = "old category"
edited.VersionCode = 9
edited.Artifact.SignerSHA256 = "signer"
return edited
}()); err != nil {
t.Fatalf("seed extra metadata: %v", err)
}
rec := doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
`{"name":"Edited","version_name":"1.0","summary":"new summary","category":"tools","version_code":12}`,
map[string]string{"id": app.ID})
if rec.Code != http.StatusOK {
t.Fatalf("edit status = %d body %s", rec.Code, rec.Body.String())
}
got, _ := h.store.Get(app.ID)
if got == nil || got.Name != "Edited" || got.Summary != "new summary" || got.Category != "tools" || got.VersionCode != 12 {
t.Fatalf("edit not persisted: %#v", got)
}
if got.Artifact.SHA256 != app.Artifact.SHA256 {
t.Fatalf("metadata edit must not touch the artifact: %#v", got.Artifact)
}
// Omitted optional fields are cleared (full replacement), but the hosted
// artifact and the immutable package survive.
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
`{"name":"Edited","version_name":"1.0"}`, map[string]string{"id": app.ID})
if rec.Code != http.StatusOK {
t.Fatalf("clear status = %d body %s", rec.Code, rec.Body.String())
}
got, _ = h.store.Get(app.ID)
if got.Summary != "" || got.Category != "" || got.VersionCode != 0 || got.Artifact.SignerSHA256 != "" {
t.Fatalf("omitted optional metadata not cleared: %#v", got)
}
if got.Package != app.Package || got.Artifact.SHA256 != app.Artifact.SHA256 {
t.Fatalf("package/artifact must be untouched: %#v", got)
}
// A changed package is refused even with valid required fields.
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
`{"package":"com.example.repackaged","name":"Edited","version_name":"1.0"}`, map[string]string{"id": app.ID})
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("immutable package: status = %d code = %s", rec.Code, errorCode(t, rec))
}
// name/version_name are required by the full replacement.
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID, `{"summary":"only summary"}`, map[string]string{"id": app.ID})
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("missing name: status = %d code = %s", rec.Code, errorCode(t, rec))
}
rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/a_missing", `{"name":"x","version_name":"1"}`, map[string]string{"id": "a_missing"})
if rec.Code != http.StatusNotFound || errorCode(t, rec) != "ERR_NOT_FOUND" {
t.Fatalf("unknown edit: status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
func TestDeleteRemovesRecordAndBytes(t *testing.T) {
h, root := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.del", "bytes")
if _, err := h.store.SaveIcon(app.ID, []byte("\x89PNG\r\n\x1a\nicon")); err != nil {
t.Fatalf("seed icon: %v", err)
}
stored, _ := h.store.Get(app.ID)
stored.IconPath = filepath.Join("icons", app.ID)
if err := h.store.Upsert(*stored); err != nil {
t.Fatalf("seed icon path: %v", err)
}
rec := doJSON(t, h, http.MethodDelete, "/api/catalog/apps/"+app.ID, "", map[string]string{"id": app.ID})
if rec.Code != http.StatusOK {
t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String())
}
if got, _ := h.store.Get(app.ID); got != nil {
t.Fatalf("record survived delete: %#v", got)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", app.ID)); !os.IsNotExist(err) {
t.Fatalf("hosted bytes survived delete: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "icons", app.ID)); !os.IsNotExist(err) {
t.Fatalf("icon survived delete: %v", err)
}
rec = doJSON(t, h, http.MethodDelete, "/api/catalog/apps/"+app.ID, "", map[string]string{"id": app.ID})
if rec.Code != http.StatusNotFound {
t.Fatalf("second delete status = %d", rec.Code)
}
}
func TestReplaceArtifactUpload(t *testing.T) {
h, root := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.replace", "old-bytes")
payload := []byte("new-bytes-longer")
buf, contentType := multipartBody(t, "file", "new.apk", "application/vnd.android.package-archive", payload)
req := httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/artifact", buf)
req.Header.Set("Content-Type", contentType)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.Artifact(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
var updated catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
t.Fatalf("decode: %v", err)
}
sum := sha256.Sum256(payload)
if updated.Artifact.Filename != "new.apk" || updated.Artifact.SizeBytes != int64(len(payload)) ||
updated.Artifact.SHA256 != hex.EncodeToString(sum[:]) || updated.Artifact.Kind != catalog.KindHosted {
t.Fatalf("artifact = %#v", updated.Artifact)
}
data, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", app.ID, "new.apk"))
if err != nil || !bytes.Equal(data, payload) {
t.Fatalf("uploaded bytes = %q err=%v", data, err)
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", app.ID, "app.apk")); !os.IsNotExist(err) {
t.Fatalf("old artifact not pruned: %v", err)
}
}
func TestReplaceIconUpload(t *testing.T) {
h, root := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.icon", "bytes")
icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
buf, contentType := multipartBody(t, "file", "icon.png", "image/png", icon)
req := httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/icon", buf)
req.Header.Set("Content-Type", contentType)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.Icon(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
got, _ := h.store.Get(app.ID)
if got == nil || got.IconPath == "" {
t.Fatalf("icon path not persisted: %#v", got)
}
if want := "http://example.com/api/catalog/icons/" + app.ID; got.WithBase("http://example.com").IconURL != want {
t.Fatalf("icon_url = %q, want %q", got.WithBase("http://example.com").IconURL, want)
}
if data, err := os.ReadFile(filepath.Join(root, "catalog", "icons", app.ID)); err != nil || !bytes.Equal(data, icon) {
t.Fatalf("icon bytes = %q err=%v", data, err)
}
// Non-image content is rejected.
buf, contentType = multipartBody(t, "file", "note.txt", "text/plain", []byte("hello"))
req = httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/icon", buf)
req.Header.Set("Content-Type", contentType)
req.SetPathValue("id", app.ID)
rec = httptest.NewRecorder()
h.Icon(rec, req)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
t.Fatalf("non-image icon: status = %d code = %s", rec.Code, errorCode(t, rec))
}
}
// multipartAdd builds a multipart POST /api/catalog/apps body with flat
// snake_case fields plus an optional file part named "file".
func multipartAdd(t *testing.T, fields map[string]string, filename string, data []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for key, value := range fields {
if err := mw.WriteField(key, value); err != nil {
t.Fatalf("write field %s: %v", key, err)
}
}
if filename != "" {
part, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write file part: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
return &buf, mw.FormDataContentType()
}
func TestAddFromMultipartUpload(t *testing.T) {
payload := []byte("UPLOADED-APK")
sum := sha256.Sum256(payload)
h, root := newTestHandler(t)
buf, contentType := multipartAdd(t, map[string]string{
"package": "com.example.upload", "name": "Uploaded", "version_name": "3.1",
"summary": "from file", "sha256": hex.EncodeToString(sum[:]),
}, "uploaded-3.1.apk", payload)
req := httptest.NewRequest(http.MethodPost, "/api/catalog/apps", buf)
req.Header.Set("Content-Type", contentType)
req.Header.Set("X-Maestro-User", "admin")
rec := httptest.NewRecorder()
h.Apps(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
var app catalog.App
if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
t.Fatalf("decode: %v", err)
}
if app.Package != "com.example.upload" || app.Summary != "from file" {
t.Fatalf("metadata not applied: %#v", app)
}
if app.Artifact.Kind != catalog.KindHosted || app.Artifact.Filename != "uploaded-3.1.apk" ||
app.Artifact.SizeBytes != int64(len(payload)) || app.Artifact.SHA256 != hex.EncodeToString(sum[:]) {
t.Fatalf("artifact = %#v", app.Artifact)
}
stored, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", app.ID, "uploaded-3.1.apk"))
if err != nil || !bytes.Equal(stored, payload) {
t.Fatalf("hosted bytes = %q err=%v", stored, err)
}
// A mismatching supplied sha256 rejects the upload and stores nothing.
wrong, wrongType := multipartAdd(t, map[string]string{
"package": "com.example.upload2", "name": "Bad", "version_name": "1.0",
"sha256": strings.Repeat("e", 64),
}, "bad.apk", payload)
req = httptest.NewRequest(http.MethodPost, "/api/catalog/apps", wrong)
req.Header.Set("Content-Type", wrongType)
rec = httptest.NewRecorder()
h.Apps(rec, req)
if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_CHECKSUM_MISMATCH" {
t.Fatalf("mismatch: status = %d code = %s", rec.Code, errorCode(t, rec))
}
if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", catalog.AppID("com.example.upload2"))); !os.IsNotExist(err) {
t.Fatalf("mismatched upload left bytes: %v", err)
}
}
+147
View File
@@ -0,0 +1,147 @@
// 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 catalog
import (
"io"
"net/http"
"os"
"strconv"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// Download serves GET /api/catalog/artifacts/{id}/{filename} — the public,
// session-free artifact surface. Hosted entries stream exact bytes with their
// integrity headers; link entries 302 to the operator's URL. Unknown entries,
// a mismatched filename and DCNTRLIZED_PUBLIC_PROFILE=false all answer 404.
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
if !env.PublicProfile() {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "catalog is not public")
return
}
id := r.PathValue("id")
filename := r.PathValue("filename")
app, err := h.store.Get(id)
if err != nil {
logger.Error("catalog: get %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if app == nil || app.Artifact.Filename == "" || catalog.SafeFilename(filename) != app.Artifact.Filename {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
return
}
if app.Artifact.Kind == catalog.KindLink {
if app.Artifact.SourceURL == "" {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
return
}
w.Header().Set("Access-Control-Allow-Origin", "*")
http.Redirect(w, r, app.Artifact.SourceURL, http.StatusFound)
return
}
path, err := h.store.ResolveArtifact(*app)
if err != nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
return
}
file, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
return
}
contentType := app.Artifact.ContentType
if contentType == "" {
contentType = catalog.DefaultArtifactContentType
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.Header().Set("X-Content-SHA256", app.Artifact.SHA256)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, file); err != nil {
logger.Warn("catalog: stream artifact %s: %v", id, err)
}
}
// IconBytes serves GET /api/catalog/icons/{id} — uploaded icon bytes, or 404
// when there is no uploaded icon / the catalog is private.
func (h *Handler) IconBytes(w http.ResponseWriter, r *http.Request) {
if !env.PublicProfile() {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "catalog is not public")
return
}
id := r.PathValue("id")
app, err := h.store.Get(id)
if err != nil {
logger.Error("catalog: get %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
return
}
if app == nil || app.IconPath == "" {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
return
}
path, err := h.store.ResolveIcon(*app)
if err != nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
return
}
file, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
return
}
head := make([]byte, 512)
n, _ := file.Read(head)
contentType := http.DetectContentType(head[:n])
if contentType == "" {
contentType = "application/octet-stream"
}
// Rewind: Read advanced the file offset.
if _, err := file.Seek(0, io.SeekStart); err != nil {
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read icon")
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, file); err != nil {
logger.Warn("catalog: stream icon %s: %v", id, err)
}
}
+156
View File
@@ -0,0 +1,156 @@
// 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 catalog
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
)
func serveDownload(t *testing.T, h *Handler, id, filename string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/catalog/artifacts/"+id+"/"+filename, nil)
req.SetPathValue("id", id)
req.SetPathValue("filename", filename)
rec := httptest.NewRecorder()
h.Download(rec, req)
return rec
}
func TestDownloadHostedExactBytesAndHeaders(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.download", "exact-apk-bytes")
rec := serveDownload(t, h, app.ID, app.Artifact.Filename)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "exact-apk-bytes" {
t.Fatalf("body = %q", rec.Body.String())
}
if got := rec.Header().Get("Content-Length"); got != "15" {
t.Fatalf("Content-Length = %q, want 15", got)
}
if got := rec.Header().Get("X-Content-SHA256"); got != app.Artifact.SHA256 {
t.Fatalf("X-Content-SHA256 = %q, want %q", got, app.Artifact.SHA256)
}
if got := rec.Header().Get("Content-Type"); got != catalog.DefaultArtifactContentType {
t.Fatalf("Content-Type = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q", got)
}
}
func TestDownloadUnknownAndWrongFilename(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.dl2", "bytes")
if rec := serveDownload(t, h, app.ID, "other.apk"); rec.Code != http.StatusNotFound {
t.Fatalf("wrong filename status = %d", rec.Code)
}
if rec := serveDownload(t, h, "a_missing", "app.apk"); rec.Code != http.StatusNotFound {
t.Fatalf("unknown id status = %d", rec.Code)
}
}
func TestDownloadLinkRedirects(t *testing.T) {
h, _ := newTestHandler(t)
link := "https://downloads.example.org/app.apk"
app := catalog.App{
ID: catalog.AppID("com.example.redirect"), Package: "com.example.redirect", Name: "Link", VersionName: "1.0",
Artifact: catalog.Artifact{
Filename: "app.apk", SHA256: strings.Repeat("c", 64),
Kind: catalog.KindLink, SourceURL: link,
},
}
if err := h.store.Upsert(app); err != nil {
t.Fatalf("seed link: %v", err)
}
rec := serveDownload(t, h, app.ID, "app.apk")
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != link {
t.Fatalf("Location = %q, want %q", loc, link)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("redirect ACAO = %q", got)
}
}
func TestDownloadPrivacySwitchOff(t *testing.T) {
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.private", "bytes")
if rec := serveDownload(t, h, app.ID, app.Artifact.Filename); rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 when the profile switch is off", rec.Code)
}
req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("icon status = %d, want 404 when the profile switch is off", rec.Code)
}
}
func TestIconBytes(t *testing.T) {
h, _ := newTestHandler(t)
app := seedHostedApp(t, h, "com.example.icon2", "bytes")
icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
if _, err := h.store.SaveIcon(app.ID, icon); err != nil {
t.Fatalf("save icon: %v", err)
}
stored, _ := h.store.Get(app.ID)
stored.IconPath = "icons/" + app.ID
if err := h.store.Upsert(*stored); err != nil {
t.Fatalf("persist icon path: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
req.SetPathValue("id", app.ID)
rec := httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(icon) {
t.Fatalf("icon body = %q", rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "image/png" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("ACAO = %q", got)
}
// App without an uploaded icon → 404.
other := seedHostedApp(t, h, "com.example.noicon", "bytes")
req = httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+other.ID, nil)
req.SetPathValue("id", other.ID)
rec = httptest.NewRecorder()
h.IconBytes(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing icon status = %d", rec.Code)
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ func UninstallContainerHandler(cache *libcontainers.ContainerCache) http.Handler
if err := libcontainers.UninstallContainer(r.Context(), req); err != nil {
logger.Error("Error uninstalling container '%s': %v", req.Name, err)
http.Error(w, "Failed to uninstall container: "+err.Error(), http.StatusInternalServerError)
writeLifecycleError(w, "Failed to uninstall container: ", err)
return
}
+14 -2
View File
@@ -17,6 +17,7 @@ package containers
import (
"encoding/json"
"errors"
"net/http"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
@@ -24,6 +25,17 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
// writeLifecycleError reports a failed lifecycle action with a status that
// reflects the cause: a service Maestro has neither a compose project nor a
// container for is a conflict (409), not an internal error.
func writeLifecycleError(w http.ResponseWriter, prefix string, err error) {
if errors.Is(err, libcontainers.ErrNotInstalled) {
http.Error(w, prefix+err.Error(), http.StatusConflict)
return
}
http.Error(w, prefix+err.Error(), http.StatusInternalServerError)
}
// StartContainerHandler starts a previously installed service container.
func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -48,7 +60,7 @@ func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc
if err := libcontainers.StartContainer(r.Context(), req); err != nil {
logger.Error("Error starting container '%s': %v", req.Name, err)
http.Error(w, "Failed to start container: "+err.Error(), http.StatusInternalServerError)
writeLifecycleError(w, "Failed to start container: ", err)
return
}
@@ -87,7 +99,7 @@ func StopContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc
if err := libcontainers.StopContainer(r.Context(), req); err != nil {
logger.Error("Error stopping container '%s': %v", req.Name, err)
http.Error(w, "Failed to stop container: "+err.Error(), http.StatusInternalServerError)
writeLifecycleError(w, "Failed to stop container: ", err)
return
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
)
// A service Maestro has neither a compose project nor a container for is a
// conflict the operator can act on — not an internal error.
func TestWriteLifecycleErrorNotInstalledIsConflict(t *testing.T) {
rec := httptest.NewRecorder()
err := fmt.Errorf("%w: store", libcontainers.ErrNotInstalled)
writeLifecycleError(rec, "Failed to stop container: ", err)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
}
if body := rec.Body.String(); !strings.Contains(body, "not installed by Maestro") {
t.Fatalf("body = %q, want the ErrNotInstalled message", body)
}
}
// Every other failure keeps the historical 500.
func TestWriteLifecycleErrorOtherIsInternal(t *testing.T) {
rec := httptest.NewRecorder()
writeLifecycleError(rec, "Failed to start container: ", fmt.Errorf("boom"))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
+45 -4
View File
@@ -21,22 +21,28 @@ import (
"net/http"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg"
)
// NewListContainersHandler takes the cache instance from main and wraps it in a handler function
func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc {
// NewListContainersHandler takes the cache instance from main and wraps it in a
// handler function. dataRoot resolves the operator web-link sources; the
// request host drives the derived href fallback.
func NewListContainersHandler(cache *containers.ContainerCache, dataRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cache.RLock()
defer cache.RUnlock()
w.Header().Set("Content-Type", "application/json")
if cache.GetServices() == nil {
services := cache.GetServices()
if services == nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("No services available\n"))
return
}
services := cache.GetServices()
services = resolveWebHrefs(services, dataRoot, r)
if err := json.NewEncoder(w).Encode(services); err != nil {
log.Printf("Error encoding services: %v", err)
http.Error(w, "Error marshaling service data", http.StatusInternalServerError)
@@ -44,3 +50,38 @@ func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc
}
}
}
// resolveWebHrefs copies the service list and resolves each live `web` block's
// href server-side (contract rev 8.2 §16.2, S40/S43). The cached value never
// carries an href, and a block with no valid target is omitted entirely. The
// returned slice is fresh: the cache's Container.Web pointers are never
// mutated.
func resolveWebHrefs(services []types.Container, dataRoot string, r *http.Request) []types.Container {
cfg := webcfg.Load(dataRoot)
out := make([]types.Container, len(services))
copy(out, services)
for i := range out {
c := &out[i]
if c.Web == nil || !c.Web.Enabled {
c.Web = nil
continue
}
name := c.ServiceName
if name == "" {
name = c.Name
}
w := *c.Web
href, ok := types.ResolveWebHref(&w, types.WebHrefSources{
ServiceName: name,
OperatorOverride: cfg.OverrideFor(name),
BaseDomainTemplate: cfg.Template(),
}, c.HostPortFor(w.Port), r)
if !ok {
c.Web = nil
continue
}
w.Href = href
c.Web = &w
}
return out
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg"
)
func serveList(t *testing.T, cache *libcontainers.ContainerCache, dataRoot, host string) []types.Container {
t.Helper()
h := NewListContainersHandler(cache, dataRoot)
r := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
r.Host = host
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var out []types.Container
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
// No href may ever carry a non-http(s) scheme, whatever the manifest said.
for _, c := range out {
if c.Web != nil && !strings.HasPrefix(c.Web.Href, "http://") && !strings.HasPrefix(c.Web.Href, "https://") {
t.Fatalf("unsafe href for %s: %q", c.ServiceName, c.Web.Href)
}
}
return out
}
func byName(items []types.Container) map[string]types.Container {
m := make(map[string]types.Container, len(items))
for _, c := range items {
m[c.ServiceName] = c
}
return m
}
// TestListContainersResolvesWebHref covers the headline feature end to end at
// the handler: a service with a valid web block gets a SERVER-resolved href, an
// unsafe manifest url is skipped in favor of the derived host:port, and a
// service with no block, a disabled block, or an invalid port omits `web`
// entirely. The cache itself is never mutated.
func TestListContainersResolvesWebHref(t *testing.T) {
root := t.TempDir()
cache := libcontainers.NewContainerCache()
cache.AddService(types.Container{
ID: "c1", Name: "calendar", ServiceName: "calendar", Status: "running",
Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
Web: &types.WebApp{Enabled: true, Port: 8080},
})
cache.AddService(types.Container{ID: "c2", Name: "no-web", ServiceName: "no-web", Status: "running"})
cache.AddService(types.Container{
ID: "c3", Name: "bad", ServiceName: "bad", Status: "running",
Web: &types.WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"},
})
cache.AddService(types.Container{
ID: "c4", Name: "disabled", ServiceName: "disabled", Status: "running",
Web: &types.WebApp{Enabled: false, Port: 8080},
})
cache.AddService(types.Container{
ID: "c5", Name: "badport", ServiceName: "badport", Status: "running",
Web: &types.WebApp{Enabled: true, Port: 0},
})
got := byName(serveList(t, cache, root, "console.example.com:8080"))
if c := got["calendar"]; c.Web == nil || c.Web.Href != "http://console.example.com:8083" {
t.Fatalf("calendar web = %+v, want derived href http://console.example.com:8083", c.Web)
}
// An unsafe manifest url is skipped, never emitted: the derived host:port
// source takes over.
if c := got["bad"]; c.Web == nil || c.Web.Href != "http://console.example.com:8080" {
t.Fatalf("bad web = %+v, want the derived href (unsafe url skipped)", c.Web)
}
for _, name := range []string{"no-web", "disabled", "badport"} {
if c := got[name]; c.Web != nil {
t.Errorf("%s must omit `web` entirely, got %+v", name, c.Web)
}
}
// The cached entry must keep the unresolved block (href is per-request).
cached, ok := cache.GetService("calendar")
if !ok {
t.Fatal("calendar missing from cache")
}
if cached.Web == nil || cached.Web.Href != "" {
t.Fatalf("cache was mutated with a resolved href: %+v", cached.Web)
}
}
// TestListContainersWebSourcePrecedence covers the operator sources: override
// beats the global template, the template beats the derived host:port, and the
// manifest url beats both.
func TestListContainersWebSourcePrecedence(t *testing.T) {
root := t.TempDir()
if err := webcfg.Save(root, webcfg.Config{
BaseDomainTemplate: "https://<service>.home.example",
Overrides: map[string]string{"override-svc": "https://calendar.home.example"},
}); err != nil {
t.Fatalf("webcfg.Save: %v", err)
}
cache := libcontainers.NewContainerCache()
cache.AddService(types.Container{
ID: "1", Name: "override-svc", ServiceName: "override-svc", Status: "running",
Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
Web: &types.WebApp{Enabled: true, Port: 8080},
})
cache.AddService(types.Container{
ID: "2", Name: "templated", ServiceName: "templated", Status: "running",
Network: types.Network{Ports: []types.PortMapping{{Host: 8084, Guest: 8080}}},
Web: &types.WebApp{Enabled: true, Port: 8080},
})
cache.AddService(types.Container{
ID: "3", Name: "manifest", ServiceName: "manifest", Status: "running",
Network: types.Network{Ports: []types.PortMapping{{Host: 8085, Guest: 8080}}},
Web: &types.WebApp{Enabled: true, Port: 8080, URL: "https://manifest.example"},
})
got := byName(serveList(t, cache, root, "console.example.com:8080"))
if c := got["override-svc"]; c.Web == nil || c.Web.Href != "https://calendar.home.example:8080" {
t.Fatalf("override href = %+v", c.Web)
}
if c := got["templated"]; c.Web == nil || c.Web.Href != "https://templated.home.example:8080" {
t.Fatalf("template href = %+v", c.Web)
}
if c := got["manifest"]; c.Web == nil || c.Web.Href != "https://manifest.example:8080" {
t.Fatalf("manifest href = %+v", c.Web)
}
}
@@ -0,0 +1,178 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import (
"encoding/json"
"strings"
"testing"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
// TestWebChipEvidenceTable is the raw-evidence generator for task-29's headline
// feature. It logs, for each manifest `web` source the operator or a service
// can supply, exactly what `GET /api/containers` returns: the resolved href, or
// `<omitted>` when no valid source exists. Run with:
//
// go test ./handlers/containers/ -run TestWebChipEvidenceTable -v
func TestWebChipEvidenceTable(t *testing.T) {
root := t.TempDir()
cache := libcontainers.NewContainerCache()
type row struct {
name string
web *types.WebApp
}
rows := []row{
{"valid-derived", &types.WebApp{Enabled: true, Port: 8080}},
{"url-javascript", &types.WebApp{Enabled: true, Port: 8080, URL: "javascript:alert(1)"}},
{"url-data", &types.WebApp{Enabled: true, Port: 8080, URL: "data:text/html,<script>alert(1)</script>"}},
{"url-file", &types.WebApp{Enabled: true, Port: 8080, URL: "file:///etc/passwd"}},
{"url-userinfo", &types.WebApp{Enabled: true, Port: 8080, URL: "https://user:[email protected]"}},
{"url-control-char", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/\x01"}},
{"url-newline", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/\n"}},
{"url-space", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/a b"}},
{"url-angle-bracket", &types.WebApp{Enabled: true, Port: 8080, URL: "https://evil.example/<x>"}},
{"url-empty-host", &types.WebApp{Enabled: true, Port: 8080, URL: "https:///path"}},
{"url-no-scheme", &types.WebApp{Enabled: true, Port: 8080, URL: "evil.example/x"}},
{"url-valid-https", &types.WebApp{Enabled: true, Port: 8080, URL: "https://calendar.example/app"}},
{"port-0", &types.WebApp{Enabled: true, Port: 0}},
{"port--1", &types.WebApp{Enabled: true, Port: -1}},
{"port-65536", &types.WebApp{Enabled: true, Port: 65536}},
{"listener-disabled", &types.WebApp{Enabled: false, Port: 8080}},
{"no-web-block", nil},
}
for i, r := range rows {
cache.AddService(types.Container{
ID: "ev" + r.name,
Name: r.name,
ServiceName: r.name,
Status: "running",
Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
Web: r.web,
})
_ = i
}
got := byName(serveList(t, cache, root, "console.example.com:8080"))
for _, r := range rows {
c := got[r.name]
if c.Web == nil {
t.Logf("%-18s -> web=<omitted>", r.name)
continue
}
raw, _ := json.Marshal(c.Web)
t.Logf("%-18s -> web=%s", r.name, raw)
}
}
// TestWebChipEvidenceNoFallback covers the acceptance case where an unsafe
// TestWebChipEvidenceUnsafeURLNeverBecomesHref is the acceptance evidence for
// the rejection cases. Contract §16.2 says an *invalid present source is
// skipped* and the next source in precedence is used, so with an enabled block
// and a valid declared port the derived `http://<request host>:<declared port>`
// source still yields a safe href — the unsafe manifest string is NEVER the
// link. `web` is omitted entirely only when no source can produce a valid href
// (no block, disabled, invalid port — see TestWebChipEvidenceTable).
func TestWebChipEvidenceUnsafeURLNeverBecomesHref(t *testing.T) {
root := t.TempDir()
cache := libcontainers.NewContainerCache()
unsafe := map[string]string{
"javascript": "javascript:alert(1)",
"data": "data:text/html,<script>alert(1)</script>",
"file": "file:///etc/passwd",
"userinfo": "https://user:[email protected]",
"control": "https://evil.example/\x01",
"newline": "https://evil.example/\n",
}
for name, url := range unsafe {
cache.AddService(types.Container{
ID: "nf" + name,
Name: name,
ServiceName: name,
Status: "running",
// No host port binding: the derived source falls back to the
// declared port, so the result is http://<host>:8080.
Web: &types.WebApp{Enabled: true, Port: 8080, URL: url},
})
}
got := byName(serveList(t, cache, root, "console.example.com:8080"))
for name, url := range unsafe {
c := got[name]
if c.Web == nil {
t.Logf("%-10s -> web=<omitted>", name)
continue
}
t.Logf("%-10s -> href=%q (unsafe url %q never used)", name, c.Web.Href, url)
if c.Web.Href == url || strings.Contains(c.Web.Href, "evil.example") || strings.Contains(c.Web.Href, "javascript:") ||
strings.Contains(c.Web.Href, "data:") || strings.Contains(c.Web.Href, "file:") || strings.Contains(c.Web.Href, "user:") {
t.Errorf("%s: unsafe url leaked into href: %q", name, c.Web.Href)
}
if !strings.HasPrefix(c.Web.Href, "http://") && !strings.HasPrefix(c.Web.Href, "https://") {
t.Errorf("%s: href is not http(s): %q", name, c.Web.Href)
}
}
}
// TestWebChipEvidenceInvalidPortOmitted is the "no valid href can be built"
// acceptance case: even an otherwise valid web block with an invalid port has
// no source at all, so `/api/containers` omits `web` entirely (no chip, no
// link).
func TestWebChipEvidenceInvalidPortOmitted(t *testing.T) {
root := t.TempDir()
cache := libcontainers.NewContainerCache()
for name, port := range map[string]int{"port0": 0, "portNeg": -1, "portBig": 65536} {
cache.AddService(types.Container{
ID: "ip" + name, Name: name, ServiceName: name, Status: "running",
Network: types.Network{Ports: []types.PortMapping{{Host: 8083, Guest: 8080}}},
Web: &types.WebApp{Enabled: true, Port: port, URL: "https://calendar.example"},
})
}
got := byName(serveList(t, cache, root, "console.example.com:8080"))
for name := range map[string]int{"port0": 0, "portNeg": -1, "portBig": 65536} {
if c := got[name]; c.Web != nil {
t.Errorf("%s must omit `web` entirely, got %+v", name, c.Web)
}
t.Logf("%-8s -> web=<omitted>", name)
}
}
// TestManifestNonNumericPortFailsDecode is the evidence for the non-numeric
// port case: a manifest whose `web.port` is not a number cannot decode, so the
// service never advertises a web app at all (fail-closed at the boundary).
func TestManifestNonNumericPortFailsDecode(t *testing.T) {
for _, body := range []string{
`{"name":"svc","web":{"enabled":true,"port":"abc"}}`,
`{"name":"svc","web":{"enabled":true,"port":"8080"}}`,
} {
var m types.Manifest
err := json.Unmarshal([]byte(body), &m)
t.Logf("%s -> err=%v", body, err)
if err == nil {
t.Fatalf("non-numeric web.port must fail manifest decode: %s", body)
}
}
// A numeric port decodes fine (control case).
var ok types.Manifest
if err := json.Unmarshal([]byte(`{"name":"svc","web":{"enabled":true,"port":8080}}`), &ok); err != nil {
t.Fatalf("numeric web.port must decode: %v", err)
}
t.Logf("numeric port control -> web=%+v", ok.Web)
}
+307
View File
@@ -0,0 +1,307 @@
// 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 dataplane is Maestro's single data plane for every installed service.
//
// The model (user ruling, 2026-10-18): requests to a service are always expected
// to be routed through Maestro, and may well be unauthenticated. Only one port
// per instance therefore needs to be open, and setup stays simple. A service sets
// up its own auth on top (typically via dcntrlized-auth); Maestro neither knows
// nor cares whether a request is authenticated, and direct service addressing is
// not a supported deployment.
//
// Reachable surface — exactly this, nothing else:
//
// /api/{service}/action/{actionName} → service /action/{actionName}
// /api/{service}/auth/{path...} → service /auth/{path...}
// /api/{service}/ws[/{path...}] → service /ws[/{path...}]
//
// ONE allowlist, ONE dispatcher, ONE passthrough: a request is relayed as a
// WebSocket when it is an upgrade and as plain HTTP otherwise. Adding a service
// namespace therefore takes no Maestro code beyond a name in this file, and the
// two halves can never disagree about a service's address because both resolve
// through lib/serviceproxy.
//
// WHAT THIS IS NOT: a general tunnel. /config and /_manifest are unreachable
// because they are not namespaces here. /config in particular is Maestro's own
// ES256-signed edge — Maestro mints the token and the service verifies it, so
// exposing it through the data plane would let a caller skip that mint.
//
// NOT OWNER-GATED, deliberately (the reasoning lives on the registration in
// main.go): the callers are apps and phones, which hold no Maestro owner session
// and must never be handed one — that cookie belongs to the instance operator.
// The service is the gate.
//
// Maestro envelopes ONLY what Maestro itself produced (400/404/409/500/502). A
// service response — success, 4xx, 5xx, redirect, raw body — is mirrored
// byte-for-byte: the service's body is the service's contract.
package dataplane
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
)
// The allowlist. A namespace is reachable only if it appears here.
const (
namespaceAction = "action"
namespaceAuth = "auth"
namespaceWS = "ws"
)
// Resolver resolves a service reference to its dialable peer. Production uses
// serviceproxy.Resolve; tests inject a fake so no Docker daemon is required.
type Resolver func(ctx context.Context, service string) (*serviceproxy.Peer, error)
// Register mounts the service data plane on mux.
func Register(mux *http.ServeMux) { RegisterWith(mux, serviceproxy.Resolve) }
// RegisterWith is Register with an injectable resolver (tests).
//
// Why a single "/api/" fallback instead of one pattern per shape: ServeMux
// panics at boot when two patterns match a path and neither is more specific.
// The naive pattern for the action surface, /api/{service}/action/{actionName},
// collides with the legacy route /api/actions/{containerId}/{actionName} on
// "/api/actions/action/<x>" (segment 2 is a wildcard in one pattern and the
// literal "actions" in the other, so the literal "action" sits at different
// positions) — and /api/{service}/auth/{path...} collides with it the same way.
// Constructing the pattern set is the only way to see this: `go build` and
// `go vet` cannot, because the mux is assembled inside main().
//
// The fallback is only reached when no concrete route matched, so every existing
// console route keeps winning its own paths, and the requested public URLs are
// exactly the ones in the package doc. TestProductionPatternSetDoesNotPanic
// pins the pattern set; TestNaivePatternSetPanics pins the trap.
func RegisterWith(mux *http.ServeMux, resolve Resolver) {
mux.Handle("/api/", fallback(resolve))
}
// fallback dispatches the data plane and answers 404 for every other /api/ path
// that no concrete route claimed.
func fallback(resolve Resolver) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
service, namespace, rest, ok := parse(r.URL.Path)
if !ok {
// Every other error under /api/ is a JSON envelope, and the phone
// clients unwrap {error:{code,message}} into a rendered sentence; a
// bare "404 page not found" would show up as raw text in the app.
writeAPIError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown API path")
return
}
target, ok := upstreamPath(namespace, rest)
if !ok {
writeAPIError(w, http.StatusNotFound, "ERR_NOT_FOUND", unknownPathMessage(namespace))
return
}
// Upgrade presence decides the relay. A socket is a socket wherever the
// service puts it, so /api/{service}/auth/events upgrading is the same
// code path as /api/{service}/ws.
if isUpgrade(r) {
relayWebSocket(resolve, service, target, w, r)
return
}
if namespace == namespaceWS {
// A plain GET on a socket endpoint is a caller mistake, not a request
// for the service's (non-existent) HTTP view of /ws.
writeAPIError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST", "this endpoint only accepts WebSocket upgrades")
return
}
relayHTTP(resolve, service, target, w, r)
})
}
// parse splits /api/{service}/{namespace}[/{rest...}].
func parse(path string) (service, namespace, rest string, ok bool) {
trimmed, found := strings.CutPrefix(path, "/api/")
if !found {
return "", "", "", false
}
parts := strings.Split(trimmed, "/")
if len(parts) < 2 || !validServiceName(parts[0]) || !allowedNamespace(parts[1]) {
return "", "", "", false
}
return parts[0], parts[1], strings.Join(parts[2:], "/"), true
}
func allowedNamespace(namespace string) bool {
switch namespace {
case namespaceAction, namespaceAuth, namespaceWS:
return true
}
return false
}
// upstreamPath maps the namespace and its remainder onto the service's own path.
// rest arrives as raw text, so it is validated here: this is the boundary between
// "a path a caller chose" and "a URL Maestro dials".
func upstreamPath(namespace, rest string) (string, bool) {
segments, ok := splitSegments(rest)
if !ok {
return "", false
}
switch namespace {
case namespaceWS:
if len(segments) == 0 {
return "/ws", true
}
return "/ws/" + strings.Join(segments, "/"), true
case namespaceAction:
// An action is one name; /action/{name}/{junk} is not a thing.
if len(segments) != 1 {
return "", false
}
return "/action/" + segments[0], true
case namespaceAuth:
// /auth/{path...} — login, register, logout, me, refresh, and any future
// auth sub-path, without Maestro needing to know them.
if len(segments) == 0 {
return "", false
}
return "/auth/" + strings.Join(segments, "/"), true
}
return "", false
}
func unknownPathMessage(namespace string) string {
switch namespace {
case namespaceAction:
return "an action path is /api/{service}/action/{action}"
case namespaceAuth:
return "an auth path is /api/{service}/auth/{path}"
case namespaceWS:
return "only the service's /ws namespace is proxied"
}
return "unknown API path"
}
// splitSegments validates a slash-separated remainder: no empty segments, no dot
// segments and nothing outside an unescaped-URL-safe charset. Descriptions are
// deliberately conservative — a namespace that accepted "../" would be a tunnel.
func splitSegments(rest string) ([]string, bool) {
rest = strings.TrimPrefix(rest, "/")
if rest == "" {
return nil, true
}
segments := strings.Split(rest, "/")
for _, segment := range segments {
if segment == "" || segment == "." || segment == ".." {
return nil, false
}
for _, char := range segment {
switch {
case char >= 'a' && char <= 'z',
char >= 'A' && char <= 'Z',
char >= '0' && char <= '9',
char == '-', char == '_', char == '.', char == '~':
default:
return nil, false
}
}
}
return segments, true
}
// validServiceName keeps the reference a plain service identifier before it is
// handed to Docker: no dot segments, no encodings, nothing that could be read as
// a path elsewhere.
func validServiceName(name string) bool {
if name == "" || name == "." || name == ".." || len(name) > 128 {
return false
}
for _, char := range name {
switch {
case char >= 'a' && char <= 'z',
char >= 'A' && char <= 'Z',
char >= '0' && char <= '9',
char == '-', char == '_', char == '.':
default:
return false
}
}
return true
}
// resolvePeer resolves the service and refuses to relay to a stopped container.
func resolvePeer(resolve Resolver, service string, w http.ResponseWriter, r *http.Request) (*serviceproxy.Peer, bool) {
peer, err := resolve(r.Context(), service)
if err != nil {
writeResolveError(w, service, err)
return nil, false
}
if !peer.Running {
writeAPIError(w, http.StatusConflict, "ERR_NOT_RUNNING", fmt.Sprintf("service %q is not running", service))
return nil, false
}
return peer, true
}
// errorBody is Maestro's uniform API error shape (same envelope as the owner-gated
// handlers, with a message so every error still says something human-readable).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
} `json:"error"`
}
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// writeResolveError maps resolution failures onto the documented statuses.
func writeResolveError(w http.ResponseWriter, service string, err error) {
switch {
case errors.Is(err, serviceproxy.ErrNotFound):
writeAPIError(w, http.StatusNotFound, "ERR_NOT_FOUND", fmt.Sprintf("unknown service %q", service))
case errors.Is(err, serviceproxy.ErrNotRunning):
writeAPIError(w, http.StatusConflict, "ERR_NOT_RUNNING", fmt.Sprintf("service %q is not running", service))
default:
logger.Debug("Service resolution failed for %q: %v", service, err)
// Maestro's own plumbing failed (dead daemon, no network, no port): a 502
// would send an operator to the service's logs for a failure that is not
// there.
writeAPIError(w, http.StatusInternalServerError, "ERR_INTERNAL",
fmt.Sprintf("failed to resolve service %q: %v", service, err))
}
}
// writeUnusableAddress answers a malformed base URL, which is Maestro's own
// plumbing failing — never a 502.
func writeUnusableAddress(w http.ResponseWriter, service string, err error) {
logger.Debug("Bad service base URL for %q: %v", service, err)
writeAPIError(w, http.StatusInternalServerError, "ERR_INTERNAL",
fmt.Sprintf("service %q has an unusable address", service))
}
// writeUpstreamUnreachable answers a transport failure to a resolved service.
func writeUpstreamUnreachable(w http.ResponseWriter, service string, err error) {
logger.Debug("Service relay failed for %q: %v", service, err)
writeAPIError(w, http.StatusBadGateway, "ERR_UPSTREAM_UNREACHABLE",
fmt.Sprintf("failed to reach service %q: %v", service, err))
}
+148
View File
@@ -0,0 +1,148 @@
// 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 dataplane
import (
"io"
"net"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
)
// connectTimeout bounds reaching the service. There is deliberately no overall
// client timeout: an action may legitimately take minutes (a scan, a sync, a
// long poll). The caller's request context bounds the request, exactly as it
// would for any other handler.
const connectTimeout = 10 * time.Second
// relayClient is the plain-HTTP half of the data plane. A fresh http.Client per
// request would leak connection pools; this one keeps them warm across calls.
var relayClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: connectTimeout, KeepAlive: 30 * time.Second}).DialContext,
MaxIdleConns: 64,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 90 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
// Mirror the service's redirects instead of following them: a 302 from a
// service is an answer to the caller, not an instruction to Maestro.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
// relayHTTP forwards one request to the service and mirrors the answer.
func relayHTTP(resolve Resolver, service, upstreamPath string, w http.ResponseWriter, r *http.Request) {
peer, ok := resolvePeer(resolve, service, w, r)
if !ok {
return
}
// The query string is forwarded verbatim (tokens live there and re-encoding
// would corrupt them).
target, err := serviceproxy.Endpoint(peer.BaseURL, upstreamPath, r.URL.RawQuery)
if err != nil {
writeUnusableAddress(w, service, err)
return
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
logger.Debug("Failed to build relay request for %q: %v", service, err)
writeAPIError(w, http.StatusInternalServerError, "ERR_INTERNAL",
"failed to build the request for service "+service)
return
}
req.ContentLength = r.ContentLength
req.Header = forwardableRequestHeaders(r.Header)
resp, err := relayClient.Do(req)
if err != nil {
writeUpstreamUnreachable(w, service, err)
return
}
defer func() { _ = resp.Body.Close() }()
// The service's status, headers and body ARE the answer: mirrored, never
// re-shaped and never enveloped (see the package doc). Only Maestro's own
// failures above use the envelope.
copyResponseHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
logger.Debug("Relay of service %q response to client failed: %v", service, err)
}
}
// forwardableRequestHeaders copies the caller's headers, minus the ones a relay
// must never forward.
func forwardableRequestHeaders(src http.Header) http.Header {
dst := http.Header{}
for name, values := range src {
if !forwardableRequestHeader(name) {
continue
}
for _, value := range values {
dst.Add(name, value)
}
}
return dst
}
// forwardableRequestHeader is a SECURITY decision, not a convenience.
//
// Hop-by-hop headers are the relay's own business. Cookie and Authorization are
// excluded deliberately twice over: this surface is not owner-gated, so a Maestro
// session cookie must never ride into a service, and services authenticate with
// their own tokens (X-Access-Token / their own Cookie issued by themselves, which
// this relay can never forward because it never forwards cookies at all).
// Sec-WebSocket-* is set by the WebSocket dialer itself; forwarding a caller's key
// would corrupt the handshake.
func forwardableRequestHeader(name string) bool {
switch strings.ToLower(name) {
case "host", "content-length", "connection", "keep-alive",
"proxy-authenticate", "proxy-authorization", "te", "trailer",
"transfer-encoding", "upgrade",
"cookie", "authorization":
return false
}
return !strings.HasPrefix(strings.ToLower(name), "sec-websocket-")
}
// copyResponseHeaders mirrors the service's response headers, minus hop-by-hop
// ones and Set-Cookie: that cookie would be scoped to Maestro's own origin, so
// mirroring it would let any service write cookies onto the console's origin.
func copyResponseHeaders(dst, src http.Header) {
for name, values := range src {
if !forwardableResponseHeader(name) {
continue
}
for _, value := range values {
dst.Add(name, value)
}
}
}
func forwardableResponseHeader(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade", "set-cookie":
return false
}
return true
}
+546
View File
@@ -0,0 +1,546 @@
// 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 dataplane
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
"github.com/gorilla/websocket"
)
// httpRecorder captures what the fake service saw for a plain-HTTP relay.
type httpRecorder struct {
mu sync.Mutex
calls int
method string
path string
query string
headers http.Header
body string
}
func (rec *httpRecorder) record(r *http.Request) {
body, _ := io.ReadAll(r.Body)
rec.mu.Lock()
defer rec.mu.Unlock()
rec.calls++
rec.method = r.Method
rec.path = r.URL.Path
rec.query = r.URL.RawQuery
rec.headers = r.Header.Clone()
rec.body = string(body)
}
func (rec *httpRecorder) snapshot() (calls int, method, path, query string, headers http.Header, body string) {
rec.mu.Lock()
defer rec.mu.Unlock()
return rec.calls, rec.method, rec.path, rec.query, rec.headers, rec.body
}
// startHTTPService runs a fake service that records the request and answers with
// the handler's response.
func startHTTPService(t *testing.T, answer http.HandlerFunc) (*httpRecorder, string) {
t.Helper()
rec := &httpRecorder{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.record(r)
answer(w, r)
}))
t.Cleanup(srv.Close)
return rec, srv.URL
}
// TestActionRelayed is the core of the model: an app calls
// /api/{service}/action/{action} with no Maestro session, and the service receives
// the action, the query, the body and the token — but never the operator's cookie
// or an Authorization header.
func TestActionRelayed(t *testing.T) {
const query = "alertId=0f8fad5b-0000-4000-8000-000000000001&accessToken=ACCESSTOKEN"
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"ok":true,"alert":{"id":"a1"}}`)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/alarm/action/createAlert?"+query,
strings.NewReader(`{"payload":{"alertId":"a1"}}`))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Access-Token", "ACCESSTOKEN")
req.Header.Set("X-Alarm-Token", "SUBTOKEN")
req.Header.Set("Cookie", "maestro_access=operator-secret; maestro_refresh=operator-refresh")
req.Header.Set("Authorization", "Bearer operator-session")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST through the data plane: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body: %s)", resp.StatusCode, body)
}
if string(body) != `{"ok":true,"alert":{"id":"a1"}}` {
t.Fatalf("body = %q, want the service's body byte-for-byte", body)
}
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("Content-Type = %q, want the service's", got)
}
calls, method, path, gotQuery, headers, gotBody := rec.snapshot()
if calls != 1 {
t.Fatalf("service calls = %d, want 1", calls)
}
if method != http.MethodPost {
t.Fatalf("method = %q, want POST", method)
}
if path != "/action/createAlert" {
t.Fatalf("path = %q, want /action/createAlert", path)
}
if gotQuery != query {
t.Fatalf("query = %q, want it forwarded verbatim (%q)", gotQuery, query)
}
if gotBody != `{"payload":{"alertId":"a1"}}` {
t.Fatalf("body = %q, want the caller's body", gotBody)
}
if got := headers.Get("X-Access-Token"); got != "ACCESSTOKEN" {
t.Fatalf("X-Access-Token = %q, want ACCESSTOKEN", got)
}
if got := headers.Get("X-Alarm-Token"); got != "SUBTOKEN" {
t.Fatalf("X-Alarm-Token = %q, want SUBTOKEN", got)
}
if got := headers.Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("Content-Type at the service = %q, want application/json", got)
}
if got := headers.Get("Cookie"); got != "" {
t.Fatalf("Cookie = %q, want none — the operator's session must never ride into a service", got)
}
if got := headers.Get("Authorization"); got != "" {
t.Fatalf("Authorization = %q, want none — services authenticate with their own tokens", got)
}
}
// TestAuthRelayed: /api/{service}/auth/{path...} reaches the service's /auth/*
// namespace with the raw JSON body, which is what the phone clients call directly.
func TestAuthRelayed(t *testing.T) {
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
// A service error body in the service's own shape — must NOT be
// reformatted into Maestro's envelope.
_, _ = io.WriteString(w, `{"error":{"code":"ERR_INVALID_CREDENTIALS","message":"wrong password"}}`)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
resp, err := http.Post(srv.URL+"/api/alarm/auth/login", "application/json",
strings.NewReader(`{"username":"admin","password":"bad"}`))
if err != nil {
t.Fatalf("POST /auth/login: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want the service's 401 (body: %s)", resp.StatusCode, body)
}
if string(body) != `{"error":{"code":"ERR_INVALID_CREDENTIALS","message":"wrong password"}}` {
t.Fatalf("body = %q, want the service's body untouched", body)
}
_, method, path, _, _, gotBody := rec.snapshot()
if method != http.MethodPost || path != "/auth/login" {
t.Fatalf("service saw %s %s, want POST /auth/login", method, path)
}
if gotBody != `{"username":"admin","password":"bad"}` {
t.Fatalf("body at the service = %q, want the raw JSON", gotBody)
}
}
// TestAuthPathDepth keeps the auth namespace a prefix, not a fixed list: a future
// service auth sub-path needs no Maestro change.
func TestAuthPathDepth(t *testing.T) {
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
resp, err := http.Post(srv.URL+"/api/alarm/auth/device/pair/confirm", "application/json", nil)
if err != nil {
t.Fatalf("POST: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want 204", resp.StatusCode)
}
if _, _, path, _, _, _ := rec.snapshot(); path != "/auth/device/pair/confirm" {
t.Fatalf("path = %q, want /auth/device/pair/confirm", path)
}
}
// TestMirrorsServiceResponses: whatever the service answers is the caller's
// answer — status, body and content type, including redirects (never followed)
// and the service's own error envelope (never re-wrapped).
func TestMirrorsServiceResponses(t *testing.T) {
tests := []struct {
name string
answer http.HandlerFunc
wantStatus int
wantBody string
wantCT string
wantLocation string
}{
{
name: "200 with a service body",
answer: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"alerts":[]}`)
},
wantStatus: http.StatusOK,
wantBody: `{"alerts":[]}`,
wantCT: "application/json",
},
{
name: "401 with the service's own envelope",
answer: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":{"code":"ERR_UNAUTHORIZED"}}`)
},
wantStatus: http.StatusUnauthorized,
wantBody: `{"error":{"code":"ERR_UNAUTHORIZED"}}`,
wantCT: "application/json",
},
{
name: "500 with a plain-text body",
answer: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "database is down")
},
wantStatus: http.StatusInternalServerError,
wantBody: "database is down",
wantCT: "text/plain",
},
{
name: "302 is mirrored, not followed",
answer: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "https://elsewhere.example/nowhere")
w.WriteHeader(http.StatusFound)
},
wantStatus: http.StatusFound,
wantLocation: "https://elsewhere.example/nowhere",
},
{
name: "204 with no body",
answer: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
},
wantStatus: http.StatusNoContent,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, upstreamURL := startHTTPService(t, test.answer)
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Post(srv.URL+"/api/alarm/action/getAlert", "application/json", nil)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != test.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", resp.StatusCode, test.wantStatus, body)
}
if test.wantBody != "" && string(body) != test.wantBody {
t.Fatalf("body = %q, want %q", body, test.wantBody)
}
if test.wantCT != "" {
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, test.wantCT) {
t.Fatalf("Content-Type = %q, want %q", got, test.wantCT)
}
}
if test.wantLocation != "" {
if got := resp.Header.Get("Location"); got != test.wantLocation {
t.Fatalf("Location = %q, want %q", got, test.wantLocation)
}
}
})
}
}
// TestSetCookieNotMirrored: a service must not be able to write cookies onto
// Maestro's own origin, so Set-Cookie never rides back.
func TestSetCookieNotMirrored(t *testing.T) {
_, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "maestro_access", Value: "forged", Path: "/"})
_, _ = io.WriteString(w, "ok")
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
resp, err := http.Post(srv.URL+"/api/alarm/auth/login", "application/json", nil)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if cookies := resp.Header.Values("Set-Cookie"); len(cookies) != 0 {
t.Fatalf("Set-Cookie = %v, want none — a service must not set cookies on the console origin", cookies)
}
}
// TestResolveErrors: unknown service 404, stopped service 409, Maestro's own
// plumbing 500 (never a 502), no answer at all 502. Every one carries a message.
func TestResolveErrors(t *testing.T) {
tests := []struct {
name string
resolve Resolver
wantCode string
wantBody string
}{
{
name: "unknown service",
resolve: resolverAnswer(nil, fmt.Errorf("%w: %q", serviceproxy.ErrNotFound, "alarm")),
wantCode: "ERR_NOT_FOUND",
wantBody: "unknown service",
},
{
name: "service not running",
resolve: resolverAnswer(&serviceproxy.Peer{BaseURL: "http://127.0.0.1:1", Running: false}, nil),
wantCode: "ERR_NOT_RUNNING",
wantBody: "is not running",
},
{
name: "docker unavailable",
resolve: resolverAnswer(nil, fmt.Errorf("%w: cannot connect", serviceproxy.ErrDocker)),
wantCode: "ERR_INTERNAL",
wantBody: "failed to resolve service",
},
{
name: "no network address",
resolve: resolverAnswer(nil, fmt.Errorf("%w: %q", serviceproxy.ErrNoNetwork, "alarm")),
wantCode: "ERR_INTERNAL",
wantBody: "failed to resolve service",
},
{
name: "upstream unreachable",
resolve: resolverAnswer(runningPeer("http://127.0.0.1:1"), nil),
wantCode: "ERR_UPSTREAM_UNREACHABLE",
wantBody: "failed to reach service",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
srv := startProxy(t, test.resolve)
resp, err := http.Post(srv.URL+"/api/alarm/action/getAlert", "application/json", nil)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"code":"`+test.wantCode+`"`) {
t.Fatalf("body = %q, want code %s", body, test.wantCode)
}
if !strings.Contains(string(body), test.wantBody) {
t.Fatalf("body = %q, want it to contain %q", body, test.wantBody)
}
if !strings.Contains(string(body), `"message":"`) {
t.Fatalf("body = %q, want a human-readable message", body)
}
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", got)
}
})
}
}
// TestNamespaceAllowlist: only action, auth and ws are reachable. /config,
// /_manifest and anything else are 404 and never reach the service — this surface
// is not a tunnel.
func TestNamespaceAllowlist(t *testing.T) {
tests := []string{
"/api/alarm/config",
"/api/alarm/_manifest",
"/api/alarm/manifest",
"/api/alarm/actions/getAlert", // plural is the console's route, not a service namespace
"/api/alarm",
"/api/alarm/",
"/api/alarm/action", // no action name
"/api/alarm/action/a/b", // an action is one segment
"/api/alarm/auth", // no auth path
"/api/typo", // no namespace at all
"/api/alarm/auth/%2e%2e/config",
}
for _, path := range tests {
t.Run(path, func(t *testing.T) {
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET %s = %d (%s), want 404", path, resp.StatusCode, body)
}
if !strings.Contains(string(body), `"code":"ERR_NOT_FOUND"`) {
t.Fatalf("GET %s body = %q, want Maestro's JSON envelope", path, body)
}
if calls, _, _, _, _, _ := rec.snapshot(); calls != 0 {
t.Fatalf("GET %s reached the service %d times, want 0", path, calls)
}
})
}
}
// TestDotSegmentsNeverReachTheService: a traversal attempt may be redirected by
// the standard library's path cleaning, but it must never be relayed.
func TestDotSegmentsNeverReachTheService(t *testing.T) {
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Get(srv.URL + "/api/../etc/passwd")
if err != nil {
t.Fatalf("GET: %v", err)
}
_ = resp.Body.Close()
// Either cleaned+404 by the mux or the mux's own redirect — never a relay.
if resp.StatusCode == http.StatusOK {
t.Fatalf("GET /api/../etc/passwd = 200, want no relay")
}
if calls, _, path, _, _, _ := rec.snapshot(); calls != 0 {
t.Fatalf("service was called (%s) for a traversal attempt", path)
}
}
// TestLegacyActionRouteStaysGated is the model in one test: the legacy console
// route keeps its owner gate, while the data plane reaches the service with no
// cookie at all.
func TestLegacyActionRouteStaysGated(t *testing.T) {
rec, upstreamURL := startHTTPService(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"ok":true}`)
})
gate := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED", "a session is required")
})
}
mux := http.NewServeMux()
mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "legacy")
})))
RegisterWith(mux, resolverAnswer(runningPeer(upstreamURL), nil))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
// The legacy route still refuses an app.
resp, err := http.Post(srv.URL+"/api/actions/alarm/createAlert", "application/json", nil)
if err != nil {
t.Fatalf("POST legacy: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("legacy route = %d, want the owner gate's 401", resp.StatusCode)
}
// The data plane does not, and the service receives the action.
resp, err = http.Post(srv.URL+"/api/alarm/action/createAlert", "application/json", nil)
if err != nil {
t.Fatalf("POST data plane: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("data plane = %d (%s), want the service's 200", resp.StatusCode, body)
}
if calls, _, path, _, _, _ := rec.snapshot(); calls != 1 || path != "/action/createAlert" {
t.Fatalf("service calls = %d path = %q, want 1 call to /action/createAlert", calls, path)
}
}
// TestResolveIsShared: an action and a socket to the same service must ask the
// same resolver, so the two edges can never disagree about a service's address.
func TestResolveIsShared(t *testing.T) {
calls := make(chan string, 8)
resolve := func(_ context.Context, service string) (*serviceproxy.Peer, error) {
calls <- service
return nil, fmt.Errorf("%w: %q", serviceproxy.ErrNotFound, service)
}
mux := http.NewServeMux()
RegisterWith(mux, resolve)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
for _, path := range []string{"/api/alarm/action/x", "/api/alarm/auth/login"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
_ = resp.Body.Close()
}
// The socket edge resolves only on a real upgrade, so drive one.
if conn, _, err := websocket.DefaultDialer.Dial(
"ws"+strings.TrimPrefix(srv.URL, "http")+"/api/alarm/ws", nil); err == nil {
_ = conn.Close()
}
for i := 0; i < 3; i++ {
select {
case got := <-calls:
if got != "alarm" {
t.Fatalf("resolver saw %q, want alarm", got)
}
default:
t.Fatalf("resolver was not called for every edge")
}
}
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dataplane
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
"github.com/gorilla/websocket"
)
const (
// dialTimeout bounds the service's handshake only. Once the socket is up
// there is deliberately NO read/write deadline on either hop: an idle alarm
// socket must survive indefinitely (a proxy deadline is how you get a socket
// that dies every 60 seconds).
dialTimeout = 10 * time.Second
// relayWriteWait bounds one relayed control frame (ping/pong/close) so a
// dead peer cannot block the frame pump forever.
relayWriteWait = 10 * time.Second
// maxCloseText keeps a relayed close reason inside a control frame (125 B).
maxCloseText = 123
)
var upgrader = websocket.Upgrader{
// Maestro is an instance-local pipe and the caller is an app, not a browser
// tab: same permissive origin policy as handlers/containers/ws.go. Access
// control is the service's token check, not the Origin header.
CheckOrigin: func(*http.Request) bool { return true },
}
// isUpgrade reports whether this request is a WebSocket handshake. It is the
// single switch between the two relay modes.
func isUpgrade(r *http.Request) bool { return websocket.IsWebSocketUpgrade(r) }
// relayWebSocket proxies a WebSocket handshake and then pumps frames both ways.
func relayWebSocket(resolve Resolver, service, upstreamPath string, w http.ResponseWriter, r *http.Request) {
peer, ok := resolvePeer(resolve, service, w, r)
if !ok {
return
}
// The query string is forwarded verbatim: it carries alertId, token,
// deviceId and accessToken, and re-encoding it would corrupt the token.
target, err := serviceproxy.Endpoint(peer.BaseURL, upstreamPath, r.URL.RawQuery)
if err != nil {
writeUnusableAddress(w, service, err)
return
}
target = wsScheme(target)
// Dial the service BEFORE touching the caller's connection: while nothing has
// been written to w we can still answer with a real HTTP status.
dialer := *websocket.DefaultDialer
dialer.HandshakeTimeout = dialTimeout
dialer.Subprotocols = websocket.Subprotocols(r)
upstream, resp, err := dialer.DialContext(r.Context(), target, forwardableRequestHeaders(r.Header))
if err != nil {
writeDialError(w, service, resp, err)
return
}
defer func() { _ = upstream.Close() }()
// Echo the subprotocol the service accepted, if any: a caller that requested
// one fails the connection when the answer does not match.
var responseHeader http.Header
if protocol := upstream.Subprotocol(); protocol != "" {
responseHeader = http.Header{"Sec-Websocket-Protocol": {protocol}}
}
client, err := upgrader.Upgrade(w, r, responseHeader)
if err != nil {
// Upgrade already wrote a plaintext error response.
logger.Debug("Client WebSocket upgrade failed for %q: %v", service, err)
return
}
defer func() { _ = client.Close() }()
// Control frames are RELAYED, not answered. If this proxy answered pings
// itself, the service's own 20s ping / 60s pong / 10s write accounting would
// be satisfied by Maestro even with the phone switched off — liveness
// detection would become a lie. Relay keeps it end-to-end.
client.SetPingHandler(func(appData string) error {
return relayControl(upstream, websocket.PingMessage, appData)
})
client.SetPongHandler(func(appData string) error {
return relayControl(upstream, websocket.PongMessage, appData)
})
upstream.SetPingHandler(func(appData string) error {
return relayControl(client, websocket.PingMessage, appData)
})
upstream.SetPongHandler(func(appData string) error {
return relayControl(client, websocket.PongMessage, appData)
})
// Two pumps, no barriers: whichever hop ends first ends the session.
type pumpEnd struct {
err error
fromUpstream bool
}
ends := make(chan pumpEnd, 2)
go func() { ends <- pumpEnd{err: pump(upstream, client), fromUpstream: true} }()
go func() { ends <- pumpEnd{err: pump(client, upstream), fromUpstream: false} }()
first := <-ends
propagateClose(first.err, first.fromUpstream, client, upstream)
// Closing both sockets unblocks the other pump; waiting for it keeps every
// goroutine inside the handler's lifetime.
_ = client.Close()
_ = upstream.Close()
<-ends
}
// wsScheme maps the plaintext service base URL onto a WebSocket URL.
func wsScheme(target string) string {
u, err := url.Parse(target)
if err != nil {
return target
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
}
return u.String()
}
// writeDialError reports a failed handshake. A service that answered and refused
// (401/403/404) is mirrored with its own status and body: the service is the gate
// on this surface, so a rejected token must look like a rejection, not like a
// broken proxy. No answer at all is a 502.
func writeDialError(w http.ResponseWriter, service string, resp *http.Response, err error) {
if resp != nil {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
}
logger.Debug("Service WebSocket dial failed for %q: %v", service, err)
writeAPIError(w, http.StatusBadGateway, "ERR_UPSTREAM_UNREACHABLE",
fmt.Sprintf("failed to reach the socket of service %q: %v", service, err))
}
// relayControl forwards one control frame to the peer hop.
func relayControl(dst *websocket.Conn, messageType int, appData string) error {
return dst.WriteControl(messageType, []byte(appData), time.Now().Add(relayWriteWait))
}
// pump copies data messages from src to dst, one frame at a time. Control frames
// never reach this loop (the per-connection handlers relay them), so the only
// outcomes are the next message and the terminal error.
func pump(src, dst *websocket.Conn) error {
for {
messageType, reader, err := src.NextReader()
if err != nil {
return err
}
writer, err := dst.NextWriter(messageType)
if err != nil {
return err
}
if _, err := io.Copy(writer, reader); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
}
}
// propagateClose tells the surviving hop why the session ended. A received close
// frame is forwarded with its original code and reason, so the client can tell
// "the admin disarmed the alert" (the service closed cleanly) from "the network
// died" (abnormal closure). Nothing is invented for a clean code.
func propagateClose(cause error, fromUpstream bool, client, upstream *websocket.Conn) {
dst, ended := upstream, "client"
if fromUpstream {
dst, ended = client, "upstream"
}
deadline := time.Now().Add(relayWriteWait)
var closeErr *websocket.CloseError
if errors.As(cause, &closeErr) &&
closeErr.Code != websocket.CloseNoStatusReceived &&
closeErr.Code != websocket.CloseAbnormalClosure {
text := closeErr.Text
if len(text) > maxCloseText {
text = text[:maxCloseText]
}
_ = dst.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(closeErr.Code, text), deadline)
return
}
_ = dst.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "the "+ended+" connection ended"),
deadline)
}
+663
View File
@@ -0,0 +1,663 @@
// 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 dataplane
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
"github.com/gorilla/websocket"
)
// recorder captures what the fake service saw when the socket was opened.
type recorder struct {
mu sync.Mutex
path string
query string
headers http.Header
}
func (rec *recorder) capture(r *http.Request) {
rec.mu.Lock()
defer rec.mu.Unlock()
rec.path = r.URL.Path
rec.query = r.URL.RawQuery
rec.headers = r.Header.Clone()
}
func (rec *recorder) snapshot() (path, query string, headers http.Header) {
rec.mu.Lock()
defer rec.mu.Unlock()
return rec.path, rec.query, rec.headers
}
// startUpstream runs a fake service socket server. run executes after a
// successful upgrade and returning from it ends the connection.
func startUpstream(t *testing.T, run func(c *websocket.Conn)) (*recorder, string) {
t.Helper()
rec := &recorder{}
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.capture(r)
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
run(conn)
}))
t.Cleanup(srv.Close)
return rec, srv.URL
}
// startProxy builds the same route table main.go builds, with a fake resolver.
func startProxy(t *testing.T, resolve Resolver) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
RegisterWith(mux, resolve)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func runningPeer(baseURL string) *serviceproxy.Peer {
return &serviceproxy.Peer{Service: "alarm", Running: true, BaseURL: baseURL}
}
// resolverAnswer resolves every reference to peer, or fails with err.
func resolverAnswer(peer *serviceproxy.Peer, err error) Resolver {
return func(context.Context, string) (*serviceproxy.Peer, error) {
if err != nil {
return nil, err
}
return peer, nil
}
}
func wsURL(t *testing.T, srv *httptest.Server, path string) string {
t.Helper()
return "ws" + strings.TrimPrefix(srv.URL, "http") + path
}
// dialProxy connects a test client through the proxy.
func dialProxy(t *testing.T, srv *httptest.Server, path string, header http.Header) *websocket.Conn {
t.Helper()
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(t, srv, path), header)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("dial %s: %v (status %d)", path, err, status)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// readText reads one text frame, failing the test on timeout or wrong type.
func readText(t *testing.T, conn *websocket.Conn) string {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("set read deadline: %v", err)
}
messageType, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read frame: %v", err)
}
if messageType != websocket.TextMessage {
t.Fatalf("frame type = %d, want text", messageType)
}
return string(data)
}
// TestFramesBothWays is (a): a frame from the service reaches the caller and a
// frame from the caller reaches the service, through the real upgrade path.
func TestFramesBothWays(t *testing.T) {
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
_ = c.WriteMessage(websocket.TextMessage, []byte("alarm-frame-from-service"))
for {
messageType, data, err := c.ReadMessage()
if err != nil {
return
}
if err := c.WriteMessage(messageType, data); err != nil {
return
}
}
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
if got := readText(t, conn); got != "alarm-frame-from-service" {
t.Fatalf("service frame = %q, want alarm-frame-from-service", got)
}
if err := conn.WriteMessage(websocket.TextMessage, []byte("ack-from-client")); err != nil {
t.Fatalf("write client frame: %v", err)
}
if got := readText(t, conn); got != "ack-from-client" {
t.Fatalf("echoed client frame = %q, want ack-from-client", got)
}
}
// TestForwardsQueryAndHeaders is (b): the service sees the caller's query string
// verbatim and the access token header, and never the caller's cookies.
func TestForwardsQueryAndHeaders(t *testing.T) {
const query = "alertId=0f8fad5b-0000-4000-8000-000000000001&token=SUBTOKEN&deviceId=pixel-7&accessToken=ACCESSTOKEN"
rec, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
time.Sleep(50 * time.Millisecond) // hold the socket open long enough to be captured
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
header := http.Header{}
header.Set("X-Access-Token", "ACCESSTOKEN")
header.Set("Cookie", "maestro_access=operator-secret")
header.Set("Origin", "http://127.0.0.1:8081")
_ = dialProxy(t, srv, "/api/alarm/ws/alarm?"+query, header)
path, gotQuery, headers := rec.snapshot()
if path != "/ws/alarm" {
t.Fatalf("upstream path = %q, want /ws/alarm", path)
}
if gotQuery != query {
t.Fatalf("upstream query = %q, want %q", gotQuery, query)
}
if got := headers.Get("X-Access-Token"); got != "ACCESSTOKEN" {
t.Fatalf("upstream X-Access-Token = %q, want ACCESSTOKEN", got)
}
if got := headers.Get("Origin"); got != "http://127.0.0.1:8081" {
t.Fatalf("upstream Origin = %q, want it forwarded", got)
}
if got := headers.Get("Cookie"); got != "" {
t.Fatalf("upstream Cookie = %q, want none — the owner session must never ride into a service", got)
}
// The dialer owns the handshake headers (Host/Upgrade/Connection/
// Sec-WebSocket-Key), so the upstream MUST see a valid upgrade even though we
// never forwarded those from the caller.
if got := headers.Get("Upgrade"); !strings.EqualFold(got, "websocket") {
t.Fatalf("upstream Upgrade = %q, want the dialer's own handshake", got)
}
}
// TestStatusCodes is (c), (d), (g), (h) and (i) as a table: the proxy answers
// with a real HTTP status before it hijacks anything.
func TestStatusCodes(t *testing.T) {
rejectingUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":{"code":"ERR_UNAUTHORIZED","message":"missing access token"}}`)
}))
t.Cleanup(rejectingUpstream.Close)
tests := []struct {
name string
resolve Resolver
path string
upgrade bool
wantStatus int
wantBody string
// wantCode is Maestro's own error code; empty means the body is the
// service's own shape, mirrored verbatim (never reformatted).
wantCode string
}{
{
name: "non-upgrade request is rejected",
resolve: resolverAnswer(runningPeer("http://127.0.0.1:1"), nil),
path: "/api/alarm/ws",
wantStatus: http.StatusBadRequest,
wantBody: "only accepts WebSocket upgrades",
wantCode: "ERR_INVALID_REQUEST",
},
{
name: "unknown service",
resolve: resolverAnswer(nil, fmt.Errorf("%w: %q", serviceproxy.ErrNotFound, "alarm")),
path: "/api/alarm/ws",
upgrade: true,
wantStatus: http.StatusNotFound,
wantBody: "unknown service", // the quoted name is JSON-escaped inside the envelope
wantCode: "ERR_NOT_FOUND",
},
{
name: "service not running",
resolve: resolverAnswer(&serviceproxy.Peer{BaseURL: "http://127.0.0.1:1", Running: false}, nil),
path: "/api/alarm/ws",
upgrade: true,
wantStatus: http.StatusConflict,
wantBody: "is not running",
wantCode: "ERR_NOT_RUNNING",
},
{
name: "docker unavailable",
resolve: resolverAnswer(nil, fmt.Errorf("%w: cannot connect", serviceproxy.ErrDocker)),
path: "/api/alarm/ws",
upgrade: true,
wantStatus: http.StatusInternalServerError,
wantBody: "failed to resolve service",
wantCode: "ERR_INTERNAL",
},
{
name: "upstream refuses the upgrade",
resolve: resolverAnswer(runningPeer(rejectingUpstream.URL), nil),
path: "/api/alarm/ws",
upgrade: true,
wantStatus: http.StatusUnauthorized,
wantBody: "err_unauthorized", // the service's own envelope, lower-cased by its code
},
{
name: "upstream is down",
resolve: resolverAnswer(runningPeer("http://127.0.0.1:1"), nil),
path: "/api/alarm/ws",
upgrade: true,
wantStatus: http.StatusBadGateway,
wantBody: "failed to reach the socket",
wantCode: "ERR_UPSTREAM_UNREACHABLE",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
srv := startProxy(t, test.resolve)
var resp *http.Response
if test.upgrade {
conn, handshake, err := websocket.DefaultDialer.Dial(wsURL(t, srv, test.path), nil)
if conn != nil {
_ = conn.Close()
}
if err == nil {
t.Fatalf("handshake succeeded, want status %d", test.wantStatus)
}
resp = handshake
} else {
var err error
resp, err = http.Get(srv.URL + test.path)
if err != nil {
t.Fatalf("GET %s: %v", test.path, err)
}
}
if resp == nil {
t.Fatal("no HTTP response")
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != test.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", resp.StatusCode, test.wantStatus, body)
}
if !strings.Contains(strings.ToLower(string(body)), strings.ToLower(test.wantBody)) {
t.Fatalf("body = %q, want it to contain %q", body, test.wantBody)
}
if test.wantCode != "" {
// Maestro's own errors must be the uniform envelope: the phone
// clients unwrap {error:{code,message}} and render the message.
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("Content-Type = %q, want application/json for %s", got, test.wantCode)
}
if !strings.Contains(string(body), `"code":"`+test.wantCode+`"`) {
t.Fatalf("body = %q, want code %s", body, test.wantCode)
}
if !strings.Contains(string(body), `"message":"`) {
t.Fatalf("body = %q, want a human-readable message", body)
}
}
})
}
}
// TestCloseCodePropagates is (e): a clean close from the service reaches the
// caller with the same code and reason, so "the admin disarmed the alert" is
// distinguishable from "the network died".
func TestCloseCodePropagates(t *testing.T) {
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
if _, _, err := c.ReadMessage(); err != nil {
return
}
_ = c.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "admin disarmed the alert"),
time.Now().Add(time.Second))
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
t.Fatalf("write: %v", err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("set read deadline: %v", err)
}
_, _, err := conn.ReadMessage()
var closeErr *websocket.CloseError
if !errors.As(err, &closeErr) {
t.Fatalf("read = %v, want a close error", err)
}
if closeErr.Code != websocket.CloseNormalClosure {
t.Fatalf("close code = %d, want %d", closeErr.Code, websocket.CloseNormalClosure)
}
if !strings.Contains(closeErr.Text, "admin disarmed") {
t.Fatalf("close reason = %q, want it to carry the service's reason", closeErr.Text)
}
}
// TestIdleConnectionSurvives is (f): no proxy deadline kills a quiet socket.
// A 2.5s idle gap is far beyond anything a naive 1s proxy deadline would allow,
// and the session must still work afterwards.
func TestIdleConnectionSurvives(t *testing.T) {
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
for {
messageType, data, err := c.ReadMessage()
if err != nil {
return
}
if err := c.WriteMessage(messageType, data); err != nil {
return
}
}
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
// Prove the socket is quiet but alive: say nothing for 2.5s, then use it.
time.Sleep(2500 * time.Millisecond)
if err := conn.WriteMessage(websocket.TextMessage, []byte("after-idle")); err != nil {
t.Fatalf("write after idle: %v", err)
}
if got := readText(t, conn); got != "after-idle" {
t.Fatalf("frame after idle = %q, want after-idle", got)
}
}
// TestPingRelayedNotAnswered is the liveness test: control frames are forwarded
// to the other hop instead of being answered by Maestro.
//
// The caller has a custom ping handler that records the ping it receives, so a
// proxy that answered pings itself would leave that channel empty (the alarm
// service's ping would never reach the phone). The service's pong channel proves
// the return trip: phone pong → proxy → service.
func TestPingRelayedNotAnswered(t *testing.T) {
servicePongs := make(chan string, 1)
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
c.SetPongHandler(func(appData string) error {
select {
case servicePongs <- appData:
default:
}
return nil
})
if err := c.WriteControl(websocket.PingMessage, []byte("service-ping"), time.Now().Add(time.Second)); err != nil {
return
}
// Keep reading so the pong can arrive.
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
conn := dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
callerPings := make(chan string, 1)
conn.SetPingHandler(func(appData string) error {
select {
case callerPings <- appData:
default:
}
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second))
})
// The caller must read for control frames to be processed at all.
go func() {
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
select {
case got := <-callerPings:
if got != "service-ping" {
t.Fatalf("relayed ping payload = %q, want service-ping", got)
}
case <-time.After(5 * time.Second):
t.Fatal("the service's ping never reached the caller — the proxy answered it locally")
}
select {
case got := <-servicePongs:
if got != "service-ping" {
t.Fatalf("pong payload = %q, want service-ping", got)
}
case <-time.After(5 * time.Second):
t.Fatal("the caller's pong never reached the service — the ping/pong path is not end-to-end")
}
}
// TestPathScoping is (3): only the service's /ws namespace is reachable.
func TestPathScoping(t *testing.T) {
// Unit level: the only paths that can be produced start at /ws.
paths := []struct {
sub string
wantPath string
wantOK bool
}{
{"", "/ws", true},
{"alarm", "/ws/alarm", true},
{"alarm/v2", "/ws/alarm/v2", true},
{"/alarm", "/ws/alarm", true},
{"../config", "", false},
{"a/../config", "", false},
{"..", "", false},
{"a//b", "", false},
{"alarm?x=1", "", false},
{"alarm#x", "", false},
{"%2e%2e", "", false},
}
for _, test := range paths {
gotPath, ok := upstreamPath(namespaceWS, test.sub)
if ok != test.wantOK || gotPath != test.wantPath {
t.Fatalf("upstreamPath(ws, %q) = (%q, %v), want (%q, %v)", test.sub, gotPath, ok, test.wantPath, test.wantOK)
}
}
// Route level: /api/{service}/config is not a proxy route (404), and the
// sub-path variant reaches the service's own /ws/alarm path.
rec, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
time.Sleep(50 * time.Millisecond)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
_ = dialProxy(t, srv, "/api/alarm/ws/alarm", nil)
if path, _, _ := rec.snapshot(); path != "/ws/alarm" {
t.Fatalf("upstream path = %q, want /ws/alarm", path)
}
for _, path := range []string{"/api/alarm/config", "/api/typo"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
t.Fatalf("read %s body: %v", path, readErr)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET %s = %d, want 404 — this route must not tunnel to /config", path, resp.StatusCode)
}
// The fallback's own 404 must use Maestro's JSON envelope, not plain text:
// the phone clients unwrap {error:{code,message}} into a rendered sentence.
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("GET %s Content-Type = %q, want application/json", path, got)
}
if !strings.Contains(string(body), `"code":"ERR_NOT_FOUND"`) ||
!strings.Contains(string(body), `"message":"unknown API path"`) {
t.Fatalf("GET %s body = %q, want the ERR_NOT_FOUND envelope with a message", path, body)
}
}
}
// TestConcurrentSessions keeps -race honest: several proxied sessions at once,
// each with its own frames.
func TestConcurrentSessions(t *testing.T) {
_, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
for {
messageType, data, err := c.ReadMessage()
if err != nil {
return
}
if err := c.WriteMessage(messageType, data); err != nil {
return
}
}
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, _, err := websocket.DefaultDialer.Dial(wsURL(t, srv, "/api/alarm/ws"), nil)
if err != nil {
t.Errorf("session %d dial: %v", id, err)
return
}
defer func() { _ = conn.Close() }()
payload := fmt.Sprintf("session-%d", id)
if err := conn.WriteMessage(websocket.TextMessage, []byte(payload)); err != nil {
t.Errorf("session %d write: %v", id, err)
return
}
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, data, err := conn.ReadMessage()
if err != nil {
t.Errorf("session %d read: %v", id, err)
return
}
if string(data) != payload {
t.Errorf("session %d got %q, want %q", id, data, payload)
}
}(i)
}
wg.Wait()
}
// TestProductionPatternSetDoesNotPanic constructs the SAME pattern set main.go
// constructs, because a ServeMux conflict is a boot panic that `go build` and
// `go vet` cannot see. The data plane is a single "/api/" fallback precisely so
// that no pattern in this set can collide with the legacy action route.
func TestProductionPatternSetDoesNotPanic(t *testing.T) {
marker := func(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, name)
})
}
mux := http.NewServeMux()
// The console's own routes, exactly as main.go registers them.
mux.Handle("/api/instance", marker("instance"))
mux.Handle("/api/containers", marker("containers"))
mux.Handle("/api/actions/{containerId}/{actionName}", marker("legacy-action"))
mux.Handle("/api/services/{service}/config", marker("config"))
mux.Handle("/api/ws", marker("maestro-ws"))
// Panics here if the data plane ever regresses to per-shape patterns.
RegisterWith(mux, resolverAnswer(runningPeer("http://127.0.0.1:1"), nil))
serve := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://x"+path, nil))
return rec
}
if rec := serve("/api/actions/abc/createAlert"); rec.Body.String() != "legacy-action" {
t.Fatalf("legacy action route was shadowed: body = %q", rec.Body.String())
}
if rec := serve("/api/services/alarm/config"); rec.Body.String() != "config" {
t.Fatalf("config route was shadowed: body = %q", rec.Body.String())
}
if rec := serve("/api/instance"); rec.Body.String() != "instance" {
t.Fatalf("instance route was shadowed: body = %q", rec.Body.String())
}
// The data plane answers its own namespaces through the fallback.
if rec := serve("/api/alarm/ws"); rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), "only accepts WebSocket upgrades") {
t.Fatalf("/api/alarm/ws = %d %q, want the data plane's 400", rec.Code, rec.Body.String())
}
if rec := serve("/api/alarm/config"); rec.Code != http.StatusNotFound {
t.Fatalf("/api/alarm/config = %d, want 404 — the data plane must not tunnel to /config", rec.Code)
}
}
// TestNaivePatternSetPanics pins WHY the fallback exists: the literal patterns for
// this surface cannot be registered next to the legacy action route. If a future
// Go release makes this set legal, this test fails and the fallback can be
// replaced by real patterns; until then, the fallback is the only option.
func TestNaivePatternSetPanics(t *testing.T) {
marker := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
for _, naive := range []string{
"/api/{service}/action/{actionName}",
"/api/{service}/auth/{path...}",
"/api/{service}/ws/{path...}",
} {
t.Run(naive, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatalf("registering %q next to the legacy action route did NOT panic; "+
"the /api/ fallback can be reconsidered", naive)
}
}()
mux := http.NewServeMux()
mux.Handle("/api/actions/{containerId}/{actionName}", marker)
mux.Handle(naive, marker)
})
}
}
// TestUpgradeOnAnotherNamespaceUsesWSRelay pins the single dispatch rule: upgrade
// presence, not the namespace name, selects the WebSocket relay.
func TestUpgradeOnAnotherNamespaceUsesWSRelay(t *testing.T) {
rec, upstreamURL := startUpstream(t, func(c *websocket.Conn) {
_ = c.WriteMessage(websocket.TextMessage, []byte("events-frame"))
time.Sleep(100 * time.Millisecond)
})
srv := startProxy(t, resolverAnswer(runningPeer(upstreamURL), nil))
conn := dialProxy(t, srv, "/api/alarm/auth/events", nil)
if got := readText(t, conn); got != "events-frame" {
t.Fatalf("frame = %q, want events-frame", got)
}
if path, _, _ := rec.snapshot(); path != "/auth/events" {
t.Fatalf("upstream path = %q, want /auth/events", path)
}
}
+47 -15
View File
@@ -25,15 +25,17 @@ import (
"sort"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/instance"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
// InstanceIdentity is the public face of this instance. Fingerprint is the
// instance CA's public-key hash — the same value a peer pins at first contact.
// instance's stable app-level identity fingerprint (rev 9: with the PKI layer
// removed there is no CA, so it is derived from a persisted instance key). It
// is an unauthenticated string a consumer can still compare to detect a
// changed peer.
type InstanceIdentity struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
@@ -55,18 +57,31 @@ type PublicService struct {
Image string `json:"image"`
}
// TrustedInstance is the retired federation trust row. Rev 9 removed the
// peer/federation trust store (no peer identity exists on a plaintext
// network), so the public profile always reports an empty list; the field is
// kept so existing consumers (the hub SPA) still see the documented shape.
type TrustedInstance struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Status string `json:"status,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
// PublicProfile is the GET /api/instance wire shape. The section pointers
// carry three distinct states: nil → field omitted (privacy switch off),
// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON [].
type PublicProfile struct {
Instance InstanceIdentity `json:"instance"`
Services *[]PublicService `json:"services,omitempty"`
TrustedInstances *[]federation.RemoteInstance `json:"trusted_instances,omitempty"`
Instance InstanceIdentity `json:"instance"`
Services *[]PublicService `json:"services,omitempty"`
TrustedInstances *[]TrustedInstance `json:"trusted_instances,omitempty"`
Apps *[]catalog.App `json:"apps,omitempty"`
}
// NewPublicProfileHandler serves GET /api/instance. getServices is the cache
// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and
// federation stores on demand.
// accessor (cache.GetServices self-locks); dataRoot resolves identity, the
// instance fingerprint and the catalog store on demand.
func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
profile := PublicProfile{
@@ -75,14 +90,14 @@ func NewPublicProfileHandler(getServices func() []types.Container, dataRoot stri
if env.PublicProfile() {
services := publicServices(getServices())
trusted, err := federation.List(dataRoot)
if err != nil {
// A malformed store must not take the profile down;
// report absence instead.
trusted = nil
}
// Rev 9: the peer/federation trust store is retired, so the
// trusted list is always empty (never nil, so the field stays an
// array rather than JSON null).
trusted := []TrustedInstance{}
apps := publicApps(dataRoot, catalog.RequestBaseURL(r))
profile.Services = &services
profile.TrustedInstances = &trusted
profile.Apps = &apps
}
w.Header().Set("Content-Type", "application/json")
@@ -99,10 +114,27 @@ func buildIdentity(dataRoot string) InstanceIdentity {
Name: id.Name,
Title: id.Title,
Description: id.Description,
Fingerprint: pki.CAFingerprint(dataRoot),
Fingerprint: instance.Fingerprint(dataRoot),
}
}
// publicApps projects the operator-authored catalog (app-catalog theme) onto
// the public profile: metadata plus absolute download/icon URLs, with the
// private owner field stripped (A7). The result is always non-nil so an empty
// catalog serializes as [] rather than null; a malformed store degrades to []
// instead of taking the profile down.
func publicApps(dataRoot, base string) []catalog.App {
apps, err := catalog.List(dataRoot)
if err != nil || apps == nil {
apps = []catalog.App{}
}
out := make([]catalog.App, 0, len(apps))
for _, app := range apps {
out = append(out, app.WithBase(base).Public())
}
return out
}
// publicServices filters hidden infrastructure (role-labeled containers such
// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
// public field set. The result is sorted case-insensitively by service name:
+89 -17
View File
@@ -21,9 +21,10 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
@@ -125,38 +126,41 @@ func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
}
}
func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) {
// TestPublicProfileTrustedInstancesAlwaysEmpty covers rev 9: the federation
// trust store is retired, so trusted_instances is always an empty array (never
// null) regardless of on-disk state, and the instance fingerprint is a stable
// app-level value derived from the persisted instance key.
func TestPublicProfileTrustedInstancesAlwaysEmpty(t *testing.T) {
dataRoot := t.TempDir()
// Store absent → JSON null (field present with null value).
body := serve(t, dataRoot, testServices())
if v, ok := body["trusted_instances"]; !ok || v != nil {
raw, _ := json.Marshal(body)
t.Fatalf("absent federation store should yield trusted_instances:null, got %s", raw)
raw, _ := json.Marshal(body["trusted_instances"])
if string(raw) != "[]" {
t.Fatalf("trusted_instances should be [] after the federation removal, got %s", raw)
}
// Present but empty → [].
// A stale federation directory on disk must not resurrect the surface.
if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil {
if err := os.WriteFile(filepath.Join(dataRoot, "federation", "instances.json"),
[]byte(`{"instances":[{"name":"peer-b","status":"authorized"}]}`), 0o600); err != nil {
t.Fatal(err)
}
body = serve(t, dataRoot, testServices())
raw, _ := json.Marshal(body["trusted_instances"])
raw, _ = json.Marshal(body["trusted_instances"])
if string(raw) != "[]" {
t.Fatalf("empty store should marshal as [], got %s", raw)
t.Fatalf("stale federation store leaked into the profile: %s", raw)
}
// Populated → rows pass through.
populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}`
if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil {
t.Fatal(err)
// The fingerprint is present and stable across requests.
first := body["instance"].(map[string]any)["fingerprint"].(string)
if !strings.HasPrefix(first, "sha256:") {
t.Fatalf("fingerprint = %q, want sha256: prefix", first)
}
body = serve(t, dataRoot, testServices())
list := body["trusted_instances"].([]any)
if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" {
t.Fatalf("populated store not surfaced: %v", list)
if again := body["instance"].(map[string]any)["fingerprint"].(string); again != first {
t.Fatalf("fingerprint not stable: %q vs %q", first, again)
}
}
@@ -174,3 +178,71 @@ func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
t.Error("instance identity must remain visible with the profile off")
}
}
func TestPublicProfileAppsSection(t *testing.T) {
dataRoot := t.TempDir()
id := catalog.AppID("com.example.profile")
app := catalog.App{
ID: id, Package: "com.example.profile", Name: "Profile App",
VersionName: "1.0", AddedAt: "2026-10-06T12:00:00Z", UpdatedAt: "2026-10-06T12:00:00Z", AddedBy: "owner",
Artifact: catalog.Artifact{
Filename: "app.apk", SizeBytes: 5, SHA256: strings.Repeat("d", 64),
Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType,
},
ArtifactPath: filepath.Join("artifacts", id, "app.apk"),
IconPath: filepath.Join("icons", id),
}
if err := catalog.NewStore(dataRoot).Upsert(app); err != nil {
t.Fatalf("seed catalog: %v", err)
}
body := serve(t, dataRoot, testServices())
raw, _ := json.Marshal(body["apps"])
var apps []map[string]any
if err := json.Unmarshal(raw, &apps); err != nil {
t.Fatalf("apps not an array: %v (%s)", err, raw)
}
if len(apps) != 1 {
t.Fatalf("apps = %s", raw)
}
row := apps[0]
if row["package"] != "com.example.profile" || row["name"] != "Profile App" {
t.Fatalf("app metadata wrong: %v", row)
}
if _, leaked := row["added_by"]; leaked {
t.Fatal("public profile leaked added_by")
}
if _, leaked := row["artifact_path"]; leaked {
t.Fatal("public profile leaked the internal artifact path")
}
artifact, ok := row["artifact"].(map[string]any)
if !ok {
t.Fatalf("artifact missing: %v", row)
}
download := artifact["download_url"].(string)
if !strings.HasPrefix(download, "http://127.0.0.1:") || !strings.HasSuffix(download, "/api/catalog/artifacts/"+id+"/app.apk") {
t.Fatalf("download_url = %q", download)
}
icon := row["icon_url"].(string)
if !strings.HasSuffix(icon, "/api/catalog/icons/"+id) {
t.Fatalf("icon_url = %q", icon)
}
if artifact["sha256"] != strings.Repeat("d", 64) {
t.Fatalf("sha256 not surfaced: %v", artifact)
}
}
func TestPublicProfileAppsEmptyAndSwitchOff(t *testing.T) {
dataRoot := t.TempDir()
body := serve(t, dataRoot, testServices())
raw, _ := json.Marshal(body["apps"])
if string(raw) != "[]" {
t.Fatalf("empty catalog must marshal as [], got %s", raw)
}
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
body = serve(t, dataRoot, testServices())
if _, ok := body["apps"]; ok {
t.Error("apps must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false")
}
}
-186
View File
@@ -1,186 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package peers serves the trusted-services surface: pending first-contact
// authorizations and the approve/revoke actions for the instance owner
// (security theme M2 §3). Maestro is the UI; the auth service owns the
// enforcement.
package peers
import (
"encoding/json"
"net/http"
"sync"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
)
// Handler wraps a lazily-initialized auth management client so identity
// issuance happens once per process, not per request.
type Handler struct {
mu sync.Mutex
client *authmgmt.Client
}
// NewHandler creates the peers handler. The underlying mTLS client is
// constructed on first use.
func NewHandler() *Handler {
return &Handler{}
}
// List serves GET /api/peers — the combined pending + approved trust state.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
state, err := client.TrustState(r.Context())
if err != nil {
http.Error(w, "failed to read trust state: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, state)
}
// Attestation serves GET /api/peers/attestation — the auth service's public
// signing key and its pinnable fingerprint.
func (h *Handler) Attestation(w http.ResponseWriter, r *http.Request) {
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
att, err := client.Attestation(r.Context())
if err != nil {
http.Error(w, "failed to read attestation: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, att)
}
type authzAction struct {
Service string `json:"service"`
Fingerprint string `json:"fingerprint"`
}
// Approve serves POST /api/peers/approve — forwards the owner's approval to
// the auth service's trust store.
func (h *Handler) Approve(w http.ResponseWriter, r *http.Request) {
var action authzAction
if !decodeAction(r, w, &action) {
return
}
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
if err := client.Approve(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to approve peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "approved"})
}
// Revoke serves POST /api/peers/revoke — forwards the owner's revocation
// (returns the peer to the no-op path immediately).
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
var action authzAction
if !decodeAction(r, w, &action) {
return
}
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
if err := client.Revoke(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to revoke peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// Reject serves POST /api/peers/reject — denies a pending first-contact
// request without authorizing it (the auth service drops the request and
// audits the decision).
func (h *Handler) Reject(w http.ResponseWriter, r *http.Request) {
var action authzAction
if !decodeAction(r, w, &action) {
return
}
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
if err := client.Reject(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to reject peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
}
// Hide serves POST /api/peers/hide — permanently hides a revoked peer from
// the dashboard: the auth service keeps the row (audit trail survives) but
// marks it hidden; a re-approval restores visibility.
func (h *Handler) Hide(w http.ResponseWriter, r *http.Request) {
var action authzAction
if !decodeAction(r, w, &action) {
return
}
client, err := h.ensureClient()
if err != nil {
http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
if err := client.Hide(r.Context(), action.Service, action.Fingerprint); err != nil {
http.Error(w, "failed to hide peer: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "hidden"})
}
func (h *Handler) ensureClient() (*authmgmt.Client, error) {
h.mu.Lock()
defer h.mu.Unlock()
if h.client == nil {
client, err := authmgmt.New()
if err != nil {
return nil, err
}
h.client = client
}
return h.client, nil
}
func decodeAction(r *http.Request, w http.ResponseWriter, out *authzAction) bool {
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return false
}
if out.Service == "" || out.Fingerprint == "" {
http.Error(w, "service and fingerprint are required", http.StatusBadRequest)
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+108
View File
@@ -0,0 +1,108 @@
// 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 weblinks exposes the operator-controlled URL sources used by the Web
// App capability (contract rev 8.2 §16.2): a global base-domain template and
// per-service overrides. Owner-gated; the values are only ever base URLs, and
// every source is re-validated at resolution time.
package weblinks
import (
"encoding/json"
"net/http"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/webcfg"
)
// Handler serves GET/PUT /api/web-links.
type Handler struct {
dataRoot string
}
// NewHandler builds the owner-gated web-links handler.
func NewHandler(dataRoot string) *Handler {
return &Handler{dataRoot: dataRoot}
}
// Root handles both the read and the full-replacement write. PUT is a full
// replacement: omitted fields clear, matching the catalog's PUT semantics.
func (h *Handler) Root(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.get(w)
case http.MethodPut:
h.put(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
}
}
func (h *Handler) get(w http.ResponseWriter) {
cfg := webcfg.Load(h.dataRoot)
if cfg.Overrides == nil {
cfg.Overrides = map[string]string{}
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *Handler) put(w http.ResponseWriter, r *http.Request) {
var body webcfg.Config
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
return
}
if body.BaseDomainTemplate != "" && !containsServicePlaceholder(body.BaseDomainTemplate) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
return
}
if !types.ValidWebTemplate(body.BaseDomainTemplate) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
return
}
for service, raw := range body.Overrides {
if service == "" || !types.ValidWebBase(raw) {
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
return
}
}
if body.Overrides == nil {
body.Overrides = map[string]string{}
}
if err := webcfg.Save(h.dataRoot, body); err != nil {
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
writeJSON(w, http.StatusOK, body)
}
func containsServicePlaceholder(tpl string) bool {
for i := 0; i+9 <= len(tpl); i++ {
if tpl[i:i+9] == "<service>" {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code}})
}
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist/assets/
.vite/
+251
View File
@@ -0,0 +1,251 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "hub",
"dependencies": {
"preact": "^10.29.3",
},
"devDependencies": {
"@preact/preset-vite": "^2.10.5",
"@types/node": "^24.13.2",
"typescript": "~6.0.2",
"vite": "^8.1.1",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-transform-react-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="],
"@babel/plugin-transform-react-jsx-development": ["@babel/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="],
"@preact/preset-vite": ["@preact/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="],
"@prefresh/babel-plugin": ["@prefresh/[email protected]", "", { "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0" } }, "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA=="],
"@prefresh/core": ["@prefresh/[email protected]", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ=="],
"@prefresh/utils": ["@prefresh/[email protected]", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="],
"@prefresh/vite": ["@prefresh/[email protected]", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="],
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="],
"babel-plugin-transform-hook-names": ["[email protected]", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="],
"boolbase": ["[email protected]", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css-select": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["[email protected]", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="],
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kolorist": ["[email protected]", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"node-html-parser": ["[email protected]", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="],
"node-releases": ["[email protected]", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="],
"nth-check": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
"preact": ["[email protected]", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"rolldown": ["[email protected]", "", { "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-x64-msvc": "1.2.13" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-code-frame": ["[email protected]", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"stack-trace": ["[email protected]", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
"vite": ["[email protected]", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
"vite-prerender-plugin": ["[email protected]", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zimmerframe": ["[email protected]", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@prefresh/vite/@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@prefresh/vite/@rollup/pluginutils/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
+27
View File
@@ -0,0 +1,27 @@
<!--
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/>.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Hub">
<rect width="32" height="32" rx="7" fill="#101318"/>
<circle cx="16" cy="16" r="3.2" fill="#e556a4"/>
<circle cx="16" cy="6.5" r="2.2" fill="#e8ecf2"/>
<circle cx="25.5" cy="16" r="2.2" fill="#e8ecf2"/>
<circle cx="16" cy="25.5" r="2.2" fill="#e8ecf2"/>
<circle cx="6.5" cy="16" r="2.2" fill="#e8ecf2"/>
<g stroke="#e556a4" stroke-width="1.6">
<path d="M16 13.2V8.7M18.8 16h4.5M16 18.8v4.5M13.2 16H8.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<!--
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/>.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/hub/favicon.svg" />
<title>Hub</title>
<script type="module" crossorigin src="/hub/assets/index-CLg9ZpTb.js"></script>
<link rel="stylesheet" crossorigin href="/hub/assets/index-BXFB8T1y.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<!--
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/>.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/hub/favicon.svg" />
<title>Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{
"name": "hub",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.29.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.5",
"@types/node": "^24.13.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+27
View File
@@ -0,0 +1,27 @@
<!--
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/>.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Hub">
<rect width="32" height="32" rx="7" fill="#101318"/>
<circle cx="16" cy="16" r="3.2" fill="#e556a4"/>
<circle cx="16" cy="6.5" r="2.2" fill="#e8ecf2"/>
<circle cx="25.5" cy="16" r="2.2" fill="#e8ecf2"/>
<circle cx="16" cy="25.5" r="2.2" fill="#e8ecf2"/>
<circle cx="6.5" cy="16" r="2.2" fill="#e8ecf2"/>
<g stroke="#e556a4" stroke-width="1.6">
<path d="M16 13.2V8.7M18.8 16h4.5M16 18.8v4.5M13.2 16H8.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+132
View File
@@ -0,0 +1,132 @@
/**
* 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 type { JSX } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { DataProvider, Shell, ThemeProvider, useData } from './components/shell';
import { matchPath, replace, subscribe, href, type Route } from './lib/router';
import { instanceDisplayName } from './lib/format';
import { InstancesScreen } from './screens/instances';
import { InstanceHomeScreen } from './screens/instance-home';
import { ServicesScreen } from './screens/services';
import { ServiceDetailScreen } from './screens/service-detail';
import { TrustedScreen } from './screens/trusted';
import { SettingsScreen } from './screens/settings';
import { NotFoundScreen } from './screens/not-found';
export function App() {
return (
<ThemeProvider>
<DataProvider>
<RouterContent />
</DataProvider>
</ThemeProvider>
);
}
function RouterContent() {
const { profile } = useData();
const [route, setRoute] = useState<Route>(() => matchPath(window.location.pathname));
useEffect(() => subscribe(() => setRoute(matchPath(window.location.pathname))), []);
// Redirects (§3.1): bare root and bare /local replace (never a history entry).
// Targets must carry the frozen /hub/ mount prefix — a bare "/instances"
// would leave the hub bundle and land on the operator console's path space.
useEffect(() => {
if (route.name === 'redirect-root') {
replace(href('/instances'));
} else if (route.name === 'redirect-local') {
replace(href('/instances/local/services'));
}
}, [route]);
// document.title per route (§3.1 column; WCAG 2.4.2).
useEffect(() => {
setDocumentTitle(route, profile);
}, [route, profile]);
// Focus moves to the route h1 on client-side navigation (§3.2).
useEffect(() => {
if (route.name === 'redirect-root' || route.name === 'redirect-local') return;
const raf = requestAnimationFrame(() => {
const h1 = document.querySelector('main h1');
if (h1 instanceof HTMLElement) h1.focus();
});
return () => cancelAnimationFrame(raf);
}, [route]);
const screen = renderScreen(route);
return <Shell route={route}>{screen}</Shell>;
}
function renderScreen(route: Route): JSX.Element {
switch (route.name) {
case 'redirect-root':
return <InstancesScreen />;
case 'redirect-local':
return <InstanceHomeScreen />;
case 'instances':
return <InstancesScreen />;
case 'services':
return <ServicesScreen />;
case 'service-detail':
return <ServiceDetailScreen serviceName={route.serviceName} />;
case 'trusted':
return <TrustedScreen />;
case 'settings':
return <SettingsScreen />;
default:
return <NotFoundScreen />;
}
}
function setDocumentTitle(route: Route, profile: ReturnType<typeof useData>['profile']): void {
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name, 'instance')
: 'instance';
switch (route.name) {
case 'instances':
document.title = 'Hub';
break;
case 'services':
document.title = profile
? `Services — ${instanceName} · Hub`
: 'Loading… · Hub';
break;
case 'service-detail': {
const service = profile?.services?.find(s => s.service_name === route.serviceName);
const serviceName = service?.title || route.serviceName;
document.title = profile ? `${serviceName} — ${instanceName} · Hub` : 'Loading… · Hub';
break;
}
case 'trusted':
document.title = profile
? `Trusted partners — ${instanceName} · Hub`
: 'Loading… · Hub';
break;
case 'settings':
document.title = 'Settings · Hub';
break;
case 'not-found':
document.title = 'Page not found · Hub';
break;
default:
document.title = 'Hub';
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* 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 type { JSX } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { CopyControl } from './ui';
/**
* Instance details dialog (§4.2): role=dialog aria-modal, focus trap,
* Escape + ✕ + backdrop close, focus returns to the invoker. Rows: Name / URL
* (window.location.origin) / Fingerprint (mono + Copy).
*/
export function DetailsDialog({
open,
name,
fingerprint,
onClose,
}: {
open: boolean;
name: string;
fingerprint?: string;
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
if (!dialog) return;
// Focus the dialog on open.
dialog.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key !== 'Tab') return;
// Simple focus trap: keep Tab cycling within the dialog.
const focusables = dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
class="dialog-backdrop"
onClick={(e: JSX.TargetedMouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="details-dialog-title"
tabindex={-1}
onClick={(e: JSX.TargetedMouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
<div class="dialog-head">
<div id="details-dialog-title" class="dialog-title">
{name}
</div>
<button type="button" class="icon-btn" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
<dl class="detail-list">
<div class="detail-row">
<dt class="detail-label">Name</dt>
<dd class="detail-value">{name}</dd>
</div>
<div class="detail-row">
<dt class="detail-label">URL</dt>
<dd class="detail-value">
<CopyControl text={origin} />
</dd>
</div>
<div class="detail-row">
<dt class="detail-label">Fingerprint</dt>
<dd class="detail-value">
{fingerprint ? <CopyControl text={fingerprint} /> : <span class="muted">—</span>}
</dd>
</div>
</dl>
</div>
</div>
);
}
function CloseIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 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 type { JSX } from 'preact';
import { useId, useRef, useState } from 'preact/hooks';
import { CopyControl, Chip } from './ui';
import { PartnerStatus } from './status';
import type { TrustedInstance } from '../lib/types';
let idCounter = 0;
const nextId = () => `partner-${++idCounter}`;
/**
* A trusted-partner row (§4.5). Tapping opens an inline disclosure explaining
* browsers can't browse partners; Escape inside collapses and returns focus to
* the row button. No focus trap and no focus movement on open — the panel is
* adjacent DOM and reading order continues naturally.
*/
export function PartnerRow({ partner }: { partner: TrustedInstance }) {
const [open, setOpen] = useState(false);
const rowId = useId() || nextId();
const panelId = `${rowId}-panel`;
const titleId = `${rowId}-title`;
const btnRef = useRef<HTMLButtonElement | null>(null);
const name = partner.name || partner.url;
const toggle = () => setOpen(o => !o);
const close = () => {
setOpen(false);
btnRef.current?.focus();
};
const onKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
close();
}
};
return (
<div>
<button
type="button"
ref={btnRef}
class="partner-row"
aria-expanded={open}
aria-controls={panelId}
aria-label={`${name} — trusted partner`}
onClick={toggle}
>
<span class="partner-icon" aria-hidden="true">
<LinkIcon />
</span>
<span class="partner-body">
<span class="partner-head">
<span class="partner-name">{name}</span>
{partner.status ? <PartnerStatus status={partner.status} /> : null}
</span>
<span class="partner-url mono">{partner.url}</span>
{partner.scopes && partner.scopes.length > 0 ? (
<span class="partner-scopes">{partner.scopes.join(', ')}</span>
) : null}
<span class="partner-meta">
<Chip soft>trusted</Chip>
</span>
</span>
<span class="partner-chevron" aria-hidden="true">
<ChevronIcon />
</span>
</button>
{open ? (
<div
id={panelId}
class="disclosure"
role="region"
aria-labelledby={titleId}
onKeyDown={onKeyDown}
>
<div id={titleId} class="disclosure-title">
Can't browse partners from the browser
</div>
<div class="disclosure-body">
A browser page may only read the instance that serves it, so this
hub can't fetch {name}’s profile. Open its address directly, or use
the Hub app on a phone.
</div>
<div class="mono tiny muted">{partner.url}</div>
<div class="disclosure-actions">
<CopyControl text={partner.url} />
<button type="button" class="btn btn-ghost small" onClick={close}>
Close
</button>
</div>
</div>
) : null}
</div>
);
}
function LinkIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
function ChevronIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="m9 18 6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 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 type { PublicService } from '../lib/types';
import { href } from '../lib/router';
import { linkClick } from './shell';
import { serviceTone, shortVersion } from '../lib/format';
import { Chip, Dot } from './ui';
/**
* Read-only service card (§4.3). The card is a real <a> to the detail route;
* the status dot is decorative (aria-hidden) — the status repeats as chip text
* and in an accessible description so it is never colour-only.
*/
export function ServiceCard({ service }: { service: PublicService }) {
const title = service.title || service.service_name;
const tone = serviceTone(service.status);
const isDb = service.capabilities?.includes('database') ?? false;
const detailHref = href(`/instances/local/services/${encodeURIComponent(service.service_name)}`);
return (
<a
class="card card-link service-card"
href={detailHref}
aria-label={`${title} — status: ${service.status}`}
onClick={linkClick(detailHref)}
>
<div class="service-card-head">
<span class="service-card-icon" aria-hidden="true">
{isDb ? <DbIcon /> : <BoxIcon />}
</span>
<span class="service-card-title">{title}</span>
<Dot tone={tone} />
</div>
<div class="service-card-name mono">{service.service_name}</div>
{service.description ? <div class="service-card-desc">{service.description}</div> : null}
<div class="service-card-chips">
{service.version ? (
<Chip soft title={service.version}>{`v${shortVersion(service.version)}`}</Chip>
) : null}
{isDb ? <Chip soft>database</Chip> : null}
<Chip soft>{service.status}</Chip>
</div>
</a>
);
}
function BoxIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M3.27 6.96 12 12.01l8.73-5.05" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 22.08V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function DbIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" stroke-width="2" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" stroke="currentColor" stroke-width="2" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" stroke="currentColor" stroke-width="2" />
</svg>
);
}
+531
View File
@@ -0,0 +1,531 @@
/**
* 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 type { JSX } from 'preact';
import { createContext } from 'preact';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { fetchPublicProfile, type FetchResult } from '../lib/api';
import { backOrReplace, href, push, type Route } from '../lib/router';
import type { DataState, InstancePublicProfile, SectionState } from '../lib/types';
import { IconButton } from './ui';
/* ------------------------------------------------------------------ */
/* Theme */
/* ------------------------------------------------------------------ */
export type Theme = 'light' | 'dark';
const ThemeCtx = createContext<{ theme: Theme; toggle: () => void }>({
theme: 'light',
toggle: () => {},
});
export function useTheme() {
return useContext(ThemeCtx);
}
function effectiveTheme(): Theme {
try {
const stored = localStorage.getItem('settings.theme');
if (stored === 'light' || stored === 'dark') return stored;
} catch {
/* ignore storage errors */
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
export function ThemeProvider({ children }: { children: JSX.Element }) {
const [theme, setTheme] = useState<Theme>(effectiveTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try {
localStorage.setItem('settings.theme', theme);
} catch {
/* ignore storage errors */
}
}, [theme]);
const toggle = useCallback(() => {
setTheme(t => (t === 'dark' ? 'light' : 'dark'));
}, []);
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
}
/* ------------------------------------------------------------------ */
/* Data polling (§4.0) */
/* ------------------------------------------------------------------ */
interface CachedProfile {
fetchedAt: number;
instance: InstancePublicProfile['instance'];
sections: { services: SectionState; trusted: SectionState };
}
const CACHE_KEY = 'hub.profile';
const BACKOFF = [5000, 15000, 30000, 60000];
const PROBE_TIMEOUT = 10000;
const initialData: DataState = {
profile: null,
status: 'disconnected',
probing: false,
error: null,
lastProbedAt: null,
lastProbeFailed: false,
offline: false,
sections: null,
};
function readCache(): CachedProfile | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw) as CachedProfile;
} catch {
return null;
}
}
function writeCache(c: CachedProfile): void {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch {
/* ignore */
}
}
interface DataApi extends DataState {
refresh: () => void;
announce: (msg: string) => void;
announcement: string;
}
const DataCtx = createContext<DataApi>({
...initialData,
refresh: () => {},
announce: () => {},
announcement: '',
});
export function useData(): DataApi {
return useContext(DataCtx);
}
export function DataProvider({ children }: { children: JSX.Element }) {
const [state, setState] = useState<DataState>(() => {
const cached = readCache();
if (!cached) return initialData;
return {
...initialData,
profile: {
instance: cached.instance,
services: cached.sections.services.value as never,
trusted_instances: cached.sections.trusted.value as never,
},
sections: cached.sections,
};
});
const [announcement, setAnnouncement] = useState('');
// Refs for the polling loop (avoid stale closures).
const inFlightRef = useRef(false);
const failuresRef = useRef(0);
const statusRef = useRef<DataState['status']>('disconnected');
const timerRef = useRef<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const profileRef = useRef<InstancePublicProfile | null>(null);
const sectionsRef = useRef<{ services: SectionState; trusted: SectionState } | null>(null);
const announce = useCallback((msg: string) => {
setAnnouncement(msg);
}, []);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const schedule = useCallback(
(delay: number) => {
clearTimer();
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
if (document.visibilityState === 'visible') {
void runOnce();
}
}, delay);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[clearTimer],
);
const updateFromResult = useCallback(
(res: FetchResult) => {
const prevStatus = statusRef.current;
failuresRef.current = 0;
statusRef.current = 'connected';
profileRef.current = res.profile;
sectionsRef.current = res.sections;
writeCache({
fetchedAt: Date.now(),
instance: res.profile.instance,
sections: res.sections,
});
setState(s => ({
...s,
profile: res.profile,
sections: res.sections,
status: 'connected',
error: null,
lastProbeFailed: false,
offline: false,
lastProbedAt: Date.now(),
probing: false,
}));
if (prevStatus !== 'connected') {
announce('Instance connected');
}
},
[announce],
);
const handleFailure = useCallback(
(message: string, offline: boolean) => {
const prevStatus = statusRef.current;
failuresRef.current += 1;
const f = failuresRef.current;
// Hysteresis: connected→flaky requires two consecutive failures.
if (prevStatus === 'connected' && f >= 2) {
statusRef.current = 'flaky';
}
const newStatus = statusRef.current;
setState(s => ({
...s,
status: newStatus,
error: message,
lastProbeFailed: true,
offline,
lastProbedAt: Date.now(),
probing: false,
}));
if (prevStatus !== newStatus && !offline) {
announce(`Instance ${newStatus}`);
}
},
[announce],
);
const runOnce = useCallback(async () => {
if (inFlightRef.current) return;
// Offline: don't burn a probe; report offline and wait for `online`.
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
handleFailure("You're offline.", true);
schedule(5000);
return;
}
inFlightRef.current = true;
abortRef.current = new AbortController();
setState(s => ({ ...s, probing: true }));
try {
const res = await fetchPublicProfile(window.location.origin, PROBE_TIMEOUT);
updateFromResult(res);
} catch (e) {
const msg =
e instanceof Error
? taxonomyMessage(e.message)
: 'Instance unreachable — the instance is not answering.';
handleFailure(msg, false);
} finally {
inFlightRef.current = false;
abortRef.current = null;
// Schedule the next probe per backoff.
const f = failuresRef.current;
const delay = f === 0 ? 5000 : BACKOFF[Math.min(f - 1, BACKOFF.length - 1)];
schedule(delay);
}
}, [updateFromResult, handleFailure, schedule]);
// Boot + events.
useEffect(() => {
void runOnce();
const onFocus = () => {
if (document.visibilityState === 'visible') void runOnce();
};
const onVis = () => {
if (document.visibilityState === 'visible') {
void runOnce();
} else {
clearTimer();
}
};
const onOnline = () => void runOnce();
const onOffline = () => {
handleFailure("You're offline.", true);
clearTimer();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVis);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
clearTimer();
};
}, [runOnce, handleFailure, clearTimer]);
// Manual refresh (shared in-flight guard with polling).
const refresh = useCallback(() => {
void runOnce();
}, [runOnce]);
const value = useMemo<DataApi>(
() => ({
...state,
refresh,
announce,
announcement,
}),
[state, refresh, announce, announcement],
);
return (
<DataCtx.Provider value={value}>
{children}
<div class="visually-hidden" role="status" aria-live="polite">
{announcement}
</div>
</DataCtx.Provider>
);
}
/** Map a raw fetch error message onto the taxonomy banner copy (§4.0). */
function taxonomyMessage(raw: string): string {
if (raw.includes('has no /api/instance')) {
return 'This instance did not return a public profile.';
}
if (raw.includes('HTTP 401') || raw.includes('HTTP 403')) {
return 'This profile is not reachable from your network.';
}
if (raw.includes('HTTP ')) {
return 'That address is not a Dcntrlized instance.';
}
return 'Instance unreachable — the instance is not answering.';
}
/** Derive the exact taxonomy banner copy from an error (for screens). */
export function taxonomyCopy(msg: string | null, status?: number): string {
if (!msg) return 'Instance unreachable — the instance is not answering.';
if (status === 401 || status === 403) {
return 'This profile is not reachable from your network.';
}
return taxonomyMessage(msg);
}
/* ------------------------------------------------------------------ */
/* Shell layout */
/* ------------------------------------------------------------------ */
export function Shell({
route,
children,
}: {
route: Route;
children: JSX.Element;
}) {
const { probing, refresh } = useData();
const inFlight = probing;
const isInstances = route.name === 'instances';
return (
<div class="app-shell">
<a class="skip-link" href="#main-content">
Skip to content
</a>
<aside class="rail">
<div class="rail-brand">Hub</div>
<a
class="rail-link"
href={href('/instances')}
aria-current={isInstances ? 'page' : undefined}
onClick={linkClick(href('/instances'))}
>
Hub
</a>
<a
class="rail-link"
href={href('/settings')}
aria-current={route.name === 'settings' ? 'page' : undefined}
onClick={linkClick(href('/settings'))}
>
Settings
</a>
</aside>
<div class="shell-main-wrap">
<header class="topbar">
<a class="topbar-brand" href={href('/instances')} onClick={linkClick(href('/instances'))}>
Hub
</a>
<div class="spacer" />
{isInstances ? (
<>
<IconButton label="Refresh" onClick={refresh} disabled={inFlight}>
<RefreshIcon />
</IconButton>
<IconButton label="Settings" onClick={() => push(href('/settings'))}>
<SettingsIcon />
</IconButton>
</>
) : null}
</header>
<main id="main-content" tabindex={-1}>
<div class="shell-body">{children}</div>
</main>
</div>
</div>
);
}
export function linkClick(target: string) {
return (e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
// Only intercept plain left-clicks (middle-click / ctrl+click stay native).
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
push(target);
};
}
/* ------------------------------------------------------------------ */
/* Instance-scope header + tab strip */
/* ------------------------------------------------------------------ */
export function TabStrip({ active }: { active: 'services' | 'trusted' }) {
const { refresh, probing } = useData();
return (
<nav class="tabstrip" aria-label="Instance sections">
<a
class="tab"
href={href('/instances/local/services')}
aria-current={active === 'services' ? 'page' : undefined}
onClick={linkClick(href('/instances/local/services'))}
>
Services
</a>
<a
class="tab"
href={href('/instances/local/trusted')}
aria-current={active === 'trusted' ? 'page' : undefined}
onClick={linkClick(href('/instances/local/trusted'))}
>
Trusted
</a>
<div class="spacer" />
<IconButton label="Refresh" onClick={refresh} disabled={probing}>
<RefreshIcon />
</IconButton>
</nav>
);
}
export function BackChevron({ target, label }: { target: string; label: string }) {
// A real <a href> (middle-click/new-tab safe); the click handler applies the
// one back rule (§3.1.1) only for plain left-clicks.
return (
<a
class="icon-btn"
href={target}
aria-label={label}
onClick={(e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
backOrReplace(target);
}}
>
<BackIcon />
</a>
);
}
export function InfoButton({ onClick, label = 'Instance details' }: { onClick: () => void; label?: string }) {
return (
<IconButton label={label} onClick={onClick}>
<InfoIcon />
</IconButton>
);
}
function RefreshIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M21 12a9 9 0 1 1-2.64-6.36" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function SettingsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
function BackIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function InfoIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path d="M12 16v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="8" r="1" fill="currentColor" />
</svg>
);
}
+99
View File
@@ -0,0 +1,99 @@
/**
* 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 type { InstanceStatus } from '../lib/types';
import {
capitalize,
instanceTone,
partnerOk,
serviceTone,
type Tone,
} from '../lib/format';
import { Dot } from './ui';
/**
* Status pill: `*-soft` fill + 6 px status dot + INK word (§6.2). The word is
* visible text (the accessible name); colour is never the only signal.
*/
export function StatusPill({ status }: { status: string }) {
const tone = serviceTone(status);
return (
<span class={`pill pill-${tone}`}>
<Dot tone={tone} />
<span>{capitalize(status)}</span>
</span>
);
}
/** Partner status: ok (online/authorized) or muted. */
export function PartnerStatus({ status }: { status: string }) {
const ok = partnerOk(status);
return (
<span class="row">
<Dot tone={ok ? 'ok' : 'muted'} />
<span class={`tiny ${ok ? 'ink-soft' : 'muted'}`}>{capitalize(status)}</span>
</span>
);
}
/** Instance connectivity indicator: wifi icon + status word + aria-label. */
export function InstanceStatusBadge({ status }: { status: InstanceStatus }) {
const tone = instanceTone(status);
return (
<span
class="row"
role="img"
aria-label={`Instance status: ${status}`}
title={`Instance status: ${status}`}
>
<WifiIcon tone={tone} />
<span class={`tiny ${tone === 'ok' ? 'ink-soft' : tone === 'warn' ? 'ink-soft' : 'muted'}`}>
{status}
</span>
</span>
);
}
function WifiIcon({ tone }: { tone: Tone }) {
const color = tone === 'ok' ? 'var(--dn-ok)' : tone === 'warn' ? 'var(--dn-warn)' : 'var(--dn-bad)';
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.55a11 11 0 0 1 14.08 0"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1.42 9a16 16 0 0 1 21.16 0"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.53 16.11a6 6 0 0 1 6.95 0"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="20" r="1" fill={color} />
</svg>
);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* 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 type { ComponentChildren, JSX } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks';
import type { Tone } from '../lib/format';
/** Decorative icon slot. */
export type IconSlot = JSX.Element | null;
export function Card({ className = '', children }: { className?: string; children: ComponentChildren }) {
return <div className={`card ${className}`}>{children}</div>;
}
export function EmptyState({
icon,
title,
subtitle,
}: {
icon?: IconSlot;
title: string;
subtitle?: string;
}) {
return (
<div class="empty">
{icon ? <div class="empty-icon">{icon}</div> : null}
<div class="empty-title">{title}</div>
{subtitle ? <div class="empty-subtitle">{subtitle}</div> : null}
</div>
);
}
export function LoadingView({ label = 'Loading…' }: { label?: string }) {
return (
<div class="loading" role="status">
<div class="spinner" aria-hidden="true" />
<div>{label}</div>
</div>
);
}
/**
* Error/offline banner. `details` (e.g. raw HTTP status) renders inside an
* optional <details> so transport detail never becomes the primary message.
*/
export function Banner({
message,
onRetry,
offline = false,
details,
retryLabel = 'Retry',
}: {
message: string;
onRetry?: () => void;
offline?: boolean;
details?: string;
retryLabel?: string;
}) {
return (
<div class={`banner ${offline ? 'banner-offline' : ''}`} role="status">
<div class="banner-title">{message}</div>
{onRetry ? (
<div class="banner-row">
<button type="button" class="btn btn-ghost small" onClick={onRetry}>
{retryLabel}
</button>
</div>
) : null}
{details ? (
<details>
<summary class="banner-details">Details</summary>
<div class="banner-details mono">{details}</div>
</details>
) : null}
</div>
);
}
export type DotTone = Tone | 'muted';
export function Dot({ tone }: { tone: DotTone }) {
return <span class={`dot dot-${tone}`} aria-hidden="true" />;
}
export function Chip({
children,
soft = false,
title,
}: {
children: ComponentChildren;
soft?: boolean;
title?: string;
}) {
return (
<span class={`chip ${soft ? 'chip-soft' : ''}`} title={title}>
{children}
</span>
);
}
export function IconButton({
label,
onClick,
disabled,
children,
className = 'icon-btn',
}: {
label: string;
onClick?: () => void;
disabled?: boolean;
children: ComponentChildren;
className?: string;
}) {
return (
<button
type="button"
class={className}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
/**
* Clipboard Copy control (§4.0). Feature-detects navigator.clipboard; on
* absence (plain-HTTP origins) selects the rendered mono value instead and
* says "Select and copy the address" — never a lying "Copied".
*/
export function CopyControl({ text }: { text: string }) {
const codeRef = useRef<HTMLElement | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const onCopy = useCallback(() => {
const target = codeRef.current;
if (!target) return;
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setFeedback('Copied');
setCopied(true);
window.setTimeout(() => {
setCopied(false);
setFeedback(null);
}, 2000);
})
.catch(() => {
selectText(target);
setFeedback('Select and copy the address');
});
} else {
selectText(target);
setFeedback('Select and copy the address');
}
}, [text]);
return (
<span class="row">
<code ref={codeRef} class="mono">
{text}
</code>
<button type="button" class="btn btn-ghost small" onClick={onCopy}>
{copied ? 'Copied' : 'Copy'}
</button>
{feedback && !copied ? <span class="copy-feedback">{feedback}</span> : null}
</span>
);
}
function selectText(el: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(el);
sel.addRange(range);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 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 type {
InstancePublicProfile,
PublicService,
SectionState,
TrustedInstance,
} from './types';
export type ProfileErrorCode =
| 'ERR_NOT_INSTANCE'
| 'ERR_UNREACHABLE'
| 'ERR_OLD_MAESTRO';
export class ProfileError extends Error {
code: ProfileErrorCode;
/** Raw HTTP status when a non-2xx response was received (may be 0). */
status: number;
constructor(code: ProfileErrorCode, message: string, status = 0) {
super(message);
this.code = code;
this.status = status;
}
}
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null;
const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
/**
* Fetch and parse a Maestro public profile (GET /api/instance).
*
* Error taxonomy (§4.0):
* - fetch rejects / abort (10 s timeout) → ERR_UNREACHABLE,
* - clean 404 → ERR_OLD_MAESTRO,
* - non-2xx / non-JSON / wrong shape / empty instance.name → ERR_NOT_INSTANCE
* (401/403 are distinguished by callers via `error.status`).
*
* The raw body is returned alongside the normalized profile so callers can
* read section *presence* before null-normalization (§4.0).
*/
export interface FetchResult {
profile: InstancePublicProfile;
/** Section presence per the raw body (drives private/empty states). */
sections: {
services: SectionState;
trusted: SectionState;
};
}
function sectionState(key: string, body: Record<string, unknown>): SectionState {
if (!(key in body)) {
return { presence: 'absent', value: [] };
}
const raw = body[key];
if (raw === null) {
return { presence: 'null', value: [] };
}
if (isArray(raw)) {
return {
presence: raw.length === 0 ? 'empty' : 'present',
value: raw as PublicService[] | TrustedInstance[],
};
}
// Malformed-but-present: treat as empty for safety.
return { presence: 'empty', value: [] };
}
export async function fetchPublicProfile(
baseUrl: string,
timeoutMs = 10000,
): Promise<FetchResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/instance`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
} catch {
throw new ProfileError('ERR_UNREACHABLE', `cannot reach ${baseUrl}`);
} finally {
clearTimeout(timer);
}
if (res.status === 404) {
throw new ProfileError('ERR_OLD_MAESTRO', `${baseUrl} has no /api/instance`, 404);
}
if (!res.ok) {
throw new ProfileError('ERR_NOT_INSTANCE', `HTTP ${res.status} from ${baseUrl}`, res.status);
}
let body: unknown;
try {
body = await res.json();
} catch {
throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} sent non-JSON`);
}
if (!isRecord(body) || !isRecord(body.instance)) {
throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} is not an instance`);
}
const inst = body.instance as Record<string, unknown>;
if (typeof inst.name !== 'string' || inst.name === '') {
throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} is not an instance`);
}
return {
profile: {
instance: body.instance as unknown as InstancePublicProfile['instance'],
services: normalizeSection<PublicService>(body.services),
trusted_instances: normalizeSection<TrustedInstance>(body.trusted_instances),
},
sections: {
services: sectionState('services', body),
trusted: sectionState('trusted_instances', body),
},
};
}
function normalizeSection<T>(v: unknown): T[] | null {
return isArray(v) ? (v as T[]) : null;
}
/** Number of listed services when the array is present (not private/null). */
export function serviceCount(sections: SectionState): number | null {
return sections.presence === 'present' || sections.presence === 'empty'
? sections.value.length
: null;
}
/** Number of listed partners when the array is present (not private/null). */
export function partnerCount(sections: SectionState): number | null {
return sections.presence === 'present' || sections.presence === 'empty'
? sections.value.length
: null;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 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 type { InstanceStatus } from './types';
/** The rendered status word is exactly the runtime set (§4.6 vocabulary). */
export const statusWord = (s: InstanceStatus): string => s;
/** Capitalize a raw wire status for display (e.g. "running" → "Running"). */
export const capitalize = (s: string): string =>
s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
/** Map a service status to a semantic dot tone (ok | warn | bad). */
export type Tone = 'ok' | 'warn' | 'bad';
export function serviceTone(status: string): Tone {
if (status === 'running') return 'ok';
if (status === 'stopped') return 'warn';
return 'bad';
}
/** Map instance connectivity to a semantic dot tone. */
export function instanceTone(status: InstanceStatus): Tone {
if (status === 'connected') return 'ok';
if (status === 'flaky') return 'warn';
return 'bad';
}
/** Map a partner status to ok (online/authorized) or muted otherwise. */
export function partnerOk(status: string): boolean {
return status === 'online' || status === 'authorized';
}
/** Pluralize a noun: (1, 'service') → "1 service". */
export function plural(n: number, singular: string): string {
return `${n} ${n === 1 ? singular : `${singular}s`}`;
}
/** The profile display name: title || name, else a fallback word. */
export function instanceDisplayName(
title: string | undefined,
name: string | undefined,
fallback = 'instance',
): string {
return title || name || fallback;
}
/**
* Version chips stay on one line: image digests can be 64-char hashes, so a
* long version is shortened for the card (the full value stays available as a
* `title` tooltip and on the service detail page).
*/
export function shortVersion(version: string): string {
return version.length > 16 ? `${version.slice(0, 12)}…` : version;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* 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/>.
*/
/**
* Path-based History-API router (§3.1). Routes live under the frozen SPA mount
* point `/hub/`. Every navigation writes `history.state = { hubDepth: n }` so
* the in-app back rule (§3.1.1) can tell whether a previous in-app entry
* exists below the current one.
*/
export type Route =
| { name: 'redirect-root' }
| { name: 'instances' }
| { name: 'redirect-local' }
| { name: 'services' }
| { name: 'service-detail'; serviceName: string }
| { name: 'trusted' }
| { name: 'settings' }
| { name: 'not-found' };
/** Strip a leading `/hub` so matching works in dev (no prefix) and prod. */
function strip(p: string): string {
if (p.startsWith('/hub')) {
p = p.slice(4) || '/';
}
return p;
}
export function matchPath(rawPath: string): Route {
const p = strip(rawPath);
if (p === '/' || p === '') return { name: 'redirect-root' };
if (p === '/instances') return { name: 'instances' };
if (p === '/instances/local') return { name: 'redirect-local' };
if (p === '/instances/local/services') return { name: 'services' };
if (p === '/instances/local/trusted') return { name: 'trusted' };
if (p === '/settings') return { name: 'settings' };
if (p.startsWith('/instances/local/services/')) {
const rest = p.slice('/instances/local/services/'.length);
if (rest && !rest.includes('/')) {
return { name: 'service-detail', serviceName: decodeURIComponent(rest) };
}
}
return { name: 'not-found' };
}
type Listener = () => void;
const listeners = new Set<Listener>();
export function subscribe(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
function emit(): void {
listeners.forEach(fn => fn());
}
window.addEventListener('popstate', emit);
function currentDepth(): number {
const s: unknown = history.state;
if (s && typeof s === 'object') {
const d = (s as { hubDepth?: unknown }).hubDepth;
if (typeof d === 'number') return d;
}
return 0;
}
/** pushState: a new in-app entry (depth + 1). Used by tab/forward links. */
export function push(target: string): void {
history.pushState({ hubDepth: currentDepth() + 1 }, '', target);
emit();
}
/** replaceState: keep the current depth (redirects, defaults). */
export function replace(target: string): void {
history.replaceState({ hubDepth: currentDepth() }, '', target);
emit();
}
/**
* The one back rule (§3.1.1): if a previous in-app entry exists below this
* one, browser Back unwinds it; otherwise replace to the target so the
* chevron never exits the app and Back leaves it as the user arrived.
*/
export function backOrReplace(target: string): void {
if (currentDepth() >= 1) {
history.back();
} else {
replace(target);
}
}
/** href (prefixed) for a canonical path. */
export function href(path: string): string {
return `/hub${path === '/' ? '' : path}`;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 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/>.
*/
/** Mirrors Maestro spec.yaml `InstanceIdentity` (hub-app M1 schema). */
export interface InstanceIdentity {
name: string;
title?: string;
description?: string;
fingerprint?: string;
}
/** Mirrors `PublicService` — no ids, IPs, or ports ever cross the wire. */
export interface PublicService {
service_name: string;
title?: string;
description?: string;
status: string;
version?: string;
capabilities?: string[];
has_auth?: boolean;
image?: string;
}
/** Mirrors `TrustedInstance`. Rev 9 retired federation, so the public profile's
* `trusted_instances` array is always empty; the type is kept for wire
* compatibility with older hub builds. */
export interface TrustedInstance {
name: string;
url: string;
fingerprint: string;
status: string;
scopes?: string[];
}
/** GET /api/instance — the one unauthenticated read surface (hub-app D2). */
export interface InstancePublicProfile {
instance: InstanceIdentity;
services?: PublicService[] | null;
trusted_instances?: TrustedInstance[] | null;
}
/**
* Connectivity health of the serving instance, shown by the WiFi icon.
* The rendered status vocabulary is exactly this set (§4.6).
*/
export type InstanceStatus = 'connected' | 'flaky' | 'disconnected';
/** A section of the profile can be absent (privacy off), null, empty or data. */
export type SectionPresence = 'absent' | 'null' | 'empty' | 'present';
export interface SectionState {
presence: SectionPresence;
/** Normalized value: [] when null/absent/empty, else the array. */
value: PublicService[] | TrustedInstance[];
}
/**
* Shell-level profile state. `profile` is the last successful decode;
* `status` is the hysteresis-buffered connectivity; `error` is the last
* failure message when a probe failed; `probing`/`lastProbedAt` drive the
* "Connecting…" neutral label and the in-flight guard.
*/
export interface DataState {
profile: InstancePublicProfile | null;
status: InstanceStatus;
probing: boolean;
error: string | null;
lastProbedAt: number | null;
/** Whether the last probe failed (drives the separate failure sentence). */
lastProbeFailed: boolean;
offline: boolean;
/** Last detected section presence (drives private/empty states). */
sections: { services: SectionState; trusted: SectionState } | null;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 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 { render } from 'preact'
import './styles.css'
import { App } from './app.tsx'
render(<App />, document.getElementById('root')!)
+28
View File
@@ -0,0 +1,28 @@
/**
* 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 { ServicesScreen } from './services';
/**
* Instance home (§4.2). The bare `/hub/instances/local` route carries no tab
* in the URL, so the app redirects it (replace, never a history entry) to the
* Services tab — native's initialRouteName. This screen renders that tab so
* the redirect target is never blank during the transition.
*/
export function InstanceHomeScreen() {
return <ServicesScreen />;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 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 { useData, linkClick } from '../components/shell';
import { Banner } from '../components/ui';
import { InstanceStatusBadge } from '../components/status';
import { href } from '../lib/router';
import { plural, instanceDisplayName } from '../lib/format';
/**
* Instances home (§4.1): one card for the same-origin instance, plus the
* static "Other instances can't be added here" peers panel. The card is a
* real <a> to /hub/instances/local.
*/
export function InstancesScreen() {
const { profile, status, error, offline, probing, lastProbedAt, sections, refresh } = useData();
const name = profile ? instanceDisplayName(profile.instance.title, profile.instance.name) : 'this instance';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const connecting = probing && lastProbedAt === null;
// Count only present, non-null sections (§4.0): an absent key (privacy off)
// or a null value (store absent) is not counted; a present [] counts as 0.
const servicesCount =
sections && (sections.services.presence === 'present' || sections.services.presence === 'empty')
? sections.services.value.length
: null;
const partnersCount =
sections && (sections.trusted.presence === 'present' || sections.trusted.presence === 'empty')
? sections.trusted.value.length
: null;
const summary = [
servicesCount !== null ? plural(servicesCount, 'service') : null,
partnersCount !== null ? plural(partnersCount, 'partner') : null,
].filter(Boolean);
return (
<div class="stack">
<h1 class="page-h1" tabindex={-1}>
Hub
</h1>
{offline ? (
<Banner message="You're offline." offline />
) : error ? (
<Banner message={error} onRetry={refresh} />
) : null}
<a
class="card card-link instance-card"
href={href('/instances/local')}
onClick={linkClick(href('/instances/local'))}
>
<span class="instance-card-icon" aria-hidden="true">
<ServerIcon />
</span>
<span class="instance-card-body">
<span class="instance-card-title">{name}</span>
<div class="instance-card-url mono">{origin}</div>
{summary.length > 0 ? <div class="instance-card-summary">{summary.join(' · ')}</div> : null}
</span>
<span class="instance-card-status">
{connecting ? <span class="tiny muted">Connecting…</span> : <InstanceStatusBadge status={status} />}
</span>
</a>
<div class="panel">
<div class="h2">Other instances can't be added here</div>
<p class="mt-2 small muted">
This hub is served by the instance it describes, and a browser page
may only read that one instance. To browse other instances, use the
Hub app on a phone, or open the other instance's own hub address
directly.
</p>
</div>
</div>
);
}
function ServerIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<rect x="2" y="2" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<rect x="2" y="14" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<path d="M6 6h.01M6 18h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 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 type { JSX } from 'preact';
import { href, push } from '../lib/router';
/** Not found (§4.7): a static route with no data dependency. */
export function NotFoundScreen() {
const target = href('/instances');
return (
<div class="empty">
<div class="empty-icon" aria-hidden="true">
<MissingIcon />
</div>
<h1 class="empty-title" tabindex={-1}>
Page not found
</h1>
<div class="empty-subtitle">There's no page at this address.</div>
<a
class="btn btn-primary mt-4"
href={target}
onClick={(e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
push(target);
}}
>
Go to Hub
</a>
</div>
);
}
function MissingIcon() {
return (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none">
<path
d="m6 9 6 6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* 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 type { JSX } from 'preact';
import { BackChevron, TabStrip, useData } from '../components/shell';
import { Banner, EmptyState, LoadingView, Card } from '../components/ui';
import { StatusPill } from '../components/status';
import { instanceDisplayName } from '../lib/format';
import { href, push } from '../lib/router';
import type { PublicService } from '../lib/types';
/**
* Service detail (§4.4): read-only all public fields. Rows use a <dl>; the
* status pill is soft fill + status dot + ink word. Not-found when the probe
* succeeded but no such service_name exists. An xl-only <aside> context pane
* (real landmarks, rendered after the rows in DOM) holds the "all services"
* nav — the only copy of those links.
*/
export function ServiceDetailScreen({ serviceName }: { serviceName: string }) {
const { profile, error, offline, probing, lastProbedAt } = useData();
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'instance';
const loading = probing && lastProbedAt === null;
const services: PublicService[] = profile?.services ?? [];
const service = services.find(s => s.service_name === serviceName);
const title = service?.title || serviceName;
const asideId = 'detail-context-pane';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
let body: JSX.Element;
if (loading && !profile) {
body = <LoadingView label="Loading service…" />;
} else if (offline && !profile) {
body = (
<>
<Banner message="You're offline." offline />
<EmptyState title="Service not found" subtitle="It may have stopped or been removed from the instance." />
</>
);
} else if (error && !profile) {
body = (
<>
<Banner message={error} />
<EmptyState title="Service not found" subtitle="It may have stopped or been removed from the instance." />
</>
);
} else if (!service) {
body = (
<>
{error ? <Banner message={error} /> : null}
{offline ? <Banner message="You're offline." offline /> : null}
<EmptyState
title="Service not found"
subtitle="It may have stopped or been removed from the instance."
/>
<div class="mt-4">
<a class="btn" href={href('/instances/local/services')} onClick={goToServices}>
All services
</a>
</div>
</>
);
} else {
const isDb = service.capabilities?.includes('database') ?? false;
body = (
<div class="detail-layout">
<div class="detail-main">
{error ? <Banner message={error} /> : null}
{offline ? <Banner message="You're offline." offline /> : null}
<Card>
<div class="row">
<span class="service-card-icon" aria-hidden="true">
{isDb ? <DbIcon /> : <BoxIcon />}
</span>
<span class="service-card-title flex-1">{title}</span>
<StatusPill status={service.status} />
</div>
{service.description ? <p class="mt-2 small ink-soft">{service.description}</p> : null}
</Card>
<Card className="mt-2">
<dl class="detail-list">
<Row label="Service name" value={service.service_name} mono />
<Row label="Version" value={service.version} />
<Row label="Image" value={service.image} mono />
<Row label="Status" value={service.status} />
<Row label="Has auth" value={service.has_auth ? 'Yes' : 'No'} />
<Row label="Capabilities" value={service.capabilities?.join(', ') || 'None'} />
</dl>
</Card>
</div>
<aside class="context-pane" aria-labelledby={asideId}>
<div id={asideId} class="h2">
Services on this instance
</div>
<nav aria-label="All services on this instance" class="context-nav">
{services.map(s => (
<a
key={s.service_name}
href={href(`/instances/local/services/${encodeURIComponent(s.service_name)}`)}
onClick={goToServices}
aria-current={s.service_name === serviceName ? 'page' : undefined}
>
{s.title || s.service_name}
</a>
))}
</nav>
<dl class="detail-list context-identity">
<div class="detail-row">
<dt class="detail-label">Instance</dt>
<dd class="detail-value">{instanceName}</dd>
</div>
<div class="detail-row">
<dt class="detail-label">Origin</dt>
<dd class="detail-value mono">{origin}</dd>
</div>
</dl>
</aside>
</div>
);
}
return (
<div>
<div class="instance-header">
<BackChevron target={href('/instances/local/services')} label="Back to services" />
<h1 class="page-h1 flex-1" tabindex={-1}>
{title}
</h1>
</div>
<TabStrip active="services" />
{body}
</div>
);
}
function Row({
label,
value,
mono = false,
}: {
label: string;
value?: string;
mono?: boolean;
}) {
return (
<div class="detail-row">
<dt class="detail-label">{label}</dt>
<dd class={`detail-value ${mono ? 'mono' : ''}`}>{value || '—'}</dd>
</div>
);
}
function goToServices(e: JSX.TargetedMouseEvent<HTMLAnchorElement>) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
push(e.currentTarget.getAttribute('href')!);
}
function BoxIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path
d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M3.27 6.96 12 12.01l8.73-5.05" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 22.08V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function DbIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" stroke-width="2" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" stroke="currentColor" stroke-width="2" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" stroke="currentColor" stroke-width="2" />
</svg>
);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 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 type { JSX } from 'preact';
import { BackChevron, InfoButton, TabStrip, useData } from '../components/shell';
import { Banner, EmptyState, LoadingView } from '../components/ui';
import { DetailsDialog } from '../components/details-dialog';
import { ServiceCard } from '../components/service-card';
import { instanceDisplayName, plural } from '../lib/format';
import { href } from '../lib/router';
/**
* Services tab (§4.3). Section presence drives the state: absent → private
* note; null/empty → native empty copy; present → the service grid.
*/
export function ServicesScreen() {
const { profile, error, offline, probing, lastProbedAt, sections, refresh } = useData();
const [detailsOpen, setDetailsOpen] = useState(false);
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'instance';
const presence = sections?.services.presence ?? null;
const services = profile?.services ?? [];
const loading = probing && lastProbedAt === null;
const info = profile?.instance;
const header = (
<div class="instance-header">
<BackChevron target={href('/instances')} label="Back to instances" />
<h1 class="page-h1 flex-1" tabindex={-1}>
{instanceName}
</h1>
<InfoButton onClick={() => setDetailsOpen(true)} />
</div>
);
let body: JSX.Element;
if (loading && !profile) {
body = <LoadingView label="Loading services…" />;
} else if (offline && !profile) {
body = (
<>
<Banner message="You're offline." offline />
<EmptyState title="No running services to show" subtitle="Hidden infrastructure is never advertised; add a service on the instance to browse it here." />
</>
);
} else if (error && !profile) {
body = (
<>
<Banner message={error} onRetry={refresh} />
<EmptyState title="No running services to show" subtitle="Hidden infrastructure is never advertised; add a service on the instance to browse it here." />
</>
);
} else {
// Ready (or flaky with cached profile): banner on top if degraded.
const errorBanner =
error && profile ? <Banner message={error} /> : null;
const offlineBanner = offline && profile ? <Banner message="You're offline." offline /> : null;
if (presence === 'absent') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="Service list is private" subtitle="This instance publishes its identity but keeps its service list private." />
</>
);
} else if (presence === null || presence === 'null' || presence === 'empty') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="No running services to show" subtitle="Hidden infrastructure is never advertised; add a service on the instance to browse it here." />
</>
);
} else {
body = (
<>
{errorBanner}
{offlineBanner}
<div class="small-caps mb-2">{`${instanceName} — ${plural(services.length, 'service')}`}</div>
<div class="service-grid">
{services.map(s => (
<ServiceCard key={s.service_name} service={s} />
))}
</div>
</>
);
}
}
return (
<div class="shell-content">
{header}
<TabStrip active="services" />
{body}
<DetailsDialog
open={detailsOpen}
name={info ? instanceDisplayName(info.title, info.name) : instanceName}
fingerprint={info?.fingerprint}
onClose={() => setDetailsOpen(false)}
/>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
/**
* 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 { BackChevron, useData, useTheme } from '../components/shell';
import { Card } from '../components/ui';
import { instanceDisplayName } from '../lib/format';
import { href } from '../lib/router';
/**
* Settings (§4.6): Connection (honest — origin is the instance), Appearance
* (Dark theme switch), About Hub (exact copy + runtime status line + separate
* failure sentence) and the AGPL footer. The status line renders only the
* runtime vocabulary connected|flaky|disconnected.
*/
export function SettingsScreen() {
const { theme, toggle } = useTheme();
const { profile, status, lastProbeFailed, offline } = useData();
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const name = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'this instance';
const failureSentence = offline
? "You're offline."
: lastProbeFailed
? 'Last check failed: the instance is not answering.'
: null;
return (
<div>
<div class="instance-header">
<BackChevron target={href('/instances')} label="Back" />
<h1 class="page-h1 flex-1" tabindex={-1}>
Settings
</h1>
</div>
<div class="settings-stack">
<Card>
<div class="settings-card-title">Connection</div>
<p class="small muted">
This hub is served by the instance it reads — there is nothing to
configure.
</p>
<div class="mono tiny muted mt-2">{origin}</div>
</Card>
<Card>
<div class="settings-card-title">Appearance</div>
<div class="setting-row">
<label class="setting-label" for="theme-switch">
Dark theme
</label>
<input
id="theme-switch"
class="switch"
type="checkbox"
role="switch"
checked={theme === 'dark'}
onChange={() => toggle()}
/>
</div>
</Card>
<Card>
<div class="settings-card-title">About Hub</div>
<p class="small muted">
Read-only window onto your Dcntrlized instances. The public profile
is unauthenticated by design — nothing here holds credentials.
</p>
<div class="status-line">
{name} · {status}
</div>
{failureSentence ? <div class="status-line">{failureSentence}</div> : null}
</Card>
<p class="settings-footer">AGPL-3.0 — Dcntrlized</p>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 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 type { JSX } from 'preact';
import { BackChevron, InfoButton, TabStrip, useData } from '../components/shell';
import { Banner, EmptyState, LoadingView } from '../components/ui';
import { DetailsDialog } from '../components/details-dialog';
import { PartnerRow } from '../components/partner-row';
import { instanceDisplayName } from '../lib/format';
import { href } from '../lib/router';
/**
* Trusted partners tab (§4.5). Section presence drives the state: absent →
* private note; null/empty → "No trusted partners yet". On web the register is
* empty by design, so rows are the advertised `trusted_instances` only.
*/
export function TrustedScreen() {
const { profile, error, offline, probing, lastProbedAt, sections } = useData();
const [detailsOpen, setDetailsOpen] = useState(false);
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'instance';
const presence = sections?.trusted.presence ?? null;
const partners = profile?.trusted_instances ?? [];
const loading = probing && lastProbedAt === null;
const info = profile?.instance;
const header = (
<div class="instance-header">
<BackChevron target={href('/instances')} label="Back to instances" />
<h1 class="page-h1 flex-1" tabindex={-1}>
{instanceName}
</h1>
<InfoButton onClick={() => setDetailsOpen(true)} />
</div>
);
let body: JSX.Element;
if (loading && !profile) {
body = <LoadingView label="Loading trusted partners…" />;
} else if (offline && !profile) {
body = (
<>
<Banner message="You're offline." offline />
<EmptyState title="No trusted partners yet" subtitle="These are instances the connected instance trusts — the list fills in automatically as this instance authorizes partners." />
</>
);
} else if (error && !profile) {
body = (
<>
<Banner message={error} />
<EmptyState title="No trusted partners yet" subtitle="These are instances the connected instance trusts — the list fills in automatically as this instance authorizes partners." />
</>
);
} else {
const errorBanner = error && profile ? <Banner message={error} /> : null;
const offlineBanner = offline && profile ? <Banner message="You're offline." offline /> : null;
if (presence === 'absent') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="Trusted list is private" subtitle="This instance publishes its identity but keeps its trusted list private." />
</>
);
} else if (presence === null || presence === 'null' || presence === 'empty') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="No trusted partners yet" subtitle="These are instances the connected instance trusts — the list fills in automatically as this instance authorizes partners." />
</>
);
} else {
body = (
<>
{errorBanner}
{offlineBanner}
<div class="small-caps mb-2">Trusted partners</div>
<p class="small muted mb-2">
These are instances this instance trusts. Browsers can't open them
from here — see the note below.
</p>
<div class="partner-list">
{partners.map(p => (
<PartnerRow key={p.url || p.name} partner={p} />
))}
</div>
</>
);
}
}
return (
<div>
{header}
<TabStrip active="trusted" />
{body}
<DetailsDialog
open={detailsOpen}
name={info ? instanceDisplayName(info.title, info.name) : instanceName}
fingerprint={info?.fingerprint}
onClose={() => setDetailsOpen(false)}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [preact()],
base: '/hub/',
build: {
outDir: 'dist',
emptyOutDir: true,
},
})
+101
View File
@@ -0,0 +1,101 @@
// 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 main
import (
"embed"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
)
// embeddedHub carries the public hub SPA bundle. It is a SEPARATE bundle from
// the owner console (//go:embed all:frontend/dist): the hub is served at /hub/
// outside the owner gate and must never be merged into the operator app.
//
// The physical hub/dist/index.html placeholder is committed so a clean checkout
// still builds; the Dockerfile builds hub/ into hub/dist alongside the console
// (browser-edge contract §10.5).
//
//go:embed all:hub/dist
var embeddedHub embed.FS
// hubContentSecurityPolicy is the strict F13 policy from the browser-edge
// contract §10.5 (RC16). /hub/ shares Maestro's origin with the owner-gated
// /api/* routes and the owner cookies are Path=/, so a hub-bundle XSS could
// otherwise issue credentialed same-origin calls to the owner API.
// `script-src 'self'` (no unsafe-inline) blocks injected inline scripts and
// event handlers; `style-src 'self'` also blocks inline style attributes, so
// the bundle must use classes only; object-src/base-uri/frame-ancestors close
// the plugin/base/framing pivots.
const hubContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; connect-src 'self'; object-src 'none'; " +
"base-uri 'none'; frame-ancestors 'none'; form-action 'none'"
// newHubHandler returns the handler for the public hub SPA. The caller strips
// the "/hub/" prefix, so the wrapped filesystem is rooted at hub/dist.
func newHubHandler() (http.Handler, error) {
sub, err := fs.Sub(embeddedHub, "hub/dist")
if err != nil {
return nil, err
}
return hubHeaders(spaFallback(sub)), nil
}
// hubHeaders applies the mandatory F13 response headers before any hub asset
// (HTML or hashed asset) is served.
func hubHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", hubContentSecurityPolicy)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// spaFallback serves the requested file when it exists, otherwise index.html,
// so every /hub/ deep link is reload-survivable and the SPA owns 404 rendering.
// index.html is written directly rather than delegated to http.FileServer,
// which would 301 any "/index.html" path to "./" and break deep links.
func spaFallback(fsys fs.FS) http.Handler {
files := http.FileServer(http.FS(fsys))
index, indexErr := fs.ReadFile(fsys, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" {
name = "index.html"
}
if name != "index.html" && fs.ValidPath(name) {
if f, err := fsys.Open(name); err == nil {
info, statErr := f.Stat()
_ = f.Close()
if statErr == nil && !info.IsDir() {
files.ServeHTTP(w, r)
return
}
}
}
if indexErr != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(index)))
_, _ = w.Write(index)
})
}
-74
View File
@@ -1,74 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"bytes"
"fmt"
"net/http"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
)
type ActionProxyPayload struct {
Method string
Action string
Data string `json:"data"`
}
type ActionProxyResponse struct {
StatusCode int
Body string
}
func ActionProxy(container_id, action string, payload ActionProxyPayload) (ActionProxyResponse, error) {
// Forward the action to the container with the given ID, and return the result to the frontend
containers := containers.NewContainerCache().GetServices()
for _, container := range containers {
if container.ID == container_id {
// Forward the action to this container
body := bytes.NewBuffer([]byte(payload.Data))
port := "80"
if len(container.Network.Ports) > 0 {
port = fmt.Sprintf("%d", container.Network.Ports[0].Guest)
}
req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], port, payload.Action), body)
if err != nil {
return ActionProxyResponse{}, err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return ActionProxyResponse{}, err
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
return ActionProxyResponse{
StatusCode: resp.StatusCode,
Body: buf.String(),
}, nil
}
}
return ActionProxyResponse{}, fmt.Errorf("container with ID %s not found", container_id)
}
+47 -187
View File
@@ -23,8 +23,6 @@ package authmgmt
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
@@ -32,26 +30,27 @@ import (
"path/filepath"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
)
// AuthServiceURL is the default DNS name of the shared auth service on the
// dcntrlized network. The auth service listens with ClientAuth=RequireAndVerify
// trusting the instance CA (M2 §1).
const AuthServiceURL = "https://dcntrlized-auth:8080"
// dcntrlized network. After rev 9 the fleet-wide internal transport is
// plaintext HTTP on the single :8080 listener; there is no mTLS anywhere.
const AuthServiceURL = "http://dcntrlized-auth:8080"
// Client talks to the auth service over mTLS using Maestro's own identity
// certificate (CN=maestro), which the auth service's RequireMaestro gate
// recognizes as the orchestrator.
// Client talks to the auth service over plaintext HTTP. It no longer presents
// any client certificate: the peer gate was removed fleet-wide in rev 9.
type Client struct {
baseURL string
http *http.Client
}
// Attestation is the auth service's public signing key statement (D3): the key
// a peer should verify access tokens with, plus its TOFU-pinnable fingerprint.
// a peer should verify access tokens with, plus the fingerprint Maestro pins
// as an app-level check (M2 §5). Rev 9 removed the peer trust store, the
// pending/approved authorization flow and every management call that used to
// live in this package.
type Attestation struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
@@ -59,44 +58,9 @@ type Attestation struct {
IssuedAt string `json:"issued_at"`
}
// AuthorizedKey is an approved peer row of the auth service's trust store.
type AuthorizedKey struct {
Service string `json:"service"`
Fingerprint string `json:"fingerprint"`
ApprovedBy string `json:"approved_by"`
ApprovedAt string `json:"approved_at"`
RevokedAt *string `json:"revoked_at"`
HiddenAt *string `json:"hidden_at"`
}
// PendingAuthorization is a first-contact request awaiting the owner's
// decision.
type PendingAuthorization struct {
Service string `json:"service"`
Fingerprint string `json:"fingerprint"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
}
// TrustState is the combined pending + approved view the dashboard shows.
type TrustState struct {
Pending []PendingAuthorization `json:"pending"`
Authorized []AuthorizedKey `json:"authorized"`
}
type authzRequest struct {
Service string `json:"service"`
Fingerprint string `json:"fingerprint"`
}
// New builds the mTLS management client, provisioning Maestro's own identity
// from the instance CA (the CA holder issues its own orchestrator cert).
// New builds the plaintext management client. There is no identity to
// provision and no transport to configure after the PKI removal.
func New() (*Client, error) {
transport, err := maestroTransport()
if err != nil {
return nil, err
}
baseURL := ""
if env.IsDev() {
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
@@ -111,84 +75,52 @@ func New() (*Client, error) {
return &Client{
baseURL: baseURL,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
Timeout: 10 * time.Second,
},
}, nil
}
// maestroTransport builds the mutual-TLS transport Maestro uses as a peer: its
// own identity certificate (CN=maestro) signed by the instance CA, trusting the
// CA as root. This is the client side of every Maestro peer edge.
func maestroTransport() (*http.Transport, error) {
dataRoot, err := dataRoot()
if err != nil {
return nil, fmt.Errorf("resolve data root: %w", err)
}
ca, err := pki.EnsureCA(dataRoot)
if err != nil {
return nil, fmt.Errorf("ensure instance CA: %w", err)
}
identity, err := pki.EnsureServiceIdentity(ca, "maestro")
if err != nil {
return nil, fmt.Errorf("provision maestro identity: %w", err)
}
cert, err := tls.LoadX509KeyPair(identity.CertPath, identity.KeyPath)
if err != nil {
return nil, fmt.Errorf("load maestro identity: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca.CertPEM) {
return nil, fmt.Errorf("no certificates parsed from instance CA")
}
return &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS12,
},
}, nil
}
// Scheme returns "http" in dev mode and "https" otherwise, for building
// satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a
// plaintext listener so the RN clients (which cannot do mTLS) can reach them;
// production satellites bind mTLS listeners.
// Scheme returns the scheme every satellite peer edge uses after rev 9:
// always "http" (plaintext on the single :8080 listener). Kept as a function
// so the call sites (manifest fetch, action routing) do not hard-code it.
func Scheme() string {
if env.IsDev() {
return "http"
}
return "https"
return "http"
}
// PeerTransport returns the transport Maestro uses for the satellite peer
// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners),
// mutual TLS with Maestro's identity in production.
func PeerTransport() (*http.Transport, error) {
if env.IsDev() {
return &http.Transport{}, nil
// ConfigToken mints a fresh, single-use ES256 config token for one service
// (contract rev 9 §17.3.6). It loads (or creates) Maestro's DEDICATED
// config-signing keypair from the data root and returns the compact JWS. The
// service holds only the public half (DCNTRLIZED_CONFIG_PUBKEY) and verifies;
// Maestro only ever mints. Wire format is frozen in §17.3.3.
func ConfigToken(serviceName string) (string, error) {
root, err := dataRoot()
if err != nil {
return "", err
}
return maestroTransport()
signer, err := configsign.LoadOrCreate(root)
if err != nil {
return "", err
}
return signer.Mint(serviceName, time.Now())
}
// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro)
// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges
// that must prove the caller is the orchestrator (e.g. /config delivery, which
// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV,
// since those services keep an always-mTLS peer listener.
func MaestroTransport() (*http.Transport, error) {
return maestroTransport()
}
// TrustState returns the pending authorizations and the approved trust store.
func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
var state TrustState
if err := c.do(ctx, http.MethodGet, "/internal/pending", nil, &state); err != nil {
return nil, err
// dataRoot resolves the directory holding Maestro's private key material
// (DCNTRLIZED_INSTALL_DIR → XDG → HOME), mirroring the containers package's
// resolution. Kept local to avoid an import cycle: containers imports authmgmt.
func dataRoot() (string, error) {
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
return dir, nil
}
return &state, nil
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, ".dcntrlized"), nil
}
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
return filepath.Join(xdgHome, ".dcntrlized"), nil
}
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".dcntrlized"), nil
}
return filepath.Join("/data", ".dcntrlized"), nil
}
// Attestation returns the auth service's public key statement.
@@ -200,35 +132,6 @@ func (c *Client) Attestation(ctx context.Context) (*Attestation, error) {
return &att, nil
}
// Approve forwards the owner's approval for a peer's fingerprint to the auth
// service's own trust store.
func (c *Client) Approve(ctx context.Context, service, fingerprint string) error {
return c.do(ctx, http.MethodPost, "/internal/authorizations",
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
}
// Revoke forwards the owner's revocation for a peer's fingerprint (returns the
// peer to the no-op path immediately).
func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error {
return c.do(ctx, http.MethodPost, "/internal/revoke",
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
}
// Reject denies a pending first-contact request: the request is dropped and
// audited; the peer stays on the no-op path until the owner approves it.
func (c *Client) Reject(ctx context.Context, service, fingerprint string) error {
return c.do(ctx, http.MethodPost, "/internal/reject",
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
}
// Hide permanently hides a revoked peer from the dashboard: the auth service
// keeps the row (audit trail survives) but marks it hidden_at; a re-approval
// clears the flag and makes the key visible again.
func (c *Client) Hide(ctx context.Context, service, fingerprint string) error {
return c.do(ctx, http.MethodPost, "/internal/hide",
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var payload []byte
if body != nil {
@@ -258,46 +161,3 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
}
return nil
}
// RevokeServiceBestEffort revokes a peer's authorization in the auth service
// trust store, logging and swallowing failures: uninstall must proceed even
// when the auth service is down (M2 §3 step 5 — "or automatically on
// uninstall").
func RevokeServiceBestEffort(ctx context.Context, service, fingerprint string) {
client, err := New()
if err != nil {
bestEffort(err, "build client for revoking "+service)
return
}
if err := client.Revoke(ctx, service, fingerprint); err != nil {
bestEffort(err, "revoke "+service)
}
}
// dataRoot resolves the directory storing the instance PKI, mirroring the
// containers package's resolution (DCNTRLIZED_INSTALL_DIR → XDG → HOME). Kept
// local to avoid an import cycle: containers imports authmgmt for the
// uninstall-revoke hook.
func dataRoot() (string, error) {
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
return dir, nil
}
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, ".dcntrlized"), nil
}
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
return filepath.Join(xdgHome, ".dcntrlized"), nil
}
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".dcntrlized"), nil
}
return filepath.Join("/data", ".dcntrlized"), nil
}
// bestEffort logs-and-continues: management calls must never take down the
// orchestrator (e.g. uninstall revoking while the auth service is down).
func bestEffort(err error, what string) {
if err != nil {
logger.Warn("authmgmt: %s failed (best-effort): %v", what, err)
}
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authmgmt
import (
"os"
"strings"
"testing"
)
// TestSchemeIsAlwaysPlaintext locks the rev-9 transport decision: every
// satellite peer edge (manifest fetch, action routing) is plaintext HTTP on the
// single :8080 listener. A future https/peer-port regression must fail here.
func TestSchemeIsAlwaysPlaintext(t *testing.T) {
if got := Scheme(); got != "http" {
t.Fatalf("Scheme() = %q, want %q", got, "http")
}
}
// TestConfigTokenMintsForService exercises the contract-named mint helper
// (rev 9 §17.3.6) end to end against a temp data root: it must create the
// dedicated config-signing keypair, return a 3-segment JWS bound to the target
// service, and expose a public key whose kid matches the token header.
func TestConfigTokenMintsForService(t *testing.T) {
dir := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", dir)
token, err := ConfigToken("calendar")
if err != nil {
t.Fatalf("ConfigToken: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token has %d segments, want 3", len(parts))
}
for _, name := range []string{"config-signing.key", "config-signing.pub"} {
if _, err := os.Stat(dir + "/maestro/" + name); err != nil {
t.Fatalf("expected %s to exist: %v", name, err)
}
}
}
+165
View File
@@ -0,0 +1,165 @@
// 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 catalog owns the instance's operator-authored app distribution: the
// app records the owner publishes (Android artifacts Maestro hosts or links),
// their on-disk bytes, and the public projection phones download from. It is
// deliberately independent of services and the container registry (app-catalog
// decisions A1/A2): nothing here is derived from installed containers.
package catalog
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"path"
"strings"
"time"
)
// Artifact kinds (A4).
const (
KindHosted = "hosted"
KindLink = "link"
)
// DefaultArtifactContentType is Android's APK MIME type.
const DefaultArtifactContentType = "application/vnd.android.package-archive"
// Artifact is the artifact sub-record of an app. DownloadURL is computed per
// request (absolute, points at this instance's public artifact endpoint);
// SourceURL is the operator-supplied link target for kind=link and stays
// internal (`json:"-"` — A7).
type Artifact struct {
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
ContentType string `json:"content_type,omitempty"`
Kind string `json:"kind"`
DownloadURL string `json:"download_url,omitempty"`
SignerSHA256 string `json:"signer_sha256,omitempty"`
SourceURL string `json:"-"`
}
// App is one catalog record (wire shape, snake_case). AddedBy is the owner
// username for the owner view and is blanked on the public projection;
// ArtifactPath/IconPath are the local filesystem locations of the hosted bytes
// and never serialize (A7).
type App struct {
ID string `json:"id"`
Package string `json:"package"`
Name string `json:"name"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
VersionName string `json:"version_name"`
VersionCode int `json:"version_code,omitempty"`
MinAndroid int `json:"min_android,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Artifact Artifact `json:"artifact"`
SourceURL string `json:"source_url,omitempty"`
AddedAt string `json:"added_at"`
UpdatedAt string `json:"updated_at"`
AddedBy string `json:"added_by,omitempty"`
ArtifactPath string `json:"-"`
IconPath string `json:"-"`
}
// AppID derives the stable id for a package: "a_" + first 12 hex chars of
// sha256(package). Stable across edits so a client refresh never renames an app.
func AppID(pkg string) string {
sum := sha256.Sum256([]byte(pkg))
return "a_" + hex.EncodeToString(sum[:])[:12]
}
// NowRFC3339 stamps records in UTC RFC3339, the store's timestamp format.
func NowRFC3339() string {
return time.Now().UTC().Format(time.RFC3339)
}
// Public returns the public projection of a record: identical metadata minus
// the private owner field (A7). Callers still apply WithBase for the absolute
// URLs.
func (a App) Public() App {
a.AddedBy = ""
return a
}
// WithBase returns a copy whose icon/download URLs are absolute for the given
// request base (e.g. "https://instance.example"). A hosted icon wins over an
// operator-supplied external icon_url; a link artifact still advertises this
// instance's redirect endpoint as its download URL.
func (a App) WithBase(base string) App {
out := a
base = strings.TrimRight(base, "/")
if a.IconPath != "" {
out.IconURL = base + "/api/catalog/icons/" + a.ID
}
if a.Artifact.Filename != "" {
out.Artifact.DownloadURL = base + "/api/catalog/artifacts/" + a.ID + "/" + url.PathEscape(a.Artifact.Filename)
}
return out
}
// RequestBaseURL derives the instance's absolute base URL from the request,
// honoring a reverse proxy's X-Forwarded-Proto / X-Forwarded-Host (falling
// back to the direct TLS state and Host header). One helper so every catalog
// surface builds URLs identically.
func RequestBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
first := strings.ToLower(strings.TrimSpace(strings.Split(proto, ",")[0]))
if first == "http" || first == "https" {
scheme = first
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
} else {
host = strings.TrimSpace(strings.Split(host, ",")[0])
}
if host == "" {
return scheme + "://localhost"
}
return scheme + "://" + host
}
// SafeFilename reduces an untrusted filename (URL path or multipart header) to
// a single clean path segment, or "" when nothing usable remains. It blocks
// traversal before any file is written.
func SafeFilename(name string) string {
n := strings.TrimSpace(name)
n = strings.ReplaceAll(n, "\\", "/")
n = path.Base(n)
if n == "" || n == "." || n == ".." || n == "/" {
return ""
}
return n
}
// FilenameFromURL extracts a usable filename from an artifact URL path.
func FilenameFromURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
return SafeFilename(u.Path)
}
+368
View File
@@ -0,0 +1,368 @@
// 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 catalog
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// Limits from the frozen contract: a hosted artifact fetch is capped at
// 512 MiB, an uploaded icon at 512 KiB.
const (
MaxArtifactBytes = int64(512) << 20
MaxIconBytes = int64(512) << 10
)
// ErrTooLarge is returned when a copied file exceeds the caller-supplied limit.
var ErrTooLarge = errors.New("catalog: file exceeds size limit")
const (
dirName = "catalog"
appsFile = "apps.json"
artifacts = "artifacts"
icons = "icons"
)
// Dir returns the catalog directory for a data root.
func Dir(dataRoot string) string {
return filepath.Join(dataRoot, dirName)
}
// Path returns the catalog index file location for a data root.
func Path(dataRoot string) string {
return filepath.Join(Dir(dataRoot), appsFile)
}
// Store is the file-backed catalog index plus the helpers that manage the
// hosted artifact/icon bytes beside it. A mutex serializes read-modify-write
// cycles so concurrent owner actions cannot lose rows.
type Store struct {
mu sync.Mutex
dataRoot string
}
// NewStore opens (without creating) the catalog under dataRoot.
func NewStore(dataRoot string) *Store {
return &Store{dataRoot: dataRoot}
}
// List reads every app in insertion order (A5: the operator's ordering is
// stable). A missing index yields a non-nil empty slice: an empty catalog
// serializes as [] and never null.
func (s *Store) List() ([]App, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.load()
}
// List is the package-level helper over the data-root-backed store.
func List(dataRoot string) ([]App, error) {
return NewStore(dataRoot).List()
}
// Get returns the app with the given id, or (nil, nil) when absent.
func (s *Store) Get(id string) (*App, error) {
apps, err := s.List()
if err != nil {
return nil, err
}
for i := range apps {
if apps[i].ID == id {
app := apps[i]
return &app, nil
}
}
return nil, nil
}
// GetByPackage returns the app owning the given package name, or (nil, nil).
func (s *Store) GetByPackage(pkg string) (*App, error) {
apps, err := s.List()
if err != nil {
return nil, err
}
for i := range apps {
if apps[i].Package == pkg {
app := apps[i]
return &app, nil
}
}
return nil, nil
}
// Upsert inserts a new app at the end (insertion order) or replaces the record
// with the same id in place.
func (s *Store) Upsert(app App) error {
s.mu.Lock()
defer s.mu.Unlock()
apps, err := s.load()
if err != nil {
return err
}
for i := range apps {
if apps[i].ID == app.ID {
apps[i] = app
return s.save(apps)
}
}
return s.save(append(apps, app))
}
// Remove drops the named record. Removing an unknown id is a no-op.
func (s *Store) Remove(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
apps, err := s.load()
if err != nil {
return err
}
kept := make([]App, 0, len(apps))
removed := false
for _, app := range apps {
if app.ID == id {
removed = true
continue
}
kept = append(kept, app)
}
if !removed {
return nil
}
return s.save(kept)
}
// SaveArtifact streams src to artifacts/<id>/<filename> (dir 0700, file 0600,
// tmp+rename), returning the data-root-relative path, byte count and sha256.
// The copy is capped at limit; exceeding it yields ErrTooLarge and leaves no
// file behind.
func (s *Store) SaveArtifact(id, filename string, src io.Reader, limit int64) (string, int64, string, error) {
name := SafeFilename(filename)
if id == "" || name == "" {
return "", 0, "", errors.New("catalog: invalid artifact id or filename")
}
dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", 0, "", fmt.Errorf("create artifact dir: %w", err)
}
final := filepath.Join(dir, name)
tmp := final + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return "", 0, "", fmt.Errorf("create artifact file: %w", err)
}
hash := sha256.New()
copied, copyErr := io.Copy(io.MultiWriter(f, hash), io.LimitReader(src, limit+1))
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return "", 0, "", copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return "", 0, "", closeErr
}
if copied > limit {
_ = os.Remove(tmp)
return "", 0, "", ErrTooLarge
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return "", 0, "", fmt.Errorf("store artifact: %w", err)
}
return filepath.Join(artifacts, id, name), copied, hex.EncodeToString(hash.Sum(nil)), nil
}
// SaveIcon writes icon bytes to icons/<id> (dir 0700, file 0600, tmp+rename)
// and returns the data-root-relative path.
func (s *Store) SaveIcon(id string, data []byte) (string, error) {
if id == "" || len(data) == 0 {
return "", errors.New("catalog: invalid icon id or empty data")
}
if int64(len(data)) > MaxIconBytes {
return "", ErrTooLarge
}
dir := filepath.Join(Dir(s.dataRoot), icons)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create icons dir: %w", err)
}
final := filepath.Join(dir, id)
tmp := final + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return "", fmt.Errorf("write icon: %w", err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("store icon: %w", err)
}
return filepath.Join(icons, id), nil
}
// PruneArtifactDir removes every file in artifacts/<id> except keep, so an
// artifact replacement does not leave the previous copy behind.
func (s *Store) PruneArtifactDir(id, keep string) error {
dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, e := range entries {
if e.Name() == keep {
continue
}
if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
return nil
}
// DeleteFiles removes an app's hosted artifact directory and uploaded icon.
func (s *Store) DeleteFiles(app App) error {
if app.ID != "" {
if err := os.RemoveAll(filepath.Join(Dir(s.dataRoot), artifacts, app.ID)); err != nil {
return err
}
}
if app.IconPath != "" {
p, err := s.ResolveIcon(app)
if err == nil {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
// ResolveArtifact returns the absolute path of an app's hosted artifact.
func (s *Store) ResolveArtifact(app App) (string, error) {
return s.resolve(app.ArtifactPath)
}
// ResolveIcon returns the absolute path of an app's uploaded icon.
func (s *Store) ResolveIcon(app App) (string, error) {
return s.resolve(app.IconPath)
}
// resolve joins a stored relative path under the catalog dir, refusing any
// value that would escape it.
func (s *Store) resolve(rel string) (string, error) {
if rel == "" {
return "", os.ErrNotExist
}
base := Dir(s.dataRoot)
p := filepath.Clean(filepath.Join(base, rel))
if p != base && !strings.HasPrefix(p, base+string(filepath.Separator)) {
return "", fmt.Errorf("catalog: path escapes store dir: %q", rel)
}
return p, nil
}
// diskApp is the private on-disk projection of a record: it adds the fields
// that must never serialize to clients (hosted artifact/icon paths and the
// operator link target) under private keys.
type diskApp struct {
App
ArtifactPath string `json:"artifact_path,omitempty"`
IconPath string `json:"icon_path,omitempty"`
ArtifactSourceURL string `json:"artifact_source_url,omitempty"`
}
func toDisk(app App) diskApp {
return diskApp{
App: app,
ArtifactPath: app.ArtifactPath,
IconPath: app.IconPath,
ArtifactSourceURL: app.Artifact.SourceURL,
}
}
func fromDisk(app diskApp) App {
out := app.App
out.ArtifactPath = app.ArtifactPath
out.IconPath = app.IconPath
out.Artifact.SourceURL = app.ArtifactSourceURL
return out
}
// load reads and parses the index. A missing file is an empty catalog.
func (s *Store) load() ([]App, error) {
data, err := os.ReadFile(Path(s.dataRoot))
if err != nil {
if os.IsNotExist(err) {
return []App{}, nil
}
return nil, err
}
var wrapped struct {
Apps []diskApp `json:"apps"`
}
if err := json.Unmarshal(data, &wrapped); err != nil {
var direct []diskApp
if err2 := json.Unmarshal(data, &direct); err2 != nil {
return nil, fmt.Errorf("parse catalog index: %w", err)
}
wrapped.Apps = direct
}
apps := make([]App, 0, len(wrapped.Apps))
for _, app := range wrapped.Apps {
apps = append(apps, fromDisk(app))
}
return apps, nil
}
// save atomically replaces the index (dir 0700, tmp+rename, file 0600).
func (s *Store) save(apps []App) error {
disk := make([]diskApp, 0, len(apps))
for _, app := range apps {
disk = append(disk, toDisk(app))
}
payload, err := json.MarshalIndent(struct {
Apps []diskApp `json:"apps"`
}{Apps: disk}, "", " ")
if err != nil {
return fmt.Errorf("marshal catalog index: %w", err)
}
payload = append(payload, '\n')
dir := Dir(s.dataRoot)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create catalog dir: %w", err)
}
path := Path(s.dataRoot)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o600); err != nil {
return fmt.Errorf("write catalog index: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace catalog index: %w", err)
}
return nil
}
+251
View File
@@ -0,0 +1,251 @@
// 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 catalog
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAppIDStable(t *testing.T) {
pkg := "org.dcntrlized.keepnote"
sum := sha256.Sum256([]byte(pkg))
want := "a_" + hex.EncodeToString(sum[:])[:12]
if got := AppID(pkg); got != want {
t.Fatalf("AppID = %q, want %q", got, want)
}
if id := AppID(pkg); id != want {
t.Fatalf("AppID not stable: %q vs %q", id, want)
}
if strings.ContainsAny(want, "ABCDEF") {
t.Fatalf("AppID must be lowercase hex: %q", want)
}
}
func TestStoreCRUDAndPermissions(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
apps, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if apps == nil || len(apps) != 0 {
t.Fatalf("empty catalog must be a non-nil empty slice, got %#v", apps)
}
first := App{ID: AppID("com.example.one"), Package: "com.example.one", Name: "One", VersionName: "1.0", Artifact: Artifact{Kind: KindLink, SHA256: strings.Repeat("a", 64)}}
second := App{ID: AppID("com.example.two"), Package: "com.example.two", Name: "Two", VersionName: "2.0"}
if err := store.Upsert(first); err != nil {
t.Fatalf("Upsert first: %v", err)
}
if err := store.Upsert(second); err != nil {
t.Fatalf("Upsert second: %v", err)
}
apps, err = store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(apps) != 2 || apps[0].Package != "com.example.one" || apps[1].Package != "com.example.two" {
t.Fatalf("insertion order not preserved: %#v", apps)
}
// Store file must be 0600 and dir 0700.
info, err := os.Stat(Path(root))
if err != nil {
t.Fatalf("stat index: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("apps.json mode = %o, want 600", info.Mode().Perm())
}
dirInfo, err := os.Stat(Dir(root))
if err != nil {
t.Fatalf("stat dir: %v", err)
}
if dirInfo.Mode().Perm() != 0o700 {
t.Fatalf("catalog dir mode = %o, want 700", dirInfo.Mode().Perm())
}
got, err := store.Get(first.ID)
if err != nil || got == nil || got.Name != "One" {
t.Fatalf("Get = %#v, %v", got, err)
}
if missing, err := store.Get("a_doesnotexist"); err != nil || missing != nil {
t.Fatalf("Get unknown = %#v, %v; want nil, nil", missing, err)
}
byPkg, err := store.GetByPackage("com.example.two")
if err != nil || byPkg == nil || byPkg.Name != "Two" {
t.Fatalf("GetByPackage = %#v, %v", byPkg, err)
}
// Upsert same id replaces in place, keeping size/order.
second.Name = "Two renamed"
if err := store.Upsert(second); err != nil {
t.Fatalf("Upsert replace: %v", err)
}
apps, _ = store.List()
if len(apps) != 2 || apps[1].Name != "Two renamed" {
t.Fatalf("in-place replace failed: %#v", apps)
}
if err := store.Remove(first.ID); err != nil {
t.Fatalf("Remove: %v", err)
}
apps, _ = store.List()
if len(apps) != 1 || apps[0].ID != second.ID {
t.Fatalf("Remove left %#v", apps)
}
if err := store.Remove("a_missing"); err != nil {
t.Fatalf("Remove unknown must be a no-op, got %v", err)
}
}
func TestSaveArtifactAndDeleteFiles(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
id := AppID("com.example.app")
rel, size, sum, err := store.SaveArtifact(id, "app-1.0.apk", strings.NewReader("APK-BYTES"), MaxArtifactBytes)
if err != nil {
t.Fatalf("SaveArtifact: %v", err)
}
if size != int64(len("APK-BYTES")) {
t.Fatalf("size = %d", size)
}
want := sha256.Sum256([]byte("APK-BYTES"))
if sum != hex.EncodeToString(want[:]) {
t.Fatalf("sha = %s", sum)
}
app := App{ID: id, ArtifactPath: rel, Artifact: Artifact{Filename: "app-1.0.apk"}}
abs, err := store.ResolveArtifact(app)
if err != nil {
t.Fatalf("ResolveArtifact: %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read artifact: %v", err)
}
if string(data) != "APK-BYTES" {
t.Fatalf("artifact bytes = %q", data)
}
if info, _ := os.Stat(abs); info.Mode().Perm() != 0o600 {
t.Fatalf("artifact mode = %o, want 600", info.Mode().Perm())
}
iconRel, err := store.SaveIcon(id, []byte("\x89PNG\r\n\x1a\nicon"))
if err != nil {
t.Fatalf("SaveIcon: %v", err)
}
app.IconPath = iconRel
iconAbs, err := store.ResolveIcon(app)
if err != nil {
t.Fatalf("ResolveIcon: %v", err)
}
if _, err := os.Stat(iconAbs); err != nil {
t.Fatalf("icon not stored: %v", err)
}
if err := store.DeleteFiles(app); err != nil {
t.Fatalf("DeleteFiles: %v", err)
}
if _, err := os.Stat(abs); !os.IsNotExist(err) {
t.Fatalf("artifact survived DeleteFiles: %v", err)
}
if _, err := os.Stat(iconAbs); !os.IsNotExist(err) {
t.Fatalf("icon survived DeleteFiles: %v", err)
}
}
func TestSaveArtifactEnforcesLimit(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
id := AppID("com.example.big")
_, _, _, err := store.SaveArtifact(id, "big.apk", strings.NewReader("0123456789"), 4)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
entries, _ := os.ReadDir(filepath.Join(Dir(root), "artifacts", id))
if len(entries) != 0 {
t.Fatalf("oversize artifact left files: %v", entries)
}
}
func TestSaveArtifactSanitizesTraversal(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
id := AppID("com.example.x")
// A name with nothing usable left is rejected outright.
if _, _, _, err := store.SaveArtifact(id, "..", strings.NewReader("x"), MaxArtifactBytes); err == nil {
t.Fatal("unusable filename must be rejected")
}
// A traversal attempt is reduced to a single segment inside the store.
rel, _, _, err := store.SaveArtifact(id, "../escape.apk", strings.NewReader("x"), MaxArtifactBytes)
if err != nil {
t.Fatalf("SaveArtifact: %v", err)
}
if rel != filepath.Join("artifacts", id, "escape.apk") {
t.Fatalf("rel = %q", rel)
}
if _, err := os.Stat(filepath.Join(root, "escape.apk")); !os.IsNotExist(err) {
t.Fatalf("artifact escaped the catalog dir (err=%v)", err)
}
// Stored relative paths are contained as well.
if _, err := store.resolve("../../etc/passwd"); err == nil {
t.Fatal("resolve must refuse paths escaping the catalog dir")
}
}
func TestSafeFilename(t *testing.T) {
cases := map[string]string{
"app.apk": "app.apk",
"../evil.apk": "evil.apk",
"..\\evil.apk": "evil.apk",
"/etc/passwd": "passwd",
"nested/dir/app.apk": "app.apk",
"": "",
"..": "",
"keepnote-1.4.2.apk": "keepnote-1.4.2.apk",
}
for in, want := range cases {
if got := SafeFilename(in); got != want {
t.Errorf("SafeFilename(%q) = %q, want %q", in, got, want)
}
}
}
func TestRequestBaseURL(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://internal:8080/api/instance", nil)
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", "apps.example")
if got := RequestBaseURL(r); got != "https://apps.example" {
t.Fatalf("base = %q", got)
}
direct := httptest.NewRequest(http.MethodGet, "http://direct.example/x", nil)
if got := RequestBaseURL(direct); got != "http://direct.example" {
t.Fatalf("direct base = %q", got)
}
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package configsign owns Maestro's DEDICATED /config signing keypair and the
// ES256 token it mints for runtime operator-config delivery (contract rev 9
// §17). The keypair is generated and held by Maestro in its data root and is
// deliberately NOT the instance CA: the PKI identity layer is removed fleet-wide,
// and a service only ever receives the public key (verify, never mint).
package configsign
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
)
const (
// keyFileName is the PKCS#8 PEM private key in <dataRoot>/maestro.
keyFileName = "config-signing.key"
// pubFileName is the PKIX PEM public key in <dataRoot>/maestro.
pubFileName = "config-signing.pub"
// Issuer is the fixed iss claim every config token carries.
Issuer = "dcntrlized-maestro"
// Scope is the fixed scope claim; the service requires exactly this value.
Scope = "config"
// TokenTTL is the frozen 60 s token lifetime.
TokenTTL = 60 * time.Second
// PublicKeyEnv is the env var Maestro injects into a service container: the
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM) of the
// dedicated config-signing public key. The service loads it to verify
// /config tokens and can never mint them.
PublicKeyEnv = "DCNTRLIZED_CONFIG_PUBKEY"
)
// Signer holds the loaded keypair and mints config tokens.
type Signer struct {
key *ecdsa.PrivateKey
kid string
pubDER []byte
pubPEM []byte
}
// LoadOrCreate opens <dataRoot>/maestro/config-signing.key, generating the
// keypair on first use. The private key is written atomically with mode 0600;
// the public key is written alongside it with mode 0644.
func LoadOrCreate(dataRoot string) (*Signer, error) {
dir := filepath.Join(dataRoot, "maestro")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("configsign: mkdir %s: %w", dir, err)
}
keyPath := filepath.Join(dir, keyFileName)
pubPath := filepath.Join(dir, pubFileName)
if raw, err := os.ReadFile(keyPath); err == nil {
block, _ := pem.Decode(raw)
if block == nil {
return nil, fmt.Errorf("configsign: %s is not PEM", keyPath)
}
key, err := parsePrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("configsign: parse %s: %w", keyPath, err)
}
return newSigner(key, pubPath)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("configsign: read %s: %w", keyPath, err)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("configsign: generate key: %w", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("configsign: marshal private key: %w", err)
}
if err := writeFileAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
return nil, err
}
return newSigner(key, pubPath)
}
// newSigner derives the kid and (re)writes the public key file.
func newSigner(key *ecdsa.PrivateKey, pubPath string) (*Signer, error) {
spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return nil, fmt.Errorf("configsign: marshal public key: %w", err)
}
sum := sha256.Sum256(spki)
s := &Signer{
key: key,
kid: hex.EncodeToString(sum[:16]),
pubDER: spki,
pubPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}),
}
if err := writeFileAtomic(pubPath, s.pubPEM, 0o644); err != nil {
return nil, err
}
return s, nil
}
// Kid is the key id placed in the JWT header; a service can recompute it from
// the public key it holds.
func (s *Signer) Kid() string { return s.kid }
// PublicKeyBase64 is the single-line env value Maestro injects into services:
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM). The service
// decodes it once and has both the key and the kid input (kid is defined over
// this exact DER).
func (s *Signer) PublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(s.pubDER)
}
// Mint returns a fresh ES256 config token for one service. Claims are frozen by
// contract rev 9: iss=dcntrlized-maestro, aud=sub=<service>, scope="config",
// iat/nbf/exp=now..now+60s, and a random 128-bit jti (single-use).
func (s *Signer) Mint(service string, now time.Time) (string, error) {
jti := make([]byte, 16)
if _, err := rand.Read(jti); err != nil {
return "", fmt.Errorf("configsign: jti: %w", err)
}
header := map[string]string{"alg": "ES256", "typ": "JWT", "kid": s.kid}
claims := map[string]any{
"iss": Issuer,
"aud": service,
"sub": service,
"scope": Scope,
"iat": now.Unix(),
"nbf": now.Unix(),
"exp": now.Add(TokenTTL).Unix(),
"jti": hex.EncodeToString(jti),
}
hb, err := json.Marshal(header)
if err != nil {
return "", err
}
cb, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := b64(hb) + "." + b64(cb)
digest := sha256.Sum256([]byte(signingInput))
r, ss, err := ecdsa.Sign(rand.Reader, s.key, digest[:])
if err != nil {
return "", fmt.Errorf("configsign: sign: %w", err)
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
ss.FillBytes(sig[32:])
return signingInput + "." + b64(sig), nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func parsePrivateKey(der []byte) (*ecdsa.PrivateKey, error) {
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
ec, ok := k.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA key")
}
return ec, nil
}
return x509.ParseECPrivateKey(der)
}
// writeFileAtomic writes data to path via a temp file + rename so a crash can
// never leave a half-written private key.
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return fmt.Errorf("configsign: write %s: %w", tmp, err)
}
if err := os.Chmod(tmp, mode); err != nil {
return fmt.Errorf("configsign: chmod %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("configsign: rename %s: %w", path, err)
}
return nil
}
// Verify is a test helper: it checks a token the Signer minted against its own
// public key (the same computation a service performs). It is not used by
// Maestro at runtime — Maestro only mints.
func (s *Signer) Verify(token string) error {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return fmt.Errorf("malformed token")
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
return fmt.Errorf("bad signature encoding")
}
r := new(big.Int).SetBytes(sig[:32])
ss := new(big.Int).SetBytes(sig[32:])
if !ecdsa.Verify(&s.key.PublicKey, digest[:], r, ss) {
return fmt.Errorf("signature does not verify")
}
return nil
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package configsign
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"math/big"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestLoadOrCreatePersistsKeypair(t *testing.T) {
root := t.TempDir()
s1, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
keyPath := filepath.Join(root, "maestro", keyFileName)
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat private key: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("private key mode = %o, want 600", perm)
}
pubInfo, err := os.Stat(filepath.Join(root, "maestro", pubFileName))
if err != nil {
t.Fatalf("stat public key: %v", err)
}
if perm := pubInfo.Mode().Perm(); perm != 0o644 {
t.Fatalf("public key mode = %o, want 644", perm)
}
// Reload must reuse the same key (kid stable).
s2, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate reload: %v", err)
}
if s1.Kid() != s2.Kid() {
t.Fatalf("kid changed across reload: %q vs %q", s1.Kid(), s2.Kid())
}
}
func TestKidIsHexOfSPKIHalf(t *testing.T) {
root := t.TempDir()
s, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(s.Kid()) {
t.Fatalf("kid = %q, want 32 lowercase hex chars", s.Kid())
}
der, err := base64.StdEncoding.DecodeString(s.PublicKeyBase64())
if err != nil {
t.Fatalf("PublicKeyBase64 is not base64: %v", err)
}
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
t.Fatalf("public key env value is not DER SPKI: %v", err)
}
sum := sha256.Sum256(der)
if want := hex.EncodeToString(sum[:16]); s.Kid() != want {
t.Fatalf("kid = %q, want sha256(spki)[:16] = %q", s.Kid(), want)
}
}
func TestMintClaimsAndSignature(t *testing.T) {
root := t.TempDir()
s, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
now := time.Unix(1_800_000_000, 0)
token, err := s.Mint("calendar", now)
if err != nil {
t.Fatalf("Mint: %v", err)
}
if err := s.Verify(token); err != nil {
t.Fatalf("minted token does not verify: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token has %d parts, want 3", len(parts))
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
t.Fatalf("signature must be raw 64-byte r||s (len=%d err=%v)", len(sig), err)
}
var header map[string]string
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
if err := json.Unmarshal(hb, &header); err != nil {
t.Fatalf("header: %v", err)
}
if header["alg"] != "ES256" || header["typ"] != "JWT" || header["kid"] != s.Kid() {
t.Fatalf("header = %v", header)
}
var claims map[string]any
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatalf("claims: %v", err)
}
want := map[string]any{
"iss": "dcntrlized-maestro",
"aud": "calendar",
"sub": "calendar",
"scope": "config",
"iat": float64(now.Unix()),
"nbf": float64(now.Unix()),
"exp": float64(now.Add(60 * time.Second).Unix()),
}
for k, v := range want {
if claims[k] != v {
t.Errorf("claim %s = %v, want %v", k, claims[k], v)
}
}
jti, _ := claims["jti"].(string)
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(jti) {
t.Fatalf("jti = %q, want 32 lowercase hex chars", jti)
}
}
// TestContractGoldenFixture cross-checks Maestro's kid derivation and ES256
// interpretation against the worked example frozen in contract rev 9 §17.3.7.
// It does not use the Signer (which holds a private key): it verifies the
// contract's token with the contract's public key, exactly as a service would.
func TestContractGoldenFixture(t *testing.T) {
const (
pubB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqBhe2nIg0c3qf2C8yIVGs9/qSqLvW8eFUliCT1yKnWmUhX5RdRCOIOo59M7APFzywtwSOxMMivQh9N5rVWXxCQ=="
kidHex = "088efbd65ad78ceed9dfe6495367ea6d"
token = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjA4OGVmYmQ2NWFkNzhjZWVkOWRmZTY0OTUzNjdlYTZkIn0.eyJpc3MiOiJkY250cmxpemVkLW1hZXN0cm8iLCJhdWQiOiJjYWxlbmRhciIsInN1YiI6ImNhbGVuZGFyIiwic2NvcGUiOiJjb25maWciLCJpYXQiOjE3NzAwMDAwMDAsIm5iZiI6MTc3MDAwMDAwMCwiZXhwIjoxNzcwMDAwMDYwLCJqdGkiOiIwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZiJ9.4F8OmePWBO4lZsR0bOG5HK6cttvtTmKjwhcYMZcY5neIGnnmTlYsGtGJzMmhaC7eM5OEz6d0rPft8ZWNiQvyfQ"
)
der, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatalf("decode pubkey: %v", err)
}
sum := sha256.Sum256(der)
if got := hex.EncodeToString(sum[:16]); got != kidHex {
t.Fatalf("kid = %s, contract says %s", got, kidHex)
}
parsed, err := x509.ParsePKIXPublicKey(der)
if err != nil {
t.Fatalf("parse pubkey: %v", err)
}
pub, ok := parsed.(*ecdsa.PublicKey)
if !ok || pub.Curve != elliptic.P256() {
t.Fatalf("pubkey is not P-256 ECDSA: %T", parsed)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("fixture token has %d segments", len(parts))
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
t.Fatalf("fixture signature not raw 64 bytes (len=%d err=%v)", len(sig), err)
}
if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
t.Fatal("contract fixture token does not verify with the contract public key")
}
var claims map[string]any
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatalf("fixture payload: %v", err)
}
if claims["iss"] != Issuer || claims["aud"] != "calendar" || claims["sub"] != "calendar" || claims["scope"] != Scope {
t.Fatalf("fixture claims mismatch: %v", claims)
}
if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 60 {
t.Fatalf("fixture exp-iat = %v, want 60", exp-iat)
}
if claims["nbf"] != claims["iat"] {
t.Fatalf("fixture nbf != iat: %v", claims)
}
}
func TestMintJTIUniqueAndTamperRejected(t *testing.T) {
root := t.TempDir()
s, err := LoadOrCreate(root)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
now := time.Now()
a, err := s.Mint("calendar", now)
if err != nil {
t.Fatal(err)
}
b, err := s.Mint("calendar", now)
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("two mints produced the same token (jti must be random)")
}
// Flip one payload byte → signature must fail.
parts := strings.Split(a, ".")
parts[1] = strings.Replace(parts[1], "e", "f", 1)
if err := s.Verify(strings.Join(parts, ".")); err == nil {
t.Fatal("tampered token verified")
}
}
+24 -47
View File
@@ -16,12 +16,13 @@
// Package containers implements Maestro's runtime write-back delivery for the
// Service Config & Secrets theme (D6). When the operator sets or clears a
// config value via the Settings panel, Maestro delivers it to the running
// service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own
// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at
// the service's dedicated peer listener port (label org.dcntrlized.peer_port,
// default 9443), which the service gates to CN=maestro. A set carries
// {field, value, type}; a clear carries {field, action:"clear"} — the service
// owns the schema and branches on what it receives.
// service's POST /config endpoint on the service's single plaintext :8080
// listener, authenticated with a short-lived, single-use ES256 config token
// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17).
// There is no mTLS and no peer port: the fleet-wide internal transport is
// plaintext HTTP. A set carries {field, value, type}; a clear carries
// {field, action:"clear"} — the service owns the schema and branches on what it
// receives.
//
// Delivery is best-effort: failures are logged (with the field name, never
// the value) and do not prevent the panel write from succeeding. Values always
@@ -42,32 +43,16 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// peerPortLabel is the compose label a service declares to advertise the
// internal port of its always-mTLS Maestro peer listener.
const peerPortLabel = "org.dcntrlized.peer_port"
// defaultPeerPort is the fleet-wide default for that listener. Services keep/ve
// to it unless the label overrides it.
const defaultPeerPort = "9443"
// peerPort resolves the configured {peer listener port} from a container's
// compose labels, falling back to the fleet default.
func peerPort(labels map[string]string) string {
if labels == nil {
return defaultPeerPort
}
if p := labels[peerPortLabel]; p != "" {
return p
}
return defaultPeerPort
}
// configPort is the single port every service serves its API, /config and web
// app on after rev 9. There is no peer listener and no compose label.
const configPort = "8080"
// ConfigDelivery is one key-value pair to deliver to a running service.
type ConfigDelivery struct {
Field string // manifest config field name
Key string // container env var name
Type string // "cleartext" | "password"
Value string // the operator-entered value (sent over mTLS; never logged)
Value string // the operator-entered value (never logged)
}
// configRequest is the JSON body POSTed to the service's /config endpoint.
@@ -79,10 +64,9 @@ type configRequest struct {
Action string `json:"action,omitempty"`
}
// DeliverConfigValues delivers the given config pairs to the running service
// over the mTLS peer edge. If the service is not running or unreachable, the
// error is logged but not returned — the values will take effect at next start
// via injectConfigSecrets.
// DeliverConfigValues delivers the given config pairs to the running service.
// If the service is not running or unreachable, the error is logged but not
// returned — the values will take effect at next start via injectConfigSecrets.
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
if len(items) == 0 {
return
@@ -114,9 +98,10 @@ func DeliverConfigClear(ctx context.Context, serviceName, field string) {
}
// postConfig performs one best-effort POST of body to the running service's
// /config endpoint. NotFound/unreachable cases are logged at debug (the write
// still applies at next start); transport errors carry the field name only,
// never a value.
// /config endpoint on the single :8080 listener, authenticated with a freshly
// minted ES256 config token. NotFound/unreachable cases are logged at debug
// (the write still applies at next start); transport errors carry the field
// name only, never a value.
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
dc, err := docker.NewDockerClient()
if err != nil {
@@ -137,30 +122,22 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
return
}
// /config is served on the service's dedicated Maestro peer listener
// (always mTLS, gated to CN=maestro), NOT the RN-facing flat edge. Maestro
// presents its own identity and targets the peer port; dev mode does not
// downgrade this edge.
port := peerPort(inspectData.Config.Labels)
transport, err := authmgmt.MaestroTransport()
token, err := authmgmt.ConfigToken(serviceName)
if err != nil {
logger.Warn("Config delivery to %s/%s: cannot build Maestro peer transport: %v", serviceName, field, err)
logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err)
return
}
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
client := &http.Client{Timeout: 5 * time.Second}
url := fmt.Sprintf("https://%s:%s/config", network.IPAddress.Address, port)
url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
@@ -174,4 +151,4 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
} else {
logger.Debug("Config delivery to %s/%s: service responded %d; will apply at next start", serviceName, field, resp.StatusCode)
}
}
}
-39
View File
@@ -1,39 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import "testing"
func TestPeerPort(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want string
}{
{"nil labels fall back to default", nil, defaultPeerPort},
{"empty labels fall back to default", map[string]string{}, defaultPeerPort},
{"label present wins", map[string]string{"org.dcntrlized.peer_port": "9000"}, "9000"},
{"empty label value falls back to default", map[string]string{"org.dcntrlized.peer_port": ""}, defaultPeerPort},
{"unrelated labels fall back to default", map[string]string{"org.dcntrlized.port": "8080"}, defaultPeerPort},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := peerPort(tt.labels); got != tt.want {
t.Fatalf("peerPort(%v) = %q, want %q", tt.labels, got, tt.want)
}
})
}
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
)
// ErrNotInstalled is returned by a lifecycle action when the service has no
// Maestro-owned compose project and no existing container to operate on. It lets
// the HTTP layer answer a conflict instead of a generic internal error.
var ErrNotInstalled = errors.New("service is not installed by Maestro")
// composeProjectAvailable reports whether Maestro owns (has downloaded) the
// compose project file for a service. The dashboard can also show containers
// that Maestro did not install — e.g. the dev debug fleet, started from each
// repo's own docker-compose.debug.yaml, which still carry the
// org.dcntrlized.service label. Those must be driven at the container level.
func composeProjectAvailable(serviceName string) bool {
dir, err := getContainerDataDir(serviceName)
if err != nil {
return false
}
info, err := os.Stat(filepath.Join(dir, "docker-compose.yaml"))
return err == nil && !info.IsDir()
}
// serviceContainers returns the containers a lifecycle action should target.
// An exact docker-name match for the requested name wins (the dashboard card is
// keyed by container name); otherwise every container of the resolved compose
// project or matching dcntrlized service label is targeted.
func serviceContainers(ctx context.Context, dc *docker.DockerClient, serviceName, requestedName string) ([]container.Summary, error) {
filter := filters.NewArgs()
filter.Add("label", "org.dcntrlized.service")
all, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
return selectServiceContainers(all, serviceName, requestedName), nil
}
// selectServiceContainers picks the containers a lifecycle action targets from
// a listing. An exact docker-name match for the requested name wins (the
// dashboard card is keyed by container name); otherwise every container of the
// resolved compose project or matching dcntrlized service label is targeted.
func selectServiceContainers(all []container.Summary, serviceName, requestedName string) []container.Summary {
var exact, labeled []container.Summary
for _, c := range all {
if containerNameMatches(c, requestedName) {
exact = append(exact, c)
continue
}
if containerBelongsToService(c, serviceName, requestedName) {
labeled = append(labeled, c)
}
}
if len(exact) > 0 {
return exact
}
return labeled
}
// containerNameMatches reports whether the container's docker name equals the
// requested name (case-insensitive).
func containerNameMatches(c container.Summary, requestedName string) bool {
if requestedName == "" {
return false
}
for _, n := range c.Names {
if strings.EqualFold(filepath.Base(n), requestedName) {
return true
}
}
return false
}
// containerBelongsToService reports whether the container belongs to the
// resolved compose project or carries a matching dcntrlized service label.
func containerBelongsToService(c container.Summary, serviceName, requestedName string) bool {
if project := c.Labels["com.docker.compose.project"]; project != "" {
if strings.EqualFold(project, serviceName) {
return true
}
}
label := c.Labels["org.dcntrlized.service"]
if label == "" {
return false
}
return strings.EqualFold(label, serviceName) || strings.EqualFold(label, requestedName)
}
// containerLabel returns a human-readable identifier for logs and errors.
func containerLabel(c container.Summary) string {
if len(c.Names) > 0 {
return filepath.Base(c.Names[0])
}
if id := c.ID; len(id) > 12 {
return id[:12]
}
return c.ID
}
// stopExternalContainers stops the containers of a service Maestro does not
// manage through compose.
func stopExternalContainers(ctx context.Context, dc *docker.DockerClient, serviceName, requestedName string) error {
containers, err := serviceContainers(ctx, dc, serviceName, requestedName)
if err != nil {
return err
}
if len(containers) == 0 {
return fmt.Errorf("%w: %s", ErrNotInstalled, serviceName)
}
for _, c := range containers {
if !strings.EqualFold(c.State, "running") && !strings.EqualFold(c.State, "restarting") {
continue
}
name := containerLabel(c)
if err := dc.ContainerStop(ctx, c.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("failed to stop container %s: %w", name, err)
}
logger.Info("Stopped externally managed container %s (service %s)", name, serviceName)
}
return nil
}
// installedForAny reports whether Maestro has install metadata for any of the
// candidate names. The dcntrlized service label may carry a display name
// ("KeepNote") while install dirs are lowercase ("keepnote"), so both forms are
// tried.
func installedForAny(names ...string) bool {
for _, n := range names {
if n == "" {
continue
}
if IsInstalled(n) || IsInstalled(strings.ToLower(n)) {
return true
}
}
return false
}
// composeConfigUnderDataRoot reports whether a container's compose provenance
// label points at a compose file inside Maestro's data root — i.e. the file
// Maestro itself downloaded for the service. A container created from a repo's
// own compose file (e.g. a debug compose in the checkout) fails this check.
func composeConfigUnderDataRoot(configFiles string) bool {
if configFiles == "" {
return false
}
root, err := getDataRoot()
if err != nil {
return false
}
root = filepath.Clean(root)
for _, f := range strings.Split(configFiles, ",") {
dir := filepath.Clean(filepath.Dir(strings.TrimSpace(f)))
if dir == root || strings.HasPrefix(dir, root+string(os.PathSeparator)) {
return true
}
}
return false
}
// externalFromLabels decides ownership from a container's labels alone (the
// watcher has no other context): a service Maestro has install metadata for,
// whose container came from Maestro's own compose file, is managed; anything
// else was started outside Maestro.
func externalFromLabels(serviceName string, labels map[string]string) bool {
if !installedForAny(serviceName) {
return true
}
return !composeConfigUnderDataRoot(labels["com.docker.compose.project.config_files"])
}
// isExternalService reports whether every container of a service was started
// outside Maestro. Install metadata is authoritative; the compose provenance
// label catches a container recreated from another project after an install. A
// service with no container at all is external unless Maestro installed it.
func isExternalService(ctx context.Context, dc *docker.DockerClient, serviceName, requestedName string) bool {
cs, err := serviceContainers(ctx, dc, serviceName, requestedName)
if err != nil || len(cs) == 0 {
return !installedForAny(serviceName, requestedName)
}
for _, c := range cs {
if !externalFromLabels(c.Labels["org.dcntrlized.service"], c.Labels) {
return false
}
}
return true
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package containers
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/docker/api/types/container"
)
func summary(id string, names []string, labels map[string]string) container.Summary {
return container.Summary{ID: id, Names: names, Labels: labels, State: "exited"}
}
// A dashboard card is keyed by container name, so an exact docker-name match
// must win over sibling containers sharing the dcntrlized service label. This is
// the dev debug case: `keepnote-debug` (and a stale `keepnote`) both carry
// org.dcntrlized.service=KeepNote.
func TestSelectServiceContainersPrefersExactName(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/keepnote-debug"}, map[string]string{
"org.dcntrlized.service": "KeepNote",
"com.docker.compose.project": "keepnote",
}),
summary("bbb", []string{"/keepnote"}, map[string]string{
"org.dcntrlized.service": "KeepNote",
"com.docker.compose.project": "keepnote",
}),
}
got := selectServiceContainers(all, "keepnote", "keepnote-debug")
if len(got) != 1 || got[0].ID != "aaa" {
t.Fatalf("selected %+v, want only the keepnote-debug container", got)
}
}
// Without an exact name match, every container of the resolved project or the
// matching service label is targeted.
func TestSelectServiceContainersFallsBackToProject(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/store-debug"}, map[string]string{
"org.dcntrlized.service": "Store",
"com.docker.compose.project": "store",
}),
summary("bbb", []string{"/store-migrate"}, map[string]string{
"org.dcntrlized.service": "Store",
"com.docker.compose.project": "store",
}),
summary("ccc", []string{"/unrelated"}, map[string]string{
"org.dcntrlized.service": "Other",
"com.docker.compose.project": "other",
}),
}
got := selectServiceContainers(all, "store", "")
if len(got) != 2 {
t.Fatalf("selected %d containers, want 2 (%+v)", len(got), got)
}
for _, c := range got {
if c.ID == "ccc" {
t.Fatalf("unrelated container selected: %+v", c)
}
}
}
// A display-name request (the manifest name, e.g. "Store") matches by label.
func TestSelectServiceContainersMatchesDisplayNameLabel(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/store-debug"}, map[string]string{
"org.dcntrlized.service": "Store",
"com.docker.compose.project": "store",
}),
}
got := selectServiceContainers(all, "store", "Store")
if len(got) != 1 || got[0].ID != "aaa" {
t.Fatalf("selected %+v, want the Store container", got)
}
}
func TestSelectServiceContainersNoMatch(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/other"}, map[string]string{"org.dcntrlized.service": "Other"}),
}
if got := selectServiceContainers(all, "store", "store-debug"); len(got) != 0 {
t.Fatalf("selected %+v, want none", got)
}
}
// TestExternalFromLabels locks the ownership rule: a service Maestro has install
// metadata for, whose container came from Maestro's own compose file, is
// managed; everything else (the dev debug fleet) is external and may only be
// stopped from the console.
func TestExternalFromLabels(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
managed := map[string]string{
"com.docker.compose.project.config_files": filepath.Join(root, "keepnote", "docker-compose.yaml"),
}
foreign := map[string]string{
"com.docker.compose.project.config_files": "/home/dev/projects/dcntrlized/KeepNote/docker-compose.debug.yaml",
}
// No install metadata at all -> external, whatever the labels say.
if !externalFromLabels("KeepNote", managed) {
t.Fatalf("service without install metadata must be external")
}
if !externalFromLabels("KeepNote", foreign) {
t.Fatalf("service without install metadata must be external")
}
// Install metadata present (lowercase dir, display-name label).
if err := os.MkdirAll(filepath.Join(root, "keepnote"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "keepnote", "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
if externalFromLabels("KeepNote", managed) {
t.Fatalf("installed service with Maestro's compose file must be managed")
}
if !externalFromLabels("KeepNote", foreign) {
t.Fatalf("installed service recreated from a foreign compose file must be external")
}
if !externalFromLabels("KeepNote", nil) {
t.Fatalf("installed service with no compose provenance must be external")
}
}
// TestInstalledForAny covers the display-name/lowercase pair (the label carries
// "KeepNote" while the install dir is "keepnote").
func TestInstalledForAny(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
if installedForAny("KeepNote") {
t.Fatalf("no install dir yet, got installed")
}
if err := os.MkdirAll(filepath.Join(root, "keepnote"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "keepnote", "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
if !installedForAny("KeepNote") {
t.Fatalf("display-name label must resolve to the lowercase install dir")
}
if installedForAny("", " ") {
t.Fatalf("blank names must never count as installed")
}
}
// composeProjectAvailable is the gate between the compose path and the
// container-level fallback.
func TestComposeProjectAvailable(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
if composeProjectAvailable("store") {
t.Fatalf("available with no data dir")
}
dir := filepath.Join(root, "store")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if composeProjectAvailable("store") {
t.Fatalf("available with an empty data dir")
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
t.Fatalf("write compose: %v", err)
}
if !composeProjectAvailable("store") {
t.Fatalf("not available with a compose file present")
}
}
func TestContainerLabel(t *testing.T) {
c := summary("0123456789abcdef0123", []string{"/store-debug"}, nil)
if got := containerLabel(c); got != "store-debug" {
t.Fatalf("label = %q, want store-debug", got)
}
c = summary("0123456789abcdef0123", nil, nil)
if got := containerLabel(c); got != "0123456789ab" {
t.Fatalf("label = %q, want 0123456789ab", got)
}
}
// TestLiveExternalLifecycle drives the external lifecycle against a real Docker
// daemon. It is opt-in: set DCNTRLIZED_LIVE_CONTAINER to a labeled container
// name to run it. Externally managed services are stop-only: StartContainer must
// refuse them with ErrNotInstalled.
func TestLiveExternalLifecycle(t *testing.T) {
name := os.Getenv("DCNTRLIZED_LIVE_CONTAINER")
if name == "" {
t.Skip("set DCNTRLIZED_LIVE_CONTAINER to run the live Docker probe")
}
ctx := context.Background()
if err := StopContainer(ctx, types.RegistryContainer{Name: name}); err != nil {
t.Fatalf("StopContainer(%s): %v", name, err)
}
err := StartContainer(ctx, types.RegistryContainer{Name: name})
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("StartContainer(%s) on an external service = %v, want ErrNotInstalled", name, err)
}
}
-11
View File
@@ -20,7 +20,6 @@ import (
"fmt"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
@@ -105,16 +104,6 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error {
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
}
// For the shared database, reconcile the CONNECT authorization against the
// peers Maestro manages (M2 §4): per-role CONNECT grants and, in mTLS mode,
// PUBLIC revoked. Best-effort — the database advertises infra-relevant
// problems as warnings, never as boot failures.
if serviceName == DatabaseServiceName {
if err := dbctl.New(dc).SyncAuthorization(ctx); err != nil {
logger.Warn("Shared database authorization sync failed: %v", err)
}
}
return nil
}
+10 -7
View File
@@ -73,10 +73,11 @@ func InstallContainer(ctx context.Context, item types.RegistryContainer) error {
}
// 2.6. Pin the auth service's signing key (security theme M2 §5): fetch its
// attestation over mTLS and store the fingerprint in the install record so
// the container gets AUTH_PINNED_FINGERPRINT at start. Best-effort — when
// the auth service is not reachable yet the pin stays empty and is retried
// on start.
// attestation over plaintext HTTP and store the fingerprint in the install
// record so the container gets AUTH_PINNED_FINGERPRINT at start. This is an
// app-level fingerprint comparison, not a transport identity. Best-effort —
// when the auth service is not reachable yet the pin stays empty and is
// retried on start.
if att, err := fetchAuthAttestation(ctx); err == nil {
metadata.AuthFingerprint = att.Fingerprint
metadata.AuthKid = att.Kid
@@ -176,7 +177,8 @@ func getDataRoot() (string, error) {
}
// DataRoot is the exported accessor for the storage root shared by installed
// container metadata and the instance PKI (CA + service identities).
// container metadata and Maestro's private key material (instance identity,
// session/secrets keys, config-signing keypair).
func DataRoot() (string, error) {
return getDataRoot()
}
@@ -500,8 +502,9 @@ func writeMetadata(path string, metadata types.InstalledMetadata) error {
}
// fetchAuthAttestation fetches the auth service's public key statement over
// mTLS (Maestro's own identity certificate). It backs the M2 §5 TOFU pin: the
// returned fingerprint is what consumer containers are pinned to.
// plaintext HTTP (rev 9 removed the mTLS identity layer). It backs the M2 §5
// app-level pin: the returned fingerprint is what consumer containers are
// pinned to.
func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) {
client, err := authmgmt.New()
if err != nil {
+5
View File
@@ -70,6 +70,7 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
HasAuth: metadata.HasAuth,
PublicKey: metadata.PublicKey,
Config: metadata.Config,
Web: metadata.Web,
Title: metadata.Title,
Vendor: metadata.Vendor,
Author: metadata.Author,
@@ -104,6 +105,10 @@ func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest)
if m.Config != nil {
metadata.Config = m.Config
}
// The manifest's `web` block is announced only while the listener is
// enabled and serving (honesty rule §16.1), so it replaces the persisted
// value — including clearing it when the service stops advertising one.
metadata.Web = m.Web
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err)
}
+75 -118
View File
@@ -18,24 +18,20 @@ package containers
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/pkg/api"
)
// identityMountDir is where Maestro bind-mounts the instance CA and the
// service's identity certificate inside every peer container.
const identityMountDir = "/dcntrlized/pki"
// StartContainer creates and starts the service containers defined in the
// service's docker-compose.yaml.
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
@@ -46,6 +42,16 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name)
// Containers the dashboard shows are not always Maestro installs: the dev
// debug fleet (started from each repo's own docker-compose.debug.yaml) also
// carries the dcntrlized service label. Those are not Maestro's to run —
// the console can stop them, but starting them stays a manual action in
// their own compose project.
if isExternalService(ctx, dc, serviceName, item.Name) {
return fmt.Errorf("%w: %s is externally managed — start it from its own compose project", ErrNotInstalled, serviceName)
}
project, err := loadComposeProject(ctx, serviceName)
if err != nil {
return err
@@ -63,15 +69,11 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
}
}
// Issue (or refresh) the service's identity certificate from the instance
// CA and mount it plus the CA bundle into the container. The compose file
// downloaded from git never declares these mounts — Maestro injects them
// (security theme M1 §2). Failing to provision identity is fatal: a peer
// without a cert cannot authenticate on the mTLS edges. Database-capable
// services additionally get their per-service database role DSN here (M2
// §4): on an armed (mTLS) shared database the shared-superuser URL from
// their compose file cannot connect at all.
if err := injectServiceIdentity(ctx, dc, project, serviceName); err != nil {
// Inject the service's runtime env (rev 9): identity env names, the
// dedicated config-signing public key when the service declares a config
// block, and the plaintext shared-database DSN for database-capable
// services. There is no PKI identity, no cert mount and no mTLS.
if err := injectServiceRuntimeEnv(ctx, project, serviceName); err != nil {
return err
}
// Operator-configured env (Service Config & Secrets theme M1): inject the
@@ -117,68 +119,45 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
return nil
}
// injectServiceIdentity issues (or refreshes) the service's identity
// certificate from the instance CA and injects the CA bundle, the identity
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
// AUTH_CLIENT_KEY) so consumers need no per-service config.
// injectServiceRuntimeEnv injects the runtime env every non-oneoff compose
// service needs after rev 9:
//
// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's
// fingerprint recorded at install. A service that already has the pin skips the
// fetch; one installed before this feature (or whose install could not reach
// the auth service) backfills it here, best-effort.
//
// For services declaring the database capability (M2 §4), when the shared
// database runs mTLS-armed, every app service additionally receives a
// per-service DATABASE_URL (role = the service's cert CN, verify-full against
// the instance CA), overriding the shared-superuser URL their compose file
// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL
// env so they can reach the armed database under the same role.
func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
// - SERVICE_NAME / DCNTRLIZED_SERVICE_NAME: the compose service name.
// - DCNTRLIZED_CONFIG_PUBKEY: the base64 DER of Maestro's DEDICATED
// config-signing public key, injected only into services that declare a
// `config` block (they are the only ones that can ever receive a /config
// delivery). The service verifies tokens with it and can never mint them.
// - DATABASE_URL: a plaintext per-service DSN for database-capable services
// (rev 9 removed the DSN mTLS; the shared database now uses
// scram-sha-256 with a password).
// - AUTH_PINNED_FINGERPRINT: the app-level TOFU pin of the auth service's
// signing key (unchanged; it is not an mTLS control).
func injectServiceRuntimeEnv(ctx context.Context, project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
return fmt.Errorf("resolve data root: %w", err)
}
ca, err := pki.EnsureCA(dataRoot)
if err != nil {
return fmt.Errorf("ensure instance CA: %w", err)
}
identity, err := pki.EnsureServiceIdentity(ca, serviceName)
if err != nil {
return fmt.Errorf("provision identity for %s: %w", serviceName, err)
}
mounts := []string{
fmt.Sprintf("%s:%s/ca.crt:ro", hostBindSource(dataRoot, pki.CertPath(dataRoot)), identityMountDir),
fmt.Sprintf("%s:%s/tls.crt:ro", hostBindSource(dataRoot, identity.CertPath), identityMountDir),
fmt.Sprintf("%s:%s/tls.key:ro", hostBindSource(dataRoot, identity.KeyPath), identityMountDir),
}
env := map[string]string{
"SERVICE_NAME": serviceName,
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
"SERVICE_NAME": serviceName,
"DCNTRLIZED_SERVICE_NAME": serviceName,
}
// Per-service database role DSN (M2 §4). Only on an armed database — a
// plain (compose-managed dev) Postgres cannot satisfy verify-full, and its
// stock trust rule needs no per-service roles anyway. Best-effort: without
// the capability in the install record (pre-manifest installs) nothing is
// injected and the compose defaults apply.
dbEnv := map[string]string{}
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
if dbctl.New(dc).Armed(ctx) {
env["DATABASE_URL"] = mtlsDatabaseURL(serviceName)
dbEnv = map[string]string{
"PGUSER": serviceName,
"PGSSLMODE": "verify-full",
"PGSSLCERT": identityMountDir + "/tls.crt",
"PGSSLKEY": identityMountDir + "/tls.key",
"PGSSLROOTCERT": identityMountDir + "/ca.crt",
}
logger.Info("Injected per-service database DSN for %s (armed shared database)", serviceName)
// The config-signing public key is only useful to services that declare a
// config block. declaredConfigFields errors for services without install
// metadata (e.g. externally-managed dev containers); that is not fatal.
if declared, err := declaredConfigFields(serviceName); err == nil && len(declared) > 0 {
signer, err := configsign.LoadOrCreate(dataRoot)
if err != nil {
return fmt.Errorf("load config signing key: %w", err)
}
env[configsign.PublicKeyEnv] = signer.PublicKeyBase64()
}
// Plaintext database DSN for database-capable services (never the DB
// itself, whose compose file owns its own credentials).
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
env["DATABASE_URL"] = plaintextDatabaseURL()
}
// The auth service is the attester itself — nothing to pin there. For every
@@ -192,33 +171,45 @@ func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project
for name, s := range project.Services {
if isOneoff(s) {
// Migrate/seed helpers talk libpq/psycopg straight to the shared
// database; on an armed database they need the identity mount plus
// the same per-service role (their compose file's PGUSER is the
// plain-dev default).
if len(dbEnv) > 0 {
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, dbEnv)
// database; they need the same plaintext DSN and role.
if dsn, ok := env["DATABASE_URL"]; ok {
s.Environment = mergeEnv(s.Environment, map[string]string{
"DATABASE_URL": dsn,
"PGUSER": databaseUser,
})
project.Services[name] = s
}
continue
}
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, env)
project.Services[name] = s
}
logger.Info("Injected service identity for %s (fingerprint %s)", serviceName, identity.Meta.Fingerprint)
logger.Info("Injected runtime env for %s", serviceName)
return nil
}
// mtlsDatabaseURL builds the per-service connection URL for the shared
// database: role = the service's cert CN, no password (clientcert=verify-full
// makes the certificate the credential), TLS verified against the instance CA.
func mtlsDatabaseURL(serviceName string) string {
return fmt.Sprintf(
"postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key",
serviceName, DatabaseServiceName, dbctl.DBName,
identityMountDir, identityMountDir, identityMountDir,
)
// databaseName / databaseUser / defaultDatabasePassword are the shared
// database's plaintext credentials after rev 9. The dcntrlized-db compose
// declares POSTGRES_HOST_AUTH_METHOD=scram-sha-256 and
// POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dcntrlized}; Maestro injects the same
// password (overridable with DCNTRLIZED_DB_PASSWORD) so every service connects
// as postgres instead of the old cert-CN role.
const (
databaseName = "dcntrlized"
databaseUser = "postgres"
defaultDatabasePassword = "dcntrlized"
)
// plaintextDatabaseURL builds the shared database DSN: plaintext TCP with
// scram-sha-256 password auth (sslmode=disable). No certs, no per-service
// role.
func plaintextDatabaseURL() string {
pw := os.Getenv("DCNTRLIZED_DB_PASSWORD")
if pw == "" {
pw = defaultDatabasePassword
}
return fmt.Sprintf("postgres://%s@%s:5432/%s?sslmode=disable",
url.UserPassword(databaseUser, pw).String(), DatabaseServiceName, databaseName)
}
// injectConfigSecrets injects the operator-configured env values (Service
@@ -337,22 +328,6 @@ func hasDatabaseCapability(serviceName string) bool {
return false
}
// hostBindSource maps a dataRoot-relative path to a bind-mount source the
// Docker daemon can resolve. Bind sources are resolved on the daemon's host
// namespace, but inside the dev container the data root lives at a
// container-internal path (/data/.dcntrlized via XDG_DATA_HOME=/data) that
// does not exist on the host — the daemon would silently create it as an
// empty directory. DCNTRLIZED_HOST_DATA_ROOT (compose-injected, e.g.
// $HOME/.dcntrlized) carries the host path backing the data root; when set,
// the container-internal prefix is swapped for it. On a host install the path
// is already host-resolvable and returned unchanged.
func hostBindSource(dataRoot, p string) string {
if host := os.Getenv("DCNTRLIZED_HOST_DATA_ROOT"); host != "" && strings.HasPrefix(p, dataRoot) {
return filepath.Join(host, strings.TrimPrefix(p, dataRoot))
}
return p
}
// pinAuthFingerprint returns the service's pinned auth fingerprint, fetching
// and persisting it when the install record does not have one yet (M2 §5).
// Best-effort: when the auth service is unreachable, it returns "" and the
@@ -388,24 +363,6 @@ func pinAuthFingerprint(ctx context.Context, serviceName string) string {
return att.Fingerprint
}
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
for _, m := range mounts {
parts := strings.SplitN(m, ":", 3)
v := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: parts[0],
Target: parts[1],
}
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
v.ReadOnly = true
}
out = append(out, v)
}
return out
}
// isOneoff reports whether a compose service is a one-shot helper (e.g. the
// migrate container) via the org.dcntrlized.oneoff label the compose file
// declares.
+92 -12
View File
@@ -16,32 +16,112 @@
package containers
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types"
)
func TestHostBindSource(t *testing.T) {
t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "")
got := hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt")
if got != "/data/.dcntrlized/pki/services/auth/tls.crt" {
t.Fatalf("without host root the path must pass through, got %q", got)
// writeTestMetadataFull is writeTestMetadata plus declared capabilities, used
// by the rev-9 runtime-env test (database DSN injection).
func writeTestMetadataFull(t *testing.T, dataRoot, serviceName string, config []types.ConfigItem, capabilities []string) {
t.Helper()
dir := filepath.Join(dataRoot, serviceName)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", dir, err)
}
m := types.InstalledMetadata{
RegistryContainer: types.RegistryContainer{Name: serviceName},
Capabilities: capabilities,
Config: config,
}
data, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0o600); err != nil {
t.Fatalf("write metadata.json: %v", err)
}
}
// TestInjectServiceRuntimeEnv covers the rev-9 runtime env: plaintext service
// names on every service, the config-signing public key only for services that
// declare config fields, a plaintext password DSN for database-capable
// services (app + one-off helpers), and NO mTLS env anywhere.
func TestInjectServiceRuntimeEnv(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_DATA_HOME", root)
t.Setenv("DCNTRLIZED_DB_PASSWORD", "")
// Point the auth pin fetch at a closed port so it fails fast and
// deterministically instead of resolving a container DNS name.
t.Setenv("AUTH_SERVICE_URL", "http://127.0.0.1:1")
dataRoot := filepath.Join(root, ".dcntrlized")
writeTestMetadataFull(t, dataRoot, "keepnote",
[]types.ConfigItem{{"api_key": {Type: types.ConfigCleartext, Key: "API_KEY"}}},
[]string{"database"})
// A service with neither config nor database capability.
writeTestMetadataFull(t, dataRoot, "plain", nil, nil)
project := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
"app": {Environment: map[string]*string{}},
"migrate": {CustomLabels: map[string]string{"org.dcntrlized.oneoff": "true"}, Environment: map[string]*string{}},
}}
if err := injectServiceRuntimeEnv(context.Background(), project, "keepnote"); err != nil {
t.Fatalf("injectServiceRuntimeEnv: %v", err)
}
t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "/home/user/.dcntrlized")
got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt")
if want := "/home/user/.dcntrlized/pki/services/auth/tls.crt"; got != want {
t.Fatalf("host root must replace the container-internal prefix, got %q want %q", got, want)
app := project.Services["app"]
if got := envValue(app.Environment, "DCNTRLIZED_SERVICE_NAME"); got != "keepnote" {
t.Errorf("DCNTRLIZED_SERVICE_NAME = %q, want keepnote", got)
}
if got := envValue(app.Environment, "SERVICE_NAME"); got != "keepnote" {
t.Errorf("SERVICE_NAME = %q, want keepnote", got)
}
if got := envValue(app.Environment, configsign.PublicKeyEnv); got == "" {
t.Error("config-declaring service must receive the config-signing public key")
}
dsn := envValue(app.Environment, "DATABASE_URL")
if !strings.Contains(dsn, "postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable") {
t.Errorf("DATABASE_URL = %q, want the plaintext scram DSN", dsn)
}
// No mTLS material may leak into the container env.
for _, banned := range []string{"AUTH_CA_BUNDLE", "AUTH_CLIENT_CERT", "AUTH_CLIENT_KEY", "PGSSLMODE", "PGSSLCERT"} {
if got := envValue(app.Environment, banned); got != "" {
t.Errorf("%s must not be injected after the mTLS removal, got %q", banned, got)
}
}
got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/ca.crt")
if want := "/home/user/.dcntrlized/pki/ca.crt"; got != want {
t.Fatalf("ca path must be remapped too, got %q want %q", got, want)
// One-off helpers get the DSN + role so migrate/seed can reach the DB.
migrate := project.Services["migrate"]
if got := envValue(migrate.Environment, "PGUSER"); got != "postgres" {
t.Errorf("one-off PGUSER = %q, want postgres", got)
}
if got := envValue(migrate.Environment, "DATABASE_URL"); got == "" {
t.Error("one-off helper must receive DATABASE_URL")
}
// A service with no config block gets no signing key; no database
// capability means no DSN.
plainProject := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
"app": {Environment: map[string]*string{}},
}}
if err := injectServiceRuntimeEnv(context.Background(), plainProject, "plain"); err != nil {
t.Fatalf("injectServiceRuntimeEnv(plain): %v", err)
}
plain := plainProject.Services["app"]
if got := envValue(plain.Environment, configsign.PublicKeyEnv); got != "" {
t.Errorf("non-config service must not receive a signing key, got %q", got)
}
if got := envValue(plain.Environment, "DATABASE_URL"); got != "" {
t.Errorf("non-database service must not receive a DSN, got %q", got)
}
}
+8
View File
@@ -35,6 +35,14 @@ func StopContainer(ctx context.Context, item types.RegistryContainer) error {
defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name)
// Externally managed containers (dev debug fleet) are not Maestro's to
// compose: stop them at the container level instead of failing, and never
// run compose down against a project file that did not create them.
if isExternalService(ctx, dc, serviceName, item.Name) || !composeProjectAvailable(serviceName) {
return stopExternalContainers(ctx, dc, serviceName, item.Name)
}
project, err := loadComposeProject(ctx, serviceName)
if err != nil {
return err
+11 -43
View File
@@ -21,11 +21,8 @@ import (
"os"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/docker/api/types/container"
@@ -43,26 +40,22 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name)
// Externally managed services have no Maestro install record: refuse instead
// of revoking an identity Maestro never issued and deleting the image while
// leaving the container behind.
if isExternalService(ctx, dc, serviceName, item.Name) {
return fmt.Errorf("%w: %s is externally managed — remove it from its own compose project", ErrNotInstalled, serviceName)
}
targetDir, err := getContainerDataDir(serviceName)
if err != nil {
return fmt.Errorf("failed to determine data directory: %w", err)
}
// Revoke the service's identity certificate and publish the CRL entry
// (security theme M1 §2 / M3 §2). A revoked service stops authenticating
// on the mTLS edges within one CRL freshness window. The authorization in
// the auth service's trust store is revoked too (M2 §3 step 5) — the
// fingerprint is read before the identity files are removed.
fingerprint := serviceFingerprint(serviceName)
if err := revokeServiceIdentity(serviceName); err != nil {
logger.Warn("Failed to revoke identity for %s: %v", serviceName, err)
}
if fingerprint != "" {
authmgmt.RevokeServiceBestEffort(ctx, serviceName, fingerprint)
}
// Drop the database CONNECT grant so the revoked peer cannot open a session
// against the shared database either (M2 §4).
dbctl.New(dc).RevokeForService(ctx, serviceName)
// Rev 9: there is no identity certificate, CRL or per-service database
// grant to revoke — the PKI layer and the DB role model are removed. The
// container, data directory and image are all that is left to clean up.
if _, err := os.Stat(targetDir); err == nil {
// Stop and remove the project containers/networks via compose down when
@@ -103,31 +96,6 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
return nil
}
// serviceFingerprint reads the service's identity fingerprint (the public key
// fingerprint the auth service's trust store is keyed on), or "" when the
// identity was never issued.
func serviceFingerprint(serviceName string) string {
dataRoot, err := getDataRoot()
if err != nil {
return ""
}
meta, err := pki.LoadServiceMeta(dataRoot, serviceName)
if err != nil {
return ""
}
return meta.Fingerprint
}
// revokeServiceIdentity revokes the service's certificate in the instance CA.
// Best-effort: uninstall proceeds even if the CA or identity is missing.
func revokeServiceIdentity(serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
return err
}
return pki.Revoke(dataRoot, serviceName)
}
// stopAndRemoveByLabel force-stops and removes every container carrying the
// org.dcntrlized.service label for the given service.
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
+22
View File
@@ -113,6 +113,7 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
hasAuth := existing.HasAuth
publicKey := existing.PublicKey
config := existing.Config
web := existing.Web
if manifest != nil {
capabilities = manifest.Capabilities
hasAuth = manifest.HasAuth
@@ -120,8 +121,19 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
if manifest.Config != nil {
config = manifest.Config
}
// Honesty rule (contract §16.1): the manifest only carries a
// `web` block while the listener is enabled and serving, so
// the live value replaces the cached one (including with nil).
web = manifest.Web
updateInstalledMetadataFromManifest(serviceLabel, manifest)
}
// Ownership is a property of the service, not of one container:
// the dev fleet can share a service label with a Maestro-managed
// container (auth-debug alongside dcntrlized-auth), so a start
// event from that sibling must not flip the service to external.
// Ask the same question the lifecycle guards ask, so the card and
// the API can never disagree.
external := isExternalService(ctx, dc, serviceLabel, containerName)
container := types.Container{
ID: containerID,
ServiceName: serviceLabel,
@@ -133,10 +145,12 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
Version: ver,
Role: role,
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
External: external,
Capabilities: capabilities,
HasAuth: hasAuth,
PublicKey: publicKey,
Config: config,
Web: web,
Title: existing.Title,
Vendor: existing.Vendor,
Author: existing.Author,
@@ -234,6 +248,11 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
key := normalizeName(serviceLabel)
cache.Lock()
entry, has := cache.services[key]
// Ownership is a property of the service, not of a single container. The
// dev fleet shares service labels with managed containers (auth-debug
// alongside dcntrlized-auth, the one-off auth-migrate), so the service is
// external only when none of its containers is Maestro-managed.
external := externalFromLabels(serviceLabel, c.Labels)
if !has {
entry = types.Container{
ServiceName: serviceLabel,
@@ -241,7 +260,10 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
Image: c.Image,
Version: imageTag(c.Image),
Status: "installed",
External: external,
}
} else {
entry.External = entry.External && external
}
entry.Status = normalizeDockerState(c.State)
entry.ID = c.ID
-331
View File
@@ -1,331 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package dbctl administers the shared database's authorization (M2 §4).
//
// The database image enables instance-CA mTLS when Maestro injects an identity
// (server cert + client-CA + hostssl cert / hostnossl reject rules), but the
// auth rules alone only prove *who* authenticates. Authorization lives here:
// the stock PUBLIC CONNECT grant is revoked and each installed peer gets a
// LOGIN role with CONNECT, so a peer that holds no role (or a revoked one)
// cannot even open a session. The admin path uses the postgres superuser over
// the in-container unix socket (trust), which deliberately bypasses the mTLS
// enforcement — Maestro owns the database and drives its lifecycle.
package dbctl
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/pkg/stdcopy"
)
const (
// DBContainerName is the canonical shared database container (image, DNS).
DBContainerName = "dcntrlized-db"
// DBName is the shared database name inside the instance.
DBName = "dcntrlized"
// AdminRole is Maestro's own database role (CN=maestro bootstrap in the
// image). It is granted like any peer so its existence is guaranteed.
AdminRole = "maestro"
// identityMountTarget is the container path of the injected leaf cert. Its
// presence in the mount list marks the database as mTLS-armed.
identityMountTarget = "/dcntrlized/pki/tls.crt"
)
// Client reconciles the shared database's CONNECT authorization state.
type Client struct {
dc *docker.DockerClient
}
// New wraps a Docker client.
func New(dc *docker.DockerClient) *Client {
return &Client{dc: dc}
}
// SyncAuthorization ensures every installed peer holds a LOGIN role with
// CONNECT on the shared database, and — when the database runs in mTLS mode —
// revokes the stock PUBLIC grant. It is safe to call on every start: role
// creation and grants are idempotent, and the PUBLIC revoke is a no-op on the
// second run. When the database is not running it returns an error; callers
// treat it as best-effort.
func (c *Client) SyncAuthorization(ctx context.Context) error {
id := c.dbContainerID(ctx)
if id == "" {
return fmt.Errorf("shared database %s is not running", DBContainerName)
}
if err := c.waitReady(ctx, id); err != nil {
return err
}
roles, err := peerRoles()
if err != nil {
return err
}
if len(roles) == 0 {
return nil
}
script := "SET client_min_messages TO warning;\n"
have := c.existingRoles(ctx, id)
for _, role := range roles {
if !have[role] {
script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role))
}
script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role))
// The peer connects and migrates under its own role (its cert CN),
// not the shared superuser: it may create tables in public (PG15+
// revoked that from PUBLIC) and must reach the tables it already
// owns or shares across every user schema (e.g. auth's own schema).
script += fmt.Sprintf("GRANT CREATE ON SCHEMA public TO %s;\n", quoteIdent(role))
script += grantExistingScript(role)
}
if c.isArmed(ctx, id) {
script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName))
}
out, code, err := c.execPSQL(ctx, id, DBName, script)
if err != nil {
return fmt.Errorf("apply database authorization: %w", err)
}
if code != 0 {
return fmt.Errorf("apply database authorization failed (exit %d): %s", code, out)
}
logger.Info("Reconciled shared database authorization for %d peer(s): %s", len(roles), strings.Join(roles, ", "))
return nil
}
// RevokeForService removes a peer's CONNECT grant when it is uninstalled.
// Best-effort: the database may already be gone.
func (c *Client) RevokeForService(ctx context.Context, serviceName string) {
if serviceName == "" {
return
}
id := c.dbContainerID(ctx)
if id == "" {
return
}
script := fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM %s;", quoteIdent(DBName), quoteIdent(serviceName))
out, code, err := c.execPSQL(ctx, id, DBName, script)
if err != nil || code != 0 {
logger.Warn("Failed to revoke database CONNECT for %s: %v (exit %d): %s", serviceName, err, code, out)
return
}
logger.Info("Revoked database CONNECT for %s", serviceName)
}
// peerRoles returns the guaranteed connectors: Maestro plus every installed
// service (identified by its install dir name, which is also its cert CN).
func peerRoles() ([]string, error) {
roles := []string{AdminRole}
root, err := dataRoot()
if err != nil {
return nil, fmt.Errorf("resolve data root: %w", err)
}
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return roles, nil
}
return nil, fmt.Errorf("list data root: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == DBContainerName {
continue
}
if _, err := os.Stat(filepath.Join(root, entry.Name(), "metadata.json")); err != nil {
continue
}
roles = append(roles, entry.Name())
}
return roles, nil
}
// dataRoot resolves the directory storing installed container metadata, with
// the same precedence as lib/containers.DataRoot (kept local to avoid an
// import cycle — lib/containers imports dbctl).
func dataRoot() (string, error) {
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
return dir, nil
}
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, ".dcntrlized"), nil
}
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
return filepath.Join(xdgHome, ".dcntrlized"), nil
}
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, ".dcntrlized"), nil
}
return filepath.Join("/data", ".dcntrlized"), nil
}
// grantExistingScript returns the DO block granting a role USAGE plus ALL
// PRIVILEGES on tables and sequences in every user schema. Existing volumes
// hold tables created by the postgres superuser (and by other services'
// earlier migrations); without these grants a per-service role could connect
// but not read or migrate anything.
func grantExistingScript(role string) string {
return fmt.Sprintf(`DO $dcntrlized_grants$ DECLARE s text; BEGIN
FOR s IN SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg_%%' AND nspname <> 'information_schema' LOOP
EXECUTE format('GRANT USAGE ON SCHEMA %%I TO %%I', s, %s);
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %%I TO %%I', s, %s);
EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %%I TO %%I', s, %s);
END LOOP; END $dcntrlized_grants$;
`, quoteLiteral(role), quoteLiteral(role), quoteLiteral(role))
}
// quoteLiteral renders a PostgreSQL string literal safe for interpolation.
func quoteLiteral(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// quoteIdent renders a PostgreSQL identifier safe for interpolation.
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// dbContainerID resolves the running canonical database container, or "".
func (c *Client) dbContainerID(ctx context.Context) string {
filter := filters.NewArgs()
filter.Add("label", "org.dcntrlized.service="+DBContainerName)
containers, err := c.dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
if err != nil {
logger.Warn("Failed to list database containers: %v", err)
return ""
}
for _, cc := range containers {
if cc.State != "running" {
continue
}
for _, n := range cc.Names {
if strings.TrimPrefix(n, "/") == DBContainerName {
return cc.ID
}
}
}
return ""
}
// Armed reports whether the running shared database received the injected
// instance-CA identity (i.e. it enforces mTLS on its TCP edge). Callers use
// this to decide which connection URL peers get: verify-full mTLS DSNs on an
// armed database, plaintext defaults otherwise.
func (c *Client) Armed(ctx context.Context) bool {
id := c.dbContainerID(ctx)
if id == "" {
return false
}
return c.isArmed(ctx, id)
}
// isArmed reports whether the database container received the injected
// instance-CA identity (i.e. it enforces mTLS on its TCP edge).
func (c *Client) isArmed(ctx context.Context, id string) bool {
inspect, err := c.dc.ContainerInspect(ctx, id)
if err != nil {
return false
}
for _, m := range inspect.Mounts {
if filepath.Clean(m.Destination) == identityMountTarget {
return true
}
}
return false
}
// waitReady polls the database until an admin statement executes or the
// deadline is reached, tolerating the first-boot delay after compose up.
func (c *Client) waitReady(ctx context.Context, id string) error {
const attempts = 30
for i := 0; i < attempts; i++ {
_, code, err := c.execPSQL(ctx, id, "postgres", "SELECT 1")
if err == nil && code == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
return fmt.Errorf("database %s did not become ready in time", DBContainerName)
}
// existingRoles returns the set of roles currently defined in the database.
func (c *Client) existingRoles(ctx context.Context, id string) map[string]bool {
have := map[string]bool{}
out, code, err := c.execPSQL(ctx, id, "postgres", "SELECT rolname FROM pg_catalog.pg_roles")
if err != nil || code != 0 {
return have
}
for _, line := range strings.Split(out, "\n") {
if name := strings.TrimSpace(line); name != "" {
have[name] = true
}
}
return have
}
// execPSQL runs a psql statement over the in-container unix socket as the
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
// administers the database). It returns trimmed stdout and the exit code.
// execPSQL runs a psql statement over the in-container unix socket as the
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
// administers the database), targeting the named database. It returns trimmed
// stdout and the exit code.
func (c *Client) execPSQL(ctx context.Context, id, dbName, sql string) (string, int, error) {
created, err := c.dc.ContainerExecCreate(ctx, id, container.ExecOptions{
User: "postgres",
AttachStdout: true,
Cmd: []string{"psql", "-U", "postgres", "-d", dbName, "-v", "ON_ERROR_STOP=1", "-tA", "-c", sql},
})
if err != nil {
return "", -1, fmt.Errorf("create psql exec: %w", err)
}
resp, err := c.dc.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
if err != nil {
return "", -1, fmt.Errorf("attach psql exec: %w", err)
}
defer resp.Close()
var stdout, stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil {
return "", -1, fmt.Errorf("read psql output: %w", err)
}
for i := 0; i < 50; i++ {
inspect, err := c.dc.ContainerExecInspect(ctx, created.ID)
if err != nil {
return "", -1, fmt.Errorf("inspect psql exec: %w", err)
}
if !inspect.Running {
return strings.TrimSpace(stdout.String()), inspect.ExitCode, nil
}
time.Sleep(100 * time.Millisecond)
}
return "", -1, fmt.Errorf("psql exec did not finish in time")
}
-84
View File
@@ -1,84 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dbctl
import (
"context"
"os"
"path/filepath"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
)
// TestSyncAuthorizationIntegration exercises the real exec path against a
// running dcntrlized-db container.
//
// 1. docker run -d --name dcntrlized-db -e POSTGRES_DB=dcntrlized \
// -e POSTGRES_USER=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
// -v $PWD/PKI/ca.crt:/dcntrlized/pki/ca.crt:ro \
// -v $PWD/PKI/tls.crt:/dcntrlized/pki/tls.crt:ro \
// -v $PWD/PKI/tls.key:/dcntrlized/pki/tls.key:ro \
// -v dctl-vol:/var/lib/postgresql/data dcntrlized-db
// 2. seed the data root with install dirs for the peers
// 3. DBCtl_INTEGRATION=1 go test ./lib/db -run TestSyncAuthorizationIntegration -v
func TestSyncAuthorizationIntegration(t *testing.T) {
if os.Getenv("DBCtl_INTEGRATION") != "1" {
t.Skip("set DBCtl_INTEGRATION=1 to run against a live database container")
}
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
for _, name := range []string{"keepnote", "dcntrlized-auth"} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, name, "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
dc, err := docker.NewDockerClient()
if err != nil {
t.Fatal(err)
}
defer dc.Close()
c := New(dc)
if err := c.SyncAuthorization(context.Background()); err != nil {
t.Fatalf("SyncAuthorization: %v", err)
}
id := c.dbContainerID(context.Background())
if id == "" {
t.Fatal("database container not found")
}
assert := func(t *testing.T, sql, contains string) {
t.Helper()
out, code, err := c.execPSQL(context.Background(), id, "postgres", sql)
if err != nil || code != 0 {
t.Fatalf("check %q failed (exit %d, err %v): %s", sql, code, err, out)
}
if want := contains; want != "" && out != want {
t.Fatalf("%s => %q, want %q", sql, out, want)
}
}
// Roles exist and hold CONNECT.
assert(t, `SELECT rolname FROM pg_catalog.pg_roles WHERE rolname IN ('maestro','keepnote','dcntrlized-auth') ORDER BY 1`, "dcntrlized-auth\nkeepnote\nmaestro")
assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
// Armed database: PUBLIC grant removed.
assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
}
-78
View File
@@ -1,78 +0,0 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dbctl
import (
"os"
"path/filepath"
"sort"
"testing"
)
func TestQuoteIdent(t *testing.T) {
cases := map[string]string{
"keepnote": `"keepnote"`,
"dcntrlized-db": `"dcntrlized-db"`,
`we"ird`: `"we""ird"`,
}
for in, want := range cases {
if got := quoteIdent(in); got != want {
t.Errorf("quoteIdent(%q) = %s, want %s", in, got, want)
}
}
}
func TestPeerRoles(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
// Installed peers (metadata.json present).
for _, name := range []string{"keepnote", "dcntrlized-auth", "calendar"} {
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
// The database itself must never be granted a role.
if err := os.MkdirAll(filepath.Join(root, DBContainerName), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, DBContainerName, "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
// A non-install directory (no metadata.json) is skipped.
if err := os.MkdirAll(filepath.Join(root, "scratch"), 0o755); err != nil {
t.Fatal(err)
}
roles, err := peerRoles()
if err != nil {
t.Fatalf("peerRoles: %v", err)
}
sort.Strings(roles)
want := []string{"calendar", "dcntrlized-auth", "keepnote", "maestro"}
if len(roles) != len(want) {
t.Fatalf("peerRoles = %v, want %v", roles, want)
}
for i := range want {
if roles[i] != want[i] {
t.Fatalf("peerRoles = %v, want %v", roles, want)
}
}
}

Some files were not shown because too many files have changed in this diff Show More