Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
711 lines
31 KiB
TypeScript
711 lines
31 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 read-only, Technitium-compatible `/api/*`.
|
|
A call aimed at one server (`node=<id>`) is forwarded to it with that server's token; a
|
|
call with no node (or `node=cluster`) asks every server in parallel and merges the
|
|
answers (see merge.ts). Anything that would change data is refused.
|
|
*/
|
|
import http from 'node:http'
|
|
import { createReadStream } from 'node:fs'
|
|
import { stat } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
|
|
import { Upstream, UpstreamError, type Params } from './upstream.ts'
|
|
import { Sessions } from './session.ts'
|
|
import { Users, can } from './users.ts'
|
|
import { LoginLimiter, lockedMessage } from './ratelimit.ts'
|
|
import { Audit } from './audit.ts'
|
|
import { routeFor } from './routes.ts'
|
|
import { buildMatrix } from './apps-matrix.ts'
|
|
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
|
|
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
|
|
import { AppsConfig, UserError } from './apps-config.ts'
|
|
import { Drift } from './drift.ts'
|
|
import {
|
|
mergeQueryLogs,
|
|
mergeSettled,
|
|
mergeStats,
|
|
mergeTopResponses,
|
|
type DashboardStats,
|
|
type QueryLogPage,
|
|
type Settled,
|
|
type TopEntry,
|
|
} from './merge.ts'
|
|
|
|
type Json = Record<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)): http.Server {
|
|
const upstream = new Upstream(config)
|
|
const drift = new Drift(config, upstream)
|
|
const sessions = new Sessions(config, users, Date.now, () => drift.count())
|
|
const limiter = new LoginLimiter()
|
|
const audit = new Audit(config.dataDir)
|
|
const writer = new AppsWriter(config, upstream)
|
|
const appsConfig = new AppsConfig(config, upstream)
|
|
const byId = new Map(config.servers.map((s) => [s.id, s]))
|
|
|
|
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
|
|
async function everywhere<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 write endpoints. The role has already been checked against the route's capability. */
|
|
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> {
|
|
if (kind === 'write-all') {
|
|
if (req.method !== 'POST') {
|
|
send(res, 405, fail('Use POST.'))
|
|
return
|
|
}
|
|
if (apiPath === 'hub/drift/check') {
|
|
const state = await drift.check()
|
|
audit.log({ user: who.user, role: who.role, ip, action: 'drift.check', outcome: 'ok', detail: `${state.apps.length} finding(s), ${drift.count()} differ` })
|
|
send(res, 200, ok(drift.get()))
|
|
return
|
|
}
|
|
if (apiPath.startsWith('hub/apps/baseline/') || apiPath.startsWith('hub/apps/overrides/') || apiPath.startsWith('hub/apps/history/') || apiPath.startsWith('hub/apps/drift/') || apiPath === 'hub/apps/import' || apiPath === 'hub/apps/config/apply') {
|
|
await configWrite(res, apiPath, params, who, ip)
|
|
return
|
|
}
|
|
if (apiPath === 'hub/apps/run') {
|
|
const action = params.action
|
|
if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') {
|
|
send(res, 400, fail('action must be install-store, update-store or uninstall.'))
|
|
return
|
|
}
|
|
const outcome = await writer.runMany(action, params.name ?? '', csv(params.targets), { suppliedUrl: params.url })
|
|
if ('error' in outcome) {
|
|
audit.log({ user: who.user, role: who.role, ip, action: `apps.${action}`, target: params.name, outcome: 'refused', detail: outcome.error })
|
|
send(res, 400, fail(outcome.error))
|
|
return
|
|
}
|
|
auditResults(who, ip, action, params.name ?? '', outcome.results)
|
|
send(res, 200, ok({ results: outcome.results }))
|
|
return
|
|
}
|
|
if (apiPath === 'hub/apps/upload') {
|
|
if (!isMultipart(req)) {
|
|
send(res, 400, fail('Send multipart/form-data.'))
|
|
return
|
|
}
|
|
const file = await uploadOf(req, res, params)
|
|
if (file === null) return
|
|
if (params.action !== 'install' && params.action !== 'update') {
|
|
send(res, 400, fail('action must be install or update.'))
|
|
return
|
|
}
|
|
const action: FileAction = params.action === 'install' ? 'install-file' : 'update-file'
|
|
const outcome = await writer.runMany(action, params.name ?? '', csv(params.targets), { file })
|
|
if ('error' in outcome) {
|
|
audit.log({ user: who.user, role: who.role, ip, action: `apps.${action}`, target: params.name, outcome: 'refused', detail: outcome.error })
|
|
send(res, 400, fail(outcome.error))
|
|
return
|
|
}
|
|
auditResults(who, ip, action, params.name ?? '', outcome.results)
|
|
send(res, 200, ok({ results: outcome.results }))
|
|
return
|
|
}
|
|
}
|
|
|
|
// write-one: an upstream endpoint aimed at ONE server (`node=<id>`).
|
|
const action = actionForPath(apiPath)
|
|
const node = params.node ?? ''
|
|
const server = byId.get(node)
|
|
if (action === null) {
|
|
send(res, 403, fail('The hub is read-only.'))
|
|
return
|
|
}
|
|
if (server === undefined) {
|
|
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose one server: this change cannot be made on every server at once here.' : `Unknown node: ${node}`))
|
|
return
|
|
}
|
|
const name = params.name ?? ''
|
|
let file: UploadedFile | undefined
|
|
if (action === 'install-file' || action === 'update-file') {
|
|
if (!isMultipart(req)) {
|
|
send(res, 400, fail('Send multipart/form-data.'))
|
|
return
|
|
}
|
|
const uploaded = await uploadOf(req, res, params)
|
|
if (uploaded === null) return
|
|
file = uploaded
|
|
}
|
|
let result: TargetResult
|
|
let response: unknown = {}
|
|
try {
|
|
let url: string | undefined
|
|
if (action === 'install-store' || action === 'update-store') {
|
|
const fromStore = server.adminToken === undefined ? null : await writer.storeUrl(server, name).catch(() => null)
|
|
if (server.adminToken !== undefined && (fromStore === null || params.url !== fromStore)) {
|
|
throw new Error("the url is not from this server's store listing")
|
|
}
|
|
url = fromStore ?? undefined
|
|
}
|
|
response = await writer.perform(server, action, name, url, file, params.config)
|
|
result = { server: server.id, ok: true }
|
|
} catch (e) {
|
|
result = { server: server.id, ok: false, message: e instanceof Error ? e.message : 'failed' }
|
|
}
|
|
auditResults(who, ip, action, name, [result])
|
|
send(res, 200, result.ok ? ok(response) : fail(`${server.id}: ${result.message}`))
|
|
}
|
|
|
|
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
|
|
// A multipart body (an app zip) is read later, once the caller is known to be allowed to send it.
|
|
// A config body may be large, but only for a signed-in caller (token in the header or the query).
|
|
const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set')
|
|
&& sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
|
|
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, !large ? MAX_BODY : apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY)
|
|
const params: Params = {}
|
|
for (const [k, v] of url.searchParams) params[k] = v
|
|
for (const [k, v] of form) params[k] = v
|
|
|
|
// Public and session endpoints.
|
|
if (apiPath === 'status') {
|
|
send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false })
|
|
return
|
|
}
|
|
const ip = clientIp(req, config.trustProxy)
|
|
if (apiPath === 'user/login') {
|
|
const name = params.user ?? ''
|
|
const locked = limiter.lockedFor(ip, name)
|
|
if (locked > 0) {
|
|
audit.log({ user: name, ip, action: 'login', outcome: 'refused', detail: 'locked' })
|
|
send(res, 429, fail(lockedMessage(locked)))
|
|
return
|
|
}
|
|
const user = await users.authenticate(name, params.pass ?? '')
|
|
if (user === null) {
|
|
limiter.failure(ip, name)
|
|
audit.log({ user: name, ip, action: 'login', outcome: 'failed', detail: users.isDisabled(name) ? 'disabled' : undefined })
|
|
send(res, 200, fail(`Invalid username or password for user: ${name}`))
|
|
return
|
|
}
|
|
limiter.success(name)
|
|
audit.log({ user: user.name, role: user.role, ip, action: 'login', outcome: 'ok' })
|
|
const token = sessions.open(user)
|
|
send(res, 200, sessions.body(token, { user: user.name, role: user.role }))
|
|
return
|
|
}
|
|
|
|
const token = bearer(req) ?? params.token ?? null
|
|
const who = sessions.touch(token)
|
|
if (who === null) {
|
|
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
|
|
return
|
|
}
|
|
if (apiPath === 'user/session/get') {
|
|
send(res, 200, sessions.body(token as string, who))
|
|
return
|
|
}
|
|
if (apiPath === 'user/logout') {
|
|
sessions.logout(token as string)
|
|
audit.log({ user: who.user, role: who.role, ip, action: 'logout', outcome: 'ok' })
|
|
send(res, 200, { status: 'ok' })
|
|
return
|
|
}
|
|
const route = routeFor(apiPath)
|
|
if (route === undefined) {
|
|
audit.log({
|
|
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
|
|
server: params.node || undefined, outcome: 'refused', detail: 'not in the permission table',
|
|
})
|
|
send(res, 403, fail('The hub is read-only.'))
|
|
return
|
|
}
|
|
if (!can(who.role, route.capability)) {
|
|
audit.log({
|
|
user: who.user, role: who.role, ip, action: route.kind === 'read' ? 'denied-read' : 'denied-write',
|
|
target: apiPath, outcome: 'refused', detail: `needs ${route.capability}`,
|
|
})
|
|
send(res, 403, fail('Your role does not allow this.'))
|
|
return
|
|
}
|
|
if (apiPath === 'hub/audit/list') {
|
|
send(res, 200, ok({ entries: audit.list(Number(params.limit ?? 200), params.before) }))
|
|
return
|
|
}
|
|
if (route.kind !== 'read') {
|
|
await write(req, res, apiPath, route.kind, params, who, ip)
|
|
return
|
|
}
|
|
if (apiPath.startsWith('hub/apps/config/') || apiPath.startsWith('hub/apps/history/') || apiPath === 'hub/apps/overrides/current' || apiPath === 'hub/apps/export') {
|
|
await configRead(res, apiPath, params, who, ip)
|
|
return
|
|
}
|
|
if (apiPath === 'hub/drift/get') {
|
|
send(res, 200, ok(drift.get()))
|
|
return
|
|
}
|
|
if (apiPath === 'apps/config/get' && !byId.has(params.node ?? '')) {
|
|
send(res, 400, fail('Choose one server: a config is read from a single server.'))
|
|
return
|
|
}
|
|
if (apiPath === 'hub/apps/matrix') {
|
|
try {
|
|
const { matrix, warnings } = await buildMatrix(config, upstream)
|
|
send(res, 200, ok(matrix, warnings))
|
|
} catch (e) {
|
|
send(res, 200, fail(e instanceof Error ? e.message : 'failed'))
|
|
}
|
|
return
|
|
}
|
|
if (apiPath === 'user/checkForUpdate') {
|
|
send(res, 200, ok({ updateAvailable: false }))
|
|
return
|
|
}
|
|
if (apiPath === 'admin/cluster/state') {
|
|
send(
|
|
res,
|
|
200,
|
|
ok({
|
|
version: HUB_VERSION,
|
|
dnsServerDomain: config.domain,
|
|
clusterInitialized: true,
|
|
clusterDomain: config.domain,
|
|
clusterNodes: config.servers.map((s, i) => ({
|
|
id: i + 1,
|
|
name: s.id,
|
|
url: s.url,
|
|
ipAddresses: [],
|
|
type: i === 0 ? 'Primary' : 'Secondary',
|
|
state: 'Connected',
|
|
})),
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
|
|
// What the real servers get: the caller's params minus the hub's own.
|
|
const { node = '', token: _token, ...forward } = params
|
|
const server = byId.get(node)
|
|
if (server !== undefined) {
|
|
send(res, 200, await single(server, apiPath, forward))
|
|
return
|
|
}
|
|
if (node !== '' && node !== 'cluster') {
|
|
send(res, 200, fail(`Unknown node: ${node}`))
|
|
return
|
|
}
|
|
send(res, 200, await aggregate(apiPath, forward))
|
|
}
|
|
|
|
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<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(', ')}`)
|
|
})
|
|
}
|