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

View File

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

View File

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

View File

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

View File

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