Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator, apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock the address or the name for 15 minutes. Logins, logouts and every refused request go to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are listed in routes.ts with their kind and capability; writes stay refused for now. Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan is in docs/hub-management-plan.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
14 KiB
TypeScript
387 lines
14 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 {
|
|
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
|
|
|
|
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): 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 > MAX_BODY) break
|
|
chunks.push(chunk as Buffer)
|
|
}
|
|
const type = String(req.headers['content-type'] ?? '')
|
|
if (size > MAX_BODY || !type.includes('application/x-www-form-urlencoded')) return new URLSearchParams()
|
|
return new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
|
}
|
|
|
|
function bearer(req: http.IncomingMessage): string | null {
|
|
const header = String(req.headers.authorization ?? '')
|
|
return header.startsWith('Bearer ') ? header.slice(7).trim() : null
|
|
}
|
|
|
|
function positive(value: string | undefined, fallback: number): number {
|
|
const n = Number(value)
|
|
return Number.isInteger(n) && n > 0 ? n : fallback
|
|
}
|
|
|
|
/* The client's address: the socket's, or with HUB_TRUST_PROXY the first X-Forwarded-For entry. */
|
|
function clientIp(req: http.IncomingMessage, trustProxy: boolean): string {
|
|
if (trustProxy) {
|
|
const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim()
|
|
if (forwarded !== '') return forwarded
|
|
}
|
|
return req.socket.remoteAddress ?? 'unknown'
|
|
}
|
|
|
|
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server {
|
|
const upstream = new Upstream(config)
|
|
const sessions = new Sessions(config, users)
|
|
const limiter = new LoginLimiter()
|
|
const audit = new Audit(config.dataDir)
|
|
const 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/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
|
|
}
|
|
}
|
|
|
|
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
|
|
const form = await readForm(req)
|
|
const params: Params = {}
|
|
for (const [k, v] of url.searchParams) params[k] = v
|
|
for (const [k, v] of form) params[k] = v
|
|
|
|
// Public and session endpoints.
|
|
if (apiPath === 'status') {
|
|
send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false })
|
|
return
|
|
}
|
|
const ip = clientIp(req, config.trustProxy)
|
|
if (apiPath === 'user/login') {
|
|
const name = params.user ?? ''
|
|
const locked = limiter.lockedFor(ip, name)
|
|
if (locked > 0) {
|
|
audit.log({ user: name, ip, action: 'login', outcome: 'refused', detail: 'locked' })
|
|
send(res, 429, fail(lockedMessage(locked)))
|
|
return
|
|
}
|
|
const user = await users.authenticate(name, params.pass ?? '')
|
|
if (user === null) {
|
|
limiter.failure(ip, name)
|
|
audit.log({ user: name, ip, action: 'login', outcome: 'failed', detail: users.isDisabled(name) ? 'disabled' : undefined })
|
|
send(res, 200, fail(`Invalid username or password for user: ${name}`))
|
|
return
|
|
}
|
|
limiter.success(name)
|
|
audit.log({ user: user.name, role: user.role, ip, action: 'login', outcome: 'ok' })
|
|
const token = sessions.open(user)
|
|
send(res, 200, sessions.body(token, { user: user.name, role: user.role }))
|
|
return
|
|
}
|
|
|
|
const token = bearer(req) ?? params.token ?? null
|
|
const who = sessions.touch(token)
|
|
if (who === null) {
|
|
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
|
|
return
|
|
}
|
|
if (apiPath === 'user/session/get') {
|
|
send(res, 200, sessions.body(token as string, who))
|
|
return
|
|
}
|
|
if (apiPath === 'user/logout') {
|
|
sessions.logout(token as string)
|
|
audit.log({ user: who.user, role: who.role, ip, action: 'logout', outcome: 'ok' })
|
|
send(res, 200, { status: 'ok' })
|
|
return
|
|
}
|
|
const route = routeFor(apiPath)
|
|
if (route === undefined) {
|
|
audit.log({
|
|
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
|
|
server: params.node || undefined, outcome: 'refused', detail: 'not in the permission table',
|
|
})
|
|
send(res, 403, fail('The hub is read-only.'))
|
|
return
|
|
}
|
|
if (!can(who.role, route.capability)) {
|
|
audit.log({
|
|
user: who.user, role: who.role, ip, action: route.kind === 'read' ? 'denied-read' : 'denied-write',
|
|
target: apiPath, outcome: 'refused', detail: `needs ${route.capability}`,
|
|
})
|
|
send(res, 403, fail('Your role does not allow this.'))
|
|
return
|
|
}
|
|
if (apiPath === 'hub/audit/list') {
|
|
send(res, 200, ok({ entries: audit.list(Number(params.limit ?? 200), params.before) }))
|
|
return
|
|
}
|
|
if (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()
|
|
}
|
|
})()
|
|
})
|
|
server.on('close', () => clearInterval(sweeper))
|
|
return server
|
|
}
|
|
|
|
/* Run directly (not imported by the tests). */
|
|
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
let config: HubConfig
|
|
try {
|
|
config = loadConfig()
|
|
} catch (e) {
|
|
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
|
|
process.exit(1)
|
|
}
|
|
let hub: http.Server
|
|
try {
|
|
const users = Users.load(config.dataDir, config.bootstrap)
|
|
if (users.notice !== null) console.log(`hub: ${users.notice}`)
|
|
hub = createHub(config, users)
|
|
} catch (e) {
|
|
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
|
|
process.exit(1)
|
|
}
|
|
hub.listen(config.port, () => {
|
|
console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`)
|
|
})
|
|
}
|