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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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