Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be changed on one chosen server through the hub, with that server's admin token. Operators manage cache and blocking; admins also zones, settings and DHCP. Under "All servers", or on a server without an admin token, the controls are disabled with a hint. The hub refuses settings/set changes to a server's web service fields and anything about users, SSO or LDAP; backup, restore, log and stats deletion and admin/* stay refused. Non-admins get settings with secrets blanked. Write params travel in the POST body, downloads pass through with a single-use token, and the audit log names no values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
14 KiB
TypeScript
347 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: true, canModify: true, canDelete: true })
|
|
expect(body.info.permissions.Administration).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('admin/users/delete', { user: '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')
|
|
})
|
|
})
|