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 <[email protected]>
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
// @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
|
|
let dataDir: 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_DATA: dataDir,
|
|
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']))
|
|
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-data-'))
|
|
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 })
|
|
rmSync(dataDir, { 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 with half the credentials or without a token', () => {
|
|
const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' }
|
|
expect(() => parseConfig({ ...env, HUB_USER: 'u' })).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')
|
|
expect(body.hubWarnings).toBeUndefined()
|
|
})
|
|
|
|
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).toEqual([])
|
|
}
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|