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:
4
control-panel/.gitignore
vendored
Normal file
4
control-panel/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
public/build/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
37
control-panel/build.mjs
Normal file
37
control-panel/build.mjs
Normal 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
2902
control-panel/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
control-panel/package.json
Normal file
35
control-panel/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
control-panel/public/api.ts
Normal file
77
control-panel/public/api.ts
Normal 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
114
control-panel/public/app.ts
Normal 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()
|
||||||
60
control-panel/public/dom.ts
Normal file
60
control-panel/public/dom.ts
Normal 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)
|
||||||
|
}
|
||||||
17
control-panel/public/index.html
Normal file
17
control-panel/public/index.html
Normal 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>
|
||||||
134
control-panel/public/styles.css
Normal file
134
control-panel/public/styles.css
Normal 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; }
|
||||||
|
}
|
||||||
161
control-panel/public/views.ts
Normal file
161
control-panel/public/views.ts
Normal 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
85
control-panel/src/app.ts
Normal 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
|
||||||
|
}
|
||||||
97
control-panel/src/config.ts
Normal file
97
control-panel/src/config.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
127
control-panel/src/cp-client.ts
Normal file
127
control-panel/src/cp-client.ts
Normal 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})`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
106
control-panel/src/manage-token.ts
Normal file
106
control-panel/src/manage-token.ts
Normal 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),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
37
control-panel/src/pairing.ts
Normal file
37
control-panel/src/pairing.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
94
control-panel/src/routes/api-routes.ts
Normal file
94
control-panel/src/routes/api-routes.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
72
control-panel/src/routes/auth-routes.ts
Normal file
72
control-panel/src/routes/auth-routes.ts
Normal 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()) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
control-panel/src/security/compare.ts
Normal file
23
control-panel/src/security/compare.ts
Normal 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)
|
||||||
|
}
|
||||||
53
control-panel/src/security/cookies.ts
Normal file
53
control-panel/src/security/cookies.ts
Normal 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 })
|
||||||
|
}
|
||||||
32
control-panel/src/security/rate-limit.ts
Normal file
32
control-panel/src/security/rate-limit.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
26
control-panel/src/security/request.ts
Normal file
26
control-panel/src/security/request.ts
Normal 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)
|
||||||
|
}
|
||||||
54
control-panel/src/security/session.ts
Normal file
54
control-panel/src/security/session.ts
Normal 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
|
||||||
|
}
|
||||||
45
control-panel/src/server.ts
Normal file
45
control-panel/src/server.ts
Normal 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)
|
||||||
|
})
|
||||||
63
control-panel/src/static.ts
Normal file
63
control-panel/src/static.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
99
control-panel/test/api-routes.test.ts
Normal file
99
control-panel/test/api-routes.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
102
control-panel/test/auth-routes.test.ts
Normal file
102
control-panel/test/auth-routes.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
control-panel/test/compare.test.ts
Normal file
31
control-panel/test/compare.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
74
control-panel/test/config.test.ts
Normal file
74
control-panel/test/config.test.ts
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
94
control-panel/test/cp-client.test.ts
Normal file
94
control-panel/test/cp-client.test.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
91
control-panel/test/helpers.ts
Normal file
91
control-panel/test/helpers.ts
Normal 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 }
|
||||||
|
}
|
||||||
59
control-panel/test/manage-token.test.ts
Normal file
59
control-panel/test/manage-token.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
control-panel/test/pairing.test.ts
Normal file
27
control-panel/test/pairing.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
32
control-panel/test/rate-limit.test.ts
Normal file
32
control-panel/test/rate-limit.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
40
control-panel/test/security-headers.test.ts
Normal file
40
control-panel/test/security-headers.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
48
control-panel/test/session.test.ts
Normal file
48
control-panel/test/session.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
control-panel/tsconfig.json
Normal file
22
control-panel/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
19
control-panel/tsconfig.web.json
Normal file
19
control-panel/tsconfig.web.json
Normal 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"]
|
||||||
|
}
|
||||||
14
control-panel/vitest.config.ts
Normal file
14
control-panel/vitest.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user