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/node_modules/
|
||||||
src/frontend/dist/
|
src/frontend/dist/
|
||||||
src/frontend/.vite/
|
src/frontend/.vite/
|
||||||
|
src/hub/node_modules/
|
||||||
|
src/hub/dist/
|
||||||
|
src/hub/.vite/
|
||||||
|
|
||||||
# Block compiled native Go binary targets and testing outputs
|
# Block compiled native Go binary targets and testing outputs
|
||||||
src/Maestro
|
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/ ./
|
COPY src/frontend/ ./
|
||||||
RUN bun run build
|
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
|
# Stage 2: Build the static Go binary
|
||||||
FROM golang:1.26-alpine AS go-builder
|
FROM golang:1.26-alpine AS go-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -31,6 +40,8 @@ RUN go mod download
|
|||||||
COPY src/ .
|
COPY src/ .
|
||||||
# Copy the built UI into the frontend folder so go:embed finds it
|
# Copy the built UI into the frontend folder so go:embed finds it
|
||||||
COPY --from=frontend-builder /app/dist ./frontend/dist
|
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
|
# 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 CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ RUN bun ci
|
|||||||
COPY src/frontend/ ./
|
COPY src/frontend/ ./
|
||||||
RUN bun run build
|
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
|
# Stage 2: Build the static Go binary
|
||||||
FROM golang:1.26-alpine AS go-builder
|
FROM golang:1.26-alpine AS go-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -34,6 +43,8 @@ RUN go mod download
|
|||||||
COPY src/ .
|
COPY src/ .
|
||||||
# Copy the built UI into the frontend folder so go:embed finds it
|
# Copy the built UI into the frontend folder so go:embed finds it
|
||||||
COPY --from=frontend-builder /app/dist ./frontend/dist
|
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 .
|
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)
|
# Stage 3: Debug-final image (alpine base, not scratch, for shell access)
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ services:
|
|||||||
image: maestro
|
image: maestro
|
||||||
container_name: maestro
|
container_name: maestro
|
||||||
ports:
|
ports:
|
||||||
# Loopback-only default (dev is localhost anyway); see docker-compose.yaml
|
# All interfaces: the debug stack is the instance a phone on the LAN
|
||||||
- "127.0.0.1:8080:8080"
|
# 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:
|
volumes:
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||||
- "${XDG_DATA_HOME:-${HOME}}/.dcntrlized:/data/.dcntrlized"
|
- "${XDG_DATA_HOME:-${HOME}}/.dcntrlized:/data/.dcntrlized"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "bun test tests",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
import { useMemo, useState } from 'preact/hooks'
|
import { useMemo, useState } from 'preact/hooks'
|
||||||
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
||||||
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
||||||
import { useTrustStore } from '@lib/hooks/useTrustStore'
|
|
||||||
import { useAuth } from '@lib/hooks/useAuth'
|
import { useAuth } from '@lib/hooks/useAuth'
|
||||||
import { mergeServices } from '@lib/domain/merge'
|
import { mergeServices } from '@lib/domain/merge'
|
||||||
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
||||||
@@ -41,7 +40,6 @@ export function App() {
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [filter, setFilter] = useState<ServiceFilter>('all')
|
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const trust = useTrustStore(settingsOpen, authed)
|
|
||||||
|
|
||||||
const services = useMemo(() => {
|
const services = useMemo(() => {
|
||||||
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
||||||
@@ -87,11 +85,8 @@ export function App() {
|
|||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
void trust.refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
|
|
||||||
|
|
||||||
if (auth.gate === 'loading') {
|
if (auth.gate === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
|
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
|
||||||
@@ -114,7 +109,7 @@ export function App() {
|
|||||||
installed={counts.installed}
|
installed={counts.installed}
|
||||||
updates={counts.updates}
|
updates={counts.updates}
|
||||||
connection={status}
|
connection={status}
|
||||||
pendingTrust={pendingTrust}
|
pendingTrust={0}
|
||||||
onShowUpdates={showUpdates}
|
onShowUpdates={showUpdates}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
@@ -155,8 +150,6 @@ export function App() {
|
|||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={setSettingsOpen}
|
||||||
trust={trust.load}
|
|
||||||
onRefreshTrust={trust.refresh}
|
|
||||||
username={auth.username}
|
username={auth.username}
|
||||||
onSignOut={() => {
|
onSignOut={() => {
|
||||||
setSettingsOpen(false)
|
setSettingsOpen(false)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function dbService(item: RegistryItem): Service {
|
|||||||
image: item.image,
|
image: item.image,
|
||||||
registryImage: item.image,
|
registryImage: item.image,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
|
sourceHref: item.source_href,
|
||||||
registryUrl: item.url,
|
registryUrl: item.url,
|
||||||
latestVersion: item.version,
|
latestVersion: item.version,
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 { Button } from '@components/ui/button'
|
||||||
import { Card, CardContent, CardFooter } from '@components/ui/card'
|
import { Card, CardContent, CardFooter } from '@components/ui/card'
|
||||||
import { statusMeta } from '@components/container/status'
|
import { statusMeta } from '@components/container/status'
|
||||||
@@ -25,6 +25,7 @@ import type { Service } from '@lib/types'
|
|||||||
import { cn } from '@lib/utils'
|
import { cn } from '@lib/utils'
|
||||||
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
|
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
|
||||||
import { DatabaseChip } from '@components/infra/database-chip'
|
import { DatabaseChip } from '@components/infra/database-chip'
|
||||||
|
import { sourceLink } from '@lib/domain/source-link'
|
||||||
import type { DatabaseState } from './service-grid'
|
import type { DatabaseState } from './service-grid'
|
||||||
|
|
||||||
interface ServiceCardProps {
|
interface ServiceCardProps {
|
||||||
@@ -47,9 +48,36 @@ function errorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : 'Unknown error'
|
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) {
|
export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
|
||||||
const meta = statusMeta(service.status)
|
const meta = statusMeta(service.status)
|
||||||
const installed = service.origin !== 'remote'
|
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 =
|
const running =
|
||||||
service.status.toLowerCase() === 'running' ||
|
service.status.toLowerCase() === 'running' ||
|
||||||
service.status.toLowerCase() === 'starting' ||
|
service.status.toLowerCase() === 'starting' ||
|
||||||
@@ -60,6 +88,9 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
|
|||||||
|
|
||||||
const shortContainerId = service.id.slice(0, 12)
|
const shortContainerId = service.id.slice(0, 12)
|
||||||
const shortImage = service.image ? service.image.split('/').slice(-2).join('/') : service.name
|
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 () => {
|
const copyId = useAsyncAction(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -193,6 +224,15 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
|
|||||||
{latest}
|
{latest}
|
||||||
</span>
|
</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 && (
|
{running && installed && (
|
||||||
<button
|
<button
|
||||||
data-slot="card-container-id"
|
data-slot="card-container-id"
|
||||||
@@ -206,26 +246,37 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
|
|||||||
<Copy aria-hidden="true" className="size-3" />
|
<Copy aria-hidden="true" className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{service.source && (
|
{source?.kind === 'link' ? (
|
||||||
<a
|
<a
|
||||||
data-slot="card-source"
|
data-slot="card-source"
|
||||||
href={service.source}
|
href={source.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
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
|
Source
|
||||||
<ExternalLink aria-hidden="true" className="size-3" />
|
<ExternalLink aria-hidden="true" className="size-3" />
|
||||||
</a>
|
</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>
|
</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>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="justify-end pt-4">
|
<CardFooter className="justify-end pt-4">
|
||||||
{installed && (
|
{managed && (
|
||||||
<Button
|
<Button
|
||||||
data-slot="card-uninstall"
|
data-slot="card-uninstall"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -239,7 +290,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{installed && onOpenConfig && (
|
{managed && onOpenConfig && (
|
||||||
<Button
|
<Button
|
||||||
data-slot="card-config"
|
data-slot="card-config"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -253,7 +304,7 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{installed && service.hasUpdate && (
|
{managed && service.hasUpdate && (
|
||||||
<Button
|
<Button
|
||||||
data-slot="card-update-action"
|
data-slot="card-update-action"
|
||||||
size="sm"
|
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.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
) : installed ? (
|
) : managed ? (
|
||||||
<Button
|
<Button
|
||||||
data-slot="card-start"
|
data-slot="card-start"
|
||||||
size="sm"
|
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.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Power aria-hidden="true" className="size-3" />}
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : installed ? null : (
|
||||||
<Button data-slot="card-install" className="" size="sm" isDisabled={busy} onPress={install.run}>
|
<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.pending ? <LoaderCircle aria-hidden="true" className="size-3 animate-spin" /> : <Download aria-hidden="true" className="size-3" />}
|
||||||
Install
|
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/>.
|
* 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 { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
||||||
import { Button } from '@components/ui/button'
|
import { Button } from '@components/ui/button'
|
||||||
import { AccountSection } from '@components/settings/account-section'
|
import { AccountSection } from '@components/settings/account-section'
|
||||||
import { TrustedServicesSection } from '@components/service/trusted-services'
|
import { AppCatalogSection } from '@components/settings/app-catalog-section'
|
||||||
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
trust: TrustLoadState
|
|
||||||
onRefreshTrust: () => void
|
|
||||||
username: string
|
username: string
|
||||||
onSignOut: () => void
|
onSignOut: () => void
|
||||||
}
|
}
|
||||||
@@ -34,8 +31,6 @@ interface SettingsDialogProps {
|
|||||||
export function SettingsDialog({
|
export function SettingsDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
trust,
|
|
||||||
onRefreshTrust,
|
|
||||||
username,
|
username,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
@@ -58,7 +53,7 @@ export function SettingsDialog({
|
|||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -75,22 +70,25 @@ export function SettingsDialog({
|
|||||||
<div className="grid min-w-0 gap-6 px-5 py-5">
|
<div className="grid min-w-0 gap-6 px-5 py-5">
|
||||||
<AccountSection username={username} onSignOut={onSignOut} />
|
<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
|
<h3
|
||||||
id="settings-trusted-title"
|
id="settings-app-catalog-title"
|
||||||
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"
|
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" />
|
<Smartphone className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
Trusted services
|
App catalog
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH
|
Apps this instance publishes for people to install on their phones. Clients
|
||||||
authorized_keys, applied to the service fleet), reject drops a request without
|
discover them here; each entry carries the artifact’s checksum so installs can be
|
||||||
authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores
|
verified.
|
||||||
one.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 min-w-0">
|
<div className="mt-4 min-w-0">
|
||||||
<TrustedServicesSection load={trust} onRefresh={onRefreshTrust} />
|
<AppCatalogSection />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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 {
|
export class ApiError extends Error {
|
||||||
readonly status: number
|
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)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
this.status = status
|
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) {
|
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)
|
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 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. */
|
/** Statuses that should win the merge when a name maps to several containers. */
|
||||||
const STATUS_PRIORITY: Record<string, number> = {
|
const STATUS_PRIORITY: Record<string, number> = {
|
||||||
@@ -46,6 +46,25 @@ function isHiddenRegistry(r: RegistryItem): boolean {
|
|||||||
return Boolean(r.hidden)
|
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 {
|
function toService(c: Container): Service {
|
||||||
return {
|
return {
|
||||||
id: c.id || c.name,
|
id: c.id || c.name,
|
||||||
@@ -53,27 +72,45 @@ function toService(c: Container): Service {
|
|||||||
status: c.status,
|
status: c.status,
|
||||||
origin: 'local',
|
origin: 'local',
|
||||||
title: c.service_name || c.name,
|
title: c.service_name || c.name,
|
||||||
|
serviceName: c.service_name,
|
||||||
|
external: c.external,
|
||||||
image: c.image,
|
image: c.image,
|
||||||
version: c.version || versionFromImage(c.image),
|
version: c.version || versionFromImage(c.image),
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
capabilities: c.capabilities ?? [],
|
capabilities: c.capabilities ?? [],
|
||||||
config: c.config,
|
config: c.config,
|
||||||
|
web: c.web,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findMatch(
|
function findMatch(
|
||||||
|
local: Service[],
|
||||||
map: Map<string, Service>,
|
map: Map<string, Service>,
|
||||||
name: string,
|
name: string,
|
||||||
registryImage: string
|
registryImage: string,
|
||||||
|
registryService?: string
|
||||||
): Service | undefined {
|
): Service | undefined {
|
||||||
const byName = map.get(name)
|
const byName = map.get(name)
|
||||||
if (byName) return byName
|
if (byName) return byName
|
||||||
|
|
||||||
const base = baseImageOf(registryImage)
|
// The declared identity (org.dcntrlized.service) is the contract: pair the
|
||||||
if (!base) return undefined
|
// 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()) {
|
const repo = repoNameOf(registryImage)
|
||||||
if (baseImageOf(item.image) === base) return item
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -87,7 +124,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
|
|||||||
const map = new Map<string, Service>()
|
const map = new Map<string, Service>()
|
||||||
|
|
||||||
for (const c of containers) {
|
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 key = c.name.toLowerCase()
|
||||||
const next = toService(c)
|
const next = toService(c)
|
||||||
const existing = map.get(key)
|
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) {
|
for (const r of registry) {
|
||||||
if (isMaestro(r.name) || isHiddenRegistry(r)) continue
|
if (isMaestro(r.name) || isHiddenRegistry(r)) continue
|
||||||
const key = r.name.toLowerCase()
|
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) {
|
if (existing) {
|
||||||
existing.origin = 'both'
|
existing.origin = 'both'
|
||||||
existing.registryImage = r.image
|
existing.registryImage = r.image
|
||||||
existing.registryUrl = r.url || r.source
|
existing.registryUrl = r.url || r.source
|
||||||
existing.source = r.source || existing.source
|
existing.source = r.source || existing.source
|
||||||
|
existing.sourceHref = r.source_href || existing.sourceHref
|
||||||
existing.title = r.title || existing.title
|
existing.title = r.title || existing.title
|
||||||
|
existing.serviceName = r.service_name || existing.serviceName
|
||||||
existing.vendor = r.vendor || existing.vendor
|
existing.vendor = r.vendor || existing.vendor
|
||||||
existing.author = r.author || existing.author
|
existing.author = r.author || existing.author
|
||||||
existing.description = r.description || existing.description
|
existing.description = r.description || existing.description
|
||||||
@@ -124,9 +167,11 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
|
|||||||
status: 'uninstalled',
|
status: 'uninstalled',
|
||||||
origin: 'remote',
|
origin: 'remote',
|
||||||
title: r.title || r.name,
|
title: r.title || r.name,
|
||||||
|
serviceName: r.service_name,
|
||||||
image: r.image,
|
image: r.image,
|
||||||
registryImage: r.image,
|
registryImage: r.image,
|
||||||
source: r.source,
|
source: r.source,
|
||||||
|
sourceHref: r.source_href,
|
||||||
registryUrl: r.url,
|
registryUrl: r.url,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
vendor: r.vendor,
|
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.
|
* 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 {
|
export function baseImageOf(imageRef?: string): string {
|
||||||
if (!imageRef) return ''
|
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 {
|
export function versionFromImage(imageRef?: string): string {
|
||||||
if (!imageRef) return ''
|
if (!imageRef || imageRef.includes('@')) return ''
|
||||||
const tag = imageRef.split('@')[0].split(':').pop() ?? ''
|
const slash = imageRef.lastIndexOf('/')
|
||||||
|
const colon = imageRef.lastIndexOf(':')
|
||||||
|
if (colon <= slash) return ''
|
||||||
|
const tag = imageRef.slice(colon + 1)
|
||||||
return tag === 'latest' ? '' : tag
|
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
|
role?: string
|
||||||
/** Infrastructure services managed by Maestro but hidden from the grid. */
|
/** Infrastructure services managed by Maestro but hidden from the grid. */
|
||||||
hidden?: boolean
|
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 declared by the service manifest (e.g. ["database"]). */
|
||||||
capabilities?: string[]
|
capabilities?: string[]
|
||||||
/** Whether the service consumes the shared auth service (manifest has_auth). */
|
/** Whether the service consumes the shared auth service (manifest has_auth). */
|
||||||
@@ -50,6 +55,25 @@ export interface Container {
|
|||||||
public_key?: string
|
public_key?: string
|
||||||
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
|
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
|
||||||
config?: ServiceConfigDeclaration[]
|
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). */
|
/** 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. */
|
/** A remote catalog entry, as served by GET /api/registry. */
|
||||||
export interface RegistryItem {
|
export interface RegistryItem {
|
||||||
name: string
|
name: string
|
||||||
|
/** The service identity declared by the image's org.dcntrlized.service label. */
|
||||||
|
service_name?: string
|
||||||
version: string
|
version: string
|
||||||
image: string
|
image: string
|
||||||
digest?: string
|
digest?: string
|
||||||
@@ -90,6 +116,12 @@ export interface RegistryItem {
|
|||||||
author?: string
|
author?: string
|
||||||
description?: string
|
description?: string
|
||||||
source?: 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
|
revision?: string
|
||||||
licenses?: string
|
licenses?: string
|
||||||
url?: string
|
url?: string
|
||||||
@@ -97,6 +129,8 @@ export interface RegistryItem {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
/** Infrastructure images (e.g. the shared database) excluded from the grid. */
|
/** Infrastructure images (e.g. the shared database) excluded from the grid. */
|
||||||
hidden?: boolean
|
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. */
|
/** Merged view model: one card per service, local state and remote catalog fused. */
|
||||||
@@ -106,6 +140,10 @@ export interface Service {
|
|||||||
status: string
|
status: string
|
||||||
origin: ContainerOrigin
|
origin: ContainerOrigin
|
||||||
title?: string
|
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
|
vendor?: string
|
||||||
author?: string
|
author?: string
|
||||||
description?: string
|
description?: string
|
||||||
@@ -113,6 +151,8 @@ export interface Service {
|
|||||||
/** The image reference published in the registry (for install/update). */
|
/** The image reference published in the registry (for install/update). */
|
||||||
registryImage?: string
|
registryImage?: string
|
||||||
source?: string
|
source?: string
|
||||||
|
/** Server-validated href for `source`; empty means render `source` as text. */
|
||||||
|
sourceHref?: string
|
||||||
registryUrl?: string
|
registryUrl?: string
|
||||||
version?: string
|
version?: string
|
||||||
latestVersion?: string
|
latestVersion?: string
|
||||||
@@ -121,6 +161,8 @@ export interface Service {
|
|||||||
capabilities: string[]
|
capabilities: string[]
|
||||||
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
|
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
|
||||||
config?: ServiceConfigDeclaration[]
|
config?: ServiceConfigDeclaration[]
|
||||||
|
/** The service's Web App capability, when advertised (see Container.web). */
|
||||||
|
web?: ServiceWeb
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live status of the shared database infra service. */
|
/** Live status of the shared database infra service. */
|
||||||
@@ -134,38 +176,6 @@ export interface FilterOption {
|
|||||||
hint: string
|
hint: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A first-contact authorization request waiting for the instance owner (security theme M2 §3). */
|
|
||||||
export interface PeerPending {
|
|
||||||
service: string
|
|
||||||
fingerprint: string
|
|
||||||
first_seen: string
|
|
||||||
last_seen: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An approved peer row of the auth service's trust store. */
|
|
||||||
export interface PeerAuthorized {
|
|
||||||
service: string
|
|
||||||
fingerprint: string
|
|
||||||
approved_by: string
|
|
||||||
approved_at: string
|
|
||||||
revoked_at?: string | null
|
|
||||||
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). */
|
/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */
|
||||||
export interface AuthStatus {
|
export interface AuthStatus {
|
||||||
setup_required: boolean
|
setup_required: boolean
|
||||||
@@ -200,7 +210,11 @@ export interface PublicService {
|
|||||||
image: string
|
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 {
|
export interface TrustedInstance {
|
||||||
name: string
|
name: string
|
||||||
url: string
|
url: string
|
||||||
@@ -215,3 +229,72 @@ export interface InstancePublicProfile {
|
|||||||
services?: PublicService[] | null
|
services?: PublicService[] | null
|
||||||
trusted_instances?: TrustedInstance[] | 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/cli v28.5.1+incompatible
|
||||||
github.com/docker/compose/v2 v2.40.3
|
github.com/docker/compose/v2 v2.40.3
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -60,7 +62,6 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/gnostic-models v0.6.8 // indirect
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
@@ -136,7 +137,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
|
|||||||
@@ -17,15 +17,14 @@ package actions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"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/logger"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/serviceproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RunAction(w http.ResponseWriter, r *http.Request) {
|
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)
|
logger.Debug("Received request to run action %s on container id %s", actionName, containerId)
|
||||||
|
|
||||||
// Get IP address of the container using containerId
|
// Resolve "where is this service" through the shared helper — the WebSocket
|
||||||
dockerClient, err := docker.NewDockerClient()
|
// 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 {
|
if err != nil {
|
||||||
http.Error(w, "Failed to create Docker client", http.StatusInternalServerError)
|
// Same status (500) and same message text as before the refactor; the real
|
||||||
return
|
// 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 {
|
||||||
inspectData, err := docker.ResolveContainer(context.Background(), dockerClient, containerId)
|
case errors.Is(err, serviceproxy.ErrNoPort):
|
||||||
if err != nil {
|
http.Error(w, "Container has no configured service port", http.StatusInternalServerError)
|
||||||
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
|
case errors.Is(err, serviceproxy.ErrDockerClient):
|
||||||
return
|
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)
|
||||||
network, _, err := docker.ExtractNetworkInfo(context.Background(), dockerClient, inspectData)
|
default:
|
||||||
if err != nil {
|
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the URL for the action endpoint, preserving the query string.
|
// Construct the URL for the action endpoint, preserving the query string.
|
||||||
// The service peer edge is mutual TLS (M2 §2): Maestro presents its own
|
// Rev 9: the service edge is plaintext HTTP on the single :8080 listener.
|
||||||
// identity cert and the service's listener requires a CA-issued client
|
actionURL := fmt.Sprintf("%s/action/%s", baseURL, actionName)
|
||||||
// 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)
|
|
||||||
if r.URL.RawQuery != "" {
|
if r.URL.RawQuery != "" {
|
||||||
actionURL += "?" + 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.
|
// Forward the request to the container's action endpoint (plaintext).
|
||||||
transport, err := authmgmt.PeerTransport()
|
resp, err := (&http.Client{}).Do(req)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug("Failed to forward action request %s to container %s: %v", actionName, containerId, err)
|
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)
|
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 {
|
if err := libcontainers.UninstallContainer(r.Context(), req); err != nil {
|
||||||
logger.Error("Error uninstalling container '%s': %v", req.Name, err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package containers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||||
@@ -24,6 +25,17 @@ import (
|
|||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"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.
|
// StartContainerHandler starts a previously installed service container.
|
||||||
func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc {
|
func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err := libcontainers.StartContainer(r.Context(), req); err != nil {
|
||||||
logger.Error("Error starting container '%s': %v", req.Name, err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +99,7 @@ func StopContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc
|
|||||||
|
|
||||||
if err := libcontainers.StopContainer(r.Context(), req); err != nil {
|
if err := libcontainers.StopContainer(r.Context(), req); err != nil {
|
||||||
logger.Error("Error stopping container '%s': %v", req.Name, err)
|
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
|
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"
|
"net/http"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
"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
|
// NewListContainersHandler takes the cache instance from main and wraps it in a
|
||||||
func NewListContainersHandler(cache *containers.ContainerCache) http.HandlerFunc {
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cache.RLock()
|
cache.RLock()
|
||||||
defer cache.RUnlock()
|
defer cache.RUnlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if cache.GetServices() == nil {
|
services := cache.GetServices()
|
||||||
|
if services == nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("No services available\n"))
|
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 {
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
||||||
log.Printf("Error encoding services: %v", err)
|
log.Printf("Error encoding services: %v", err)
|
||||||
http.Error(w, "Error marshaling service data", http.StatusInternalServerError)
|
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"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
"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/instance"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstanceIdentity is the public face of this instance. Fingerprint is the
|
// 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 {
|
type InstanceIdentity struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
@@ -55,18 +57,31 @@ type PublicService struct {
|
|||||||
Image string `json:"image"`
|
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
|
// PublicProfile is the GET /api/instance wire shape. The section pointers
|
||||||
// carry three distinct states: nil → field omitted (privacy switch off),
|
// carry three distinct states: nil → field omitted (privacy switch off),
|
||||||
// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON [].
|
// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON [].
|
||||||
type PublicProfile struct {
|
type PublicProfile struct {
|
||||||
Instance InstanceIdentity `json:"instance"`
|
Instance InstanceIdentity `json:"instance"`
|
||||||
Services *[]PublicService `json:"services,omitempty"`
|
Services *[]PublicService `json:"services,omitempty"`
|
||||||
TrustedInstances *[]federation.RemoteInstance `json:"trusted_instances,omitempty"`
|
TrustedInstances *[]TrustedInstance `json:"trusted_instances,omitempty"`
|
||||||
|
Apps *[]catalog.App `json:"apps,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPublicProfileHandler serves GET /api/instance. getServices is the cache
|
// NewPublicProfileHandler serves GET /api/instance. getServices is the cache
|
||||||
// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and
|
// accessor (cache.GetServices self-locks); dataRoot resolves identity, the
|
||||||
// federation stores on demand.
|
// instance fingerprint and the catalog store on demand.
|
||||||
func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
|
func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
profile := PublicProfile{
|
profile := PublicProfile{
|
||||||
@@ -75,14 +90,14 @@ func NewPublicProfileHandler(getServices func() []types.Container, dataRoot stri
|
|||||||
|
|
||||||
if env.PublicProfile() {
|
if env.PublicProfile() {
|
||||||
services := publicServices(getServices())
|
services := publicServices(getServices())
|
||||||
trusted, err := federation.List(dataRoot)
|
// Rev 9: the peer/federation trust store is retired, so the
|
||||||
if err != nil {
|
// trusted list is always empty (never nil, so the field stays an
|
||||||
// A malformed store must not take the profile down;
|
// array rather than JSON null).
|
||||||
// report absence instead.
|
trusted := []TrustedInstance{}
|
||||||
trusted = nil
|
apps := publicApps(dataRoot, catalog.RequestBaseURL(r))
|
||||||
}
|
|
||||||
profile.Services = &services
|
profile.Services = &services
|
||||||
profile.TrustedInstances = &trusted
|
profile.TrustedInstances = &trusted
|
||||||
|
profile.Apps = &apps
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -99,10 +114,27 @@ func buildIdentity(dataRoot string) InstanceIdentity {
|
|||||||
Name: id.Name,
|
Name: id.Name,
|
||||||
Title: id.Title,
|
Title: id.Title,
|
||||||
Description: id.Description,
|
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
|
// publicServices filters hidden infrastructure (role-labeled containers such
|
||||||
// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
|
// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
|
||||||
// public field set. The result is sorted case-insensitively by service name:
|
// public field set. The result is sorted case-insensitively by service name:
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"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()
|
dataRoot := t.TempDir()
|
||||||
|
|
||||||
// Store absent → JSON null (field present with null value).
|
|
||||||
body := serve(t, dataRoot, testServices())
|
body := serve(t, dataRoot, testServices())
|
||||||
if v, ok := body["trusted_instances"]; !ok || v != nil {
|
raw, _ := json.Marshal(body["trusted_instances"])
|
||||||
raw, _ := json.Marshal(body)
|
if string(raw) != "[]" {
|
||||||
t.Fatalf("absent federation store should yield trusted_instances:null, got %s", 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 {
|
if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
body = serve(t, dataRoot, testServices())
|
body = serve(t, dataRoot, testServices())
|
||||||
raw, _ := json.Marshal(body["trusted_instances"])
|
raw, _ = json.Marshal(body["trusted_instances"])
|
||||||
if string(raw) != "[]" {
|
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.
|
// The fingerprint is present and stable across requests.
|
||||||
populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}`
|
first := body["instance"].(map[string]any)["fingerprint"].(string)
|
||||||
if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil {
|
if !strings.HasPrefix(first, "sha256:") {
|
||||||
t.Fatal(err)
|
t.Fatalf("fingerprint = %q, want sha256: prefix", first)
|
||||||
}
|
}
|
||||||
body = serve(t, dataRoot, testServices())
|
body = serve(t, dataRoot, testServices())
|
||||||
list := body["trusted_instances"].([]any)
|
if again := body["instance"].(map[string]any)["fingerprint"].(string); again != first {
|
||||||
if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" {
|
t.Fatalf("fingerprint not stable: %q vs %q", first, again)
|
||||||
t.Fatalf("populated store not surfaced: %v", list)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,3 +178,71 @@ func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
|
|||||||
t.Error("instance identity must remain visible with the profile off")
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,26 +30,27 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthServiceURL is the default DNS name of the shared auth service on the
|
// AuthServiceURL is the default DNS name of the shared auth service on the
|
||||||
// dcntrlized network. The auth service listens with ClientAuth=RequireAndVerify
|
// dcntrlized network. After rev 9 the fleet-wide internal transport is
|
||||||
// trusting the instance CA (M2 §1).
|
// plaintext HTTP on the single :8080 listener; there is no mTLS anywhere.
|
||||||
const AuthServiceURL = "https://dcntrlized-auth:8080"
|
const AuthServiceURL = "http://dcntrlized-auth:8080"
|
||||||
|
|
||||||
// Client talks to the auth service over mTLS using Maestro's own identity
|
// Client talks to the auth service over plaintext HTTP. It no longer presents
|
||||||
// certificate (CN=maestro), which the auth service's RequireMaestro gate
|
// any client certificate: the peer gate was removed fleet-wide in rev 9.
|
||||||
// recognizes as the orchestrator.
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attestation is the auth service's public signing key statement (D3): the key
|
// Attestation is the auth service's public signing key statement (D3): the key
|
||||||
// a peer should verify access tokens with, plus its TOFU-pinnable fingerprint.
|
// a peer should verify access tokens with, plus the fingerprint Maestro pins
|
||||||
|
// as an app-level check (M2 §5). Rev 9 removed the peer trust store, the
|
||||||
|
// pending/approved authorization flow and every management call that used to
|
||||||
|
// live in this package.
|
||||||
type Attestation struct {
|
type Attestation struct {
|
||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
@@ -59,44 +58,9 @@ type Attestation struct {
|
|||||||
IssuedAt string `json:"issued_at"`
|
IssuedAt string `json:"issued_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizedKey is an approved peer row of the auth service's trust store.
|
// New builds the plaintext management client. There is no identity to
|
||||||
type AuthorizedKey struct {
|
// provision and no transport to configure after the PKI removal.
|
||||||
Service string `json:"service"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
ApprovedBy string `json:"approved_by"`
|
|
||||||
ApprovedAt string `json:"approved_at"`
|
|
||||||
RevokedAt *string `json:"revoked_at"`
|
|
||||||
HiddenAt *string `json:"hidden_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PendingAuthorization is a first-contact request awaiting the owner's
|
|
||||||
// decision.
|
|
||||||
type PendingAuthorization struct {
|
|
||||||
Service string `json:"service"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
FirstSeen string `json:"first_seen"`
|
|
||||||
LastSeen string `json:"last_seen"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrustState is the combined pending + approved view the dashboard shows.
|
|
||||||
type TrustState struct {
|
|
||||||
Pending []PendingAuthorization `json:"pending"`
|
|
||||||
Authorized []AuthorizedKey `json:"authorized"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type authzRequest struct {
|
|
||||||
Service string `json:"service"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds the mTLS management client, provisioning Maestro's own identity
|
|
||||||
// from the instance CA (the CA holder issues its own orchestrator cert).
|
|
||||||
func New() (*Client, error) {
|
func New() (*Client, error) {
|
||||||
transport, err := maestroTransport()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseURL := ""
|
baseURL := ""
|
||||||
if env.IsDev() {
|
if env.IsDev() {
|
||||||
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
|
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
|
||||||
@@ -111,84 +75,52 @@ func New() (*Client, error) {
|
|||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Transport: transport,
|
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maestroTransport builds the mutual-TLS transport Maestro uses as a peer: its
|
// Scheme returns the scheme every satellite peer edge uses after rev 9:
|
||||||
// own identity certificate (CN=maestro) signed by the instance CA, trusting the
|
// always "http" (plaintext on the single :8080 listener). Kept as a function
|
||||||
// CA as root. This is the client side of every Maestro peer edge.
|
// so the call sites (manifest fetch, action routing) do not hard-code it.
|
||||||
func maestroTransport() (*http.Transport, error) {
|
|
||||||
dataRoot, err := dataRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("resolve data root: %w", err)
|
|
||||||
}
|
|
||||||
ca, err := pki.EnsureCA(dataRoot)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ensure instance CA: %w", err)
|
|
||||||
}
|
|
||||||
identity, err := pki.EnsureServiceIdentity(ca, "maestro")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("provision maestro identity: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := tls.LoadX509KeyPair(identity.CertPath, identity.KeyPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("load maestro identity: %w", err)
|
|
||||||
}
|
|
||||||
pool := x509.NewCertPool()
|
|
||||||
if !pool.AppendCertsFromPEM(ca.CertPEM) {
|
|
||||||
return nil, fmt.Errorf("no certificates parsed from instance CA")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
Certificates: []tls.Certificate{cert},
|
|
||||||
RootCAs: pool,
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scheme returns "http" in dev mode and "https" otherwise, for building
|
|
||||||
// satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a
|
|
||||||
// plaintext listener so the RN clients (which cannot do mTLS) can reach them;
|
|
||||||
// production satellites bind mTLS listeners.
|
|
||||||
func Scheme() string {
|
func Scheme() string {
|
||||||
if env.IsDev() {
|
return "http"
|
||||||
return "http"
|
|
||||||
}
|
|
||||||
return "https"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerTransport returns the transport Maestro uses for the satellite peer
|
// ConfigToken mints a fresh, single-use ES256 config token for one service
|
||||||
// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners),
|
// (contract rev 9 §17.3.6). It loads (or creates) Maestro's DEDICATED
|
||||||
// mutual TLS with Maestro's identity in production.
|
// config-signing keypair from the data root and returns the compact JWS. The
|
||||||
func PeerTransport() (*http.Transport, error) {
|
// service holds only the public half (DCNTRLIZED_CONFIG_PUBKEY) and verifies;
|
||||||
if env.IsDev() {
|
// Maestro only ever mints. Wire format is frozen in §17.3.3.
|
||||||
return &http.Transport{}, nil
|
func ConfigToken(serviceName string) (string, error) {
|
||||||
|
root, err := dataRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
return maestroTransport()
|
signer, err := configsign.LoadOrCreate(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return signer.Mint(serviceName, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro)
|
// dataRoot resolves the directory holding Maestro's private key material
|
||||||
// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges
|
// (DCNTRLIZED_INSTALL_DIR → XDG → HOME), mirroring the containers package's
|
||||||
// that must prove the caller is the orchestrator (e.g. /config delivery, which
|
// resolution. Kept local to avoid an import cycle: containers imports authmgmt.
|
||||||
// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV,
|
func dataRoot() (string, error) {
|
||||||
// since those services keep an always-mTLS peer listener.
|
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||||
func MaestroTransport() (*http.Transport, error) {
|
return dir, nil
|
||||||
return maestroTransport()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrustState returns the pending authorizations and the approved trust store.
|
|
||||||
func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
|
|
||||||
var state TrustState
|
|
||||||
if err := c.do(ctx, http.MethodGet, "/internal/pending", nil, &state); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return &state, nil
|
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||||
|
return filepath.Join(xdg, ".dcntrlized"), nil
|
||||||
|
}
|
||||||
|
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
||||||
|
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
||||||
|
}
|
||||||
|
if home := os.Getenv("HOME"); home != "" {
|
||||||
|
return filepath.Join(home, ".dcntrlized"), nil
|
||||||
|
}
|
||||||
|
return filepath.Join("/data", ".dcntrlized"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attestation returns the auth service's public key statement.
|
// Attestation returns the auth service's public key statement.
|
||||||
@@ -200,35 +132,6 @@ func (c *Client) Attestation(ctx context.Context) (*Attestation, error) {
|
|||||||
return &att, nil
|
return &att, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve forwards the owner's approval for a peer's fingerprint to the auth
|
|
||||||
// service's own trust store.
|
|
||||||
func (c *Client) Approve(ctx context.Context, service, fingerprint string) error {
|
|
||||||
return c.do(ctx, http.MethodPost, "/internal/authorizations",
|
|
||||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke forwards the owner's revocation for a peer's fingerprint (returns the
|
|
||||||
// peer to the no-op path immediately).
|
|
||||||
func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error {
|
|
||||||
return c.do(ctx, http.MethodPost, "/internal/revoke",
|
|
||||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject denies a pending first-contact request: the request is dropped and
|
|
||||||
// audited; the peer stays on the no-op path until the owner approves it.
|
|
||||||
func (c *Client) Reject(ctx context.Context, service, fingerprint string) error {
|
|
||||||
return c.do(ctx, http.MethodPost, "/internal/reject",
|
|
||||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide permanently hides a revoked peer from the dashboard: the auth service
|
|
||||||
// keeps the row (audit trail survives) but marks it hidden_at; a re-approval
|
|
||||||
// clears the flag and makes the key visible again.
|
|
||||||
func (c *Client) Hide(ctx context.Context, service, fingerprint string) error {
|
|
||||||
return c.do(ctx, http.MethodPost, "/internal/hide",
|
|
||||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||||
var payload []byte
|
var payload []byte
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -258,46 +161,3 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeServiceBestEffort revokes a peer's authorization in the auth service
|
|
||||||
// trust store, logging and swallowing failures: uninstall must proceed even
|
|
||||||
// when the auth service is down (M2 §3 step 5 — "or automatically on
|
|
||||||
// uninstall").
|
|
||||||
func RevokeServiceBestEffort(ctx context.Context, service, fingerprint string) {
|
|
||||||
client, err := New()
|
|
||||||
if err != nil {
|
|
||||||
bestEffort(err, "build client for revoking "+service)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := client.Revoke(ctx, service, fingerprint); err != nil {
|
|
||||||
bestEffort(err, "revoke "+service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dataRoot resolves the directory storing the instance PKI, mirroring the
|
|
||||||
// containers package's resolution (DCNTRLIZED_INSTALL_DIR → XDG → HOME). Kept
|
|
||||||
// local to avoid an import cycle: containers imports authmgmt for the
|
|
||||||
// uninstall-revoke hook.
|
|
||||||
func dataRoot() (string, error) {
|
|
||||||
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
|
||||||
return dir, nil
|
|
||||||
}
|
|
||||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
|
||||||
return filepath.Join(xdg, ".dcntrlized"), nil
|
|
||||||
}
|
|
||||||
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
|
||||||
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
|
||||||
}
|
|
||||||
if home := os.Getenv("HOME"); home != "" {
|
|
||||||
return filepath.Join(home, ".dcntrlized"), nil
|
|
||||||
}
|
|
||||||
return filepath.Join("/data", ".dcntrlized"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// bestEffort logs-and-continues: management calls must never take down the
|
|
||||||
// orchestrator (e.g. uninstall revoking while the auth service is down).
|
|
||||||
func bestEffort(err error, what string) {
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("authmgmt: %s failed (best-effort): %v", what, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package authmgmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSchemeIsAlwaysPlaintext locks the rev-9 transport decision: every
|
||||||
|
// satellite peer edge (manifest fetch, action routing) is plaintext HTTP on the
|
||||||
|
// single :8080 listener. A future https/peer-port regression must fail here.
|
||||||
|
func TestSchemeIsAlwaysPlaintext(t *testing.T) {
|
||||||
|
if got := Scheme(); got != "http" {
|
||||||
|
t.Fatalf("Scheme() = %q, want %q", got, "http")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigTokenMintsForService exercises the contract-named mint helper
|
||||||
|
// (rev 9 §17.3.6) end to end against a temp data root: it must create the
|
||||||
|
// dedicated config-signing keypair, return a 3-segment JWS bound to the target
|
||||||
|
// service, and expose a public key whose kid matches the token header.
|
||||||
|
func TestConfigTokenMintsForService(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("DCNTRLIZED_INSTALL_DIR", dir)
|
||||||
|
|
||||||
|
token, err := ConfigToken("calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigToken: %v", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("token has %d segments, want 3", len(parts))
|
||||||
|
}
|
||||||
|
for _, name := range []string{"config-signing.key", "config-signing.pub"} {
|
||||||
|
if _, err := os.Stat(dir + "/maestro/" + name); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
// Package containers implements Maestro's runtime write-back delivery for the
|
||||||
// Service Config & Secrets theme (D6). When the operator sets or clears a
|
// Service Config & Secrets theme (D6). When the operator sets or clears a
|
||||||
// config value via the Settings panel, Maestro delivers it to the running
|
// config value via the Settings panel, Maestro delivers it to the running
|
||||||
// service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own
|
// service's POST /config endpoint on the service's single plaintext :8080
|
||||||
// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at
|
// listener, authenticated with a short-lived, single-use ES256 config token
|
||||||
// the service's dedicated peer listener port (label org.dcntrlized.peer_port,
|
// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17).
|
||||||
// default 9443), which the service gates to CN=maestro. A set carries
|
// There is no mTLS and no peer port: the fleet-wide internal transport is
|
||||||
// {field, value, type}; a clear carries {field, action:"clear"} — the service
|
// plaintext HTTP. A set carries {field, value, type}; a clear carries
|
||||||
// owns the schema and branches on what it receives.
|
// {field, action:"clear"} — the service owns the schema and branches on what it
|
||||||
|
// receives.
|
||||||
//
|
//
|
||||||
// Delivery is best-effort: failures are logged (with the field name, never
|
// Delivery is best-effort: failures are logged (with the field name, never
|
||||||
// the value) and do not prevent the panel write from succeeding. Values always
|
// the value) and do not prevent the panel write from succeeding. Values always
|
||||||
@@ -42,32 +43,16 @@ import (
|
|||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// peerPortLabel is the compose label a service declares to advertise the
|
// configPort is the single port every service serves its API, /config and web
|
||||||
// internal port of its always-mTLS Maestro peer listener.
|
// app on after rev 9. There is no peer listener and no compose label.
|
||||||
const peerPortLabel = "org.dcntrlized.peer_port"
|
const configPort = "8080"
|
||||||
|
|
||||||
// defaultPeerPort is the fleet-wide default for that listener. Services keep/ve
|
|
||||||
// to it unless the label overrides it.
|
|
||||||
const defaultPeerPort = "9443"
|
|
||||||
|
|
||||||
// peerPort resolves the configured {peer listener port} from a container's
|
|
||||||
// compose labels, falling back to the fleet default.
|
|
||||||
func peerPort(labels map[string]string) string {
|
|
||||||
if labels == nil {
|
|
||||||
return defaultPeerPort
|
|
||||||
}
|
|
||||||
if p := labels[peerPortLabel]; p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return defaultPeerPort
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigDelivery is one key-value pair to deliver to a running service.
|
// ConfigDelivery is one key-value pair to deliver to a running service.
|
||||||
type ConfigDelivery struct {
|
type ConfigDelivery struct {
|
||||||
Field string // manifest config field name
|
Field string // manifest config field name
|
||||||
Key string // container env var name
|
Key string // container env var name
|
||||||
Type string // "cleartext" | "password"
|
Type string // "cleartext" | "password"
|
||||||
Value string // the operator-entered value (sent over mTLS; never logged)
|
Value string // the operator-entered value (never logged)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configRequest is the JSON body POSTed to the service's /config endpoint.
|
// configRequest is the JSON body POSTed to the service's /config endpoint.
|
||||||
@@ -79,10 +64,9 @@ type configRequest struct {
|
|||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliverConfigValues delivers the given config pairs to the running service
|
// DeliverConfigValues delivers the given config pairs to the running service.
|
||||||
// over the mTLS peer edge. If the service is not running or unreachable, the
|
// If the service is not running or unreachable, the error is logged but not
|
||||||
// error is logged but not returned — the values will take effect at next start
|
// returned — the values will take effect at next start via injectConfigSecrets.
|
||||||
// via injectConfigSecrets.
|
|
||||||
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
|
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return
|
return
|
||||||
@@ -114,9 +98,10 @@ func DeliverConfigClear(ctx context.Context, serviceName, field string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postConfig performs one best-effort POST of body to the running service's
|
// postConfig performs one best-effort POST of body to the running service's
|
||||||
// /config endpoint. NotFound/unreachable cases are logged at debug (the write
|
// /config endpoint on the single :8080 listener, authenticated with a freshly
|
||||||
// still applies at next start); transport errors carry the field name only,
|
// minted ES256 config token. NotFound/unreachable cases are logged at debug
|
||||||
// never a value.
|
// (the write still applies at next start); transport errors carry the field
|
||||||
|
// name only, never a value.
|
||||||
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
|
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
|
||||||
dc, err := docker.NewDockerClient()
|
dc, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,30 +122,22 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// /config is served on the service's dedicated Maestro peer listener
|
token, err := authmgmt.ConfigToken(serviceName)
|
||||||
// (always mTLS, gated to CN=maestro), NOT the RN-facing flat edge. Maestro
|
|
||||||
// presents its own identity and targets the peer port; dev mode does not
|
|
||||||
// downgrade this edge.
|
|
||||||
port := peerPort(inspectData.Config.Labels)
|
|
||||||
|
|
||||||
transport, err := authmgmt.MaestroTransport()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Config delivery to %s/%s: cannot build Maestro peer transport: %v", serviceName, field, err)
|
logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
Transport: transport,
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("https://%s:%s/config", network.IPAddress.Address, port)
|
url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
|
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
// Copyright (C) 2026 Dcntrlized
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published
|
|
||||||
// by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package containers
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestPeerPort(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
labels map[string]string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"nil labels fall back to default", nil, defaultPeerPort},
|
|
||||||
{"empty labels fall back to default", map[string]string{}, defaultPeerPort},
|
|
||||||
{"label present wins", map[string]string{"org.dcntrlized.peer_port": "9000"}, "9000"},
|
|
||||||
{"empty label value falls back to default", map[string]string{"org.dcntrlized.peer_port": ""}, defaultPeerPort},
|
|
||||||
{"unrelated labels fall back to default", map[string]string{"org.dcntrlized.port": "8080"}, defaultPeerPort},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := peerPort(tt.labels); got != tt.want {
|
|
||||||
t.Fatalf("peerPort(%v) = %q, want %q", tt.labels, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
|
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
|
||||||
@@ -105,16 +104,6 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error {
|
|||||||
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
|
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
|
||||||
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
|
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For the shared database, reconcile the CONNECT authorization against the
|
|
||||||
// peers Maestro manages (M2 §4): per-role CONNECT grants and, in mTLS mode,
|
|
||||||
// PUBLIC revoked. Best-effort — the database advertises infra-relevant
|
|
||||||
// problems as warnings, never as boot failures.
|
|
||||||
if serviceName == DatabaseServiceName {
|
|
||||||
if err := dbctl.New(dc).SyncAuthorization(ctx); err != nil {
|
|
||||||
logger.Warn("Shared database authorization sync failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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
|
// attestation over plaintext HTTP and store the fingerprint in the install
|
||||||
// the container gets AUTH_PINNED_FINGERPRINT at start. Best-effort — when
|
// record so the container gets AUTH_PINNED_FINGERPRINT at start. This is an
|
||||||
// the auth service is not reachable yet the pin stays empty and is retried
|
// app-level fingerprint comparison, not a transport identity. Best-effort —
|
||||||
// on start.
|
// when the auth service is not reachable yet the pin stays empty and is
|
||||||
|
// retried on start.
|
||||||
if att, err := fetchAuthAttestation(ctx); err == nil {
|
if att, err := fetchAuthAttestation(ctx); err == nil {
|
||||||
metadata.AuthFingerprint = att.Fingerprint
|
metadata.AuthFingerprint = att.Fingerprint
|
||||||
metadata.AuthKid = att.Kid
|
metadata.AuthKid = att.Kid
|
||||||
@@ -176,7 +177,8 @@ func getDataRoot() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DataRoot is the exported accessor for the storage root shared by installed
|
// 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) {
|
func DataRoot() (string, error) {
|
||||||
return getDataRoot()
|
return getDataRoot()
|
||||||
}
|
}
|
||||||
@@ -500,8 +502,9 @@ func writeMetadata(path string, metadata types.InstalledMetadata) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchAuthAttestation fetches the auth service's public key statement over
|
// fetchAuthAttestation fetches the auth service's public key statement over
|
||||||
// mTLS (Maestro's own identity certificate). It backs the M2 §5 TOFU pin: the
|
// plaintext HTTP (rev 9 removed the mTLS identity layer). It backs the M2 §5
|
||||||
// returned fingerprint is what consumer containers are pinned to.
|
// app-level pin: the returned fingerprint is what consumer containers are
|
||||||
|
// pinned to.
|
||||||
func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) {
|
func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) {
|
||||||
client, err := authmgmt.New()
|
client, err := authmgmt.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
|
|||||||
HasAuth: metadata.HasAuth,
|
HasAuth: metadata.HasAuth,
|
||||||
PublicKey: metadata.PublicKey,
|
PublicKey: metadata.PublicKey,
|
||||||
Config: metadata.Config,
|
Config: metadata.Config,
|
||||||
|
Web: metadata.Web,
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
Vendor: metadata.Vendor,
|
Vendor: metadata.Vendor,
|
||||||
Author: metadata.Author,
|
Author: metadata.Author,
|
||||||
@@ -104,6 +105,10 @@ func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest)
|
|||||||
if m.Config != nil {
|
if m.Config != nil {
|
||||||
metadata.Config = m.Config
|
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 {
|
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
|
||||||
logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err)
|
logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-118
@@ -18,24 +18,20 @@ package containers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/docker/compose/v2/pkg/api"
|
"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
|
// StartContainer creates and starts the service containers defined in the
|
||||||
// service's docker-compose.yaml.
|
// service's docker-compose.yaml.
|
||||||
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
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()
|
defer dc.Close()
|
||||||
|
|
||||||
serviceName := resolveServiceName(ctx, dc, item.Name)
|
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)
|
project, err := loadComposeProject(ctx, serviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Inject the service's runtime env (rev 9): identity env names, the
|
||||||
// CA and mount it plus the CA bundle into the container. The compose file
|
// dedicated config-signing public key when the service declares a config
|
||||||
// downloaded from git never declares these mounts — Maestro injects them
|
// block, and the plaintext shared-database DSN for database-capable
|
||||||
// (security theme M1 §2). Failing to provision identity is fatal: a peer
|
// services. There is no PKI identity, no cert mount and no mTLS.
|
||||||
// without a cert cannot authenticate on the mTLS edges. Database-capable
|
if err := injectServiceRuntimeEnv(ctx, project, serviceName); err != nil {
|
||||||
// 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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Operator-configured env (Service Config & Secrets theme M1): inject the
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectServiceIdentity issues (or refreshes) the service's identity
|
// injectServiceRuntimeEnv injects the runtime env every non-oneoff compose
|
||||||
// certificate from the instance CA and injects the CA bundle, the identity
|
// service needs after rev 9:
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's
|
// - SERVICE_NAME / DCNTRLIZED_SERVICE_NAME: the compose service name.
|
||||||
// fingerprint recorded at install. A service that already has the pin skips the
|
// - DCNTRLIZED_CONFIG_PUBKEY: the base64 DER of Maestro's DEDICATED
|
||||||
// fetch; one installed before this feature (or whose install could not reach
|
// config-signing public key, injected only into services that declare a
|
||||||
// the auth service) backfills it here, best-effort.
|
// `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.
|
||||||
// For services declaring the database capability (M2 §4), when the shared
|
// - DATABASE_URL: a plaintext per-service DSN for database-capable services
|
||||||
// database runs mTLS-armed, every app service additionally receives a
|
// (rev 9 removed the DSN mTLS; the shared database now uses
|
||||||
// per-service DATABASE_URL (role = the service's cert CN, verify-full against
|
// scram-sha-256 with a password).
|
||||||
// the instance CA), overriding the shared-superuser URL their compose file
|
// - AUTH_PINNED_FINGERPRINT: the app-level TOFU pin of the auth service's
|
||||||
// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL
|
// signing key (unchanged; it is not an mTLS control).
|
||||||
// env so they can reach the armed database under the same role.
|
func injectServiceRuntimeEnv(ctx context.Context, project *composetypes.Project, serviceName string) error {
|
||||||
func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
|
|
||||||
dataRoot, err := getDataRoot()
|
dataRoot, err := getDataRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve data root: %w", err)
|
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{
|
env := map[string]string{
|
||||||
"SERVICE_NAME": serviceName,
|
"SERVICE_NAME": serviceName,
|
||||||
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
|
"DCNTRLIZED_SERVICE_NAME": serviceName,
|
||||||
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
|
|
||||||
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-service database role DSN (M2 §4). Only on an armed database — a
|
// The config-signing public key is only useful to services that declare a
|
||||||
// plain (compose-managed dev) Postgres cannot satisfy verify-full, and its
|
// config block. declaredConfigFields errors for services without install
|
||||||
// stock trust rule needs no per-service roles anyway. Best-effort: without
|
// metadata (e.g. externally-managed dev containers); that is not fatal.
|
||||||
// the capability in the install record (pre-manifest installs) nothing is
|
if declared, err := declaredConfigFields(serviceName); err == nil && len(declared) > 0 {
|
||||||
// injected and the compose defaults apply.
|
signer, err := configsign.LoadOrCreate(dataRoot)
|
||||||
dbEnv := map[string]string{}
|
if err != nil {
|
||||||
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
|
return fmt.Errorf("load config signing key: %w", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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
|
// 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 {
|
for name, s := range project.Services {
|
||||||
if isOneoff(s) {
|
if isOneoff(s) {
|
||||||
// Migrate/seed helpers talk libpq/psycopg straight to the shared
|
// Migrate/seed helpers talk libpq/psycopg straight to the shared
|
||||||
// database; on an armed database they need the identity mount plus
|
// database; they need the same plaintext DSN and role.
|
||||||
// the same per-service role (their compose file's PGUSER is the
|
if dsn, ok := env["DATABASE_URL"]; ok {
|
||||||
// plain-dev default).
|
s.Environment = mergeEnv(s.Environment, map[string]string{
|
||||||
if len(dbEnv) > 0 {
|
"DATABASE_URL": dsn,
|
||||||
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
|
"PGUSER": databaseUser,
|
||||||
s.Environment = mergeEnv(s.Environment, dbEnv)
|
})
|
||||||
project.Services[name] = s
|
project.Services[name] = s
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
|
|
||||||
s.Environment = mergeEnv(s.Environment, env)
|
s.Environment = mergeEnv(s.Environment, env)
|
||||||
project.Services[name] = s
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mtlsDatabaseURL builds the per-service connection URL for the shared
|
// databaseName / databaseUser / defaultDatabasePassword are the shared
|
||||||
// database: role = the service's cert CN, no password (clientcert=verify-full
|
// database's plaintext credentials after rev 9. The dcntrlized-db compose
|
||||||
// makes the certificate the credential), TLS verified against the instance CA.
|
// declares POSTGRES_HOST_AUTH_METHOD=scram-sha-256 and
|
||||||
func mtlsDatabaseURL(serviceName string) string {
|
// POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dcntrlized}; Maestro injects the same
|
||||||
return fmt.Sprintf(
|
// password (overridable with DCNTRLIZED_DB_PASSWORD) so every service connects
|
||||||
"postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key",
|
// as postgres instead of the old cert-CN role.
|
||||||
serviceName, DatabaseServiceName, dbctl.DBName,
|
const (
|
||||||
identityMountDir, identityMountDir, identityMountDir,
|
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
|
// injectConfigSecrets injects the operator-configured env values (Service
|
||||||
@@ -337,22 +328,6 @@ func hasDatabaseCapability(serviceName string) bool {
|
|||||||
return false
|
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
|
// pinAuthFingerprint returns the service's pinned auth fingerprint, fetching
|
||||||
// and persisting it when the install record does not have one yet (M2 §5).
|
// 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
|
// 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
|
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
|
// 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
|
// migrate container) via the org.dcntrlized.oneoff label the compose file
|
||||||
// declares.
|
// declares.
|
||||||
|
|||||||
@@ -16,32 +16,112 @@
|
|||||||
package containers
|
package containers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHostBindSource(t *testing.T) {
|
// writeTestMetadataFull is writeTestMetadata plus declared capabilities, used
|
||||||
t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "")
|
// by the rev-9 runtime-env test (database DSN injection).
|
||||||
got := hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt")
|
func writeTestMetadataFull(t *testing.T, dataRoot, serviceName string, config []types.ConfigItem, capabilities []string) {
|
||||||
if got != "/data/.dcntrlized/pki/services/auth/tls.crt" {
|
t.Helper()
|
||||||
t.Fatalf("without host root the path must pass through, got %q", got)
|
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")
|
app := project.Services["app"]
|
||||||
got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt")
|
if got := envValue(app.Environment, "DCNTRLIZED_SERVICE_NAME"); got != "keepnote" {
|
||||||
if want := "/home/user/.dcntrlized/pki/services/auth/tls.crt"; got != want {
|
t.Errorf("DCNTRLIZED_SERVICE_NAME = %q, want keepnote", got)
|
||||||
t.Fatalf("host root must replace the container-internal prefix, got %q want %q", got, want)
|
}
|
||||||
|
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")
|
// One-off helpers get the DSN + role so migrate/seed can reach the DB.
|
||||||
if want := "/home/user/.dcntrlized/pki/ca.crt"; got != want {
|
migrate := project.Services["migrate"]
|
||||||
t.Fatalf("ca path must be remapped too, got %q want %q", got, want)
|
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()
|
defer dc.Close()
|
||||||
|
|
||||||
serviceName := resolveServiceName(ctx, dc, item.Name)
|
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)
|
project, err := loadComposeProject(ctx, serviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -21,11 +21,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"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/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
"github.com/docker/compose/v2/pkg/api"
|
"github.com/docker/compose/v2/pkg/api"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
@@ -43,26 +40,22 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
|
|||||||
defer dc.Close()
|
defer dc.Close()
|
||||||
|
|
||||||
serviceName := resolveServiceName(ctx, dc, item.Name)
|
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)
|
targetDir, err := getContainerDataDir(serviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to determine data directory: %w", err)
|
return fmt.Errorf("failed to determine data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke the service's identity certificate and publish the CRL entry
|
// Rev 9: there is no identity certificate, CRL or per-service database
|
||||||
// (security theme M1 §2 / M3 §2). A revoked service stops authenticating
|
// grant to revoke — the PKI layer and the DB role model are removed. The
|
||||||
// on the mTLS edges within one CRL freshness window. The authorization in
|
// container, data directory and image are all that is left to clean up.
|
||||||
// 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)
|
|
||||||
|
|
||||||
if _, err := os.Stat(targetDir); err == nil {
|
if _, err := os.Stat(targetDir); err == nil {
|
||||||
// Stop and remove the project containers/networks via compose down when
|
// Stop and remove the project containers/networks via compose down when
|
||||||
@@ -103,31 +96,6 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// serviceFingerprint reads the service's identity fingerprint (the public key
|
|
||||||
// fingerprint the auth service's trust store is keyed on), or "" when the
|
|
||||||
// identity was never issued.
|
|
||||||
func serviceFingerprint(serviceName string) string {
|
|
||||||
dataRoot, err := getDataRoot()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
meta, err := pki.LoadServiceMeta(dataRoot, serviceName)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return meta.Fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
// revokeServiceIdentity revokes the service's certificate in the instance CA.
|
|
||||||
// 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
|
// stopAndRemoveByLabel force-stops and removes every container carrying the
|
||||||
// org.dcntrlized.service label for the given service.
|
// org.dcntrlized.service label for the given service.
|
||||||
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
|
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
|
hasAuth := existing.HasAuth
|
||||||
publicKey := existing.PublicKey
|
publicKey := existing.PublicKey
|
||||||
config := existing.Config
|
config := existing.Config
|
||||||
|
web := existing.Web
|
||||||
if manifest != nil {
|
if manifest != nil {
|
||||||
capabilities = manifest.Capabilities
|
capabilities = manifest.Capabilities
|
||||||
hasAuth = manifest.HasAuth
|
hasAuth = manifest.HasAuth
|
||||||
@@ -120,8 +121,19 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
|||||||
if manifest.Config != nil {
|
if manifest.Config != nil {
|
||||||
config = manifest.Config
|
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)
|
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{
|
container := types.Container{
|
||||||
ID: containerID,
|
ID: containerID,
|
||||||
ServiceName: serviceLabel,
|
ServiceName: serviceLabel,
|
||||||
@@ -133,10 +145,12 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
|||||||
Version: ver,
|
Version: ver,
|
||||||
Role: role,
|
Role: role,
|
||||||
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
|
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
|
||||||
|
External: external,
|
||||||
Capabilities: capabilities,
|
Capabilities: capabilities,
|
||||||
HasAuth: hasAuth,
|
HasAuth: hasAuth,
|
||||||
PublicKey: publicKey,
|
PublicKey: publicKey,
|
||||||
Config: config,
|
Config: config,
|
||||||
|
Web: web,
|
||||||
Title: existing.Title,
|
Title: existing.Title,
|
||||||
Vendor: existing.Vendor,
|
Vendor: existing.Vendor,
|
||||||
Author: existing.Author,
|
Author: existing.Author,
|
||||||
@@ -234,6 +248,11 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
|
|||||||
key := normalizeName(serviceLabel)
|
key := normalizeName(serviceLabel)
|
||||||
cache.Lock()
|
cache.Lock()
|
||||||
entry, has := cache.services[key]
|
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 {
|
if !has {
|
||||||
entry = types.Container{
|
entry = types.Container{
|
||||||
ServiceName: serviceLabel,
|
ServiceName: serviceLabel,
|
||||||
@@ -241,7 +260,10 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
|
|||||||
Image: c.Image,
|
Image: c.Image,
|
||||||
Version: imageTag(c.Image),
|
Version: imageTag(c.Image),
|
||||||
Status: "installed",
|
Status: "installed",
|
||||||
|
External: external,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
entry.External = entry.External && external
|
||||||
}
|
}
|
||||||
entry.Status = normalizeDockerState(c.State)
|
entry.Status = normalizeDockerState(c.State)
|
||||||
entry.ID = c.ID
|
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