Hub stage 2: read-only Technitium-compatible server over several servers
node:http server that serves the console build and a /api/* the console accepts: its own login and session (clusterInitialized, the servers as nodes, hub flag, Dashboard and Logs only), node=<id> forwarded with that server's token, no node or cluster merged (stats, top lists, query logs, apps), every write refused with 403, a short response cache, and an unreachable server reported as hubWarnings. The dev mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9df2c2b908
commit
5f29a438c9
8 files changed
+980
-7
No files matched your search
+14
-6
@@ -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 <n>` and `--scale <x>`: 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})`))
|
||||
@@ -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<string, string | undefined>
|
||||
|
||||
/* `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)
|
||||
}
|
||||
@@ -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<Fake> {
|
||||
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<string, string> = {}) {
|
||||
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<string> {
|
||||
await new Promise<void>((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<string, string> = {}, 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<string, any> }
|
||||
}
|
||||
|
||||
async function login(): Promise<string> {
|
||||
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'), '<html>root</html>')
|
||||
mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true })
|
||||
writeFileSync(path.join(staticDir, 'logs', 'index.html'), '<html>logs</html>')
|
||||
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<string, string> = { 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('<html>root</html>')
|
||||
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('<html>logs</html>')
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
+332
@@ -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=<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 {
|
||||
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
|
||||
|
||||
/* 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<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
|
||||
}
|
||||
|
||||
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<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' }
|
||||
})
|
||||
}
|
||||
|
||||
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<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
|
||||
}
|
||||
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<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)
|
||||
}
|
||||
|
||||
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 `<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()
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
/* 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(', ')}`)
|
||||
})
|
||||
}
|
||||
+111
@@ -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<string, number>()
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Calls to the real Technitium servers: `GET <url>/api/<path>?<params>&token=<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<string, string>
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
response: unknown
|
||||
}
|
||||
|
||||
const MAX_CACHED = 500
|
||||
|
||||
export class Upstream {
|
||||
#config: HubConfig
|
||||
#fetch: typeof fetch
|
||||
#cache = new Map<string, Cached>()
|
||||
#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<unknown> {
|
||||
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('***')
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+4
-1
@@ -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/**'],
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user