Files
DenozordecandClaude Opus 5.5 172e06f1ce
CI / check (push) Successful in 3m7s
Hub / check (push) Successful in 53s
CI / image (push) Successful in 1m29s
Hub / image (push) Successful in 1m19s
Blocking: count Advanced Blocking, Dashboard layout and KPI cards
- Status, sidebar mark and header treat a server that blocks with the
  Advanced Blocking app as blocking, per server behind the hub.
- Overview panel summarising the app's groups; Lists and Rules show and
  edit the app's list URLs and rules (read, change one field, write back).
- Block List Domains adds the app's list domains, counted by the hub
  (hub/blocking/app-lists/count, public hosts only unless
  HUB_LISTS_ALLOW_PRIVATE=1) or by the browser where CORS allows.
- Header and KPI cards follow the Dashboard: selector in the chrome slot,
  Compare servers in the header actions, period under the title, StatCard
  tiles without sparklines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:34:17 +07:00

1118 lines
53 KiB
TypeScript

/*
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 Technitium-compatible `/api/*`. A read aimed at
one server (`node=<id>`) is forwarded to it with that server's token; a read with no node (or
`node=cluster`) asks every server in parallel and merges the answers (see merge.ts), and the
reads that have no merger (`perServer` in routes.ts) are refused without a node. A change
is made on ONE chosen server with its admin token, or by the app and config endpoints; whatever
is not in the table of routes.ts is refused.
*/
import http from 'node:http'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'
import type { ReadableStream as WebStream } from 'node:stream/web'
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 { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts'
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts'
import { buildMatrix } from './apps-matrix.ts'
import { ServersRunner, planAction } from './servers-run.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 { Webhook, type WebhookOptions } from './webhook.ts'
import { Profiles } from './profiles.ts'
import { SettingsConfig } from './settings-config.ts'
import { AppLists } from './app-lists.ts'
import { BLOCKING_FIELDS } from './settings-sync.ts'
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
import {
mergeQueryLogs,
mergeSettled,
mergeStats,
mergeTopResponses,
type DashboardStats,
type QueryLogPage,
type Settled,
type TopEntry,
} from './merge.ts'
type Json = Record<string, unknown>
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<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',
'.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<URLSearchParams> {
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<string, unknown>
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), options: { webhook?: WebhookOptions } = {}): http.Server {
const upstream = new Upstream(config)
const webhook = new Webhook(config, options.webhook)
const drift = new Drift(config, upstream, Date.now, { webhook })
const sessions = new Sessions(config, users, Date.now, () => drift.count())
const limiter = new LoginLimiter()
const audit = new Audit(config.dataDir)
// A failed write is told to the webhook: who, what, where and the audited reason (never a value). Logins and the test itself are left out.
audit.onEntry((e) => {
if (e.outcome !== 'failed' || e.action === 'login' || e.action.startsWith('webhook.')) return
webhook.queueFailed({
action: e.action,
...(e.server === undefined ? {} : { server: e.server }),
...(e.target === undefined ? {} : { target: e.target }),
user: e.user,
...(e.detail === undefined ? {} : { detail: e.detail.slice(0, 200) }),
at: e.ts,
})
})
const writer = new AppsWriter(config, upstream)
const runner = new ServersRunner(config, upstream)
const appsConfig = new AppsConfig(config, upstream)
const zonesSync = new ZonesSync(config, upstream)
const settingsConfig = new SettingsConfig(config, upstream)
const appLists = new AppLists(upstream, { allowPrivate: config.listsAllowPrivate })
const profiles = new Profiles(config, upstream, appsConfig, settingsConfig, writer)
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<T>(apiPath: string, params: Params): Promise<Settled<T>[]> {
const results = await Promise.allSettled(config.servers.map((s) => upstream.call(s, apiPath, params)))
return results.map((r, i): Settled<T> => {
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<Json> {
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<Json> {
try {
switch (apiPath) {
case 'dashboard/stats/get': {
const { data, warnings } = mergeSettled(await everywhere<DashboardStats>(apiPath, params))
return ok(mergeStats(data), warnings)
}
case 'dashboard/stats/getTop': {
const { data, warnings } = mergeSettled(await everywhere<Record<string, TopEntry[]>>(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<QueryLogPage>(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<string, string[]>()
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<string, { name: string }>()
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<UploadedFile | null> {
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<void> {
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<void> {
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<string, string>
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<string, string>
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 merged zone list and the previews of copying a zone and of making it a Secondary: no change is made. */
async function zonesRead(res: http.ServerResponse, apiPath: string, params: Params): Promise<void> {
try {
if (apiPath === 'hub/zones/matrix') {
const { matrix, warnings } = await buildZoneMatrix(config, upstream)
send(res, 200, ok(matrix, warnings))
} else if (apiPath === 'hub/zones/copy/preview') {
send(res, 200, ok(await zonesSync.copyPreview(params.source ?? '', params.zone ?? '', csv(params.targets))))
} else if (apiPath === 'hub/zones/secondary/preview') {
send(res, 200, ok(await zonesSync.secondaryPreview(secondaryInput(params))))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
if (e instanceof ZoneUserError) send(res, 400, fail(e.message))
else if (e instanceof Error && !(e instanceof UpstreamError)) send(res, 200, fail(e.message))
else throw e
}
}
const secondaryInput = (params: Params) => ({
source: params.source ?? '',
target: params.target ?? '',
zone: params.zone ?? '',
primaryAddress: params.primaryAddress ?? '',
targetAddress: params.targetAddress ?? '',
tsigKeyName: params.tsigKeyName,
})
/* Copy a zone and make a Secondary. One audit line per server: zone, source, target, outcome. */
async function zonesWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const copy = apiPath === 'hub/zones/copy'
const line = { user: who.user, role: who.role, ip, action: copy ? 'zones.copy' : 'zones.secondary', target: (params.zone ?? '').slice(0, 120) || undefined }
try {
const done = copy
? await zonesSync.copy(params.source ?? '', params.zone ?? '', csv(params.targets), csv(params.overwrite))
: await zonesSync.makeSecondary(secondaryInput(params))
for (const r of done.results) {
audit.log({ ...line, server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: `from ${params.source ?? ''}${copy ? '' : ` to ${params.target ?? ''}`}: ${r.message ?? ''}`.slice(0, 300) })
}
send(res, 200, ok({ results: done.results }))
} catch (e) {
if (!(e instanceof ZoneUserError)) throw e
audit.log({ ...line, outcome: 'refused', detail: e.message.slice(0, 200) })
send(res, 400, fail(e.message))
}
}
/* `group=blocking` limits a settings preview or apply to the blocking fields. */
const onlyOf = (params: Params): readonly string[] | undefined => (params.group === 'blocking' ? BLOCKING_FIELDS : undefined)
const userMessage = (e: unknown): string | null => (e instanceof UserError || e instanceof ZoneUserError ? e.message : null)
/* Settings and blocking lists: compare, preview, history. No change is made. */
async function settingsRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
try {
if (apiPath === 'hub/settings/compare') send(res, 200, ok(await settingsConfig.compare()))
else if (apiPath === 'hub/settings/preview') {
const preview = await settingsConfig.preview(csv(params.targets), onlyOf(params))
audit.log({ user: who.user, role: who.role, ip, action: 'settings.preview', outcome: 'ok', detail: csv(params.targets).join(',') })
send(res, 200, ok(preview))
} else if (apiPath === 'hub/settings/history/list') send(res, 200, ok({ entries: settingsConfig.historyList() }))
else if (apiPath === 'hub/settings/history/get') send(res, 200, ok(settingsConfig.historyGet(params.id ?? '')))
else if (apiPath === 'hub/blocking/lists/compare') send(res, 200, ok(await settingsConfig.listsCompare()))
else if (apiPath === 'hub/blocking/lists/preview') send(res, 200, ok(await settingsConfig.listsPreview(csv(params.targets), params.removeExtras === 'true')))
else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
send(res, 400, fail(message))
}
}
/* Baseline, local fields and apply for settings; baseline and apply for the lists. Audit lines carry field names, counts and outcomes, never a value. */
async function settingsWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const action = apiPath.replace('hub/', '').replace(/\//g, '.')
const base = { user: who.user, role: who.role, ip, action }
try {
if (apiPath === 'hub/settings/baseline/import') {
const done = await settingsConfig.importBaseline(params.server ?? '', params.group, who.user)
audit.log({ ...base, server: params.server, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ baseline: settingsConfig.baselineView() }))
} else if (apiPath === 'hub/settings/baseline/save') {
const done = settingsConfig.saveBaseline(params.text ?? '', who.user)
audit.log({ ...base, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ baseline: settingsConfig.baselineView() }))
} else if (apiPath === 'hub/settings/overrides/save') {
const done = settingsConfig.saveOverrides(csv(params.paths))
audit.log({ ...base, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ overrides: { paths: csv(params.paths) } }))
} else if (apiPath === 'hub/settings/apply') {
const expect = jsonParam(params.expect, {}) as Record<string, string>
const done = await settingsConfig.apply(csv(params.targets), expect, onlyOf(params))
for (const r of done.results) {
audit.log({ ...base, server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server] === undefined ? '' : `fields: ${done.details[r.server]}`].filter(Boolean).join(' | ').slice(0, 400) })
}
send(res, 200, ok({ results: done.results }))
} else if (apiPath === 'hub/blocking/lists/baseline/import') {
const done = await settingsConfig.listsImport(params.server ?? '', who.user)
audit.log({ ...base, server: params.server, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ imported: true }))
} else if (apiPath === 'hub/blocking/lists/apply') {
const done = await settingsConfig.listsApply(csv(params.targets), params.removeExtras === 'true')
for (const r of done.results) {
audit.log({ ...base, server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server]].filter(Boolean).join(' | ').slice(0, 400) })
}
send(res, 200, ok({ results: done.results }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
audit.log({ ...base, outcome: 'refused', detail: apiPath.endsWith('baseline/save') ? 'invalid baseline' : message.slice(0, 120) })
send(res, 400, fail(message))
}
}
/* Profiles (profiles.ts): list, view, export and the preview. No change is made. */
async function profilesRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.') }
try {
if (apiPath === 'hub/profiles/list') send(res, 200, ok({ profiles: profiles.list() }))
else if (apiPath === 'hub/profiles/get') send(res, 200, ok(profiles.get(params.name ?? '')))
else if (apiPath === 'hub/profiles/preview') {
const preview = await profiles.preview(params.name ?? '', csv(params.targets), params.removeExtras === 'true')
audit.log({ ...line, target: params.name, outcome: 'ok', detail: csv(params.targets).join(',') })
send(res, 200, ok(preview))
} else if (apiPath === 'hub/profiles/export') {
const file = profiles.exportFile(params.name)
audit.log({ ...line, target: params.name || undefined, outcome: 'ok', detail: `${file.profiles.length} profile(s)` })
const text = JSON.stringify(file, null, 2) + '\n'
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="hub-profiles-${file.exportedAt.slice(0, 10)}.json"`,
'cache-control': 'no-store',
'content-length': Buffer.byteLength(text),
})
res.end(text)
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
send(res, 400, fail(message))
}
}
/* Make, delete, import and apply a profile. Audit lines carry names, counts and the outcome of each step, never a value. */
async function profilesWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.'), target: (params.name ?? '').slice(0, 60) || undefined }
try {
if (apiPath === 'hub/profiles/snapshot') {
const done = await profiles.snapshot(params.name ?? '', { apps: params.apps === undefined ? undefined : csv(params.apps), settings: params.settings === 'true', lists: params.lists === 'true' }, who.user)
audit.log({ ...line, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ profile: profiles.get(params.name ?? ''), replaced: done.replaced }))
} else if (apiPath === 'hub/profiles/delete') {
profiles.delete(params.name ?? '')
audit.log({ ...line, outcome: 'ok' })
send(res, 200, ok({ deleted: true }))
} else if (apiPath === 'hub/profiles/import') {
const done = profiles.importFile(params.data ?? '', params.apply === 'true', who.user)
const count = (status: string) => done.profiles.filter((p) => p.status === status).length
audit.log({ ...line, target: undefined, outcome: 'ok', detail: `${done.applied ? 'applied' : 'dry run'}: ${count('new')} new, ${count('changed')} changed, ${count('same')} same` })
send(res, 200, ok(done))
} else if (apiPath === 'hub/profiles/apply') {
const from = jsonParam(params.from, {}) as Record<string, string>
const done = await profiles.apply(params.name ?? '', csv(params.targets), { removeExtras: params.removeExtras === 'true', from: from !== null && typeof from === 'object' ? from : {} })
for (const r of done.results) {
for (const s of r.steps.length > 0 ? r.steps : [{ step: 'apps', status: r.ok ? 'ok' : 'failed', message: r.message }]) {
if (s.status === 'not-run' || s.status === 'skipped') continue
audit.log({ ...line, server: r.server, outcome: s.status === 'ok' ? 'ok' : 'failed', detail: `${s.step}: ${s.message}`.slice(0, 300) })
}
}
send(res, 200, ok({ results: done.results }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
audit.log({ ...line, outcome: 'refused', detail: message.slice(0, 120) })
send(res, 400, fail(message))
}
}
/* 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, route: Route, params: Params, who: Who, ip: string): Promise<void> {
if (route.kind === 'write-all') {
if (req.method !== 'POST') {
send(res, 405, fail('Use POST.'))
return
}
if (apiPath === 'hub/webhook/test') {
const sent = await webhook.deliver('webhook.test', [{ message: 'A test notice from the hub.', by: who.user }])
audit.log({ user: who.user, role: who.role, ip, action: 'webhook.test', outcome: sent.ok ? 'ok' : 'failed', detail: sent.ok ? undefined : sent.error })
send(res, 200, ok(webhook.status()))
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.startsWith('hub/profiles/')) {
await profilesWrite(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsWrite(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/zones/copy' || apiPath === 'hub/zones/secondary') {
await zonesWrite(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/servers/run') {
const planned = planAction(params.action, params)
const line = { user: who.user, role: who.role, ip, action: 'error' in planned ? 'servers.run' : `servers.${planned.plan.action}` }
if ('error' in planned) {
audit.log({ ...line, outcome: 'refused', detail: planned.error })
send(res, 400, fail(planned.error))
return
}
const outcome = await runner.run(planned.plan, csv(params.targets))
if ('error' in outcome) {
audit.log({ ...line, target: planned.plan.target, outcome: 'refused', detail: outcome.error })
send(res, 400, fail(outcome.error))
return
}
// One line per server. The target is a domain or a count, never the list of URLs.
for (const r of outcome.results) {
audit.log({ ...line, server: r.server, target: planned.plan.target, outcome: r.ok ? 'ok' : 'failed', detail: r.message })
}
send(res, 200, ok({ results: outcome.results }))
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=<id>`).
const action = actionForPath(apiPath)
if (action === null) {
await serverWrite(req, res, apiPath, route, params, who, ip)
return
}
const node = params.node ?? ''
const server = byId.get(node)
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}`))
}
const textBody = (req: http.IncomingMessage): boolean => /^text\/plain/i.test(String(req.headers['content-type'] ?? ''))
/*
A change to ONE server from the sections it is managed from (zones, cache, blocking, DNS client,
settings, DHCP). The role has been checked against the route. The server is the one named by
`node`, and its admin token does the work. What is forwarded is exactly the parameters that were
looked at here, in a form body (see `Upstream.write`), minus `node` and `token`. The audit line
holds the path, the server, one identifier and the outcome, never a value.
*/
async function serverWrite(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
const node = params.node ?? ''
const server = byId.get(node)
const line = { user: who.user, role: who.role, ip, action: apiPath.replace(/\//g, '.') }
if (server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this change is made on one server at a time.' : `Unknown node: ${node}`))
return
}
if (server.adminToken === undefined) {
audit.log({ ...line, server: server.id, target: safeTarget(params), outcome: 'refused', detail: 'no admin token' })
send(res, 403, fail(`${server.id}: the hub has no admin token for this server, so it cannot change it.`))
return
}
const forward: Params = {}
for (const [k, v] of Object.entries(params)) if (k !== 'node' && k !== 'token') forward[k] = v
// A file or a text body (a zone import). The parameters may also arrive as multipart fields.
let body: FormData | string | undefined
if (route.upload === true && (isMultipart(req) || textBody(req))) {
let data: Buffer
try {
data = await readLimited(req)
} 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
}
if (isMultipart(req)) {
let parsed
try {
parsed = parseMultipart(data, String(req.headers['content-type'] ?? ''))
} catch {
send(res, 400, fail('The upload could not be read.'))
return
}
for (const [k, v] of Object.entries(parsed.fields)) if (k !== 'node' && k !== 'token') forward[k] = v
const file = parsed.files[0]
if (file !== undefined) {
const form = new FormData()
form.append(file.field, new Blob([file.data], { type: file.type }), file.filename)
body = form
}
} else body = data.toString('utf8')
}
// settings/set: no change to the web service the hub itself uses, and nothing about users (guards.ts).
if (apiPath === 'settings/set') {
if (Object.keys(forward).some(isForbiddenSettingsField)) {
audit.log({ ...line, server: server.id, outcome: 'refused', detail: 'user, SSO or LDAP field' })
send(res, 400, fail("User, SSO and LDAP settings are changed in that server's own console."))
return
}
try {
upstream.invalidate(server.id)
const changed = webServiceChanges(forward, await upstream.call(server, 'settings/get', {}, server.adminToken))
if (changed.length > 0) {
audit.log({ ...line, server: server.id, outcome: 'refused', detail: `web service: ${changed.join(', ').slice(0, 200)}` })
send(res, 400, fail(WEB_SERVICE_MESSAGE))
return
}
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
audit.log({ ...line, server: server.id, outcome: 'failed', detail: 'could not read the current settings' })
send(res, 200, fail(`${server.id}: the web service settings could not be checked first (${e.message}); nothing was changed.`))
return
}
}
try {
const response = await upstream.write(server, apiPath, forward, body)
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'ok' })
send(res, 200, apiPath === 'settings/set' ? { ...ok(response), server: server.id } : ok(response))
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'failed', detail: e.kind === 'unreachable' ? 'no answer' : 'refused by the server' })
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
}
/* A file from one server (a zone, a log, a block list), passed on as it comes. */
async function download(res: http.ServerResponse, server: HubServer, apiPath: string, params: Params): Promise<void> {
try {
const file = await upstream.raw(server, apiPath, params)
res.writeHead(200, {
'content-type': file.contentType,
...(file.disposition === undefined ? {} : { 'content-disposition': file.disposition }),
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
})
if (file.body === null) {
res.end()
return
}
res.on('close', () => file.abort())
Readable.fromWeb(file.body as WebStream).on('error', () => res.destroy()).pipe(res)
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
}
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
// A multipart body (an app zip, a zone file) and a text body (a pasted zone file) are read later,
// once the caller is known to be allowed to send them.
// A config body may be large, but only for a signed-in caller (token in the header or the query).
const own = apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set'
const wanted = own ? (apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY) : largeBodyLimit(apiPath)
const large = wanted !== undefined && sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const deferred = isMultipart(req) || (routeFor(apiPath)?.upload === true && textBody(req))
const form = deferred ? new URLSearchParams() : await readForm(req, large ? (wanted as number) : MAX_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
// A download opened in a new tab carries a single-use token instead of the session's.
const who = sessions.touch(token) ?? (req.method === 'GET' && routeFor(apiPath)?.raw === true ? sessions.takeSingleUse(token) : null)
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
}
if (apiPath === 'user/createSingleUseToken') {
send(res, 200, ok({ token: sessions.openSingleUse(token as string) }))
return
}
const route = effectiveRoute(apiPath, params)
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, 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.startsWith('hub/profiles/')) {
await profilesRead(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/blocking/app-lists/count') {
const server = byId.get(params.node ?? '')
if (server === undefined) {
send(res, 400, fail('Choose a server: the lists are counted for one server at a time.'))
return
}
try {
send(res, 200, ok(await appLists.count(server, params.name || 'Advanced Blocking')))
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsRead(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/zones/')) {
await zonesRead(res, apiPath, params)
return
}
if (apiPath === 'hub/webhook/status') {
send(res, 200, ok(webhook.status()))
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)
// A read with no merger is never answered by "the first server" for the aggregate: it needs a server.
if (route.perServer === true && server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this is read from one server at a time.' : `Unknown node: ${node}`))
return
}
if (route.raw === true && server !== undefined) {
await download(res, server, apiPath, forward)
return
}
let answer: Json
if (server !== undefined) answer = await single(server, apiPath, forward)
else if (node !== '' && node !== 'cluster') answer = fail(`Unknown node: ${node}`)
else answer = await aggregate(apiPath, forward)
// The settings hold secrets (TSIG keys, passwords): only an admin sees them (guards.ts).
if (apiPath === 'settings/get' && !can(who.role, 'admin') && answer.status === 'ok') answer = { ...answer, response: redactSettings(answer.response) }
send(res, 200, answer)
}
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
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<string, string | number> = {
'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 `<prefix>/api/<path>` 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(', ')}`)
})
}