Compare commits
11
Commits
3220944b12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ea1a7ee6 | ||
|
|
94d60482e6 | ||
|
|
ef4c7c584d | ||
|
|
ea12039af8 | ||
|
|
ad670f30ac | ||
|
|
b7fa29849d | ||
|
|
4038334c28 | ||
|
|
786b7a3b5f | ||
|
|
13f6d14521 | ||
|
|
7afd019215 | ||
|
|
17d5307110 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Template action to generate OCI-compliant containers
|
||||
# Don't forget to change the template variables if any were missed
|
||||
|
||||
name: Build and Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: alpine-latest
|
||||
steps:
|
||||
- name: Install Dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache bash docker-cli docker-cli-buildx git
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bash docker.io git docker-buildx
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Get full git history
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.REGISTRY }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Normalize release version
|
||||
id: version
|
||||
shell: sh
|
||||
run: |
|
||||
ORG_REPO="$(printf '%s' "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "image_base=${{ secrets.REGISTRY }}/${ORG_REPO}" >> "${GITHUB_OUTPUT}"
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
PRERELEASE=""
|
||||
case "$TAG" in
|
||||
*-*) PRERELEASE="-${TAG#*-}"; TAG="${TAG%%-*}";;
|
||||
esac
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<EOF
|
||||
$TAG
|
||||
EOF
|
||||
MAJOR="${MAJOR:-0}"
|
||||
MINOR="${MINOR:-0}"
|
||||
PATCH="${PATCH:-0}"
|
||||
VERSION="${MAJOR}.${MINOR}.${PATCH}${PRERELEASE}"
|
||||
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Extract metadata (labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.version.outputs.image_base }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Maestro
|
||||
org.opencontainers.image.vendor=Dcntrlized
|
||||
org.opencontainers.image.description=Central Dcntrlized service orchestrator
|
||||
org.opencontainers.image.authors=Admin <[email protected]>
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.version.outputs.image_base }}:${{ steps.version.outputs.version }}
|
||||
${{ steps.version.outputs.image_base }}:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
IMAGE_VERSION=${{ steps.version.outputs.version }}
|
||||
IMAGE_REVISION=${{ gitea.sha }}
|
||||
Vendored
-42
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-68
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "bun test tests",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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'>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist/assets/
|
||||
.vite/
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -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 |
Vendored
+32
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')!)
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||
)
|
||||
|
||||
type ActionProxyPayload struct {
|
||||
Method string
|
||||
Action string
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type ActionProxyResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func ActionProxy(container_id, action string, payload ActionProxyPayload) (ActionProxyResponse, error) {
|
||||
// Forward the action to the container with the given ID, and return the result to the frontend
|
||||
|
||||
containers := containers.NewContainerCache().GetServices()
|
||||
for _, container := range containers {
|
||||
if container.ID == container_id {
|
||||
// Forward the action to this container
|
||||
|
||||
body := bytes.NewBuffer([]byte(payload.Data))
|
||||
|
||||
port := "80"
|
||||
if len(container.Network.Ports) > 0 {
|
||||
port = fmt.Sprintf("%d", container.Network.Ports[0].Guest)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], port, payload.Action), body)
|
||||
if err != nil {
|
||||
return ActionProxyResponse{}, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ActionProxyResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
buf.ReadFrom(resp.Body)
|
||||
|
||||
return ActionProxyResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: buf.String(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return ActionProxyResponse{}, fmt.Errorf("container with ID %s not found", container_id)
|
||||
}
|
||||
+47
-187
@@ -23,8 +23,6 @@ package authmgmt
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package authmgmt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSchemeIsAlwaysPlaintext locks the rev-9 transport decision: every
|
||||
// satellite peer edge (manifest fetch, action routing) is plaintext HTTP on the
|
||||
// single :8080 listener. A future https/peer-port regression must fail here.
|
||||
func TestSchemeIsAlwaysPlaintext(t *testing.T) {
|
||||
if got := Scheme(); got != "http" {
|
||||
t.Fatalf("Scheme() = %q, want %q", got, "http")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigTokenMintsForService exercises the contract-named mint helper
|
||||
// (rev 9 §17.3.6) end to end against a temp data root: it must create the
|
||||
// dedicated config-signing keypair, return a 3-segment JWS bound to the target
|
||||
// service, and expose a public key whose kid matches the token header.
|
||||
func TestConfigTokenMintsForService(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DCNTRLIZED_INSTALL_DIR", dir)
|
||||
|
||||
token, err := ConfigToken("calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToken: %v", err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token has %d segments, want 3", len(parts))
|
||||
}
|
||||
for _, name := range []string{"config-signing.key", "config-signing.pub"} {
|
||||
if _, err := os.Stat(dir + "/maestro/" + name); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package configsign owns Maestro's DEDICATED /config signing keypair and the
|
||||
// ES256 token it mints for runtime operator-config delivery (contract rev 9
|
||||
// §17). The keypair is generated and held by Maestro in its data root and is
|
||||
// deliberately NOT the instance CA: the PKI identity layer is removed fleet-wide,
|
||||
// and a service only ever receives the public key (verify, never mint).
|
||||
package configsign
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// keyFileName is the PKCS#8 PEM private key in <dataRoot>/maestro.
|
||||
keyFileName = "config-signing.key"
|
||||
// pubFileName is the PKIX PEM public key in <dataRoot>/maestro.
|
||||
pubFileName = "config-signing.pub"
|
||||
|
||||
// Issuer is the fixed iss claim every config token carries.
|
||||
Issuer = "dcntrlized-maestro"
|
||||
// Scope is the fixed scope claim; the service requires exactly this value.
|
||||
Scope = "config"
|
||||
// TokenTTL is the frozen 60 s token lifetime.
|
||||
TokenTTL = 60 * time.Second
|
||||
|
||||
// PublicKeyEnv is the env var Maestro injects into a service container: the
|
||||
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM) of the
|
||||
// dedicated config-signing public key. The service loads it to verify
|
||||
// /config tokens and can never mint them.
|
||||
PublicKeyEnv = "DCNTRLIZED_CONFIG_PUBKEY"
|
||||
)
|
||||
|
||||
// Signer holds the loaded keypair and mints config tokens.
|
||||
type Signer struct {
|
||||
key *ecdsa.PrivateKey
|
||||
kid string
|
||||
pubDER []byte
|
||||
pubPEM []byte
|
||||
}
|
||||
|
||||
// LoadOrCreate opens <dataRoot>/maestro/config-signing.key, generating the
|
||||
// keypair on first use. The private key is written atomically with mode 0600;
|
||||
// the public key is written alongside it with mode 0644.
|
||||
func LoadOrCreate(dataRoot string) (*Signer, error) {
|
||||
dir := filepath.Join(dataRoot, "maestro")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("configsign: mkdir %s: %w", dir, err)
|
||||
}
|
||||
keyPath := filepath.Join(dir, keyFileName)
|
||||
pubPath := filepath.Join(dir, pubFileName)
|
||||
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("configsign: %s is not PEM", keyPath)
|
||||
}
|
||||
key, err := parsePrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: parse %s: %w", keyPath, err)
|
||||
}
|
||||
return newSigner(key, pubPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("configsign: read %s: %w", keyPath, err)
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: generate key: %w", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: marshal private key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newSigner(key, pubPath)
|
||||
}
|
||||
|
||||
// newSigner derives the kid and (re)writes the public key file.
|
||||
func newSigner(key *ecdsa.PrivateKey, pubPath string) (*Signer, error) {
|
||||
spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: marshal public key: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(spki)
|
||||
s := &Signer{
|
||||
key: key,
|
||||
kid: hex.EncodeToString(sum[:16]),
|
||||
pubDER: spki,
|
||||
pubPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}),
|
||||
}
|
||||
if err := writeFileAtomic(pubPath, s.pubPEM, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Kid is the key id placed in the JWT header; a service can recompute it from
|
||||
// the public key it holds.
|
||||
func (s *Signer) Kid() string { return s.kid }
|
||||
|
||||
// PublicKeyBase64 is the single-line env value Maestro injects into services:
|
||||
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM). The service
|
||||
// decodes it once and has both the key and the kid input (kid is defined over
|
||||
// this exact DER).
|
||||
func (s *Signer) PublicKeyBase64() string {
|
||||
return base64.StdEncoding.EncodeToString(s.pubDER)
|
||||
}
|
||||
|
||||
// Mint returns a fresh ES256 config token for one service. Claims are frozen by
|
||||
// contract rev 9: iss=dcntrlized-maestro, aud=sub=<service>, scope="config",
|
||||
// iat/nbf/exp=now..now+60s, and a random 128-bit jti (single-use).
|
||||
func (s *Signer) Mint(service string, now time.Time) (string, error) {
|
||||
jti := make([]byte, 16)
|
||||
if _, err := rand.Read(jti); err != nil {
|
||||
return "", fmt.Errorf("configsign: jti: %w", err)
|
||||
}
|
||||
header := map[string]string{"alg": "ES256", "typ": "JWT", "kid": s.kid}
|
||||
claims := map[string]any{
|
||||
"iss": Issuer,
|
||||
"aud": service,
|
||||
"sub": service,
|
||||
"scope": Scope,
|
||||
"iat": now.Unix(),
|
||||
"nbf": now.Unix(),
|
||||
"exp": now.Add(TokenTTL).Unix(),
|
||||
"jti": hex.EncodeToString(jti),
|
||||
}
|
||||
hb, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cb, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := b64(hb) + "." + b64(cb)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
r, ss, err := ecdsa.Sign(rand.Reader, s.key, digest[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("configsign: sign: %w", err)
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
ss.FillBytes(sig[32:])
|
||||
return signingInput + "." + b64(sig), nil
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
func parsePrivateKey(der []byte) (*ecdsa.PrivateKey, error) {
|
||||
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
ec, ok := k.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an ECDSA key")
|
||||
}
|
||||
return ec, nil
|
||||
}
|
||||
return x509.ParseECPrivateKey(der)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temp file + rename so a crash can
|
||||
// never leave a half-written private key.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return fmt.Errorf("configsign: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
return fmt.Errorf("configsign: chmod %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("configsign: rename %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify is a test helper: it checks a token the Signer minted against its own
|
||||
// public key (the same computation a service performs). It is not used by
|
||||
// Maestro at runtime — Maestro only mints.
|
||||
func (s *Signer) Verify(token string) error {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("malformed token")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != 64 {
|
||||
return fmt.Errorf("bad signature encoding")
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
ss := new(big.Int).SetBytes(sig[32:])
|
||||
if !ecdsa.Verify(&s.key.PublicKey, digest[:], r, ss) {
|
||||
return fmt.Errorf("signature does not verify")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configsign
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadOrCreatePersistsKeypair(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s1, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
keyPath := filepath.Join(root, "maestro", keyFileName)
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat private key: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("private key mode = %o, want 600", perm)
|
||||
}
|
||||
pubInfo, err := os.Stat(filepath.Join(root, "maestro", pubFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("stat public key: %v", err)
|
||||
}
|
||||
if perm := pubInfo.Mode().Perm(); perm != 0o644 {
|
||||
t.Fatalf("public key mode = %o, want 644", perm)
|
||||
}
|
||||
|
||||
// Reload must reuse the same key (kid stable).
|
||||
s2, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate reload: %v", err)
|
||||
}
|
||||
if s1.Kid() != s2.Kid() {
|
||||
t.Fatalf("kid changed across reload: %q vs %q", s1.Kid(), s2.Kid())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKidIsHexOfSPKIHalf(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(s.Kid()) {
|
||||
t.Fatalf("kid = %q, want 32 lowercase hex chars", s.Kid())
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(s.PublicKeyBase64())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKeyBase64 is not base64: %v", err)
|
||||
}
|
||||
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
|
||||
t.Fatalf("public key env value is not DER SPKI: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
if want := hex.EncodeToString(sum[:16]); s.Kid() != want {
|
||||
t.Fatalf("kid = %q, want sha256(spki)[:16] = %q", s.Kid(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintClaimsAndSignature(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
token, err := s.Mint("calendar", now)
|
||||
if err != nil {
|
||||
t.Fatalf("Mint: %v", err)
|
||||
}
|
||||
if err := s.Verify(token); err != nil {
|
||||
t.Fatalf("minted token does not verify: %v", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token has %d parts, want 3", len(parts))
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != 64 {
|
||||
t.Fatalf("signature must be raw 64-byte r||s (len=%d err=%v)", len(sig), err)
|
||||
}
|
||||
|
||||
var header map[string]string
|
||||
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err := json.Unmarshal(hb, &header); err != nil {
|
||||
t.Fatalf("header: %v", err)
|
||||
}
|
||||
if header["alg"] != "ES256" || header["typ"] != "JWT" || header["kid"] != s.Kid() {
|
||||
t.Fatalf("header = %v", header)
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatalf("claims: %v", err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"iss": "dcntrlized-maestro",
|
||||
"aud": "calendar",
|
||||
"sub": "calendar",
|
||||
"scope": "config",
|
||||
"iat": float64(now.Unix()),
|
||||
"nbf": float64(now.Unix()),
|
||||
"exp": float64(now.Add(60 * time.Second).Unix()),
|
||||
}
|
||||
for k, v := range want {
|
||||
if claims[k] != v {
|
||||
t.Errorf("claim %s = %v, want %v", k, claims[k], v)
|
||||
}
|
||||
}
|
||||
jti, _ := claims["jti"].(string)
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(jti) {
|
||||
t.Fatalf("jti = %q, want 32 lowercase hex chars", jti)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractGoldenFixture cross-checks Maestro's kid derivation and ES256
|
||||
// interpretation against the worked example frozen in contract rev 9 §17.3.7.
|
||||
// It does not use the Signer (which holds a private key): it verifies the
|
||||
// contract's token with the contract's public key, exactly as a service would.
|
||||
func TestContractGoldenFixture(t *testing.T) {
|
||||
const (
|
||||
pubB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqBhe2nIg0c3qf2C8yIVGs9/qSqLvW8eFUliCT1yKnWmUhX5RdRCOIOo59M7APFzywtwSOxMMivQh9N5rVWXxCQ=="
|
||||
kidHex = "088efbd65ad78ceed9dfe6495367ea6d"
|
||||
token = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjA4OGVmYmQ2NWFkNzhjZWVkOWRmZTY0OTUzNjdlYTZkIn0.eyJpc3MiOiJkY250cmxpemVkLW1hZXN0cm8iLCJhdWQiOiJjYWxlbmRhciIsInN1YiI6ImNhbGVuZGFyIiwic2NvcGUiOiJjb25maWciLCJpYXQiOjE3NzAwMDAwMDAsIm5iZiI6MTc3MDAwMDAwMCwiZXhwIjoxNzcwMDAwMDYwLCJqdGkiOiIwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZiJ9.4F8OmePWBO4lZsR0bOG5HK6cttvtTmKjwhcYMZcY5neIGnnmTlYsGtGJzMmhaC7eM5OEz6d0rPft8ZWNiQvyfQ"
|
||||
)
|
||||
|
||||
der, err := base64.StdEncoding.DecodeString(pubB64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode pubkey: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
if got := hex.EncodeToString(sum[:16]); got != kidHex {
|
||||
t.Fatalf("kid = %s, contract says %s", got, kidHex)
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse pubkey: %v", err)
|
||||
}
|
||||
pub, ok := parsed.(*ecdsa.PublicKey)
|
||||
if !ok || pub.Curve != elliptic.P256() {
|
||||
t.Fatalf("pubkey is not P-256 ECDSA: %T", parsed)
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("fixture token has %d segments", len(parts))
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != 64 {
|
||||
t.Fatalf("fixture signature not raw 64 bytes (len=%d err=%v)", len(sig), err)
|
||||
}
|
||||
if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
|
||||
t.Fatal("contract fixture token does not verify with the contract public key")
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatalf("fixture payload: %v", err)
|
||||
}
|
||||
if claims["iss"] != Issuer || claims["aud"] != "calendar" || claims["sub"] != "calendar" || claims["scope"] != Scope {
|
||||
t.Fatalf("fixture claims mismatch: %v", claims)
|
||||
}
|
||||
if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 60 {
|
||||
t.Fatalf("fixture exp-iat = %v, want 60", exp-iat)
|
||||
}
|
||||
if claims["nbf"] != claims["iat"] {
|
||||
t.Fatalf("fixture nbf != iat: %v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintJTIUniqueAndTamperRejected(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
a, err := s.Mint("calendar", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.Mint("calendar", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("two mints produced the same token (jti must be random)")
|
||||
}
|
||||
|
||||
// Flip one payload byte → signature must fail.
|
||||
parts := strings.Split(a, ".")
|
||||
parts[1] = strings.Replace(parts[1], "e", "f", 1)
|
||||
if err := s.Verify(strings.Join(parts, ".")); err == nil {
|
||||
t.Fatal("tampered token verified")
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,13 @@
|
||||
// Package containers implements Maestro's runtime write-back delivery for the
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package dbctl administers the shared database's authorization (M2 §4).
|
||||
//
|
||||
// The database image enables instance-CA mTLS when Maestro injects an identity
|
||||
// (server cert + client-CA + hostssl cert / hostnossl reject rules), but the
|
||||
// auth rules alone only prove *who* authenticates. Authorization lives here:
|
||||
// the stock PUBLIC CONNECT grant is revoked and each installed peer gets a
|
||||
// LOGIN role with CONNECT, so a peer that holds no role (or a revoked one)
|
||||
// cannot even open a session. The admin path uses the postgres superuser over
|
||||
// the in-container unix socket (trust), which deliberately bypasses the mTLS
|
||||
// enforcement — Maestro owns the database and drives its lifecycle.
|
||||
package dbctl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
)
|
||||
|
||||
const (
|
||||
// DBContainerName is the canonical shared database container (image, DNS).
|
||||
DBContainerName = "dcntrlized-db"
|
||||
// DBName is the shared database name inside the instance.
|
||||
DBName = "dcntrlized"
|
||||
// AdminRole is Maestro's own database role (CN=maestro bootstrap in the
|
||||
// image). It is granted like any peer so its existence is guaranteed.
|
||||
AdminRole = "maestro"
|
||||
// identityMountTarget is the container path of the injected leaf cert. Its
|
||||
// presence in the mount list marks the database as mTLS-armed.
|
||||
identityMountTarget = "/dcntrlized/pki/tls.crt"
|
||||
)
|
||||
|
||||
// Client reconciles the shared database's CONNECT authorization state.
|
||||
type Client struct {
|
||||
dc *docker.DockerClient
|
||||
}
|
||||
|
||||
// New wraps a Docker client.
|
||||
func New(dc *docker.DockerClient) *Client {
|
||||
return &Client{dc: dc}
|
||||
}
|
||||
|
||||
// SyncAuthorization ensures every installed peer holds a LOGIN role with
|
||||
// CONNECT on the shared database, and — when the database runs in mTLS mode —
|
||||
// revokes the stock PUBLIC grant. It is safe to call on every start: role
|
||||
// creation and grants are idempotent, and the PUBLIC revoke is a no-op on the
|
||||
// second run. When the database is not running it returns an error; callers
|
||||
// treat it as best-effort.
|
||||
func (c *Client) SyncAuthorization(ctx context.Context) error {
|
||||
id := c.dbContainerID(ctx)
|
||||
if id == "" {
|
||||
return fmt.Errorf("shared database %s is not running", DBContainerName)
|
||||
}
|
||||
|
||||
if err := c.waitReady(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roles, err := peerRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := "SET client_min_messages TO warning;\n"
|
||||
have := c.existingRoles(ctx, id)
|
||||
for _, role := range roles {
|
||||
if !have[role] {
|
||||
script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role))
|
||||
}
|
||||
script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role))
|
||||
// The peer connects and migrates under its own role (its cert CN),
|
||||
// not the shared superuser: it may create tables in public (PG15+
|
||||
// revoked that from PUBLIC) and must reach the tables it already
|
||||
// owns or shares across every user schema (e.g. auth's own schema).
|
||||
script += fmt.Sprintf("GRANT CREATE ON SCHEMA public TO %s;\n", quoteIdent(role))
|
||||
script += grantExistingScript(role)
|
||||
}
|
||||
if c.isArmed(ctx, id) {
|
||||
script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName))
|
||||
}
|
||||
|
||||
out, code, err := c.execPSQL(ctx, id, DBName, script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply database authorization: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
return fmt.Errorf("apply database authorization failed (exit %d): %s", code, out)
|
||||
}
|
||||
logger.Info("Reconciled shared database authorization for %d peer(s): %s", len(roles), strings.Join(roles, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeForService removes a peer's CONNECT grant when it is uninstalled.
|
||||
// Best-effort: the database may already be gone.
|
||||
func (c *Client) RevokeForService(ctx context.Context, serviceName string) {
|
||||
if serviceName == "" {
|
||||
return
|
||||
}
|
||||
id := c.dbContainerID(ctx)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM %s;", quoteIdent(DBName), quoteIdent(serviceName))
|
||||
out, code, err := c.execPSQL(ctx, id, DBName, script)
|
||||
if err != nil || code != 0 {
|
||||
logger.Warn("Failed to revoke database CONNECT for %s: %v (exit %d): %s", serviceName, err, code, out)
|
||||
return
|
||||
}
|
||||
logger.Info("Revoked database CONNECT for %s", serviceName)
|
||||
}
|
||||
|
||||
// peerRoles returns the guaranteed connectors: Maestro plus every installed
|
||||
// service (identified by its install dir name, which is also its cert CN).
|
||||
func peerRoles() ([]string, error) {
|
||||
roles := []string{AdminRole}
|
||||
root, err := dataRoot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve data root: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return roles, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list data root: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || entry.Name() == DBContainerName {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, entry.Name(), "metadata.json")); err != nil {
|
||||
continue
|
||||
}
|
||||
roles = append(roles, entry.Name())
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// dataRoot resolves the directory storing installed container metadata, with
|
||||
// the same precedence as lib/containers.DataRoot (kept local to avoid an
|
||||
// import cycle — lib/containers imports dbctl).
|
||||
func dataRoot() (string, error) {
|
||||
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, ".dcntrlized"), nil
|
||||
}
|
||||
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
||||
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
||||
}
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
return filepath.Join(home, ".dcntrlized"), nil
|
||||
}
|
||||
return filepath.Join("/data", ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
// grantExistingScript returns the DO block granting a role USAGE plus ALL
|
||||
// PRIVILEGES on tables and sequences in every user schema. Existing volumes
|
||||
// hold tables created by the postgres superuser (and by other services'
|
||||
// earlier migrations); without these grants a per-service role could connect
|
||||
// but not read or migrate anything.
|
||||
func grantExistingScript(role string) string {
|
||||
return fmt.Sprintf(`DO $dcntrlized_grants$ DECLARE s text; BEGIN
|
||||
FOR s IN SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg_%%' AND nspname <> 'information_schema' LOOP
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA %%I TO %%I', s, %s);
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %%I TO %%I', s, %s);
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %%I TO %%I', s, %s);
|
||||
END LOOP; END $dcntrlized_grants$;
|
||||
`, quoteLiteral(role), quoteLiteral(role), quoteLiteral(role))
|
||||
}
|
||||
|
||||
// quoteLiteral renders a PostgreSQL string literal safe for interpolation.
|
||||
func quoteLiteral(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// quoteIdent renders a PostgreSQL identifier safe for interpolation.
|
||||
func quoteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
// dbContainerID resolves the running canonical database container, or "".
|
||||
func (c *Client) dbContainerID(ctx context.Context) string {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", "org.dcntrlized.service="+DBContainerName)
|
||||
containers, err := c.dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list database containers: %v", err)
|
||||
return ""
|
||||
}
|
||||
for _, cc := range containers {
|
||||
if cc.State != "running" {
|
||||
continue
|
||||
}
|
||||
for _, n := range cc.Names {
|
||||
if strings.TrimPrefix(n, "/") == DBContainerName {
|
||||
return cc.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Armed reports whether the running shared database received the injected
|
||||
// instance-CA identity (i.e. it enforces mTLS on its TCP edge). Callers use
|
||||
// this to decide which connection URL peers get: verify-full mTLS DSNs on an
|
||||
// armed database, plaintext defaults otherwise.
|
||||
func (c *Client) Armed(ctx context.Context) bool {
|
||||
id := c.dbContainerID(ctx)
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
return c.isArmed(ctx, id)
|
||||
}
|
||||
|
||||
// isArmed reports whether the database container received the injected
|
||||
// instance-CA identity (i.e. it enforces mTLS on its TCP edge).
|
||||
func (c *Client) isArmed(ctx context.Context, id string) bool {
|
||||
inspect, err := c.dc.ContainerInspect(ctx, id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, m := range inspect.Mounts {
|
||||
if filepath.Clean(m.Destination) == identityMountTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitReady polls the database until an admin statement executes or the
|
||||
// deadline is reached, tolerating the first-boot delay after compose up.
|
||||
func (c *Client) waitReady(ctx context.Context, id string) error {
|
||||
const attempts = 30
|
||||
for i := 0; i < attempts; i++ {
|
||||
_, code, err := c.execPSQL(ctx, id, "postgres", "SELECT 1")
|
||||
if err == nil && code == 0 {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("database %s did not become ready in time", DBContainerName)
|
||||
}
|
||||
|
||||
// existingRoles returns the set of roles currently defined in the database.
|
||||
func (c *Client) existingRoles(ctx context.Context, id string) map[string]bool {
|
||||
have := map[string]bool{}
|
||||
out, code, err := c.execPSQL(ctx, id, "postgres", "SELECT rolname FROM pg_catalog.pg_roles")
|
||||
if err != nil || code != 0 {
|
||||
return have
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if name := strings.TrimSpace(line); name != "" {
|
||||
have[name] = true
|
||||
}
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
// execPSQL runs a psql statement over the in-container unix socket as the
|
||||
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
|
||||
// administers the database). It returns trimmed stdout and the exit code.
|
||||
// execPSQL runs a psql statement over the in-container unix socket as the
|
||||
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
|
||||
// administers the database), targeting the named database. It returns trimmed
|
||||
// stdout and the exit code.
|
||||
func (c *Client) execPSQL(ctx context.Context, id, dbName, sql string) (string, int, error) {
|
||||
created, err := c.dc.ContainerExecCreate(ctx, id, container.ExecOptions{
|
||||
User: "postgres",
|
||||
AttachStdout: true,
|
||||
Cmd: []string{"psql", "-U", "postgres", "-d", dbName, "-v", "ON_ERROR_STOP=1", "-tA", "-c", sql},
|
||||
})
|
||||
if err != nil {
|
||||
return "", -1, fmt.Errorf("create psql exec: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.dc.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return "", -1, fmt.Errorf("attach psql exec: %w", err)
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil {
|
||||
return "", -1, fmt.Errorf("read psql output: %w", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
inspect, err := c.dc.ContainerExecInspect(ctx, created.ID)
|
||||
if err != nil {
|
||||
return "", -1, fmt.Errorf("inspect psql exec: %w", err)
|
||||
}
|
||||
if !inspect.Running {
|
||||
return strings.TrimSpace(stdout.String()), inspect.ExitCode, nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return "", -1, fmt.Errorf("psql exec did not finish in time")
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package dbctl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
)
|
||||
|
||||
// TestSyncAuthorizationIntegration exercises the real exec path against a
|
||||
// running dcntrlized-db container.
|
||||
//
|
||||
// 1. docker run -d --name dcntrlized-db -e POSTGRES_DB=dcntrlized \
|
||||
// -e POSTGRES_USER=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
|
||||
// -v $PWD/PKI/ca.crt:/dcntrlized/pki/ca.crt:ro \
|
||||
// -v $PWD/PKI/tls.crt:/dcntrlized/pki/tls.crt:ro \
|
||||
// -v $PWD/PKI/tls.key:/dcntrlized/pki/tls.key:ro \
|
||||
// -v dctl-vol:/var/lib/postgresql/data dcntrlized-db
|
||||
// 2. seed the data root with install dirs for the peers
|
||||
// 3. DBCtl_INTEGRATION=1 go test ./lib/db -run TestSyncAuthorizationIntegration -v
|
||||
func TestSyncAuthorizationIntegration(t *testing.T) {
|
||||
if os.Getenv("DBCtl_INTEGRATION") != "1" {
|
||||
t.Skip("set DBCtl_INTEGRATION=1 to run against a live database container")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
|
||||
for _, name := range []string{"keepnote", "dcntrlized-auth"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, name, "metadata.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
dc, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dc.Close()
|
||||
|
||||
c := New(dc)
|
||||
if err := c.SyncAuthorization(context.Background()); err != nil {
|
||||
t.Fatalf("SyncAuthorization: %v", err)
|
||||
}
|
||||
|
||||
id := c.dbContainerID(context.Background())
|
||||
if id == "" {
|
||||
t.Fatal("database container not found")
|
||||
}
|
||||
assert := func(t *testing.T, sql, contains string) {
|
||||
t.Helper()
|
||||
out, code, err := c.execPSQL(context.Background(), id, "postgres", sql)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("check %q failed (exit %d, err %v): %s", sql, code, err, out)
|
||||
}
|
||||
if want := contains; want != "" && out != want {
|
||||
t.Fatalf("%s => %q, want %q", sql, out, want)
|
||||
}
|
||||
}
|
||||
// Roles exist and hold CONNECT.
|
||||
assert(t, `SELECT rolname FROM pg_catalog.pg_roles WHERE rolname IN ('maestro','keepnote','dcntrlized-auth') ORDER BY 1`, "dcntrlized-auth\nkeepnote\nmaestro")
|
||||
assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
|
||||
// Armed database: PUBLIC grant removed.
|
||||
assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user