/* The hub: a small server that impersonates ONE Technitium DNS Server for the console and answers from several real ones. node --experimental-strip-types hub/server.ts It serves the console's static build and a read-only, Technitium-compatible `/api/*`. A call aimed at one server (`node=`) is forwarded to it with that server's token; a call with no node (or `node=cluster`) asks every server in parallel and merges the answers (see merge.ts). Anything that would change data is refused. */ import http from 'node:http' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts' import { Upstream, UpstreamError, type Params } from './upstream.ts' import { Sessions } from './session.ts' import { Users, can } from './users.ts' import { LoginLimiter, lockedMessage } from './ratelimit.ts' import { Audit } from './audit.ts' import { routeFor } from './routes.ts' import { buildMatrix } from './apps-matrix.ts' import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts' import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts' import { mergeQueryLogs, mergeSettled, mergeStats, mergeTopResponses, type DashboardStats, type QueryLogPage, type Settled, type TopEntry, } from './merge.ts' type Json = Record const MAX_BODY = 64 * 1024 const TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8', '.webmanifest': 'application/manifest+json', } const HTML_HEADERS = { 'content-security-policy': "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'", 'x-content-type-options': 'nosniff', } function send(res: http.ServerResponse, code: number, body: unknown): void { const text = JSON.stringify(body) res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text), }) res.end(text) } const fail = (errorMessage: string): Json => ({ status: 'error', errorMessage }) async function readForm(req: http.IncomingMessage): Promise { if (req.method !== 'POST') return new URLSearchParams() const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { size += (chunk as Buffer).length if (size > MAX_BODY) break chunks.push(chunk as Buffer) } const type = String(req.headers['content-type'] ?? '') if (size > MAX_BODY) return new URLSearchParams() const text = Buffer.concat(chunks).toString('utf8') if (type.includes('application/x-www-form-urlencoded')) return new URLSearchParams(text) if (type.includes('application/json')) { // A flat object; an array becomes a comma-separated list, as the form fields are. const out = new URLSearchParams() try { const data = JSON.parse(text) as Record for (const [k, v] of Object.entries(data)) { if (Array.isArray(v)) out.set(k, v.map(String).join(',')) else if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out.set(k, String(v)) } } catch { // Not JSON: no parameters. } return out } return new URLSearchParams() } const isMultipart = (req: http.IncomingMessage): boolean => String(req.headers['content-type'] ?? '').toLowerCase().startsWith('multipart/form-data') const csv = (value: string | undefined): string[] => (value ?? '').split(',').map((v) => v.trim()).filter((v) => v !== '') function bearer(req: http.IncomingMessage): string | null { const header = String(req.headers.authorization ?? '') return header.startsWith('Bearer ') ? header.slice(7).trim() : null } function positive(value: string | undefined, fallback: number): number { const n = Number(value) return Number.isInteger(n) && n > 0 ? n : fallback } /* The client's address: the socket's, or with HUB_TRUST_PROXY the first X-Forwarded-For entry. */ function clientIp(req: http.IncomingMessage, trustProxy: boolean): string { if (trustProxy) { const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim() if (forwarded !== '') return forwarded } return req.socket.remoteAddress ?? 'unknown' } export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server { const upstream = new Upstream(config) const sessions = new Sessions(config, users) const limiter = new LoginLimiter() const audit = new Audit(config.dataDir) const writer = new AppsWriter(config, upstream) const byId = new Map(config.servers.map((s) => [s.id, s])) /* Ask every server; the ones that fail become warnings (see mergeSettled). */ async function everywhere(apiPath: string, params: Params): Promise[]> { const results = await Promise.allSettled(config.servers.map((s) => upstream.call(s, apiPath, params))) return results.map((r, i): Settled => { const node = config.servers[i].id return r.status === 'fulfilled' ? { node, ok: true, data: r.value as T } : { node, ok: false, error: r.reason instanceof Error ? r.reason.message : 'failed' } }) } /* `warnings` is given only by the aggregate answers (empty when every server answered), so the console can tell "all back" from "not an aggregate" and clear its warning only on the former. */ function ok(response: unknown, warnings?: string[]): Json { return warnings === undefined ? { status: 'ok', response } : { status: 'ok', response, hubWarnings: warnings } } async function single(server: HubServer, apiPath: string, params: Params): Promise { try { return ok(await upstream.call(server, apiPath, params)) } catch (e) { if (e instanceof UpstreamError) { return fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`) } throw e } } async function aggregate(apiPath: string, params: Params): Promise { try { switch (apiPath) { case 'dashboard/stats/get': { const { data, warnings } = mergeSettled(await everywhere(apiPath, params)) return ok(mergeStats(data), warnings) } case 'dashboard/stats/getTop': { const { data, warnings } = mergeSettled(await everywhere>(apiPath, params)) const limit = params.limit === undefined ? undefined : positive(params.limit, 1000) return ok(mergeTopResponses(data, limit), warnings) } case 'logs/query': { const pageNumber = positive(params.pageNumber, 1) const entriesPerPage = positive(params.entriesPerPage, 25) // Page N of the merged list is cut from the head of every server's list. const asked = { ...params, pageNumber: '1', entriesPerPage: String(pageNumber * entriesPerPage) } const settled = await everywhere(apiPath, asked) const { data, warnings } = mergeSettled(settled) // Only the servers that answered, in server order, so the i-th page is the i-th survivor's. const answered = settled.filter((r) => r.ok).map((r) => r.node) const pages = data.map((page, i) => ({ node: answered[i], page })) return ok(mergeQueryLogs(pages, { pageNumber, entriesPerPage, descending: params.descendingOrder !== 'false' }), warnings) } case 'apps/listStoreApps': { // The first answering server's listing, each app tagged with the servers it is installed on. const { data, warnings } = mergeSettled(await everywhere<{ storeApps?: { name: string }[] }>(apiPath, params)) const installedOn = new Map() for (const r of await everywhere<{ apps?: { name: string }[] }>('apps/list', {})) { if (r.ok) for (const a of r.data.apps ?? []) installedOn.set(a.name, [...(installedOn.get(a.name) ?? []), r.node]) } const storeApps = (data[0].storeApps ?? []).map((a) => ({ ...a, installedOn: installedOn.get(a.name) ?? [] })) return ok({ ...data[0], storeApps }, warnings) } case 'apps/list': { const { data, warnings } = mergeSettled(await everywhere<{ apps?: { name: string }[] }>(apiPath, params)) const seen = new Map() for (const r of data) for (const app of r.apps ?? []) if (!seen.has(app.name)) seen.set(app.name, app) return ok({ ...data[0], apps: [...seen.values()] }, warnings) } default: // Everything else read-only is answered by the first server. return await single(config.servers[0], apiPath, params) } } catch (e) { if (e instanceof Error && !(e instanceof UpstreamError)) return fail(e.message) throw e } } type Who = { user: string; role: string } /* One audit line per target: who, which server, which app, what happened. */ function auditResults(who: Who, ip: string, action: string, name: string, results: TargetResult[]): void { for (const r of results) { audit.log({ user: who.user, role: who.role, ip, action: `apps.${action}`, server: r.server, target: name, outcome: r.ok ? 'ok' : 'failed', detail: r.message, }) } } async function uploadOf(req: http.IncomingMessage, res: http.ServerResponse, params: Params): Promise { let parsed try { parsed = parseMultipart(await readLimited(req), String(req.headers['content-type'] ?? '')) } catch (e) { if (e instanceof TooLarge) { res.setHeader('connection', 'close') send(res, 413, fail(e.message)) } else send(res, 400, fail('The upload could not be read.')) return null } Object.assign(params, parsed.fields) const file = parsed.files[0] if (file === undefined || file.data.length === 0) { send(res, 400, fail('The zip file is missing.')) return null } return file } /* The write endpoints. The role has already been checked against the route's capability. */ async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise { if (kind === 'write-all') { if (req.method !== 'POST') { send(res, 405, fail('Use POST.')) return } if (apiPath === 'hub/apps/run') { const action = params.action if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') { send(res, 400, fail('action must be install-store, update-store or uninstall.')) return } const outcome = await writer.runMany(action, params.name ?? '', csv(params.targets), { suppliedUrl: params.url }) if ('error' in outcome) { audit.log({ user: who.user, role: who.role, ip, action: `apps.${action}`, target: params.name, outcome: 'refused', detail: outcome.error }) send(res, 400, fail(outcome.error)) return } auditResults(who, ip, action, params.name ?? '', outcome.results) send(res, 200, ok({ results: outcome.results })) return } if (apiPath === 'hub/apps/upload') { if (!isMultipart(req)) { send(res, 400, fail('Send multipart/form-data.')) return } const file = await uploadOf(req, res, params) if (file === null) return if (params.action !== 'install' && params.action !== 'update') { send(res, 400, fail('action must be install or update.')) return } const action: FileAction = params.action === 'install' ? 'install-file' : 'update-file' const outcome = await writer.runMany(action, params.name ?? '', csv(params.targets), { file }) if ('error' in outcome) { audit.log({ user: who.user, role: who.role, ip, action: `apps.${action}`, target: params.name, outcome: 'refused', detail: outcome.error }) send(res, 400, fail(outcome.error)) return } auditResults(who, ip, action, params.name ?? '', outcome.results) send(res, 200, ok({ results: outcome.results })) return } } // write-one: an upstream endpoint aimed at ONE server (`node=`). const action = actionForPath(apiPath) const node = params.node ?? '' const server = byId.get(node) if (action === null) { send(res, 403, fail('The hub is read-only.')) return } if (server === undefined) { send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose one server: this change cannot be made on every server at once here.' : `Unknown node: ${node}`)) return } const name = params.name ?? '' let file: UploadedFile | undefined if (action === 'install-file' || action === 'update-file') { if (!isMultipart(req)) { send(res, 400, fail('Send multipart/form-data.')) return } const uploaded = await uploadOf(req, res, params) if (uploaded === null) return file = uploaded } let result: TargetResult let response: unknown = {} try { let url: string | undefined if (action === 'install-store' || action === 'update-store') { const fromStore = server.adminToken === undefined ? null : await writer.storeUrl(server, name).catch(() => null) if (server.adminToken !== undefined && (fromStore === null || params.url !== fromStore)) { throw new Error("the url is not from this server's store listing") } url = fromStore ?? undefined } response = await writer.perform(server, action, name, url, file) result = { server: server.id, ok: true } } catch (e) { result = { server: server.id, ok: false, message: e instanceof Error ? e.message : 'failed' } } auditResults(who, ip, action, name, [result]) send(res, 200, result.ok ? ok(response) : fail(`${server.id}: ${result.message}`)) } async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise { // A multipart body (an app zip) is read later, once the caller is known to be allowed to send it. const form = isMultipart(req) ? new URLSearchParams() : await readForm(req) const params: Params = {} for (const [k, v] of url.searchParams) params[k] = v for (const [k, v] of form) params[k] = v // Public and session endpoints. if (apiPath === 'status') { send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false }) return } const ip = clientIp(req, config.trustProxy) if (apiPath === 'user/login') { const name = params.user ?? '' const locked = limiter.lockedFor(ip, name) if (locked > 0) { audit.log({ user: name, ip, action: 'login', outcome: 'refused', detail: 'locked' }) send(res, 429, fail(lockedMessage(locked))) return } const user = await users.authenticate(name, params.pass ?? '') if (user === null) { limiter.failure(ip, name) audit.log({ user: name, ip, action: 'login', outcome: 'failed', detail: users.isDisabled(name) ? 'disabled' : undefined }) send(res, 200, fail(`Invalid username or password for user: ${name}`)) return } limiter.success(name) audit.log({ user: user.name, role: user.role, ip, action: 'login', outcome: 'ok' }) const token = sessions.open(user) send(res, 200, sessions.body(token, { user: user.name, role: user.role })) return } const token = bearer(req) ?? params.token ?? null const who = sessions.touch(token) if (who === null) { send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' }) return } if (apiPath === 'user/session/get') { send(res, 200, sessions.body(token as string, who)) return } if (apiPath === 'user/logout') { sessions.logout(token as string) audit.log({ user: who.user, role: who.role, ip, action: 'logout', outcome: 'ok' }) send(res, 200, { status: 'ok' }) return } const route = routeFor(apiPath) if (route === undefined) { audit.log({ user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath, server: params.node || undefined, outcome: 'refused', detail: 'not in the permission table', }) send(res, 403, fail('The hub is read-only.')) return } if (!can(who.role, route.capability)) { audit.log({ user: who.user, role: who.role, ip, action: route.kind === 'read' ? 'denied-read' : 'denied-write', target: apiPath, outcome: 'refused', detail: `needs ${route.capability}`, }) send(res, 403, fail('Your role does not allow this.')) return } if (apiPath === 'hub/audit/list') { send(res, 200, ok({ entries: audit.list(Number(params.limit ?? 200), params.before) })) return } if (route.kind !== 'read') { await write(req, res, apiPath, route.kind, params, who, ip) return } if (apiPath === 'hub/apps/matrix') { try { const { matrix, warnings } = await buildMatrix(config, upstream) send(res, 200, ok(matrix, warnings)) } catch (e) { send(res, 200, fail(e instanceof Error ? e.message : 'failed')) } return } if (apiPath === 'user/checkForUpdate') { send(res, 200, ok({ updateAvailable: false })) return } if (apiPath === 'admin/cluster/state') { send( res, 200, ok({ version: HUB_VERSION, dnsServerDomain: config.domain, clusterInitialized: true, clusterDomain: config.domain, clusterNodes: config.servers.map((s, i) => ({ id: i + 1, name: s.id, url: s.url, ipAddresses: [], type: i === 0 ? 'Primary' : 'Secondary', state: 'Connected', })), }), ) return } // What the real servers get: the caller's params minus the hub's own. const { node = '', token: _token, ...forward } = params const server = byId.get(node) if (server !== undefined) { send(res, 200, await single(server, apiPath, forward)) return } if (node !== '' && node !== 'cluster') { send(res, 200, fail(`Unknown node: ${node}`)) return } send(res, 200, await aggregate(apiPath, forward)) } async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise { if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { allow: 'GET, HEAD' }).end() return } const root = path.resolve(config.staticDir) let pathname: string try { pathname = decodeURIComponent(url.pathname) } catch { res.writeHead(400).end() return } const target = path.resolve(root, '.' + pathname) if (pathname.includes('\0') || (target !== root && !target.startsWith(root + path.sep))) { res.writeHead(403).end() return } let file = target let info = await stat(target).catch(() => null) if (info?.isDirectory()) { const index = path.join(target, 'index.html') if ((await stat(index).catch(() => null))?.isFile() !== true) info = null else if (!pathname.endsWith('/')) { // As Technitium: `/x` -> `/x/` when the folder has an index. res.writeHead(301, { location: `${url.pathname}/${url.search}` }).end() return } else { file = index info = await stat(index) } } if (info === null || !info.isFile()) { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Not found') return } const ext = path.extname(file).toLowerCase() const headers: Record = { 'content-type': TYPES[ext] ?? 'application/octet-stream', 'content-length': info.size, } if (ext === '.html') Object.assign(headers, HTML_HEADERS, { 'cache-control': 'no-cache' }) res.writeHead(200, headers) if (req.method === 'HEAD') { res.end() return } createReadStream(file).on('error', () => res.destroy()).pipe(res) } const sweeper = setInterval(() => limiter.sweep(), 5 * 60_000) sweeper.unref() const server = http.createServer((req, res) => { void (async () => { try { const url = new URL(req.url ?? '/', 'http://hub') // The console may sit at a sub path: any `/api/` is the API. const match = /^(?:.*\/)?api\/(.+?)\/?$/.exec(url.pathname) if (match !== null) await api(req, res, match[1], url) else await serveStatic(req, res, url) } catch (e) { console.error('hub error:', e instanceof Error ? e.message : e) if (!res.headersSent) send(res, 500, fail('Internal hub error.')) else res.destroy() } })() }) server.on('close', () => clearInterval(sweeper)) return server } /* Run directly (not imported by the tests). */ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { let config: HubConfig try { config = loadConfig() } catch (e) { console.error(`hub: ${e instanceof Error ? e.message : String(e)}`) process.exit(1) } let hub: http.Server try { const users = Users.load(config.dataDir, config.bootstrap) if (users.notice !== null) console.log(`hub: ${users.notice}`) hub = createHub(config, users) } catch (e) { console.error(`hub: ${e instanceof Error ? e.message : String(e)}`) process.exit(1) } hub.listen(config.port, () => { console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`) }) }