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:
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' })
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user