diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index 344e455..6aa87eb 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -4,6 +4,7 @@ A stand-in for the Technitium API, with made-up data, to look at and click throu whole console without a server and without Docker. node dev/mock-api.mjs # listens on 127.0.0.1:5380 + node dev/mock-api.mjs --port 5381 --scale 2 # another port; counters and hits times 2 npm run dev # the console, proxying /api to it Any user and password log in. It answers the endpoints the screens call, keeps what you @@ -14,7 +15,10 @@ behaviour: it exists so a design can be seen with data in it. The real harness i */ import http from 'node:http' -const PORT = Number(process.env.PORT ?? 5380) +// `--port ` and `--scale `: two instances with different numbers stand in for two servers. +const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined } +const PORT = Number(arg('port') ?? process.env.PORT ?? 5380) +const SCALE = Number(arg('scale') ?? 1) const now = Date.now() const iso = (ms) => new Date(ms).toISOString() const ago = (min) => iso(now - min * 60_000) @@ -46,12 +50,12 @@ const SERIES = [ function dashboard(type) { const r = RANGES[type] ?? RANGES.LastDay const instants = Array.from({ length: r.n }, (_, i) => iso(now - (r.n - 1 - i) * r.step)) - const wave = (base, i) => Math.max(0, Math.round(base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7)))) + const wave = (base, i) => Math.max(0, Math.round(SCALE * base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7)))) const totals = Object.fromEntries(SERIES.map(([name, base]) => [name, instants.reduce((a, _, i) => a + wave(base, i), 0)])) const stats = { totalQueries: totals.Total, totalNoError: totals['No Error'], totalServerFailure: totals['Server Failure'], totalNxDomain: totals['NX Domain'], totalRefused: totals.Refused, totalAuthoritative: totals.Authoritative, totalRecursive: totals.Recursive, totalCached: totals.Cached, - totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: 128, zones: zones.length, cachedEntries: 48210, + totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: Math.round(128 * SCALE), zones: zones.length, cachedEntries: 48210, allowedZones: allowed.size, blockedZones: blocked.size, allowListZones: 1, blockListZones: 1482903, } return { @@ -67,9 +71,9 @@ const CLIENTS = [['192.168.10.14', 'laptop-anna.lan'], ['192.168.10.31', 'tv-liv const DOMAINS = ['connectivitycheck.gstatic.com', 'time.cloudflare.com', 'api.github.com', 'updates.example.net', 'www.example.net', 'mail.example.net', 'cdn.jsdelivr.net', 'fonts.googleapis.com', 'api.telegram.org', 'ocsp.digicert.com'] const BLOCKED = ['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com', 'stats.adnetwork-example.net', 'track.shop-example.org', 'ads2.tracker-example.com', 'collect.data-example.io', 'sdk.ads-example.com'] function top(kind) { - if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round(84210 / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) })) + if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round((SCALE * 84210) / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) })) const list = kind === 'domains' ? DOMAINS : BLOCKED - return list.map((name, i) => ({ name, hits: Math.round((kind === 'domains' ? 22480 : 9804) / (1 + i * 0.5)) })) + return list.map((name, i) => ({ name, hits: Math.round((SCALE * (kind === 'domains' ? 22480 : 9804)) / (1 + i * 0.5)) })) } // ── Zones ────────────────────────────────────────────────────────────────── @@ -228,6 +232,10 @@ function route(path, q) { case 'user/login': case 'user/session/get': return session case 'user/profile/get': return ok({ displayName: 'Administrator', username: 'admin', totpEnabled: true, sessionTimeoutSeconds: 1800, memberOfGroups: ['Administrators'], sessions }) case 'user/checkForUpdate': return ok({ updateAvailable: false }) + case 'dashboard/metrics/json': { + const total = Math.round(SCALE * 1_250_000) + return ok({ uptimestamp: ago(60 * 24 * 9), lifetimeCounters: { totalQueries: total, totalNoError: Math.round(total * 0.96), totalServerFailure: 40, totalNxDomain: Math.round(total * 0.03), totalRefused: 180, totalAuthoritative: Math.round(total * 0.08), totalRecursive: Math.round(total * 0.33), totalCached: Math.round(total * 0.48), totalBlocked: Math.round(total * 0.15), totalDropped: 5, totalClients: 128 } }) + } case 'dashboard/stats/get': return ok(dashboard(q.get('type') ?? 'LastDay')) case 'dashboard/stats/getTop': return ok({ [{ TopClients: 'topClients', TopDomains: 'topDomains', TopBlockedDomains: 'topBlockedDomains' }[q.get('statsType')] ?? 'topDomains']: top({ TopClients: 'clients', TopDomains: 'domains', TopBlockedDomains: 'blocked' }[q.get('statsType')] ?? 'domains') }) case 'zones/list': { @@ -297,4 +305,4 @@ http.createServer((req, res) => { res.setHeader('content-type', 'application/json') res.end(JSON.stringify(route(path, q))) }) -}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password)`)) +}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password, scale ${SCALE})`)) diff --git a/hub/config.ts b/hub/config.ts new file mode 100644 index 0000000..94a302e --- /dev/null +++ b/hub/config.ts @@ -0,0 +1,81 @@ +/* +The hub's configuration, read from the environment. `parseConfig` is pure so the +tests can feed it any environment; `loadConfig` is the one that reads `process.env`. +*/ + +export const HUB_VERSION = '1.0.0' + +export interface HubServer { + id: string + url: string + token: string +} + +export interface HubConfig { + servers: HubServer[] + user: string + password: string + port: number + cacheSeconds: number + timeoutMs: number + /** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */ + domain: string + /** The console's static build. */ + staticDir: string +} + +type Env = Record + +/* `ndns` -> `NDNS_TOKEN`, `my-dns.1` -> `MY_DNS_1_TOKEN`. */ +export function tokenVariable(id: string): string { + return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN` +} + +function number(env: Env, name: string, fallback: number, min: number): number { + const raw = env[name] + if (raw === undefined || raw.trim() === '') return fallback + const value = Number(raw) + if (!Number.isFinite(value) || value < min) throw new Error(`${name} must be a number of at least ${min}.`) + return value +} + +export function parseConfig(env: Env): HubConfig { + const user = env.HUB_USER ?? '' + const password = env.HUB_PASSWORD ?? '' + if (user === '' || password === '') throw new Error('HUB_USER and HUB_PASSWORD are required.') + + const servers: HubServer[] = [] + for (const part of (env.HUB_SERVERS ?? '').split(',')) { + const entry = part.trim() + if (entry === '') continue + const at = entry.indexOf('=') + const id = at < 0 ? '' : entry.slice(0, at).trim() + const url = at < 0 ? '' : entry.slice(at + 1).trim().replace(/\/+$/, '') + if (!/^[A-Za-z0-9._-]+$/.test(id) || id === 'cluster' || !/^https?:\/\/[^/]/.test(url)) { + throw new Error(`HUB_SERVERS entry "${entry}" is not of the form id=https://host (id: letters, digits, . _ -).`) + } + if (servers.some((s) => s.id === id)) throw new Error(`HUB_SERVERS lists "${id}" twice.`) + const variable = tokenVariable(id) + const token = env[variable] ?? '' + if (token === '') throw new Error(`${variable} is required for the server "${id}".`) + servers.push({ id, url, token }) + } + if (servers.length === 0) { + throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.') + } + + return { + servers, + user, + password, + port: number(env, 'HUB_PORT', 8080, 0), + cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0), + timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1), + domain: env.HUB_DOMAIN?.trim() || 'hub', + staticDir: env.HUB_STATIC?.trim() || './dist', + } +} + +export function loadConfig(): HubConfig { + return parseConfig(process.env) +} diff --git a/hub/server.test.ts b/hub/server.test.ts new file mode 100644 index 0000000..8ffd66e --- /dev/null +++ b/hub/server.test.ts @@ -0,0 +1,340 @@ +// @vitest-environment node +import http from 'node:http' +import type { AddressInfo } from 'node:net' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { parseConfig } from './config.ts' +import { createHub } from './server.ts' + +/* A fake Technitium: answers `canned(path, query)` and records every request. */ +interface Fake { + server: http.Server + url: string + calls: { path: string; query: URLSearchParams }[] + canned: (path: string, query: URLSearchParams) => unknown + down: boolean +} + +function fakeUpstream(canned: Fake['canned']): Promise { + return new Promise((resolve) => { + const fake: Fake = { server: http.createServer(), url: '', calls: [], canned, down: false } + fake.server.on('request', (req, res) => { + if (fake.down) { + req.socket.destroy() + return + } + const url = new URL(req.url ?? '/', 'http://fake') + const apiPath = url.pathname.replace(/^\/api\//, '') + fake.calls.push({ path: apiPath, query: url.searchParams }) + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ status: 'ok', response: fake.canned(apiPath, url.searchParams) })) + }) + fake.server.listen(0, '127.0.0.1', () => { + fake.url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}` + resolve(fake) + }) + }) +} + +const stats = (queries: number) => ({ + stats: { totalQueries: queries, totalBlocked: queries / 10 }, + mainChartData: { labels: ['a', 'b'], datasets: [{ label: 'Total', data: [queries, queries] }] }, + topDomains: [{ name: 'x.example', hits: queries }], +}) + +function logPage(prefix: string, stamps: string[], total: number) { + return { + pageNumber: 1, + totalPages: 1, + totalEntries: total, + entries: stamps.map((timestamp, i) => ({ rowNumber: i + 1, timestamp, qname: `${prefix}${i}.example` })), + } +} + +function canned(queries: number, stamps: string[]): Fake['canned'] { + return (apiPath, query) => { + switch (apiPath) { + case 'dashboard/stats/get': + return stats(queries) + case 'dashboard/stats/getTop': + return { topDomains: [{ name: 'x.example', hits: queries }, { name: `only-${queries}.example`, hits: 1 }] } + case 'logs/query': + return logPage(`q${queries}-`, stamps.slice(0, Number(query.get('entriesPerPage') ?? 25)), stamps.length) + case 'apps/list': + return { apps: [{ name: 'Shared' }, { name: `App ${queries}` }] } + case 'settings/get': + return { enableBlocking: true, from: queries } + default: + return {} + } + } +} + +let a: Fake +let b: Fake +let hub: http.Server +let base: string +let staticDir: string + +function makeConfig(over: Record = {}) { + return parseConfig({ + HUB_SERVERS: `a=${a.url},b=${b.url}`, + A_TOKEN: 'token-a', + B_TOKEN: 'token-b', + HUB_USER: 'admin', + HUB_PASSWORD: 'secret', + HUB_STATIC: staticDir, + HUB_CACHE_SECONDS: '15', + HUB_TIMEOUT_MS: '2000', + ...over, + }) +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return `http://127.0.0.1:${(server.address() as AddressInfo).port}` +} + +async function call(apiPath: string, params: Record = {}, token?: string, method = 'GET') { + const query = new URLSearchParams(params) + const url = `${base}/api/${apiPath}${method === 'GET' && params ? `?${query}` : ''}` + const init: RequestInit = { method, headers: token ? { authorization: `Bearer ${token}` } : {} } + if (method === 'POST') { + init.body = query.toString() + init.headers = { ...init.headers, 'content-type': 'application/x-www-form-urlencoded' } + } + const res = await fetch(url, init) + return { code: res.status, body: (await res.json()) as Record } +} + +async function login(): Promise { + const { body } = await call('user/login', { user: 'admin', pass: 'secret', includeInfo: 'true' }, undefined, 'POST') + return body.token as string +} + +beforeAll(async () => { + a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z'])) + b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z'])) + staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-')) + writeFileSync(path.join(staticDir, 'index.html'), 'root') + mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true }) + writeFileSync(path.join(staticDir, 'logs', 'index.html'), 'logs') + mkdirSync(path.join(staticDir, 'assets')) + writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}') +}) + +afterAll(() => { + a.server.close() + b.server.close() + rmSync(staticDir, { recursive: true, force: true }) +}) + +beforeEach(async () => { + a.down = false + b.down = false + hub?.close() + hub = createHub(makeConfig()) + base = await listen(hub) + a.calls.length = 0 + b.calls.length = 0 +}) + +describe('config', () => { + it('refuses to start without credentials or a token', () => { + const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' } + expect(() => parseConfig(env)).toThrow(/HUB_USER/) + expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/) + }) + + it('derives the token variable from the id and applies defaults', () => { + const c = parseConfig({ HUB_SERVERS: 'my-dns.1=http://x/', MY_DNS_1_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' }) + expect(c.servers).toEqual([{ id: 'my-dns.1', url: 'http://x', token: 't' }]) + expect([c.port, c.cacheSeconds, c.timeoutMs, c.domain, c.staticDir]).toEqual([8080, 15, 8000, 'hub', './dist']) + }) +}) + +describe('session', () => { + it('logs in and describes the servers as cluster nodes', async () => { + const token = await login() + expect(token).toMatch(/^[0-9a-f]{64}$/) + const { body } = await call('user/session/get', {}, token) + expect(body.status).toBe('ok') + expect(body.info.hub).toBe(true) + expect(body.info.clusterInitialized).toBe(true) + expect(body.info.clusterNodes).toEqual([{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }]) + expect(body.info.permissions.Dashboard.canView).toBe(true) + expect(body.info.permissions.Logs.canView).toBe(true) + expect(body.info.permissions.Zones).toEqual({ canView: false, canModify: false, canDelete: false }) + }) + + it('refuses a wrong password', async () => { + const { body } = await call('user/login', { user: 'admin', pass: 'nope' }, undefined, 'POST') + expect(body.status).toBe('error') + expect(body.token).toBeUndefined() + }) + + it('answers invalid-token for a missing, unknown or logged-out token', async () => { + expect((await call('dashboard/stats/get')).body.status).toBe('invalid-token') + expect((await call('dashboard/stats/get', {}, 'bogus')).body.status).toBe('invalid-token') + const token = await login() + await call('user/logout', {}, token) + expect((await call('user/session/get', {}, token)).body.status).toBe('invalid-token') + }) + + it('accepts the token as a parameter too', async () => { + const token = await login() + expect((await call('user/checkForUpdate', { token })).body.response.updateAvailable).toBe(false) + }) +}) + +describe('read-only', () => { + it('refuses everything that is not a listed read', async () => { + const token = await login() + const { code, body } = await call('zones/delete', { zone: 'x' }, token, 'POST') + expect(code).toBe(403) + expect(body).toEqual({ status: 'error', errorMessage: 'The hub is read-only.' }) + expect(a.calls).toHaveLength(0) + }) +}) + +describe('single node', () => { + it('forwards to that server with its token and without node', async () => { + const token = await login() + const { body } = await call('dashboard/stats/get', { type: 'LastDay', node: 'b' }, token) + expect(body.response.stats.totalQueries).toBe(300) + expect(a.calls).toHaveLength(0) + expect(b.calls).toHaveLength(1) + expect(b.calls[0].query.get('token')).toBe('token-b') + expect(b.calls[0].query.has('node')).toBe(false) + expect(b.calls[0].query.get('type')).toBe('LastDay') + }) + + it('reports an unreachable server by its id', async () => { + const token = await login() + a.down = true + const { body } = await call('settings/get', { node: 'a' }, token) + expect(body.status).toBe('error') + expect(body.errorMessage).toMatch(/^a did not answer: /) + expect(body.errorMessage).not.toContain('token-a') + }) + + it('rejects an unknown node', async () => { + const token = await login() + expect((await call('settings/get', { node: 'zzz' }, token)).body.status).toBe('error') + }) +}) + +describe('aggregate', () => { + it('sums the stats of every server', async () => { + const token = await login() + for (const node of [undefined, 'cluster']) { + const params: Record = { type: 'LastDay' } + if (node) params.node = node + const { body } = await call('dashboard/stats/get', params, token) + expect(body.response.stats.totalQueries).toBe(400) + expect(body.response.mainChartData.datasets[0].data).toEqual([400, 400]) + expect(body.hubWarnings).toBeUndefined() + } + }) + + it('merges the top lists by name', async () => { + const token = await login() + const { body } = await call('dashboard/stats/getTop', { type: 'LastDay', statsType: 'TopDomains', limit: '2' }, token) + expect(body.response.topDomains).toEqual([ + { name: 'x.example', hits: 400 }, + { name: 'only-100.example', hits: 1 }, + ]) + }) + + it('merges query logs across servers with node tags and pages', async () => { + const token = await login() + const page1 = await call('logs/query', { pageNumber: '1', entriesPerPage: '3', descendingOrder: 'true' }, token) + expect(page1.body.response.totalEntries).toBe(4) + expect(page1.body.response.totalPages).toBe(2) + expect(page1.body.response.entries.map((e: any) => [e.node, e.timestamp])).toEqual([ + ['a', '2026-01-01T10:00:04Z'], + ['b', '2026-01-01T10:00:03Z'], + ['a', '2026-01-01T10:00:02Z'], + ]) + // Each server was asked for the head of the list that page N needs. + expect(a.calls[0].query.get('entriesPerPage')).toBe('3') + expect(a.calls[0].query.get('pageNumber')).toBe('1') + + a.calls.length = 0 + const page2 = await call('logs/query', { pageNumber: '2', entriesPerPage: '3', descendingOrder: 'true' }, token) + expect(a.calls[0].query.get('entriesPerPage')).toBe('6') + expect(page2.body.response.entries.map((e: any) => [e.node, e.rowNumber])).toEqual([['b', 4]]) + + const asc = await call('logs/query', { pageNumber: '1', entriesPerPage: '2', descendingOrder: 'false' }, token) + expect(asc.body.response.entries.map((e: any) => e.timestamp)).toEqual(['2026-01-01T10:00:01Z', '2026-01-01T10:00:02Z']) + }) + + it('unions the apps by name and asks the first server for other reads', async () => { + const token = await login() + const apps = await call('apps/list', {}, token) + expect(apps.body.response.apps.map((x: any) => x.name)).toEqual(['Shared', 'App 100', 'App 300']) + const settings = await call('settings/get', {}, token) + expect(settings.body.response.from).toBe(100) + expect(b.calls).toHaveLength(1) + }) + + it('serves a repeated call from the cache', async () => { + const token = await login() + await call('dashboard/stats/get', { type: 'LastDay' }, token) + await call('dashboard/stats/get', { type: 'LastDay' }, token) + expect(a.calls).toHaveLength(1) + await call('dashboard/stats/get', { type: 'LastWeek' }, token) + expect(a.calls).toHaveLength(2) + }) + + it('answers with the other server and a warning when one is down', async () => { + const token = await login() + b.down = true + const { body } = await call('dashboard/stats/get', { type: 'Warn' }, token) + expect(body.status).toBe('ok') + expect(body.response.stats.totalQueries).toBe(100) + expect(body.hubWarnings).toHaveLength(1) + expect(body.hubWarnings[0]).toMatch(/^b: /) + }) + + it('is an error when every server is down', async () => { + const token = await login() + a.down = true + b.down = true + const { body } = await call('dashboard/stats/get', { type: 'Dead' }, token) + expect(body.status).toBe('error') + expect(body.errorMessage).toMatch(/No server answered/) + }) +}) + +describe('static files', () => { + it('serves files and the index of a folder, with the console headers on html', async () => { + const root = await fetch(`${base}/`) + expect(await root.text()).toBe('root') + expect(root.headers.get('content-security-policy')).toBe("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'") + expect(root.headers.get('x-content-type-options')).toBe('nosniff') + + const logs = await fetch(`${base}/logs/`) + expect(await logs.text()).toBe('logs') + + const js = await fetch(`${base}/assets/app.js`) + expect(js.headers.get('content-type')).toMatch(/^text\/javascript/) + }) + + it('redirects a folder without the slash and 404s the rest', async () => { + const redirect = await fetch(`${base}/logs`, { redirect: 'manual' }) + expect(redirect.status).toBe(301) + expect(redirect.headers.get('location')).toBe('/logs/') + expect((await fetch(`${base}/nope.js`)).status).toBe(404) + // A folder with no index of its own is not a route. + expect((await fetch(`${base}/assets`, { redirect: 'manual' })).status).toBe(404) + expect((await fetch(`${base}/..%2f..%2fetc/passwd`)).status).toBeGreaterThanOrEqual(400) + }) + + it('answers the api under a sub path', async () => { + const res = await fetch(`${base}/dashboard/api/status`) + expect(((await res.json()) as { status: string }).status).toBe('ok') + }) +}) diff --git a/hub/server.ts b/hub/server.ts new file mode 100644 index 0000000..1f0560a --- /dev/null +++ b/hub/server.ts @@ -0,0 +1,332 @@ +/* +The hub: a small server that impersonates ONE Technitium DNS Server for the console and +answers from several real ones. + + node --experimental-strip-types hub/server.ts + +It serves the console's static build and a read-only, Technitium-compatible `/api/*`. +A call aimed at one server (`node=`) is forwarded to it with that server's token; a +call with no node (or `node=cluster`) asks every server in parallel and merges the +answers (see merge.ts). Anything that would change data is refused. +*/ +import http from 'node:http' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts' +import { Upstream, UpstreamError, type Params } from './upstream.ts' +import { Sessions } from './session.ts' +import { + mergeQueryLogs, + mergeSettled, + mergeStats, + mergeTopResponses, + type DashboardStats, + type QueryLogPage, + type Settled, + type TopEntry, +} from './merge.ts' + +type Json = Record + +const MAX_BODY = 64 * 1024 + +/* Paths relative to `/api/`. Anything not listed here is refused with 403. */ +const READ_ENDPOINTS = new Set([ + 'dashboard/stats/get', + 'dashboard/stats/getTop', + 'dashboard/metrics/json', + 'apps/list', + 'logs/query', + 'logs/list', + 'settings/get', + 'user/checkForUpdate', + 'admin/cluster/state', +]) + +const TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json', +} + +const HTML_HEADERS = { + 'content-security-policy': "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'", + 'x-content-type-options': 'nosniff', +} + +function send(res: http.ServerResponse, code: number, body: unknown): void { + const text = JSON.stringify(body) + res.writeHead(code, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(text), + }) + res.end(text) +} + +const fail = (errorMessage: string): Json => ({ status: 'error', errorMessage }) + +async function readForm(req: http.IncomingMessage): Promise { + if (req.method !== 'POST') return new URLSearchParams() + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of req) { + size += (chunk as Buffer).length + if (size > 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 +} + +export function createHub(config: HubConfig): http.Server { + const upstream = new Upstream(config) + const sessions = new Sessions(config) + const byId = new Map(config.servers.map((s) => [s.id, s])) + + /* Ask every server; the ones that fail become warnings (see mergeSettled). */ + async function everywhere(apiPath: string, params: Params): Promise[]> { + const results = await Promise.allSettled(config.servers.map((s) => upstream.call(s, apiPath, params))) + return results.map((r, i): Settled => { + const node = config.servers[i].id + return r.status === 'fulfilled' + ? { node, ok: true, data: r.value as T } + : { node, ok: false, error: r.reason instanceof Error ? r.reason.message : 'failed' } + }) + } + + function ok(response: unknown, warnings: string[] = []): Json { + return warnings.length > 0 ? { status: 'ok', response, hubWarnings: warnings } : { status: 'ok', response } + } + + async function single(server: HubServer, apiPath: string, params: Params): Promise { + try { + return ok(await upstream.call(server, apiPath, params)) + } catch (e) { + if (e instanceof UpstreamError) { + return fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`) + } + throw e + } + } + + async function aggregate(apiPath: string, params: Params): Promise { + try { + switch (apiPath) { + case 'dashboard/stats/get': { + const { data, warnings } = mergeSettled(await everywhere(apiPath, params)) + return ok(mergeStats(data), warnings) + } + case 'dashboard/stats/getTop': { + const { data, warnings } = mergeSettled(await everywhere>(apiPath, params)) + const limit = params.limit === undefined ? undefined : positive(params.limit, 1000) + return ok(mergeTopResponses(data, limit), warnings) + } + case 'logs/query': { + const pageNumber = positive(params.pageNumber, 1) + const entriesPerPage = positive(params.entriesPerPage, 25) + // Page N of the merged list is cut from the head of every server's list. + const asked = { ...params, pageNumber: '1', entriesPerPage: String(pageNumber * entriesPerPage) } + const settled = await everywhere(apiPath, asked) + const { data, warnings } = mergeSettled(settled) + // Only the servers that answered, in server order, so the i-th page is the i-th survivor's. + const answered = settled.filter((r) => r.ok).map((r) => r.node) + const pages = data.map((page, i) => ({ node: answered[i], page })) + return ok(mergeQueryLogs(pages, { pageNumber, entriesPerPage, descending: params.descendingOrder !== 'false' }), warnings) + } + case 'apps/list': { + const { data, warnings } = mergeSettled(await everywhere<{ apps?: { name: string }[] }>(apiPath, params)) + const seen = new Map() + for (const r of data) for (const app of r.apps ?? []) if (!seen.has(app.name)) seen.set(app.name, app) + return ok({ ...data[0], apps: [...seen.values()] }, warnings) + } + default: + // Everything else read-only is answered by the first server. + return await single(config.servers[0], apiPath, params) + } + } catch (e) { + if (e instanceof Error && !(e instanceof UpstreamError)) return fail(e.message) + throw e + } + } + + async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise { + 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 + } + if (apiPath === 'user/login') { + const token = sessions.login(params.user ?? '', params.pass ?? '') + send(res, 200, token === null ? fail(`Invalid username or password for user: ${params.user ?? ''}`) : sessions.body(token)) + return + } + + const token = bearer(req) ?? params.token ?? null + if (!sessions.touch(token)) { + 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)) + return + } + if (apiPath === 'user/logout') { + sessions.logout(token as string) + send(res, 200, { status: 'ok' }) + return + } + if (!READ_ENDPOINTS.has(apiPath)) { + send(res, 403, fail('The hub is read-only.')) + return + } + if (apiPath === 'user/checkForUpdate') { + send(res, 200, ok({ updateAvailable: false })) + return + } + if (apiPath === 'admin/cluster/state') { + send( + res, + 200, + ok({ + version: HUB_VERSION, + dnsServerDomain: config.domain, + clusterInitialized: true, + clusterDomain: config.domain, + clusterNodes: config.servers.map((s, i) => ({ + id: i + 1, + name: s.id, + url: s.url, + ipAddresses: [], + type: i === 0 ? 'Primary' : 'Secondary', + state: 'Connected', + })), + }), + ) + return + } + + // What the real servers get: the caller's params minus the hub's own. + const { node = '', token: _token, ...forward } = params + const server = byId.get(node) + if (server !== undefined) { + send(res, 200, await single(server, apiPath, forward)) + return + } + if (node !== '' && node !== 'cluster') { + send(res, 200, fail(`Unknown node: ${node}`)) + return + } + send(res, 200, await aggregate(apiPath, forward)) + } + + async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { allow: 'GET, HEAD' }).end() + return + } + const root = path.resolve(config.staticDir) + let pathname: string + try { + pathname = decodeURIComponent(url.pathname) + } catch { + res.writeHead(400).end() + return + } + const target = path.resolve(root, '.' + pathname) + if (pathname.includes('\0') || (target !== root && !target.startsWith(root + path.sep))) { + res.writeHead(403).end() + return + } + + let file = target + let info = await stat(target).catch(() => null) + if (info?.isDirectory()) { + const index = path.join(target, 'index.html') + if ((await stat(index).catch(() => null))?.isFile() !== true) info = null + else if (!pathname.endsWith('/')) { + // As Technitium: `/x` -> `/x/` when the folder has an index. + res.writeHead(301, { location: `${url.pathname}/${url.search}` }).end() + return + } else { + file = index + info = await stat(index) + } + } + if (info === null || !info.isFile()) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Not found') + return + } + + const ext = path.extname(file).toLowerCase() + const headers: Record = { + 'content-type': TYPES[ext] ?? 'application/octet-stream', + 'content-length': info.size, + } + if (ext === '.html') Object.assign(headers, HTML_HEADERS, { 'cache-control': 'no-cache' }) + res.writeHead(200, headers) + if (req.method === 'HEAD') { + res.end() + return + } + createReadStream(file).on('error', () => res.destroy()).pipe(res) + } + + return http.createServer((req, res) => { + void (async () => { + try { + const url = new URL(req.url ?? '/', 'http://hub') + // The console may sit at a sub path: any `/api/` is the API. + const match = /^(?:.*\/)?api\/(.+?)\/?$/.exec(url.pathname) + if (match !== null) await api(req, res, match[1], url) + else await serveStatic(req, res, url) + } catch (e) { + console.error('hub error:', e instanceof Error ? e.message : e) + if (!res.headersSent) send(res, 500, fail('Internal hub error.')) + else res.destroy() + } + })() + }) +} + +/* 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) + } + createHub(config).listen(config.port, () => { + console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`) + }) +} diff --git a/hub/session.ts b/hub/session.ts new file mode 100644 index 0000000..afb959a --- /dev/null +++ b/hub/session.ts @@ -0,0 +1,111 @@ +/* +The hub's own sign-in. One user from the environment, sessions kept in memory with an +idle expiry, and the `info` object the console reads from `user/login` and +`user/session/get` (flat, as the real server sends it). +*/ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { HUB_VERSION, type HubConfig } from './config.ts' + +export const IDLE_MS = 30 * 60_000 + +/* Compared as hashes so the lengths always match and the time does not depend on the content. */ +function sameSecret(a: string, b: string): boolean { + const ha = createHash('sha256').update(a).digest() + const hb = createHash('sha256').update(b).digest() + return timingSafeEqual(ha, hb) +} + +/* +Every key `src/app/sections.ts` reads, spelled out: the console hides a section only +when `canView === false`, so a missing key would leave it visible. The hub shows the +Dashboard and the Logs; About has no permission and is always there. The hub is +read-only, so nothing can be modified or deleted. +*/ +const PERMISSION_KEYS = [ + 'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs', +] +const VIEWABLE = new Set(['Dashboard', 'Logs']) + +export class Sessions { + #config: HubConfig + #now: () => number + #started: string + #seen = new Map() + + constructor(config: HubConfig, now: () => number = Date.now) { + this.#config = config + this.#now = now + this.#started = new Date(now()).toISOString() + } + + /** A new session token, or null when the credentials are wrong. */ + login(user: string, password: string): string | null { + // Both are always compared, so a wrong user and a wrong password cost the same. + const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase()) + const passOk = sameSecret(password, this.#config.password) + if (!(userOk && passOk)) return null + this.#sweep() + const token = randomBytes(32).toString('hex') + this.#seen.set(token, this.#now()) + return token + } + + /** True while the token is valid; each use extends it. */ + touch(token: string | null | undefined): boolean { + if (!token) return false + const last = this.#seen.get(token) + if (last === undefined) return false + if (this.#now() - last > IDLE_MS) { + this.#seen.delete(token) + return false + } + this.#seen.set(token, this.#now()) + return true + } + + logout(token: string): void { + this.#seen.delete(token) + } + + #sweep(): void { + const now = this.#now() + for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token) + } + + info() { + const { servers, domain } = this.#config + return { + version: HUB_VERSION, + uptimestamp: this.#started, + dnsServerDomain: domain, + permissions: Object.fromEntries( + PERMISSION_KEYS.map((key) => [key, { canView: VIEWABLE.has(key), canModify: false, canDelete: false }]), + ), + /* The console offers its node selector when the cluster is initialised. The + first server plays the Primary (`primaryNodeName` looks for one). */ + clusterInitialized: true, + clusterDomain: domain, + clusterNodes: servers.map((s, i) => ({ name: s.id, type: i === 0 ? 'Primary' : 'Secondary' })), + defaultRecordTtl: 3600, + defaultNsRecordTtl: 3600, + defaultSoaRecordTtl: 900, + useSoaSerialDateScheme: true, + dnssecValidation: true, + hub: true, + } + } + + /** The flat body of `user/login` and `user/session/get`. */ + body(token: string) { + return { + status: 'ok', + token, + username: this.#config.user, + displayName: this.#config.user, + type: 'Local', + isSsoUser: false, + totpEnabled: false, + info: this.info(), + } + } +} diff --git a/hub/upstream.ts b/hub/upstream.ts new file mode 100644 index 0000000..a73caed --- /dev/null +++ b/hub/upstream.ts @@ -0,0 +1,97 @@ +/* +Calls to the real Technitium servers: `GET /api/?&token=`. +Successful answers are cached for a few seconds, so a dashboard that refreshes every +minute (and several people looking at it) does not load the servers. +*/ +import type { HubConfig, HubServer } from './config.ts' + +/* +`unreachable`: no answer (network, timeout, not JSON). `rejected`: the server answered +with a status other than `ok`. The hub never relays the server's `invalid-token`, or +the console would sign the hub's own user out because a hub token expired. +*/ +export class UpstreamError extends Error { + kind: 'unreachable' | 'rejected' + constructor(kind: 'unreachable' | 'rejected', message: string) { + super(message) + this.name = 'UpstreamError' + this.kind = kind + } +} + +export type Params = Record + +interface Cached { + until: number + response: unknown +} + +const MAX_CACHED = 500 + +export class Upstream { + #config: HubConfig + #fetch: typeof fetch + #cache = new Map() + #now: () => number + + constructor(config: HubConfig, fetchImpl: typeof fetch = fetch, now: () => number = Date.now) { + this.#config = config + this.#fetch = fetchImpl + this.#now = now + } + + /** The `response` of the server's envelope, or an `UpstreamError`. */ + async call(server: HubServer, path: string, params: Params = {}): Promise { + const sorted = Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + const key = `${server.id}\n${path}\n${new URLSearchParams(sorted).toString()}` + const hit = this.#cache.get(key) + if (hit !== undefined && hit.until > this.#now()) return hit.response + + const query = new URLSearchParams(sorted) + query.set('token', server.token) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs) + + let envelope: { status?: string; response?: unknown; errorMessage?: string } + try { + const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, { + signal: controller.signal, + headers: { accept: 'application/json' }, + }) + envelope = (await res.json()) as typeof envelope + } catch (e) { + throw new UpstreamError('unreachable', this.#describe(e, server)) + } finally { + clearTimeout(timer) + } + + if (envelope?.status !== 'ok') { + const why = + envelope?.status === 'invalid-token' + ? 'its API token was refused' + : (envelope?.errorMessage ?? `status ${String(envelope?.status)}`) + throw new UpstreamError('rejected', why) + } + + if (this.#config.cacheSeconds > 0) { + if (this.#cache.size >= MAX_CACHED) this.#prune() + this.#cache.set(key, { until: this.#now() + this.#config.cacheSeconds * 1000, response: envelope.response }) + } + return envelope.response + } + + #prune(): void { + const now = this.#now() + for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key) + // Still full of live entries: drop the oldest inserted. + while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string) + } + + /* A short reason without anything that could carry the token. */ + #describe(e: unknown, server: HubServer): string { + if (e instanceof Error && e.name === 'AbortError') return `timed out after ${this.#config.timeoutMs} ms` + const cause = e instanceof Error ? (e.cause as { code?: unknown } | undefined) : undefined + const text = typeof cause?.code === 'string' ? cause.code : e instanceof Error ? e.message : 'request failed' + return text.split(server.token).join('***') + } +} diff --git a/package.json b/package.json index 024529d..62f84c5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite", "mock": "node dev/mock-api.mjs", + "hub": "node --experimental-strip-types hub/server.ts", "build": "tsc -b && vite build", "lint": "oxlint", "lint:language": "node dev/check-language.mjs", diff --git a/vitest.config.ts b/vitest.config.ts index dad9484..dcfbcfe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { defineConfig } from 'vitest/config' +import { configDefaults, defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' /* The build injects this from package.json (see vite.config.ts). The tests run @@ -19,5 +19,8 @@ export default defineConfig({ menus in a row took more than the default 5 s there while taking about one here. */ testTimeout: 20000, hookTimeout: 20000, + /* Git worktrees of other sessions live under `.claude/`: their copies of the + tests are not this checkout's. */ + exclude: [...configDefaults.exclude, '.claude/**'], }, })