/* 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 { AppsConfig, UserError } from './apps-config.ts' import { Drift } from './drift.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 /* The config endpoints carry config texts (up to 1 MB). */ const MAX_CONFIG_BODY = 2 * 1024 * 1024 /* A file of all baselines. */ const MAX_IMPORT_BODY = 5 * 1024 * 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, limit: number = MAX_BODY): 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 > limit) break chunks.push(chunk as Buffer) } const type = String(req.headers['content-type'] ?? '') if (size > limit) 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.some((x) => x !== null && typeof x === 'object') ? JSON.stringify(v) : v.map(String).join(',')) else if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out.set(k, String(v)) else if (v !== null && typeof v === 'object') out.set(k, JSON.stringify(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 drift = new Drift(config, upstream) const sessions = new Sessions(config, users, Date.now, () => drift.count()) const limiter = new LoginLimiter() const audit = new Audit(config.dataDir) const writer = new AppsWriter(config, upstream) const appsConfig = new AppsConfig(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 } /* A value from a JSON-encoded parameter, or the fallback. */ function jsonParam(value: string | undefined, fallback: unknown): unknown { if (value === undefined || value === '') return fallback try { return JSON.parse(value) } catch { throw new UserError('A parameter is not valid JSON.') } } function userFailure(res: http.ServerResponse, e: unknown): void { if (e instanceof UserError) send(res, 400, fail(e.message)) else throw e } /* compare, preview and the live local values: no change is made. */ async function configRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise { const name = params.name ?? '' try { if (apiPath === 'hub/apps/export') { const file = appsConfig.exportAll() audit.log({ user: who.user, role: who.role, ip, action: 'apps.export', outcome: 'ok', detail: `${file.apps.length} app(s)` }) const text = JSON.stringify(file, null, 2) + '\n' res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-disposition': `attachment; filename="hub-baselines-${file.exportedAt.slice(0, 10)}.json"`, 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text), }) res.end(text) return } if (name === '') throw new UserError('An app name is required.') if (apiPath === 'hub/apps/config/compare') send(res, 200, ok(await appsConfig.compare(name))) else if (apiPath === 'hub/apps/config/preview') { const preview = await appsConfig.preview(name, csv(params.targets)) audit.log({ user: who.user, role: who.role, ip, action: 'apps.config-preview', target: name, outcome: 'ok', detail: csv(params.targets).join(',') }) send(res, 200, ok(preview)) } else if (apiPath === 'hub/apps/history/list') { send(res, 200, ok({ entries: appsConfig.historyList(name) })) } else if (apiPath === 'hub/apps/history/get') { send(res, 200, ok(appsConfig.historyGet(name, params.id ?? ''))) } else if (apiPath === 'hub/apps/history/diff') { send(res, 200, ok(await appsConfig.historyDiff(name, params.id ?? '', params.server ?? ''))) } else if (apiPath === 'hub/apps/overrides/current') { send(res, 200, ok({ values: await appsConfig.currentLocalValues(name, csv(params.paths)) })) } else send(res, 403, fail('The hub is read-only.')) } catch (e) { userFailure(res, e) } } /* baseline import and save, local fields save, apply. */ async function configWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise { const name = params.name ?? '' const base = { user: who.user, role: who.role, ip, target: name === '' ? undefined : name } const action = apiPath.replace('hub/apps/', 'apps.').replace(/\//g, '-') try { if (apiPath === 'hub/apps/import') { const done = appsConfig.importAll(params.data ?? '', params.apply === 'true', who.user) const count = (status: string) => done.apps.filter((a) => a.status === status).length audit.log({ ...base, action: 'apps.import', outcome: 'ok', detail: `${done.applied ? 'applied' : 'dry run'}: ${count('new')} new, ${count('changed')} changed, ${count('same')} same` }) send(res, 200, ok(done)) return } if (name === '' || name.length > 200) throw new UserError('An app name is required.') if (apiPath === 'hub/apps/baseline/import') { const done = await appsConfig.importBaseline(name, params.server ?? '', who.user) audit.log({ ...base, action, server: params.server, outcome: 'ok', detail: done.detail }) send(res, 200, ok({ baseline: appsConfig.getBaseline(name) })) } else if (apiPath === 'hub/apps/baseline/save') { const done = appsConfig.saveBaseline(name, params.text ?? '', who.user) audit.log({ ...base, action, outcome: 'ok', detail: done.detail }) send(res, 200, ok({ baseline: appsConfig.getBaseline(name) })) } else if (apiPath === 'hub/apps/baseline/restore') { const done = appsConfig.restoreBaseline(name, params.id ?? '', who.user) audit.log({ ...base, action, outcome: 'ok', detail: done.detail }) send(res, 200, ok({ baseline: appsConfig.getBaseline(name) })) } else if (apiPath === 'hub/apps/drift/accept') { const done = await appsConfig.acceptDrift(name, params.server ?? '', who.user) audit.log({ ...base, action: 'apps.drift-accept', server: params.server, outcome: 'ok', detail: done.detail }) await drift.check() send(res, 200, ok({ baseline: appsConfig.getBaseline(name), overrides: appsConfig.getOverrides(name) })) } else if (apiPath === 'hub/apps/history/rollback') { const expect = jsonParam(params.expect, {}) as Record const done = await appsConfig.rollback(name, params.id ?? '', csv(params.targets), expect) for (const r of done.results) { audit.log({ ...base, action: 'apps.history-rollback', server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server]].filter(Boolean).join(' | ') }) } send(res, 200, ok({ results: done.results })) } else if (apiPath === 'hub/apps/overrides/save') { const done = appsConfig.saveOverrides(name, csv(params.paths), jsonParam(params.values, {})) audit.log({ ...base, action, outcome: 'ok', detail: done.detail }) send(res, 200, ok({ overrides: appsConfig.getOverrides(name) })) } else if (apiPath === 'hub/apps/config/apply') { const expect = jsonParam(params.expect, {}) as Record const done = await appsConfig.apply(name, csv(params.targets), expect) for (const r of done.results) { audit.log({ ...base, action: 'apps.config-apply', server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server]].filter(Boolean).join(' | ') }) } send(res, 200, ok({ results: done.results })) } else send(res, 403, fail('The hub is read-only.')) } catch (e) { if (e instanceof UserError) { audit.log({ ...base, action, outcome: 'refused', detail: apiPath.endsWith('baseline/save') ? 'invalid baseline' : e.message.slice(0, 120) }) } userFailure(res, e) } } /* 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/drift/check') { const state = await drift.check() audit.log({ user: who.user, role: who.role, ip, action: 'drift.check', outcome: 'ok', detail: `${state.apps.length} finding(s), ${drift.count()} differ` }) send(res, 200, ok(drift.get())) return } if (apiPath.startsWith('hub/apps/baseline/') || apiPath.startsWith('hub/apps/overrides/') || apiPath.startsWith('hub/apps/history/') || apiPath.startsWith('hub/apps/drift/') || apiPath === 'hub/apps/import' || apiPath === 'hub/apps/config/apply') { await configWrite(res, apiPath, params, who, ip) 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, params.config) 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. // A config body may be large, but only for a signed-in caller (token in the header or the query). const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set') && sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, !large ? MAX_BODY : apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY) 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.startsWith('hub/apps/config/') || apiPath.startsWith('hub/apps/history/') || apiPath === 'hub/apps/overrides/current' || apiPath === 'hub/apps/export') { await configRead(res, apiPath, params, who, ip) return } if (apiPath === 'hub/drift/get') { send(res, 200, ok(drift.get())) return } if (apiPath === 'apps/config/get' && !byId.has(params.node ?? '')) { send(res, 400, fail('Choose one server: a config is read from a single server.')) 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() } })() }) // The drift check: once shortly after start, then every HUB_DRIFT_MINUTES. Off when that is 0. const timers: NodeJS.Timeout[] = [] if (config.driftMinutes > 0) { const run = () => void drift.check().catch((e: unknown) => console.error(`hub: drift check failed (${e instanceof Error ? e.message : String(e)}).`)) timers.push(setTimeout(run, 5000), setInterval(run, config.driftMinutes * 60_000)) for (const t of timers) t.unref() } server.on('close', () => { clearInterval(sweeper) for (const t of timers) clearTimeout(t) }) 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(', ')}`) }) }