feat(control-panel): web admin UI for the zero-touch tunnel

Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time,
signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy
that mints a fresh 60s manage capability token per call to the control-plane admin
API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security
headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests
pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
This commit is contained in:
Yaojia Wang
2026-07-19 19:47:51 +02:00
parent 7c1d43376d
commit 675de771c7
38 changed files with 5207 additions and 0 deletions

4
control-panel/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
public/build/
coverage/
*.log

37
control-panel/build.mjs Normal file
View File

@@ -0,0 +1,37 @@
/**
* Frontend build — mirrors the base app's esbuild style (vanilla TS → ESM bundle). Emits
* public/build/{app.js, index.html, styles.css}. index.html + styles.css are copied verbatim
* (index.html already references ./app.js and ./styles.css, both siblings in the build dir).
*/
import { build } from 'esbuild'
import { mkdir, copyFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const pub = resolve(here, 'public')
const out = resolve(pub, 'build')
async function main() {
await mkdir(out, { recursive: true })
await build({
entryPoints: [resolve(pub, 'app.ts')],
bundle: true,
format: 'esm',
target: 'es2022',
minify: true,
sourcemap: true,
outfile: resolve(out, 'app.js'),
logLevel: 'info',
})
await copyFile(resolve(pub, 'index.html'), resolve(out, 'index.html'))
await copyFile(resolve(pub, 'styles.css'), resolve(out, 'styles.css'))
process.stdout.write('[control-panel] frontend build → public/build\n')
}
main().catch((err) => {
process.stderr.write(`[control-panel] build failed: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

2902
control-panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "control-panel",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Web control panel for the zero-touch tunnel system. Loopback-only Fastify backend that gates an operator session behind a password, then proxies the control-plane admin API (list hosts / issue pairing codes / revoke hosts), minting a fresh short-TTL `manage` capability token per call. Vanilla-TS + esbuild SPA. NEVER logs secrets/tokens/passwords.",
"engines": {
"node": ">=18"
},
"main": "src/server.ts",
"scripts": {
"start": "tsx src/server.ts",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"build": "node build.mjs"
},
"dependencies": {
"fastify": "^4.28.1",
"qrcode": "^1.5.4",
"relay-auth": "file:../relay-auth",
"relay-contracts": "file:../relay-contracts",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"tsx": "^4.19.2",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}

View File

@@ -0,0 +1,77 @@
/**
* Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically
* (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure
* so the UI can react (e.g. bounce to the login screen on 401).
*/
export interface HostView {
readonly hostId: string
readonly subdomain: string
readonly status: string
readonly lastSeen?: string
readonly createdAt?: string
readonly revokedAt?: string | null
readonly notAfter?: string
}
export interface PairingArtifacts {
readonly code: string
readonly expiresAt: string
readonly pairCommand: string
readonly qrDataUrl: string
}
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request(method: string, url: string, body?: unknown): Promise<Response> {
const init: RequestInit = { method, credentials: 'same-origin', headers: {} }
if (body !== undefined) {
init.headers = { 'content-type': 'application/json' }
init.body = JSON.stringify(body)
}
return fetch(url, init)
}
async function json<T>(res: Response): Promise<T> {
if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`)
return (await res.json()) as T
}
export async function getSession(): Promise<boolean> {
const res = await request('GET', '/api/session')
const data = await json<{ authenticated: boolean }>(res)
return data.authenticated === true
}
/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */
export async function login(password: string): Promise<boolean> {
const res = await request('POST', '/login', { password })
if (res.ok) return true
throw new ApiError(res.status, `login failed (${res.status})`)
}
export async function logout(): Promise<void> {
await request('POST', '/logout')
}
export async function getHosts(): Promise<readonly HostView[]> {
const res = await request('GET', '/api/hosts')
const data = await json<{ hosts: HostView[] }>(res)
return data.hosts ?? []
}
export async function createPairingCode(): Promise<PairingArtifacts> {
const res = await request('POST', '/api/pairing-codes')
return json<PairingArtifacts>(res)
}
export async function revokeHost(hostId: string): Promise<void> {
const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`)
if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`)
}

114
control-panel/public/app.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* SPA bootstrap + state machine. On load: check the session → render login or dashboard. The
* dashboard polls the host list on an interval and refreshes after mutations. A 401 from any proxy
* call bounces the operator back to the login screen (session expired).
*/
import { mount } from './dom.js'
import * as api from './api.js'
import { ApiError } from './api.js'
import { renderLogin, renderDashboard, pairingModal, confirmDialog, toast, type DashboardHandlers } from './views.js'
import type { HostView } from './api.js'
const POLL_INTERVAL_MS = 15_000
function rootEl(): HTMLElement {
const root = document.getElementById('app')
if (root === null) throw new Error('#app root not found')
return root
}
let pollTimer: number | undefined
function stopPolling(): void {
if (pollTimer !== undefined) {
clearInterval(pollTimer)
pollTimer = undefined
}
}
/** True if an error is an auth failure that should bounce to login. */
function isUnauthorized(err: unknown): boolean {
return err instanceof ApiError && err.status === 401
}
async function showLogin(): Promise<void> {
stopPolling()
const root = rootEl()
const view = renderLogin(async (password) => {
const errorNode = (view as HTMLElement & { errorNode?: HTMLElement }).errorNode
try {
await api.login(password)
await showDashboard()
} catch (err) {
const msg = err instanceof ApiError && err.status === 429 ? 'Too many attempts. Wait and retry.' : err instanceof ApiError && err.status === 503 ? 'Panel not configured (no password set).' : 'Incorrect password.'
if (errorNode) errorNode.textContent = msg
}
})
mount(root, view)
}
async function loadHosts(): Promise<readonly HostView[]> {
return api.getHosts()
}
async function refreshDashboard(root: HTMLElement, handlers: DashboardHandlers): Promise<void> {
try {
const hosts = await loadHosts()
mount(root, renderDashboard(hosts, handlers))
} catch (err) {
if (isUnauthorized(err)) {
await showLogin()
return
}
toast(root, 'Failed to load hosts.', 'error')
}
}
async function showDashboard(): Promise<void> {
const root = rootEl()
const handlers: DashboardHandlers = {
onRefresh: () => void refreshDashboard(root, handlers),
onLogout: async () => {
stopPolling()
await api.logout()
await showLogin()
},
onNewCode: async () => {
try {
const artifacts = await api.createPairingCode()
document.body.append(pairingModal(artifacts))
} catch (err) {
if (isUnauthorized(err)) return void showLogin()
toast(root, 'Failed to create pairing code.', 'error')
}
},
onRevoke: async (host) => {
const ok = await confirmDialog(`Revoke host "${host.subdomain}"? This removes its tunnel access.`)
if (!ok) return
try {
await api.revokeHost(host.hostId)
toast(root, `Revoked ${host.subdomain}.`, 'info')
await refreshDashboard(root, handlers)
} catch (err) {
if (isUnauthorized(err)) return void showLogin()
toast(root, 'Failed to revoke host.', 'error')
}
},
}
await refreshDashboard(root, handlers)
stopPolling()
pollTimer = window.setInterval(() => void refreshDashboard(root, handlers), POLL_INTERVAL_MS)
}
async function boot(): Promise<void> {
try {
const authed = await api.getSession()
await (authed ? showDashboard() : showLogin())
} catch {
await showLogin()
}
}
void boot()

View File

@@ -0,0 +1,60 @@
/**
* Tiny DOM builder. ALL text and attribute values are set via `textContent` / `setAttribute` — this
* module NEVER assigns `innerHTML`, so untrusted server/host data (host subdomains, etc.) can never
* inject markup. Children passed as strings become text nodes (also safe).
*/
export type Child = Node | string
export interface ElProps {
class?: string
text?: string
type?: string
name?: string
placeholder?: string
value?: string
disabled?: boolean
autocomplete?: string
title?: string
src?: string
alt?: string
role?: string
ariaLabel?: string
onClick?: (e: MouseEvent) => void
onSubmit?: (e: SubmitEvent) => void
}
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: ElProps = {},
children: Child[] = [],
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (props.class !== undefined) node.className = props.class
if (props.text !== undefined) node.textContent = props.text
if (props.type !== undefined) node.setAttribute('type', props.type)
if (props.name !== undefined) node.setAttribute('name', props.name)
if (props.placeholder !== undefined) node.setAttribute('placeholder', props.placeholder)
if (props.value !== undefined) (node as HTMLInputElement).value = props.value
if (props.disabled) node.setAttribute('disabled', 'true')
if (props.autocomplete !== undefined) node.setAttribute('autocomplete', props.autocomplete)
if (props.title !== undefined) node.setAttribute('title', props.title)
if (props.src !== undefined) node.setAttribute('src', props.src)
if (props.alt !== undefined) node.setAttribute('alt', props.alt)
if (props.role !== undefined) node.setAttribute('role', props.role)
if (props.ariaLabel !== undefined) node.setAttribute('aria-label', props.ariaLabel)
if (props.onClick !== undefined) node.addEventListener('click', props.onClick as EventListener)
if (props.onSubmit !== undefined) node.addEventListener('submit', props.onSubmit as EventListener)
for (const child of children) node.append(child)
return node
}
/** Remove all children of a node. */
export function clear(node: Element): void {
while (node.firstChild) node.removeChild(node.firstChild)
}
/** Replace a node's content with a single new child. */
export function mount(root: Element, child: Node): void {
clear(root)
root.append(child)
}

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="robots" content="noindex, nofollow" />
<title>Tunnel Control Panel</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app" class="app">
<div class="centered"><p class="muted">Loading…</p></div>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,134 @@
/* Tunnel Control Panel — themed (light/dark via prefers-color-scheme), responsive. */
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f0f2f5;
--text: #1b1f24;
--muted: #5b6572;
--border: #dfe3e8;
--primary: #2563eb;
--primary-text: #ffffff;
--danger: #dc2626;
--ok: #16a34a;
--warn: #d97706;
--shadow: 0 6px 24px rgba(20, 24, 33, 0.12);
--radius: 12px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1319;
--surface: #171c24;
--surface-2: #1f2630;
--text: #e7ebf0;
--muted: #9aa5b3;
--border: #2a323d;
--primary: #3b82f6;
--danger: #ef4444;
--ok: #22c55e;
--warn: #f59e0b;
--shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 17px; margin: 0 0 12px; }
.app { min-height: 100vh; }
.centered { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
/* Buttons */
.btn {
appearance: none;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
padding: 9px 14px;
border-radius: 9px;
font-size: 14px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.02s ease;
}
.btn:hover { border-color: var(--primary); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.btn.ghost { background: transparent; }
.btn.small { padding: 6px 10px; font-size: 13px; }
.icon-btn { background: none; border: none; color: var(--muted); font-size: 18px; cursor: pointer; line-height: 1; }
/* Login */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px;
}
.login { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }
.field-label { font-size: 13px; color: var(--muted); }
.login input {
width: 100%;
padding: 11px 12px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-size: 15px;
}
.login input:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 0; }
/* Dashboard */
.dashboard { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.topbar .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.dashboard .card { padding: 20px; }
.table-scroll { overflow-x: auto; }
table.hosts { width: 100%; border-collapse: collapse; font-size: 14px; }
table.hosts th, table.hosts td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
table.hosts th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
table.hosts tr:last-child td { border-bottom: none; }
.empty { color: var(--muted); padding: 24px 4px; text-align: center; }
/* Status pills */
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--border); }
.pill-online, .pill-active { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); }
.pill-offline { color: var(--muted); }
.pill-revoked, .pill-suspended { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); }
.pill-unknown { color: var(--warn); }
/* Modal */
.overlay { position: fixed; inset: 0; background: rgba(6, 9, 14, 0.55); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 420px; }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; align-items: center; text-align: center; }
.code-big { font-size: 26px; font-weight: 700; letter-spacing: 0.08em; padding: 8px 12px; background: var(--surface-2); border-radius: 9px; }
.qr { width: 200px; height: 200px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 9px; }
.command-row { display: flex; gap: 8px; align-items: center; width: 100%; }
.command-row.end { justify-content: flex-end; }
.command { flex: 1; text-align: left; background: var(--surface-2); padding: 9px 11px; border-radius: 8px; font-size: 13px; overflow-x: auto; white-space: nowrap; }
/* Toasts */
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 10px 16px; border-radius: 9px; box-shadow: var(--shadow); z-index: 60; font-size: 14px; }
.toast-info { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.toast-error { background: var(--danger); color: #fff; }
@media (max-width: 560px) {
.topbar { flex-direction: column; align-items: stretch; }
.topbar .actions { justify-content: stretch; }
.topbar .actions .btn { flex: 1; }
}

View File

@@ -0,0 +1,161 @@
/**
* View builders. Every dynamic value (host subdomain/status/dates, pairing code, command) is placed
* with `textContent` via the `el` helper — NEVER innerHTML — so host data (treated as untrusted for
* XSS even though it comes from an authenticated CP) can't inject markup. The QR is an <img> whose
* src is a data: URL our own backend generated with the `qrcode` lib.
*/
import { el, clear, type Child } from './dom.js'
import type { HostView, PairingArtifacts } from './api.js'
export interface DashboardHandlers {
onNewCode: () => void
onRevoke: (host: HostView) => void
onLogout: () => void
onRefresh: () => void
}
/** Login card with a password field; `onSubmit(password)` fires on submit. */
export function renderLogin(onSubmit: (password: string) => void): HTMLElement {
const input = el('input', { type: 'password', name: 'password', placeholder: 'Panel password', autocomplete: 'current-password' })
const error = el('p', { class: 'error', role: 'alert' })
const form = el(
'form',
{
class: 'card login',
onSubmit: (e) => {
e.preventDefault()
error.textContent = ''
onSubmit(input.value)
},
},
[
el('h1', { text: 'Tunnel Control Panel' }),
el('label', { text: 'Password', class: 'field-label' }),
input,
el('button', { type: 'submit', class: 'btn primary', text: 'Sign in' }),
error,
],
)
const wrap = el('div', { class: 'centered' }, [form])
// Expose the error node so the app can show login failures.
;(wrap as HTMLElement & { errorNode?: HTMLElement }).errorNode = error
queueMicrotask(() => input.focus())
return wrap
}
function statusPill(status: string): HTMLElement {
const known = ['online', 'offline', 'revoked', 'suspended', 'active'].includes(status)
return el('span', { class: `pill pill-${known ? status : 'unknown'}`, text: status })
}
function fmtDate(value: string | undefined | null): string {
if (value === undefined || value === null || value === '') return '—'
const t = Date.parse(value)
return Number.isNaN(t) ? value : new Date(t).toLocaleString()
}
function hostRow(host: HostView, handlers: DashboardHandlers): HTMLElement {
const revoke = el('button', {
class: 'btn danger small',
text: 'Revoke',
title: `Revoke ${host.subdomain}`,
onClick: () => handlers.onRevoke(host),
})
return el('tr', {}, [
el('td', { text: host.subdomain, class: 'mono' }),
el('td', {}, [statusPill(host.status)]),
el('td', { text: fmtDate(host.notAfter), class: 'muted' }),
el('td', { text: fmtDate(host.lastSeen), class: 'muted' }),
el('td', {}, [revoke]),
])
}
function hostsTable(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
if (hosts.length === 0) {
return el('div', { class: 'empty', text: 'No hosts enrolled yet. Create a pairing code to add one.' })
}
const head = el('thead', {}, [
el('tr', {}, [
el('th', { text: 'Subdomain' }),
el('th', { text: 'Status' }),
el('th', { text: 'Cert expiry' }),
el('th', { text: 'Last seen' }),
el('th', { text: '' }),
]),
])
const body = el('tbody', {}, hosts.map((h) => hostRow(h, handlers)))
return el('div', { class: 'table-scroll' }, [el('table', { class: 'hosts' }, [head, body])])
}
/** Full dashboard: header (refresh / new-code / logout) + hosts table. */
export function renderDashboard(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
const header = el('header', { class: 'topbar' }, [
el('h1', { text: 'Tunnel Control Panel' }),
el('div', { class: 'actions' }, [
el('button', { class: 'btn', text: 'Refresh', onClick: () => handlers.onRefresh() }),
el('button', { class: 'btn primary', text: 'New pairing code', onClick: () => handlers.onNewCode() }),
el('button', { class: 'btn ghost', text: 'Log out', onClick: () => handlers.onLogout() }),
]),
])
return el('div', { class: 'dashboard' }, [
header,
el('section', { class: 'card' }, [el('h2', { text: 'Hosts' }), hostsTable(hosts, handlers)]),
])
}
/** Generic modal overlay; returns { overlay, close }. Clicking the backdrop or ✕ closes it. */
function modal(title: string, children: Child[]): { overlay: HTMLElement; close: () => void } {
const close = (): void => overlay.remove()
const dialog = el('div', { class: 'modal', role: 'dialog' }, [
el('div', { class: 'modal-head' }, [el('h2', { text: title }), el('button', { class: 'icon-btn', text: '✕', ariaLabel: 'Close', onClick: close })]),
el('div', { class: 'modal-body' }, children),
])
const overlay = el('div', { class: 'overlay', onClick: (e) => { if (e.target === overlay) close() } }, [dialog])
return { overlay, close }
}
/** Pairing-code modal: the code, its QR, the copyable command, and the expiry. */
export function pairingModal(artifacts: PairingArtifacts): HTMLElement {
const command = el('code', { class: 'command', text: artifacts.pairCommand })
const copyBtn = el('button', {
class: 'btn small',
text: 'Copy command',
onClick: () => {
void navigator.clipboard?.writeText(artifacts.pairCommand).then(
() => { copyBtn.textContent = 'Copied!' },
() => { copyBtn.textContent = 'Copy failed' },
)
},
})
const { overlay } = modal('Pairing code', [
el('p', { class: 'muted', text: 'Run this on the new host, or scan the QR from the phone client. Single-use.' }),
el('div', { class: 'code-big mono', text: artifacts.code }),
el('img', { class: 'qr', src: artifacts.qrDataUrl, alt: 'Pairing code QR' }),
el('div', { class: 'command-row' }, [command, copyBtn]),
el('p', { class: 'muted small', text: `Expires: ${fmtDate(artifacts.expiresAt)}` }),
])
return overlay
}
/** Confirm dialog → resolves true (confirmed) / false (cancelled). */
export function confirmDialog(message: string): Promise<boolean> {
return new Promise((resolve) => {
const { overlay, close } = modal('Please confirm', [
el('p', { text: message }),
el('div', { class: 'command-row end' }, [
el('button', { class: 'btn ghost', text: 'Cancel', onClick: () => { close(); resolve(false) } }),
el('button', { class: 'btn danger', text: 'Revoke', onClick: () => { close(); resolve(true) } }),
]),
])
document.body.append(overlay)
})
}
/** A transient toast for errors/success (textContent only). */
export function toast(root: HTMLElement, message: string, kind: 'error' | 'info' = 'info'): void {
const t = el('div', { class: `toast toast-${kind}`, text: message, role: 'status' })
root.append(t)
setTimeout(() => t.remove(), 4000)
}
export { clear }

85
control-panel/src/app.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Fastify app assembly. All collaborators are injected (config, CP client, token minter, static
* root, clock) so the whole surface is unit-testable via `app.inject()` with fakes and NO network.
* Route order matters: auth + api plugins register BEFORE the static wildcard so specific routes win.
*/
import Fastify, { type FastifyInstance } from 'fastify'
import type { PanelConfig } from './config.js'
import type { CpClient } from './cp-client.js'
import type { ManageTokenMinter } from './manage-token.js'
import type { RateLimiter } from './security/rate-limit.js'
import { buildAuthRoutes } from './routes/auth-routes.js'
import { buildApiRoutes } from './routes/api-routes.js'
import { buildStaticRoutes } from './static.js'
/**
* Baseline security response headers applied to EVERY response (via an onRequest hook, so they are
* present on 404/401/error paths and on streamed static files too). The CSP is tuned for this
* self-contained SPA: it loads /build/app.js (script) and /build/styles.css (stylesheet) same-origin
* and renders the pairing QR as a `data:` image — hence `img-src 'self' data:`. `style-src` allows
* inline styles defensively; everything else is locked to 'self', framing is forbidden, and
* object-src/base-uri are neutralised.
*/
const SECURITY_HEADERS: Readonly<Record<string, string>> = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy':
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
}
export interface AppDeps {
readonly config: PanelConfig
readonly cpClient: CpClient
readonly minter: ManageTokenMinter
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
/** Per-IP login limiter — injectable for tests. */
readonly rateLimiter?: RateLimiter
/** Absolute path to the built SPA (public/build). `null` ⇒ skip static serving (tests). */
readonly staticRoot?: string | null
/** Server-side error log (no secrets). Defaults to a no-op. */
readonly logError?: (message: string) => void
}
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
// Fastify's own logger is disabled: we control what is logged so no secret/token/password leaks.
//
// trustProxy: the panel sits behind nginx on loopback. The nginx vhost MUST set
// `X-Forwarded-For $remote_addr` (a clean, non-client-controllable value — NOT
// `$proxy_add_x_forwarded_for`, which would append attacker-supplied hops). Trusting the proxy makes
// `req.ip` reflect the REAL client address; without it every request looks like 127.0.0.1 and the
// /login rate limiter collapses into a single global bucket → a trivial unauthenticated lockout DoS.
const app = Fastify({ logger: false, bodyLimit: 64 * 1024, trustProxy: true })
// Defense-in-depth: stamp security headers on every response before routing (so 404/401/error
// responses and streamed static files all carry them). Registered first, applies to all child plugins.
app.addHook('onRequest', async (_req, reply) => {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) reply.header(name, value)
})
// Conditional spreads keep `exactOptionalPropertyTypes` happy (never pass an explicit `undefined`).
const nowPart = deps.now !== undefined ? { now: deps.now } : {}
await app.register(
buildAuthRoutes({
config: deps.config,
...nowPart,
...(deps.rateLimiter !== undefined ? { rateLimiter: deps.rateLimiter } : {}),
}),
)
await app.register(
buildApiRoutes({
config: deps.config,
cpClient: deps.cpClient,
minter: deps.minter,
...nowPart,
...(deps.logError !== undefined ? { logError: deps.logError } : {}),
}),
)
if (deps.staticRoot != null) {
await app.register(buildStaticRoutes(deps.staticRoot))
}
return app
}

View File

@@ -0,0 +1,97 @@
/**
* Boot configuration — zod-validated at the process boundary, FAIL-CLOSED.
*
* Every field is read from the environment ONCE at startup and returned as an immutable
* `PanelConfig`. Structural problems (missing SESSION_SECRET, a non-numeric port, an empty
* OPERATOR_ACCOUNT_ID) throw here so the server never boots half-configured.
*
* `PANEL_PASSWORD` is intentionally OPTIONAL at this layer: an unset password is a valid (if
* useless) deployment state that the /login route turns into a fail-closed 503 — the panel must
* still start so an operator can see the error, rather than crash-looping. Every other secret is
* required. Secrets are NEVER logged; this module only ever returns them inside the config object.
*/
import { z } from 'zod'
/** Default control-plane admin API base (loopback — the CP admin surface must never be public). */
export const DEFAULT_CP_URL = 'http://127.0.0.1:8080'
/** Default PKCS#8 Ed25519 capability signing key (matches relay-run/mint-manage-token.ts). */
export const DEFAULT_CAPABILITY_SIGN_KEY_PATH = '/etc/relay/capability/capability-sign.key.pem'
/** Default tunnel zone used to render the `pair` command shown to the operator. */
export const DEFAULT_TUNNEL_ZONE = 'terminal.yaojia.wang'
/** Default loopback bind port for the panel HTTP server. */
export const DEFAULT_PANEL_BIND_PORT = 8090
/** SESSION_SECRET must have enough entropy to make cookie forgery infeasible. */
export const MIN_SESSION_SECRET_LEN = 16
/**
* True iff `hostname` is a loopback literal: `localhost`, `::1`, or anything in 127.0.0.0/8.
* The panel's CP admin client must ONLY ever reach the local control-plane — enforcing this at the
* config boundary is anti-SSRF (a misconfigured CP_URL pointing at a remote/internal host is refused).
*/
export function isLoopbackHost(hostname: string): boolean {
const h = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase() // strip IPv6 brackets
if (h === 'localhost' || h === '::1') return true
const m = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(h)
return m !== null && Number(m[1]) === 127
}
/** True iff `url` parses AND its host is a loopback literal. Malformed URLs fail closed (false). */
function cpUrlHostIsLoopback(url: string): boolean {
try {
return isLoopbackHost(new URL(url).hostname)
} catch {
return false
}
}
const EnvSchema = z.object({
// Optional: unset ⇒ /login returns 503 (fail-closed at the route, not at boot).
PANEL_PASSWORD: z.string().min(1).optional(),
// Required: HMAC key that signs the session cookie. No default — a predictable secret is a bug.
SESSION_SECRET: z.string().min(MIN_SESSION_SECRET_LEN, `SESSION_SECRET must be >= ${MIN_SESSION_SECRET_LEN} chars`),
// Must be loopback (127.0.0.0/8, ::1, localhost): the CP admin surface is local-only. Fail closed otherwise.
CP_URL: z
.string()
.url()
.refine(cpUrlHostIsLoopback, 'CP_URL host must be loopback (127.0.0.0/8, ::1, or localhost)')
.default(DEFAULT_CP_URL),
// `aud` of every minted manage token — MUST equal the control-plane's expectedAud (its BASE_DOMAIN).
BASE_DOMAIN: z.string().min(1),
// `sub`/accountId scoped into every manage token and admin path.
OPERATOR_ACCOUNT_ID: z.string().min(1),
CAPABILITY_SIGN_KEY_PATH: z.string().min(1).default(DEFAULT_CAPABILITY_SIGN_KEY_PATH),
TUNNEL_ZONE: z.string().min(1).default(DEFAULT_TUNNEL_ZONE),
PANEL_BIND_PORT: z.coerce.number().int().min(1).max(65535).default(DEFAULT_PANEL_BIND_PORT),
})
export interface PanelConfig {
readonly panelPassword: string | undefined
readonly sessionSecret: string
readonly cpUrl: string
readonly baseDomain: string
readonly operatorAccountId: string
readonly capabilitySignKeyPath: string
readonly tunnelZone: string
readonly panelBindPort: number
}
/** Loopback-only bind host — the admin panel must never listen on a public interface. */
export const PANEL_BIND_HOST = '127.0.0.1'
/**
* Parse + validate the environment into an immutable config. Throws a `ZodError` on any structural
* problem (fail-closed). Never logs the values it reads.
*/
export function loadConfig(env: NodeJS.ProcessEnv): PanelConfig {
const parsed = EnvSchema.parse(env)
return {
panelPassword: parsed.PANEL_PASSWORD,
sessionSecret: parsed.SESSION_SECRET,
cpUrl: parsed.CP_URL.replace(/\/+$/, ''), // normalise: strip trailing slashes for clean joins
baseDomain: parsed.BASE_DOMAIN,
operatorAccountId: parsed.OPERATOR_ACCOUNT_ID,
capabilitySignKeyPath: parsed.CAPABILITY_SIGN_KEY_PATH,
tunnelZone: parsed.TUNNEL_ZONE,
panelBindPort: parsed.PANEL_BIND_PORT,
}
}

View File

@@ -0,0 +1,127 @@
/**
* Control-plane ADMIN API client. Each method takes a freshly-minted `manage` bearer token and calls
* the loopback CP admin surface. Responses are Zod-validated at the boundary (the CP is trusted for
* auth but its payloads are still external data → validate, never `as`). Non-2xx ⇒ `CpClientError`.
*
* CP contract (control-plane/src/api/provision.ts, mounted at root, `Authorization: Bearer <token>`):
* - GET /accounts/:id/hosts → 200 [HostRecord...] (agentPubkey base64)
* - POST /accounts/:id/pairing-codes → 201 { code, expiresAt }
* - DELETE /hosts/:hostId → 204
*
* We expose only a curated host view to the SPA (never leak agentPubkey/enrollFpr).
*/
import { z } from 'zod'
export interface HostView {
readonly hostId: string
readonly subdomain: string
readonly status: string
readonly lastSeen: string | undefined
readonly createdAt: string | undefined
readonly revokedAt: string | null | undefined
/** Cert expiry, if the CP ever includes one (not in today's HostRecord) — surfaced when present. */
readonly notAfter: string | undefined
}
export interface IssuedPairing {
readonly code: string
readonly expiresAt: string
}
export interface CpClient {
listHosts(accountId: string, token: string): Promise<readonly HostView[]>
createPairingCode(accountId: string, token: string): Promise<IssuedPairing>
deleteHost(hostId: string, token: string): Promise<void>
}
/** Upstream failure — carries an HTTP-ish status for the route layer to map (kept generic; no CP body leaked). */
export class CpClientError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'CpClientError'
this.status = status
}
}
// Lenient: require the fields we render; passthrough-tolerate everything else the CP adds/removes.
const HostRecordSchema = z
.object({
hostId: z.string(),
subdomain: z.string(),
status: z.string(),
lastSeen: z.string().optional(),
createdAt: z.string().optional(),
revokedAt: z.string().nullable().optional(),
notAfter: z.string().optional(),
})
.passthrough()
const HostListSchema = z.array(HostRecordSchema)
const IssuedPairingSchema = z.object({ code: z.string().min(1), expiresAt: z.string().min(1) })
function toHostView(rec: z.infer<typeof HostRecordSchema>): HostView {
return {
hostId: rec.hostId,
subdomain: rec.subdomain,
status: rec.status,
lastSeen: rec.lastSeen,
createdAt: rec.createdAt,
revokedAt: rec.revokedAt,
notAfter: rec.notAfter,
}
}
type FetchFn = typeof fetch
interface CpClientDeps {
readonly cpUrl: string
readonly fetchFn?: FetchFn
}
async function readJson(res: Response): Promise<unknown> {
try {
return await res.json()
} catch {
throw new CpClientError(502, 'control-plane returned a non-JSON response')
}
}
export function createCpClient(deps: CpClientDeps): CpClient {
const doFetch: FetchFn = deps.fetchFn ?? fetch
const authHeaders = (token: string): Record<string, string> => ({ authorization: `Bearer ${token}`, accept: 'application/json' })
const call = async (path: string, init: RequestInit): Promise<Response> => {
try {
return await doFetch(`${deps.cpUrl}${path}`, init)
} catch {
// Network/DNS/connection failure — the CP is unreachable.
throw new CpClientError(502, 'control-plane unreachable')
}
}
return {
async listHosts(accountId, token) {
const res = await call(`/accounts/${encodeURIComponent(accountId)}/hosts`, { headers: authHeaders(token) })
if (!res.ok) throw new CpClientError(res.status, `list hosts failed (${res.status})`)
const parsed = HostListSchema.parse(await readJson(res))
return parsed.map(toHostView)
},
async createPairingCode(accountId, token) {
const res = await call(`/accounts/${encodeURIComponent(accountId)}/pairing-codes`, {
method: 'POST',
headers: { ...authHeaders(token), 'content-type': 'application/json' },
body: '{}',
})
if (!res.ok) throw new CpClientError(res.status, `issue pairing code failed (${res.status})`)
const parsed = IssuedPairingSchema.parse(await readJson(res))
return { code: parsed.code, expiresAt: parsed.expiresAt }
},
async deleteHost(hostId, token) {
const res = await call(`/hosts/${encodeURIComponent(hostId)}`, { method: 'DELETE', headers: authHeaders(token) })
if (!res.ok && res.status !== 204) throw new CpClientError(res.status, `revoke host failed (${res.status})`)
},
}
}

View File

@@ -0,0 +1,106 @@
/**
* In-process `manage` capability-token minter — the in-code equivalent of
* relay-run/scripts/mint-manage-token.ts. Every proxied admin call mints a FRESH short-TTL token so
* a leaked token's blast radius is ~60s.
*
* The token is a §4.3 PASETO v4.public capability token signed by the P5 PRIVATE Ed25519 key
* (PKCS#8 PEM at CAPABILITY_SIGN_KEY_PATH): `aud = BASE_DOMAIN`, `sub = OPERATOR_ACCOUNT_ID`,
* `rights = ['manage']`, `ttl = 60s`. `issueCapabilityToken` mandates a well-formed DPoP `cnf.jkt`,
* so we stamp one from a throwaway ephemeral key (the CP admin API verifies signature+aud+rights but
* does NOT require a live DPoP proof — see the mint script's header note).
*
* SECURITY: the signing-key material and the minted token are NEVER logged.
*/
import { readFile } from 'node:fs/promises'
import { issueCapabilityToken } from 'relay-auth'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
/** Manage tokens are `manage`-scoped, single-account, 60s TTL. `host` is a placeholder (issue() forbids '*'/''). */
const MANAGE_TOKEN_TTL_SEC = 60
const MANAGE_HOST_PLACEHOLDER = '_manage_'
/** Raised when the signing key cannot be loaded/imported. Message is safe (no key material). */
export class ManageTokenError extends Error {
constructor(message: string) {
super(message)
this.name = 'ManageTokenError'
}
}
export interface ManageTokenMinter {
/** Mint a fresh manage token for the configured operator account. */
mint(): Promise<string>
}
export interface ManageTokenConfig {
readonly capabilitySignKeyPath: string
readonly baseDomain: string
readonly operatorAccountId: string
}
/** Import a PKCS#8 PEM Ed25519 private key into a non-extractable signing CryptoKey. */
async function importSigningKeyFromPem(pemPath: string): Promise<CryptoKey> {
let pem: string
try {
pem = await readFile(pemPath, 'utf8')
} catch {
throw new ManageTokenError(`capability signing key not readable at ${pemPath}`)
}
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
if (b64.length === 0) throw new ManageTokenError('capability signing key PEM is empty')
let der: Uint8Array<ArrayBuffer>
try {
// Copy into a fresh ArrayBuffer-backed view: WebCrypto's BufferSource requires Uint8Array<ArrayBuffer>,
// which Buffer (ArrayBufferLike) does not satisfy under strict lib types.
const raw = Buffer.from(b64, 'base64')
der = new Uint8Array(new ArrayBuffer(raw.byteLength))
der.set(raw)
} catch {
throw new ManageTokenError('capability signing key PEM is not valid base64')
}
try {
return await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign'])
} catch {
throw new ManageTokenError('capability signing key is not a valid PKCS#8 Ed25519 key')
}
}
/**
* Build a minter that lazily loads + memoizes the signing key (so the panel boots even if the key
* file is briefly unavailable — a missing key surfaces as an upstream error on the first proxy call,
* not a boot crash). Clock is injectable for tests.
*/
export function createManageTokenMinter(config: ManageTokenConfig, now: () => number = () => Date.now()): ManageTokenMinter {
let keyPromise: Promise<CryptoKey> | undefined
const signingKey = (): Promise<CryptoKey> => {
if (keyPromise === undefined) {
keyPromise = importSigningKeyFromPem(config.capabilitySignKeyPath).catch((err: unknown) => {
keyPromise = undefined // allow a later retry after the operator fixes the key
throw err
})
}
return keyPromise
}
return {
async mint(): Promise<string> {
const key = await signingKey()
const eph = await generateEd25519KeyPair()
const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey))
return issueCapabilityToken(
{
principal: { accountId: config.operatorAccountId } as never, // runtime reads only accountId
aud: config.baseDomain,
host: MANAGE_HOST_PLACEHOLDER,
rights: ['manage'],
ttlSeconds: MANAGE_TOKEN_TTL_SEC,
cnfJkt,
},
key,
Math.floor(now() / 1000),
)
},
}
}

View File

@@ -0,0 +1,37 @@
/**
* Operator-facing pairing artifacts derived from a freshly-issued pairing code:
* - the ready-to-run `web-terminal-agent pair <code> --install --zone <zone>` command line, and
* - a QR PNG data: URL of the code (rendered with the `qrcode` dep) for phone scanning.
* Pure/deterministic given the code + zone (except the QR, which is a stable render of the code).
*/
import QRCode from 'qrcode'
/** Build the exact command an operator runs on the new host to enroll it into the tunnel. */
export function buildPairCommand(code: string, zone: string): string {
return `web-terminal-agent pair ${code} --install --zone ${zone}`
}
/** Render a QR PNG data: URL encoding the pairing code (for scanning on the phone client). */
export async function buildQrDataUrl(code: string): Promise<string> {
return QRCode.toDataURL(code, { errorCorrectionLevel: 'M', margin: 1, width: 256 })
}
export interface PairingArtifacts {
readonly code: string
readonly expiresAt: string
readonly pairCommand: string
readonly qrDataUrl: string
}
/** Combine an issued code with its operator-facing command + QR into the /api/pairing-codes payload. */
export async function buildPairingArtifacts(
issued: { readonly code: string; readonly expiresAt: string },
zone: string,
): Promise<PairingArtifacts> {
return {
code: issued.code,
expiresAt: issued.expiresAt,
pairCommand: buildPairCommand(issued.code, zone),
qrDataUrl: await buildQrDataUrl(issued.code),
}
}

View File

@@ -0,0 +1,94 @@
/**
* Session-gated proxy routes. A `preHandler` rejects any request without a valid session cookie
* (401) — nothing downstream runs unauthenticated. For EACH call we mint a FRESH `manage` token and
* hand it to the CP admin client:
* - GET /api/hosts → CP GET /accounts/{op}/hosts → curated host list
* - POST /api/pairing-codes → CP POST /accounts/{op}/pairing-codes → { code, expiresAt, pairCommand, qrDataUrl }
* - DELETE /api/hosts/:hostId → CP DELETE /hosts/:hostId → 204
*
* Upstream/mint failures map to a generic 502 (never leak CP internals or token/key material).
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { PanelConfig } from '../config.js'
import type { CpClient } from '../cp-client.js'
import { CpClientError } from '../cp-client.js'
import type { ManageTokenMinter } from '../manage-token.js'
import { buildPairingArtifacts } from '../pairing.js'
import { requestIsAuthed } from '../security/request.js'
// hostId is a server-issued UUIDv4 — accept only a conservative id charset (defence-in-depth).
// A bare `.` or `..` passes the charset but would reshape the outbound CP admin URL
// (`${cpUrl}/hosts/${hostId}`) via dot-segment normalization, so reject those two exact values.
export const HostIdSchema = z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.refine((v) => v !== '.' && v !== '..', { message: 'host id must not be a dot-segment' })
export interface ApiRoutesDeps {
readonly config: PanelConfig
readonly cpClient: CpClient
readonly minter: ManageTokenMinter
readonly now?: () => number
/** Server-side logger for upstream failures (no secrets). Defaults to a no-op. */
readonly logError?: (message: string) => void
}
function mapUpstreamError(reply: FastifyReply, err: unknown, log: (m: string) => void): FastifyReply {
if (err instanceof CpClientError) {
log(`upstream control-plane error: ${err.message}`)
return reply.code(502).send({ error: 'upstream_error' })
}
log(`proxy error: ${err instanceof Error ? err.name : 'unknown'}`)
return reply.code(502).send({ error: 'upstream_error' })
}
export function buildApiRoutes(deps: ApiRoutesDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const log = deps.logError ?? (() => {})
const accountId = deps.config.operatorAccountId
return async (app) => {
// Deny-by-default session gate for every route in this plugin.
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
if (!requestIsAuthed(req, deps.config.sessionSecret, now())) {
await reply.code(401).send({ error: 'unauthenticated' })
}
})
app.get('/api/hosts', async (_req, reply) => {
try {
const token = await deps.minter.mint()
const hosts = await deps.cpClient.listHosts(accountId, token)
return reply.code(200).send({ hosts })
} catch (err) {
return mapUpstreamError(reply, err, log)
}
})
app.post('/api/pairing-codes', async (_req, reply) => {
try {
const token = await deps.minter.mint()
const issued = await deps.cpClient.createPairingCode(accountId, token)
const artifacts = await buildPairingArtifacts(issued, deps.config.tunnelZone)
return reply.code(201).send(artifacts)
} catch (err) {
return mapUpstreamError(reply, err, log)
}
})
app.delete('/api/hosts/:hostId', async (req, reply) => {
const parsed = HostIdSchema.safeParse((req.params as { hostId?: unknown }).hostId)
if (!parsed.success) return reply.code(400).send({ error: 'invalid host id' })
try {
const token = await deps.minter.mint()
await deps.cpClient.deleteHost(parsed.data, token)
return reply.code(204).send()
} catch (err) {
return mapUpstreamError(reply, err, log)
}
})
}
}

View File

@@ -0,0 +1,72 @@
/**
* Auth routes: POST /login, POST /logout, GET /api/session.
*
* SECURITY:
* - /login is RATE-LIMITED per client IP BEFORE any credential work (brute-force throttle → 429).
* - FAIL-CLOSED: unset PANEL_PASSWORD ⇒ 503 (never authenticates); wrong password ⇒ 401.
* - credential compare is CONSTANT-TIME (compare.ts SHA-256 fixed-length).
* - on success, set a signed HttpOnly; SameSite=Strict; Secure-when-https session cookie (12h).
* - the password and session token are NEVER logged; input is Zod-validated at the boundary.
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { PanelConfig } from '../config.js'
import { constantTimeEqual } from '../security/compare.js'
import { buildClearCookie, buildSetCookie } from '../security/cookies.js'
import { createSessionToken, SESSION_TTL_SEC } from '../security/session.js'
import { createSlidingWindowLimiter, DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, type RateLimiter } from '../security/rate-limit.js'
import { isSecureRequest, requestIsAuthed } from '../security/request.js'
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
export interface AuthRoutesDeps {
readonly config: PanelConfig
readonly now?: () => number
readonly rateLimiter?: RateLimiter
readonly clientKey?: (req: FastifyRequest) => string
}
function setCookie(reply: FastifyReply, value: string): void {
reply.header('set-cookie', value)
}
export function buildAuthRoutes(deps: AuthRoutesDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
const limiter = deps.rateLimiter ?? createSlidingWindowLimiter(DEFAULT_LOGIN_RATE_MAX, DEFAULT_LOGIN_RATE_WINDOW_MS, now)
return async (app) => {
app.post('/login', async (req, reply) => {
// Throttle FIRST so brute force is limited regardless of outcome.
if (!limiter.allow(clientKey(req))) {
return reply.code(429).send({ error: 'rate_limited' })
}
const parsed = LoginBodySchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'invalid request' })
// Fail-closed: an unconfigured panel authenticates no one.
const configured = deps.config.panelPassword
if (typeof configured !== 'string' || configured.length === 0) {
return reply.code(503).send({ error: 'login unavailable' })
}
if (!constantTimeEqual(parsed.data.password, configured)) {
return reply.code(401).send({ error: 'rejected' })
}
const token = createSessionToken(deps.config.sessionSecret, now())
setCookie(reply, buildSetCookie({ value: token, maxAgeSec: SESSION_TTL_SEC, secure: isSecureRequest(req) }))
return reply.code(200).send({ authenticated: true })
})
app.post('/logout', async (req, reply) => {
setCookie(reply, buildClearCookie({ secure: isSecureRequest(req) }))
return reply.code(200).send({ authenticated: false })
})
app.get('/api/session', async (req, reply) => {
return reply.code(200).send({ authenticated: requestIsAuthed(req, deps.config.sessionSecret, now()) })
})
}
}

View File

@@ -0,0 +1,23 @@
/**
* Constant-time string comparison (SECURITY-CRITICAL — never use `===` for secrets).
*
* Mirrors src/http/auth.ts in the base app: both inputs are hashed with SHA-256 to a FIXED 32
* bytes, then compared with `crypto.timingSafeEqual`. Hashing-to-fixed-length removes the length
* side-channel and sidesteps `timingSafeEqual`'s throw-on-length-mismatch. A missing/empty
* candidate short-circuits to `false` before the comparator (it is not a secret-compare oracle).
*/
import { createHash, timingSafeEqual } from 'node:crypto'
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
if (a.length === 0 || b.length === 0) return false
const ha = createHash('sha256').update(a, 'utf8').digest()
const hb = createHash('sha256').update(b, 'utf8').digest()
return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
}
/** Constant-time byte comparison of two equal-length buffers (false on length mismatch). */
export function constantTimeEqualBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}

View File

@@ -0,0 +1,53 @@
/**
* Cookie parse + Set-Cookie serialization — dependency-light (no @fastify/cookie), mirroring the
* base app's src/http/auth.ts discipline. Values are returned verbatim (NOT URL-decoded); the
* session token uses a cookie-safe charset (base64url + '.') so there is nothing to decode.
*/
/** The panel session cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate it). */
export const SESSION_COOKIE_NAME = 'panel_session'
/**
* Parse a raw `Cookie:` header into a name→value map. Malformed pairs (no `=`, empty name) are
* ignored; last write wins on duplicate names.
*/
export function parseCookieHeader(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {}
if (header === undefined || header === '') return out
for (const part of header.split(';')) {
const eq = part.indexOf('=')
if (eq <= 0) continue
const name = part.slice(0, eq).trim()
if (name === '') continue
out[name] = part.slice(eq + 1).trim()
}
return out
}
interface SetCookieOptions {
readonly value: string
readonly maxAgeSec: number
readonly secure: boolean
}
/**
* Build a `Set-Cookie` value. Flags: HttpOnly (no JS read), SameSite=Strict (cross-site pages can't
* ride the cookie — CSRF/CSWSH defence), Path=/, Max-Age, and Secure ONLY when `secure` (a Secure
* cookie is never sent over http:// — forcing it would break a loopback/http operator session).
*/
export function buildSetCookie(opts: SetCookieOptions): string {
const parts = [
`${SESSION_COOKIE_NAME}=${opts.value}`,
'Path=/',
`Max-Age=${opts.maxAgeSec}`,
'HttpOnly',
'SameSite=Strict',
]
if (opts.secure) parts.push('Secure')
return parts.join('; ')
}
/** Build a `Set-Cookie` that immediately expires the session cookie (logout). */
export function buildClearCookie(opts: { secure: boolean }): string {
return buildSetCookie({ value: '', maxAgeSec: 0, secure: opts.secure })
}

View File

@@ -0,0 +1,32 @@
/**
* In-process sliding-window rate limiter keyed by an arbitrary client bucket (per-IP for /login).
* Mirrors the base app's control-plane auth-login limiter: a rejected attempt is NOT recorded, so a
* throttled client can't push its own window forward. Pure/injectable clock for tests.
*/
export interface RateLimiter {
/** Returns true and records the hit if under the limit; false (no record) when the window is full. */
allow(key: string): boolean
}
/** Default per-IP login attempts within the window. */
export const DEFAULT_LOGIN_RATE_MAX = 10
/** Default login rate window (ms): 15 minutes. */
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
export function createSlidingWindowLimiter(max: number, windowMs: number, now: () => number): RateLimiter {
const hits = new Map<string, number[]>()
return {
allow(key: string): boolean {
const ts = now()
const cutoff = ts - windowMs
const kept = (hits.get(key) ?? []).filter((t) => t > cutoff)
if (kept.length >= max) {
hits.set(key, kept) // persist the pruned window; do NOT record this rejected attempt
return false
}
kept.push(ts)
hits.set(key, kept)
return true
},
}
}

View File

@@ -0,0 +1,26 @@
/**
* Request-scoped security helpers shared by the route plugins: detect HTTPS (drives the Secure
* cookie flag) and read/verify the session cookie off an incoming Fastify request.
*/
import type { FastifyRequest } from 'fastify'
import { parseCookieHeader, SESSION_COOKIE_NAME } from './cookies.js'
import { verifySessionToken } from './session.js'
/**
* True iff the request arrived over HTTPS — directly (`socket.encrypted`) or via a TLS-terminating
* edge that set `x-forwarded-proto: https`. Even though the panel binds loopback, a reverse proxy
* may front it; honour XFP so the Secure flag is correct on the tunnel path.
*/
export function isSecureRequest(req: FastifyRequest): boolean {
const xfp = req.headers['x-forwarded-proto']
const proto = Array.isArray(xfp) ? xfp[0] : xfp
if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') return true
const socket = req.raw.socket as { encrypted?: boolean } | undefined
return socket?.encrypted === true
}
/** True iff the request carries a valid, unexpired session cookie signed with `sessionSecret`. */
export function requestIsAuthed(req: FastifyRequest, sessionSecret: string, nowMs: number): boolean {
const cookies = parseCookieHeader(req.headers.cookie)
return verifySessionToken(sessionSecret, cookies[SESSION_COOKIE_NAME], nowMs)
}

View File

@@ -0,0 +1,54 @@
/**
* Signed session token — an HMAC-SHA256 MAC over a short-TTL expiry claim. The cookie value is
* `<expEpochSec>.<macBase64url>`; the MAC covers the expiry so a client cannot extend its own
* session. Verification is constant-time and rejects tampering, wrong-secret, and past-expiry
* tokens. Self-contained (node:crypto only) — no external signing dependency.
*/
import { createHmac } from 'node:crypto'
import { constantTimeEqualBytes } from './compare.js'
/** Session lifetime (seconds): 12h — long enough to avoid constant re-auth, short enough to bound replay. */
export const SESSION_TTL_SEC = 12 * 60 * 60
function macFor(secret: string, payload: string): Buffer {
return createHmac('sha256', secret).update(payload, 'utf8').digest()
}
function b64url(buf: Buffer): string {
return buf.toString('base64url')
}
/**
* Mint a session token that expires `ttlSec` after `nowMs`. The expiry is embedded and signed.
*/
export function createSessionToken(secret: string, nowMs: number, ttlSec: number = SESSION_TTL_SEC): string {
const expSec = Math.floor(nowMs / 1000) + ttlSec
const payload = String(expSec)
return `${payload}.${b64url(macFor(secret, payload))}`
}
/**
* True iff `token` is a well-formed, correctly-signed, unexpired session token.
* Any structural problem, MAC mismatch, or past-expiry ⇒ false (deny-by-default).
*/
export function verifySessionToken(secret: string, token: string | undefined, nowMs: number): boolean {
if (typeof token !== 'string' || token.length === 0) return false
const dot = token.indexOf('.')
if (dot <= 0 || dot === token.length - 1) return false
const payload = token.slice(0, dot)
const presentedMacB64 = token.slice(dot + 1)
// Expiry must be a positive integer string; reject anything else before touching crypto.
if (!/^\d+$/.test(payload)) return false
let presentedMac: Buffer
try {
presentedMac = Buffer.from(presentedMacB64, 'base64url')
} catch {
return false
}
const expectedMac = macFor(secret, payload)
if (!constantTimeEqualBytes(presentedMac, expectedMac)) return false
const expSec = Number(payload)
return Number.isSafeInteger(expSec) && expSec * 1000 > nowMs
}

View File

@@ -0,0 +1,45 @@
/**
* Process entrypoint. Loads + validates config (fail-closed), wires the REAL collaborators (CP
* client over CP_URL, lazy manage-token minter reading the PKCS#8 capability key), and binds the
* app to LOOPBACK ONLY (127.0.0.1) — the admin panel must never listen on a public interface.
*
* Logging here is deliberately minimal and secret-free: only the bind address/port and startup
* errors (never the password, session secret, key material, or any minted token) are printed.
*/
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { loadConfig, PANEL_BIND_HOST } from './config.js'
import { createCpClient } from './cp-client.js'
import { createManageTokenMinter } from './manage-token.js'
import { buildApp } from './app.js'
const HERE = dirname(fileURLToPath(import.meta.url))
/** Built SPA lives at control-panel/public/build (sibling of src/). */
const STATIC_ROOT = resolve(HERE, '..', 'public', 'build')
async function main(): Promise<void> {
const config = loadConfig(process.env)
const cpClient = createCpClient({ cpUrl: config.cpUrl })
const minter = createManageTokenMinter({
capabilitySignKeyPath: config.capabilitySignKeyPath,
baseDomain: config.baseDomain,
operatorAccountId: config.operatorAccountId,
})
const app = await buildApp({
config,
cpClient,
minter,
staticRoot: STATIC_ROOT,
// Structured, secret-free server log for upstream failures.
logError: (message: string) => process.stderr.write(`[control-panel] ${message}\n`),
})
await app.listen({ host: PANEL_BIND_HOST, port: config.panelBindPort })
process.stdout.write(`[control-panel] listening on http://${PANEL_BIND_HOST}:${config.panelBindPort}\n`)
}
main().catch((err: unknown) => {
process.stderr.write(`[control-panel] fatal: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})

View File

@@ -0,0 +1,63 @@
/**
* Minimal self-contained static SPA server (no @fastify/static dependency). Serves the esbuild
* output from `root` (control-panel/public/build) with a small content-type map, path-traversal
* guard, and an index.html fallback for extension-less GETs (SPA routing). Registered LAST so the
* specific /login and /api/* routes always win in Fastify's router.
*/
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { resolve, sep, extname } from 'node:path'
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
const CONTENT_TYPES: Readonly<Record<string, string>> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
}
async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile()
} catch {
return false
}
}
async function sendFile(reply: FastifyReply, filePath: string): Promise<void> {
const type = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
await reply.type(type).send(createReadStream(filePath))
}
export function buildStaticRoutes(root: string): FastifyPluginAsync {
const rootResolved = resolve(root)
const indexHtml = resolve(rootResolved, 'index.html')
return async (app) => {
app.get('/*', async (req, reply) => {
// Only GET/HEAD reach here (Fastify method routing); resolve the URL path safely.
const urlPath = decodeURIComponent(req.url.split('?')[0] ?? '/')
const candidate = resolve(rootResolved, '.' + urlPath)
// Path-traversal guard: the resolved target must stay within root.
if (candidate !== rootResolved && !candidate.startsWith(rootResolved + sep)) {
return reply.code(403).send({ error: 'forbidden' })
}
if (urlPath !== '/' && (await isFile(candidate))) {
return sendFile(reply, candidate)
}
// SPA fallback: serve index.html for '/' and any unknown extension-less route.
if (await isFile(indexHtml)) {
return sendFile(reply, indexHtml)
}
return reply.code(404).send({ error: 'not found' })
})
}
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { CpClientError } from '../src/cp-client.js'
import { HostIdSchema } from '../src/routes/api-routes.js'
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader } from './helpers.js'
const AUTH = { cookie: authCookieHeader() }
describe('session gating', () => {
it('rejects every proxy route without a valid session cookie (401)', async () => {
const app = await buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null })
for (const r of [
{ method: 'GET' as const, url: '/api/hosts' },
{ method: 'POST' as const, url: '/api/pairing-codes' },
{ method: 'DELETE' as const, url: '/api/hosts/abc' },
]) {
const res = await app.inject(r)
expect(res.statusCode).toBe(401)
}
await app.close()
})
})
describe('GET /api/hosts', () => {
it('mints a token and returns the CP host list for the operator account', async () => {
const cpClient = fakeCpClient({
hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }],
})
const minter = fakeMinter('MINTED')
const app = await buildApp({ config: makeConfig({ operatorAccountId: 'acct-XYZ' }), cpClient, minter, staticRoot: null })
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }] })
expect(minter.calls()).toBe(1)
expect(cpClient.calls[0]).toMatchObject({ method: 'listHosts', accountIdOrHostId: 'acct-XYZ', token: 'MINTED' })
await app.close()
})
it('maps an upstream CP failure to 502', async () => {
const cpClient = fakeCpClient({ throwErr: new CpClientError(403, 'denied') })
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
expect(res.statusCode).toBe(502)
expect(res.json()).toEqual({ error: 'upstream_error' })
await app.close()
})
})
describe('POST /api/pairing-codes', () => {
it('returns the code, pair command, and a QR data URL', async () => {
const cpClient = fakeCpClient({ pairing: { code: 'ABCD-EFGH', expiresAt: '2026-03-01T00:00:00.000Z' } })
const app = await buildApp({ config: makeConfig({ tunnelZone: 'z.test' }), cpClient, minter: fakeMinter(), staticRoot: null })
const res = await app.inject({ method: 'POST', url: '/api/pairing-codes', headers: AUTH })
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.code).toBe('ABCD-EFGH')
expect(body.expiresAt).toBe('2026-03-01T00:00:00.000Z')
expect(body.pairCommand).toBe('web-terminal-agent pair ABCD-EFGH --install --zone z.test')
expect(String(body.qrDataUrl).startsWith('data:image/png;base64,')).toBe(true)
await app.close()
})
})
describe('DELETE /api/hosts/:hostId', () => {
it('revokes the host and returns 204', async () => {
const cpClient = fakeCpClient()
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter('T'), staticRoot: null })
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/host-42', headers: AUTH })
expect(res.statusCode).toBe(204)
expect(cpClient.calls[0]).toMatchObject({ method: 'deleteHost', accountIdOrHostId: 'host-42', token: 'T' })
await app.close()
})
it('rejects an invalid host id with 400', async () => {
const cpClient = fakeCpClient()
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/' + encodeURIComponent('bad id!'), headers: AUTH })
expect(res.statusCode).toBe(400)
expect(cpClient.calls).toHaveLength(0)
await app.close()
})
})
describe('HostIdSchema dot-segment rejection', () => {
// The HTTP router already normalizes literal `.`/`..` path segments, but the outbound CP admin URL is
// built as `${cpUrl}/hosts/${hostId}` — so the schema itself must refuse dot-segments (defense-in-depth).
it('rejects a bare "." and ".."', () => {
expect(HostIdSchema.safeParse('.').success).toBe(false)
expect(HostIdSchema.safeParse('..').success).toBe(false)
})
it('accepts a UUID host id, a hyphenated id, and "..." (not a dot-segment)', () => {
expect(HostIdSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').success).toBe(true)
expect(HostIdSchema.safeParse('host-42').success).toBe(true)
expect(HostIdSchema.safeParse('...').success).toBe(true)
})
})

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
import { createSlidingWindowLimiter, type RateLimiter } from '../src/security/rate-limit.js'
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader, TEST_SESSION_SECRET } from './helpers.js'
async function makeApp(overrides: Parameters<typeof buildApp>[0] extends infer T ? Partial<T> : never = {}) {
return buildApp({
config: makeConfig(),
cpClient: fakeCpClient(),
minter: fakeMinter(),
staticRoot: null,
...overrides,
})
}
describe('POST /login', () => {
it('returns 503 when PANEL_PASSWORD is unset (fail-closed)', async () => {
const app = await makeApp({ config: makeConfig({ panelPassword: undefined }) })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'anything' } })
expect(res.statusCode).toBe(503)
await app.close()
})
it('returns 401 on the wrong password (no cookie set)', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'wrong' } })
expect(res.statusCode).toBe(401)
expect(res.headers['set-cookie']).toBeUndefined()
await app.close()
})
it('returns 200 and sets an HttpOnly SameSite=Strict session cookie on success', async () => {
const app = await makeApp({ config: makeConfig({ panelPassword: 'secret-pw' }) })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'secret-pw' } })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ authenticated: true })
const cookie = String(res.headers['set-cookie'])
expect(cookie).toContain(`${SESSION_COOKIE_NAME}=`)
expect(cookie).toContain('HttpOnly')
expect(cookie).toContain('SameSite=Strict')
await app.close()
})
it('returns 429 when rate-limited (before credential work)', async () => {
const denyAll: RateLimiter = { allow: () => false }
const app = await makeApp({ rateLimiter: denyAll })
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'whatever' } })
expect(res.statusCode).toBe(429)
await app.close()
})
it('returns 400 on a malformed body', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/login', payload: { notpassword: 1 } })
expect(res.statusCode).toBe(400)
await app.close()
})
// trustProxy: true (app.ts) makes req.ip read X-Forwarded-For, so the limiter buckets by REAL client
// IP. Without it every request would share the single 127.0.0.1 bucket (a global lockout DoS).
it('rate-limits per forwarded client IP: same X-Forwarded-For shares a budget, a different one is independent', async () => {
const limiter = createSlidingWindowLimiter(1, 60_000, () => 0) // one attempt per window per key
const app = await makeApp({ rateLimiter: limiter })
const login = (xff: string) =>
app.inject({ method: 'POST', url: '/login', headers: { 'x-forwarded-for': xff }, payload: { password: 'wrong' } })
// IP A, 1st attempt: budget available → reaches credential check → 401 (not throttled).
expect((await login('203.0.113.10')).statusCode).toBe(401)
// IP A, 2nd attempt: same bucket exhausted → 429.
expect((await login('203.0.113.10')).statusCode).toBe(429)
// IP B: its own bucket → 401, proving req.ip reflects the forwarded address (else it would be 429).
expect((await login('198.51.100.20')).statusCode).toBe(401)
await app.close()
})
})
describe('POST /logout', () => {
it('clears the session cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'POST', url: '/logout' })
expect(res.statusCode).toBe(200)
expect(String(res.headers['set-cookie'])).toContain('Max-Age=0')
await app.close()
})
})
describe('GET /api/session', () => {
it('reports authenticated:false without a cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session' })
expect(res.json()).toEqual({ authenticated: false })
await app.close()
})
it('reports authenticated:true with a valid session cookie', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session', headers: { cookie: authCookieHeader(TEST_SESSION_SECRET) } })
expect(res.json()).toEqual({ authenticated: true })
await app.close()
})
})

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { constantTimeEqual, constantTimeEqualBytes } from '../src/security/compare.js'
describe('constantTimeEqual', () => {
it('returns true for identical strings', () => {
expect(constantTimeEqual('correct-horse', 'correct-horse')).toBe(true)
})
it('returns false for different strings', () => {
expect(constantTimeEqual('correct-horse', 'battery-staple')).toBe(false)
})
it('returns false for different-length strings (no length oracle)', () => {
expect(constantTimeEqual('abc', 'abcdef')).toBe(false)
})
it('returns false when either side is empty or undefined', () => {
expect(constantTimeEqual('', 'x')).toBe(false)
expect(constantTimeEqual('x', '')).toBe(false)
expect(constantTimeEqual(undefined, 'x')).toBe(false)
expect(constantTimeEqual('x', undefined)).toBe(false)
})
})
describe('constantTimeEqualBytes', () => {
it('true for equal buffers, false for differing or mismatched length', () => {
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aa'))).toBe(true)
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('ab'))).toBe(false)
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aaa'))).toBe(false)
})
})

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import {
loadConfig,
DEFAULT_CP_URL,
DEFAULT_TUNNEL_ZONE,
DEFAULT_PANEL_BIND_PORT,
DEFAULT_CAPABILITY_SIGN_KEY_PATH,
} from '../src/config.js'
const base = {
SESSION_SECRET: 'a-sufficiently-long-secret-value',
BASE_DOMAIN: 'terminal.yaojia.wang',
OPERATOR_ACCOUNT_ID: 'acct-1',
}
describe('loadConfig', () => {
it('applies defaults for optional fields', () => {
const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv)
expect(cfg.cpUrl).toBe(DEFAULT_CP_URL)
expect(cfg.tunnelZone).toBe(DEFAULT_TUNNEL_ZONE)
expect(cfg.panelBindPort).toBe(DEFAULT_PANEL_BIND_PORT)
expect(cfg.capabilitySignKeyPath).toBe(DEFAULT_CAPABILITY_SIGN_KEY_PATH)
expect(cfg.panelPassword).toBeUndefined()
})
it('reads all provided values and strips trailing slash from CP_URL', () => {
const cfg = loadConfig({
...base,
PANEL_PASSWORD: 'pw',
CP_URL: 'http://127.0.0.1:9000/',
TUNNEL_ZONE: 'z.example',
PANEL_BIND_PORT: '9999',
} as NodeJS.ProcessEnv)
expect(cfg.panelPassword).toBe('pw')
expect(cfg.cpUrl).toBe('http://127.0.0.1:9000')
expect(cfg.tunnelZone).toBe('z.example')
expect(cfg.panelBindPort).toBe(9999)
})
it('throws when SESSION_SECRET is missing (fail-closed)', () => {
const { SESSION_SECRET: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when SESSION_SECRET is too short', () => {
expect(() => loadConfig({ ...base, SESSION_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow()
})
it('throws when BASE_DOMAIN is missing', () => {
const { BASE_DOMAIN: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when OPERATOR_ACCOUNT_ID is missing', () => {
const { OPERATOR_ACCOUNT_ID: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when PANEL_BIND_PORT is out of range', () => {
expect(() => loadConfig({ ...base, PANEL_BIND_PORT: '70000' } as NodeJS.ProcessEnv)).toThrow()
})
it('accepts loopback CP_URL hosts (127.0.0.0/8, ::1, localhost)', () => {
for (const url of ['http://127.0.0.1:8080', 'http://127.9.9.9:1', 'http://[::1]:8080', 'http://localhost:8080']) {
expect(loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv).cpUrl).toBe(url)
}
})
it('throws when CP_URL host is not loopback (anti-SSRF, fail-closed)', () => {
for (const url of ['http://evil.example.com:8080', 'http://169.254.169.254/', 'http://10.0.0.5:8080', 'http://8.8.8.8']) {
expect(() => loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv)).toThrow()
}
})
})

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { createCpClient, CpClientError } from '../src/cp-client.js'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}
describe('createCpClient.listHosts', () => {
it('maps the CP host records to a curated view and strips agentPubkey/enrollFpr', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return jsonResponse([
{
hostId: 'h1',
accountId: 'acct-1',
subdomain: 'alpha',
agentPubkey: 'BASE64PUBKEY',
enrollFpr: 'fpr',
status: 'online',
lastSeen: '2026-01-02T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
},
])
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const hosts = await client.listHosts('acct-1', 'TOKEN123')
expect(captured.url).toBe('http://127.0.0.1:8080/accounts/acct-1/hosts')
expect((captured.init?.headers as Record<string, string>).authorization).toBe('Bearer TOKEN123')
expect(hosts).toHaveLength(1)
expect(hosts[0]).toMatchObject({ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: '2026-01-02T00:00:00.000Z' })
expect(hosts[0]).not.toHaveProperty('agentPubkey')
expect(hosts[0]).not.toHaveProperty('enrollFpr')
})
it('throws CpClientError carrying the upstream status on non-2xx', async () => {
const fetchFn = (async () => new Response('nope', { status: 403 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ name: 'CpClientError', status: 403 })
})
it('maps a network failure to a 502 CpClientError', async () => {
const fetchFn = (async () => {
throw new Error('ECONNREFUSED')
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ status: 502 })
})
})
describe('createCpClient.createPairingCode', () => {
it('POSTs and maps { code, expiresAt }', async () => {
const captured: { init?: RequestInit | undefined } = {}
const fetchFn = (async (_url: string | URL | Request, init?: RequestInit) => {
captured.init = init
return jsonResponse({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' }, 201)
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const issued = await client.createPairingCode('acct-1', 'TOK')
expect(captured.init?.method).toBe('POST')
expect(issued).toEqual({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' })
})
it('rejects a malformed CP pairing response', async () => {
const fetchFn = (async () => jsonResponse({ nope: true }, 201)) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.createPairingCode('acct-1', 't')).rejects.toBeInstanceOf(Error)
})
})
describe('createCpClient.deleteHost', () => {
it('DELETEs and resolves on 204', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return new Response(null, { status: 204 })
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await client.deleteHost('host-42', 'TOK')
expect(captured.url).toBe('http://127.0.0.1:8080/hosts/host-42')
expect(captured.init?.method).toBe('DELETE')
})
it('throws CpClientError on a non-2xx delete', async () => {
const fetchFn = (async () => new Response('no', { status: 404 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.deleteHost('h', 't')).rejects.toMatchObject({ status: 404 })
})
})

View File

@@ -0,0 +1,91 @@
/**
* Shared test helpers: a valid PanelConfig factory, an authed session cookie, an Ed25519 PKCS#8 PEM
* generator (for the manage-token test), and simple fakes for the CP client + token minter.
*/
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
import { createSessionToken } from '../src/security/session.js'
import type { PanelConfig } from '../src/config.js'
import type { CpClient, HostView, IssuedPairing } from '../src/cp-client.js'
import type { ManageTokenMinter } from '../src/manage-token.js'
export const TEST_SESSION_SECRET = 'test-session-secret-0123456789'
export function makeConfig(overrides: Partial<PanelConfig> = {}): PanelConfig {
return {
panelPassword: 'hunter2-correct-horse',
sessionSecret: TEST_SESSION_SECRET,
cpUrl: 'http://127.0.0.1:8080',
baseDomain: 'terminal.yaojia.wang',
operatorAccountId: 'acct-operator-001',
capabilitySignKeyPath: '/nonexistent/key.pem',
tunnelZone: 'terminal.yaojia.wang',
panelBindPort: 8090,
...overrides,
}
}
/** A valid session cookie header value for injection (`cookie` header). */
export function authCookieHeader(secret: string = TEST_SESSION_SECRET, nowMs: number = Date.now()): string {
return `${SESSION_COOKIE_NAME}=${createSessionToken(secret, nowMs)}`
}
/** A minter that returns a fixed opaque token and records how many times it was called. */
export function fakeMinter(token = 'fake.manage.token'): ManageTokenMinter & { readonly calls: () => number } {
let n = 0
return {
async mint() {
n += 1
return token
},
calls: () => n,
}
}
export interface FakeCpCall {
readonly method: string
readonly accountIdOrHostId: string
readonly token: string
}
/** A CP client fake that records calls and returns canned data (or throws an injected error). */
export function fakeCpClient(opts: {
hosts?: readonly HostView[]
pairing?: IssuedPairing
throwErr?: Error
} = {}): CpClient & { readonly calls: readonly FakeCpCall[] } {
const calls: FakeCpCall[] = []
const guard = (): void => {
if (opts.throwErr) throw opts.throwErr
}
return {
calls,
async listHosts(accountId, token) {
calls.push({ method: 'listHosts', accountIdOrHostId: accountId, token })
guard()
return opts.hosts ?? []
},
async createPairingCode(accountId, token) {
calls.push({ method: 'createPairingCode', accountIdOrHostId: accountId, token })
guard()
return opts.pairing ?? { code: 'ABCD-EFGH', expiresAt: '2026-01-01T00:00:00.000Z' }
},
async deleteHost(hostId, token) {
calls.push({ method: 'deleteHost', accountIdOrHostId: hostId, token })
guard()
},
}
}
/** Generate an Ed25519 PKCS#8 PEM (private key) for signing-key tests. */
export async function generatePkcs8Pem(): Promise<{ pem: string; publicRaw: Uint8Array }> {
const pair = (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as {
publicKey: CryptoKey
privateKey: CryptoKey
}
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey))
const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
const b64 = Buffer.from(pkcs8).toString('base64')
const lines = b64.match(/.{1,64}/g) ?? [b64]
const pem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`
return { pem, publicRaw: raw }
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { writeFile, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { peekPasetoClaims } from 'relay-auth/src/crypto/paseto.js'
import { createManageTokenMinter, ManageTokenError } from '../src/manage-token.js'
import { generatePkcs8Pem } from './helpers.js'
async function writeKey(): Promise<string> {
const { pem } = await generatePkcs8Pem()
const dir = await mkdtemp(join(tmpdir(), 'cp-key-'))
const path = join(dir, 'capability-sign.key.pem')
await writeFile(path, pem, 'utf8')
return path
}
describe('createManageTokenMinter', () => {
it('mints a manage token with the correct aud, sub, rights, and TTL', async () => {
const keyPath = await writeKey()
const minter = createManageTokenMinter({
capabilitySignKeyPath: keyPath,
baseDomain: 'terminal.yaojia.wang',
operatorAccountId: 'acct-op-1',
})
const token = await minter.mint()
expect(token.startsWith('v4.public.')).toBe(true)
const claims = peekPasetoClaims(token) as Record<string, unknown>
expect(claims.aud).toBe('terminal.yaojia.wang')
expect(claims.sub).toBe('acct-op-1')
expect(claims.rights).toEqual(['manage'])
expect(typeof claims.host).toBe('string')
expect((claims.exp as number) - (claims.iat as number)).toBe(60)
// The additive DPoP proof-of-possession binding must be present.
expect((claims.cnf as { jkt?: string })?.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/)
})
it('mints a fresh token each call (distinct jti)', async () => {
const keyPath = await writeKey()
const minter = createManageTokenMinter({
capabilitySignKeyPath: keyPath,
baseDomain: 'd',
operatorAccountId: 'a',
})
const a = peekPasetoClaims(await minter.mint()) as Record<string, unknown>
const b = peekPasetoClaims(await minter.mint()) as Record<string, unknown>
expect(a.jti).not.toBe(b.jti)
})
it('throws ManageTokenError when the key file is missing', async () => {
const minter = createManageTokenMinter({
capabilitySignKeyPath: '/nonexistent/does-not-exist.pem',
baseDomain: 'd',
operatorAccountId: 'a',
})
await expect(minter.mint()).rejects.toBeInstanceOf(ManageTokenError)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { buildPairCommand, buildQrDataUrl, buildPairingArtifacts } from '../src/pairing.js'
describe('pairing artifacts', () => {
it('builds the ready-to-run pair command with the code and zone', () => {
const cmd = buildPairCommand('ABCD-EFGH', 'terminal.yaojia.wang')
expect(cmd).toBe('web-terminal-agent pair ABCD-EFGH --install --zone terminal.yaojia.wang')
})
it('renders a PNG data URL for the code', async () => {
const url = await buildQrDataUrl('ABCD-EFGH')
expect(url.startsWith('data:image/png;base64,')).toBe(true)
expect(url.length).toBeGreaterThan(100)
})
it('combines an issued code into the full artifacts payload', async () => {
const artifacts = await buildPairingArtifacts(
{ code: 'WXYZ-1234', expiresAt: '2026-05-01T00:00:00.000Z' },
'z.example',
)
expect(artifacts.code).toBe('WXYZ-1234')
expect(artifacts.expiresAt).toBe('2026-05-01T00:00:00.000Z')
expect(artifacts.pairCommand).toContain('WXYZ-1234')
expect(artifacts.pairCommand).toContain('z.example')
expect(artifacts.qrDataUrl.startsWith('data:image/png;base64,')).toBe(true)
})
})

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { createSlidingWindowLimiter } from '../src/security/rate-limit.js'
describe('sliding-window rate limiter', () => {
it('allows up to max, then blocks within the window', () => {
let t = 0
const limiter = createSlidingWindowLimiter(3, 1000, () => t)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(false)
})
it('does not record a rejected attempt (window frees up after it elapses)', () => {
let t = 0
const limiter = createSlidingWindowLimiter(2, 1000, () => t)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(false) // blocked, NOT recorded
t = 1001 // original two hits now outside the window
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
})
it('tracks buckets independently per key', () => {
let t = 0
const limiter = createSlidingWindowLimiter(1, 1000, () => t)
expect(limiter.allow('a')).toBe(true)
expect(limiter.allow('a')).toBe(false)
expect(limiter.allow('b')).toBe(true)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { makeConfig, fakeCpClient, fakeMinter } from './helpers.js'
async function makeApp() {
return buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null })
}
const HARDENING: Readonly<Record<string, string>> = {
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'referrer-policy': 'no-referrer',
}
describe('security response headers', () => {
it('stamps CSP + hardening headers on a matched route', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session' })
expect(res.statusCode).toBe(200)
for (const [name, value] of Object.entries(HARDENING)) expect(res.headers[name]).toBe(value)
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("img-src 'self' data:") // pairing QR is a data: image
expect(csp).toContain("style-src 'self' 'unsafe-inline'")
expect(csp).toContain("object-src 'none'")
expect(csp).toContain("base-uri 'none'")
expect(csp).toContain("frame-ancestors 'none'")
await app.close()
})
it('stamps the headers even on an unmatched (404) response', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/definitely-not-a-route' })
expect(res.statusCode).toBe(404)
expect(res.headers['x-frame-options']).toBe('DENY')
expect(String(res.headers['content-security-policy'])).toContain("default-src 'self'")
await app.close()
})
})

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { createSessionToken, verifySessionToken, SESSION_TTL_SEC } from '../src/security/session.js'
const SECRET = 'session-secret-value-1234567890'
describe('session token', () => {
it('round-trips: a freshly minted token verifies', () => {
const now = 1_000_000_000_000
const token = createSessionToken(SECRET, now)
expect(verifySessionToken(SECRET, token, now)).toBe(true)
})
it('rejects a token signed with a different secret', () => {
const now = Date.now()
const token = createSessionToken(SECRET, now)
expect(verifySessionToken('another-secret-value-000000000', token, now)).toBe(false)
})
it('rejects a tampered MAC', () => {
const now = Date.now()
const token = createSessionToken(SECRET, now)
const [payload] = token.split('.')
expect(verifySessionToken(SECRET, `${payload}.deadbeef`, now)).toBe(false)
})
it('rejects a tampered (extended) expiry', () => {
const now = Date.now()
const token = createSessionToken(SECRET, now)
const mac = token.split('.')[1]
const farFuture = Math.floor(now / 1000) + 999999
expect(verifySessionToken(SECRET, `${farFuture}.${mac}`, now)).toBe(false)
})
it('rejects an expired token', () => {
const now = 1_000_000_000_000
const token = createSessionToken(SECRET, now)
const afterExpiry = now + (SESSION_TTL_SEC + 1) * 1000
expect(verifySessionToken(SECRET, token, afterExpiry)).toBe(false)
})
it('rejects malformed tokens', () => {
const now = Date.now()
expect(verifySessionToken(SECRET, undefined, now)).toBe(false)
expect(verifySessionToken(SECRET, '', now)).toBe(false)
expect(verifySessionToken(SECRET, 'no-dot', now)).toBe(false)
expect(verifySessionToken(SECRET, 'notanumber.abcd', now)).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "public"]
}

View File

@@ -0,0 +1,19 @@
{
"// note": "Frontend (browser) config for public/*.ts — TYPE-CHECK ONLY (noEmit). The browser bundle is produced by esbuild (build.mjs), which emits public/build/app.js loaded by index.html.",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["public/**/*.ts"],
"exclude": ["public/build"]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
// server.ts is the process entrypoint (binds a socket) — an integration seam, not a unit.
exclude: ['**/*.d.ts', 'src/server.ts'],
},
},
})