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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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