The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1102 lines
53 KiB
TypeScript
1102 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 { 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 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.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(', ')}`)
|
|
})
|
|
}
|