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:
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