Hub stage 5: manage each server from one console

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>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 22:06:37 +07:00
1 parent 57e492cceb
commit 8bb1e01936
45 files changed
+1851 -190

No files matched your search

+31 -4
View File
@@ -8,11 +8,11 @@ read-only, Technitium-compatible `/api/*`:
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers
(statistics are summed, top lists and query logs are merged). If a server does not
answer, the others are shown and the response carries `hubWarnings`.
- Reads go with each server's read token. The only writes are the app changes below, which use a separate admin token and a role with `apps.write`. Anything else that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
- Reads go with each server's read token. Writes use a separate admin token: the app changes below (role with `apps.write`) and the per-server changes of "Managing one server" (roles with `operate` or `admin`). Anything else that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
The servers themselves are not changed: each only needs a read-only API token. Create it
for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
apps Query Logs reads from) and Settings (the blocking state).
The servers themselves are not changed: each needs a read-only API token (View, and nothing else, on Dashboard, Logs,
Apps, Settings, Zones, Cache, Allowed, Blocked, DnsClient and DhcpServer) and, for writes, an admin token whose group has
Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
## Configuration
@@ -86,6 +86,33 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo
Everything else answers `403 The hub is read-only.` and is audited.
## Managing one server
The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a
time. `routes.ts` lists exactly the paths the console calls; an `operate` role (`operator`, `admin`) may change the cache,
Blocked and Allowed, the temporary blocking switch and the list update; zones (records, DNSSEC, import), settings and DHCP
need `admin`. `viewer` and `apps-admin` only read.
- A write needs `node=<id>` of a server with an admin token. No node, `cluster` or an unknown id answers `400`
("Choose a server: this change is made on one server at a time."); a server without an admin token answers `403`.
- The parameters travel to the server in a form body (only `token` stays in the query). A zone import goes on as
`multipart/form-data` or `text/plain` (up to 20 MB); `settings/set`, record and scope writes take up to 2 MB and the
Blocked/Allowed imports up to 5 MB, for a signed-in caller only.
- `dnsClient/resolve` is a read, unless `import` is set: then it writes into a zone and needs `admin`.
- `settings/set` is refused when it changes a `webService*` field (compared with the server's current `settings/get`; the
console sends the block unchanged with every save) or carries a user, SSO or LDAP field. `settings/get` blanks the TSIG
shared secrets and the passwords for every role but `admin`.
- Reads that have no merger (`zones/list`, `cache/list`, `allowed|blocked/list`, `dhcp/*`, `logs/list`, `dnsClient/resolve`,
the exports) are refused without a node instead of being answered by the first server. `settings/get` is the exception:
the header's blocking mark reads it without a node.
- Downloads (`zones/export`, `logs/download`, `logs/export`, `allowed|blocked/export`) are passed through (type, file name,
body) with the read token. A new tab has no Authorization header, so `user/createSingleUseToken` gives a token that works
once, for 60 seconds, for these paths only.
- Refused, even for `admin`: `admin/*`, `user/*` (except the hub's own sign-in), `logs/delete`, `logs/deleteAll`,
`dashboard/stats/deleteAll`, `settings/backup`, `settings/restore` and `zones/permissions/*`.
- The audit line holds the user, role, address, path (dots for slashes), server, one identifier (zone, domain, name or scope)
and the outcome. Never a value.
## App writes
Needs the `apps.write` role (`apps-admin`, `admin`); others get `403 Your role does not allow this.` and a `denied-write` audit line.
+7 -3
View File
@@ -40,8 +40,12 @@ function fake(): Promise<Fake> {
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
const token = url.searchParams.get('token') ?? ''
const name = url.searchParams.get('name') ?? ''
f.calls.push({ path: apiPath, token, method: req.method ?? '', query: url.searchParams, body: Buffer.concat(chunks), contentType: String(req.headers['content-type'] ?? '') })
const contentType = String(req.headers['content-type'] ?? '')
// Technitium reads query or form alike; `query` here is both (`token` is the only one the hub leaves in the URL).
const query = new URLSearchParams(url.searchParams)
if (contentType.includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(Buffer.concat(chunks).toString('utf8'))) query.set(k, v)
const name = query.get('name') ?? ''
f.calls.push({ path: apiPath, token, method: req.method ?? '', query, body: Buffer.concat(chunks), contentType })
res.setHeader('content-type', 'application/json')
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
@@ -58,7 +62,7 @@ function fake(): Promise<Fake> {
return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
case 'apps/downloadAndInstall':
case 'apps/downloadAndUpdate': {
const entry = f.store.find((s) => s.name === name && s.url === url.searchParams.get('url'))
const entry = f.store.find((s) => s.name === name && s.url === query.get('url'))
if (!entry) return error('not in store')
f.apps[name] = { version: entry.version, config: f.apps[name]?.config ?? null }
return ok()
+96
View File
@@ -0,0 +1,96 @@
/*
Checks the hub makes on what passes through it to a server (stage 5): which `settings/set`
fields it will not forward, which values of `settings/get` a role may see, and which single
identifier of a write may go into the audit log.
Pure functions, so the tests feed them any input.
*/
/*
`settings/set` and the hub's own way in.
The hub reaches a server through that server's web service, with an API token. Changing the
service's bind addresses, ports, TLS or reverse-proxy settings from here can cut the hub off with
no way back except the server's own console, so the hub refuses a change to any `webService*`
field. The console sends the whole Web Service block with every save, unchanged values included,
so a field is refused only when its value DIFFERS from what the server holds now (see
`webServiceChanges`). Users, SSO and LDAP are not part of `settings/set`; a field that looks like
one (`user*`, `sso*`, `ldap*`, `auth*`) is refused outright, in case a server version adds it.
*/
export const WEB_SERVICE_MESSAGE = "Change the web service settings in that server's own console."
const FORBIDDEN_FIELD = /^(user|sso|ldap|auth)/i
const WEB_FIELD = /^webService/
/** True for a field the hub never forwards, whatever its value. */
export const isForbiddenSettingsField = (name: string): boolean => FORBIDDEN_FIELD.test(name)
/* A list as the console sends it (`a,b`, one per line, or `false` for none) or as the server holds it. */
function listOf(value: unknown): string[] {
const items = Array.isArray(value) ? value.map(String) : String(value ?? '').split(/[,\n]/)
return items.map((v) => v.trim()).filter((v) => v !== '' && v !== 'false')
}
/* Whether a submitted text means what the server holds. Booleans and numbers by value, lists as sets, text trimmed. */
function sameSetting(submitted: string, current: unknown): boolean {
if (typeof current === 'boolean') return submitted.trim().toLowerCase() === String(current)
if (typeof current === 'number') return submitted.trim() !== '' && Number(submitted) === current
if (Array.isArray(current)) {
const a = listOf(submitted).sort()
const b = listOf(current).sort()
return a.length === b.length && a.every((v, i) => v === b[i])
}
return submitted.trim() === String(current ?? '').trim()
}
/*
The `webService*` fields of a `settings/set` whose value differs from the server's current
settings (`settings/get`). A field the server does not report is not compared: it would be ignored
there too.
*/
export function webServiceChanges(submitted: Record<string, string>, current: unknown): string[] {
const now = (current !== null && typeof current === 'object' ? current : {}) as Record<string, unknown>
const changed: string[] = []
for (const [name, value] of Object.entries(submitted)) {
if (!WEB_FIELD.test(name) || !Object.hasOwn(now, name)) continue
if (!sameSetting(value, now[name])) changed.push(name)
}
return changed
}
/*
`settings/get` carries secrets: the TSIG shared secrets, the forward proxy password and the two
certificate passwords. A role without `admin` gets a copy with those values blanked (a viewer
needs the screen, not the keys); an admin gets them, since saving the form sends them back. The
blanked copy is for display only and `settings/set` is `admin`-only, so a blank cannot be saved
over a real secret by someone who may not read it.
*/
export function redactSettings(response: unknown): unknown {
if (response === null || typeof response !== 'object') return response
const copy = { ...(response as Record<string, unknown>) }
for (const key of ['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword']) {
if (typeof copy[key] === 'string' && copy[key] !== '') copy[key] = ''
}
if (Array.isArray(copy.tsigKeys)) {
copy.tsigKeys = copy.tsigKeys.map((k: unknown) => (k !== null && typeof k === 'object' ? { ...k, sharedSecret: '' } : k))
}
const proxy = copy.proxy
if (proxy !== null && typeof proxy === 'object' && typeof (proxy as { password?: unknown }).password === 'string') {
copy.proxy = { ...proxy, password: '' }
}
return copy
}
/*
The one identifier an audit line may carry for a write: a zone, a domain, a name or a scope. Never
the other parameters (records carry data, settings carry passwords and TSIG secrets). Cut short.
*/
const IDENTIFIERS = ['zone', 'domain', 'name', 'scope', 'zones']
export function safeTarget(params: Record<string, string>): string | undefined {
for (const key of IDENTIFIERS) {
const value = params[key]
if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 120)
}
return undefined
}
+128 -3
View File
@@ -3,8 +3,19 @@ What each `/api/<path>` does and who may call it. `kind` says what it touches:
`read` (no change), `write-one` (changes one server) and `write-all` (changes every server).
A path that is not listed is refused. The app writes need `apps.write`.
Not in the table: `status`, `user/login`, `user/session/get` and `user/logout`, which the
server answers itself before the table is consulted.
The sections a server is managed from (stage 5) are listed by what the console calls. A
write there goes to ONE server (`node=<id>`) with that server's admin token. Capabilities:
cache and Blocked/Allowed writes, the temporary blocking switch and the list update need
`operate`; zones (records and DNSSEC too), settings and DHCP need `admin`.
Left out on purpose, so they are refused: everything under `admin/` and `user/` (users,
groups, sessions, SSO, LDAP, tokens: those stay in each server's own console), `logs/delete`,
`logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup` and `settings/restore` (a
backup holds the users and their tokens), and `zones/permissions/*` (they name users).
Not in the table: `status`, `user/login`, `user/session/get`, `user/logout` and
`user/createSingleUseToken` (the hub's own, for downloads), which the server answers itself
before the table is consulted.
*/
import type { Capability } from './users.ts'
@@ -13,11 +24,22 @@ export type RouteKind = 'read' | 'write-one' | 'write-all'
export interface Route {
kind: RouteKind
capability: Capability
/* A read whose answer is a file, not JSON: it is passed through (see `Upstream.raw`). */
raw?: boolean
/* A read that has no merger: without `node=<id>` it is refused, not answered by the first server. */
perServer?: boolean
/* A write that may carry a file or a text body (a zone import). */
upload?: boolean
}
const read = (capability: Capability = 'read'): Route => ({ kind: 'read', capability })
const writeOne = (capability: Capability): Route => ({ kind: 'write-one', capability })
const writeAll = (capability: Capability): Route => ({ kind: 'write-all', capability })
/* A read that only makes sense for one server. */
const readOne = (): Route => ({ kind: 'read', capability: 'read', perServer: true })
/* A download: one server's file. */
const download = (): Route => ({ kind: 'read', capability: 'read', perServer: true, raw: true })
const upload = (capability: Capability): Route => ({ ...writeOne(capability), upload: true })
export const ROUTES: Record<string, Route> = {
'dashboard/stats/get': read(),
@@ -25,10 +47,81 @@ export const ROUTES: Record<string, Route> = {
'dashboard/metrics/json': read(),
'apps/list': read(),
'logs/query': read(),
'logs/list': read(),
'logs/list': readOne(),
'settings/get': read(),
'user/checkForUpdate': read(),
'admin/cluster/state': read(),
// Zones. Reads need a server; writes need `admin`.
'zones/list': readOne(),
'zones/catalogs/list': readOne(),
'zones/options/get': readOne(),
'zones/records/get': readOne(),
'zones/dnssec/properties/get': readOne(),
'zones/dnssec/viewDS': readOne(),
'zones/export': download(),
'zones/create': upload('admin'),
'zones/import': upload('admin'),
'zones/delete': writeOne('admin'),
'zones/enable': writeOne('admin'),
'zones/disable': writeOne('admin'),
'zones/resync': writeOne('admin'),
'zones/clone': writeOne('admin'),
'zones/convert': writeOne('admin'),
'zones/options/set': writeOne('admin'),
'zones/records/add': writeOne('admin'),
'zones/records/update': writeOne('admin'),
'zones/records/delete': writeOne('admin'),
'zones/dnssec/sign': writeOne('admin'),
'zones/dnssec/unsign': writeOne('admin'),
'zones/dnssec/properties/addPrivateKey': writeOne('admin'),
'zones/dnssec/properties/updatePrivateKey': writeOne('admin'),
'zones/dnssec/properties/deletePrivateKey': writeOne('admin'),
'zones/dnssec/properties/publishAllPrivateKeys': writeOne('admin'),
'zones/dnssec/properties/activateKskDnsKey': writeOne('admin'),
'zones/dnssec/properties/rolloverDnsKey': writeOne('admin'),
'zones/dnssec/properties/retireDnsKey': writeOne('admin'),
'zones/dnssec/properties/convertToNSEC': writeOne('admin'),
'zones/dnssec/properties/convertToNSEC3': writeOne('admin'),
'zones/dnssec/properties/updateNSEC3Params': writeOne('admin'),
'zones/dnssec/properties/updateDnsKeyTtl': writeOne('admin'),
// Cache.
'cache/list': readOne(),
'cache/flush': writeOne('operate'),
'cache/delete': writeOne('operate'),
// Blocked and Allowed.
'allowed/list': readOne(),
'blocked/list': readOne(),
'allowed/export': download(),
'blocked/export': download(),
'allowed/add': writeOne('operate'),
'allowed/delete': writeOne('operate'),
'allowed/flush': writeOne('operate'),
'allowed/import': writeOne('operate'),
'blocked/add': writeOne('operate'),
'blocked/delete': writeOne('operate'),
'blocked/flush': writeOne('operate'),
'blocked/import': writeOne('operate'),
// DHCP.
'dhcp/leases/list': readOne(),
'dhcp/scopes/list': readOne(),
'dhcp/scopes/get': readOne(),
'dhcp/scopes/set': writeOne('admin'),
'dhcp/scopes/enable': writeOne('admin'),
'dhcp/scopes/disable': writeOne('admin'),
'dhcp/scopes/delete': writeOne('admin'),
'dhcp/leases/remove': writeOne('admin'),
'dhcp/leases/convertToReserved': writeOne('admin'),
'dhcp/leases/convertToDynamic': writeOne('admin'),
// DNS Client. `import=true` turns a resolve into a zone write: see `effectiveRoute`.
'dnsClient/resolve': readOne(),
// Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node.
'settings/getTsigKeyNames': readOne(),
'settings/set': writeOne('admin'),
'settings/temporaryDisableBlocking': writeOne('operate'),
'settings/forceUpdateBlockLists': writeOne('operate'),
// Logs (no delete).
'logs/download': download(),
'logs/export': download(),
// The hub's own namespace; never forwarded to a server.
'hub/audit/list': read('audit.read'),
'hub/apps/matrix': read(),
@@ -69,4 +162,36 @@ export function routeFor(apiPath: string): Route | undefined {
return Object.hasOwn(ROUTES, apiPath) ? ROUTES[apiPath] : undefined
}
/*
The route for THIS call. `dnsClient/resolve` reads, but with `import=true` it also imports the
answer into a zone of the server: that variant is a zone write (`admin`, one server). Any value
but an absent or `false` one counts, so a spelling the server might still accept does not slip
through as a read.
*/
export function effectiveRoute(apiPath: string, params: Record<string, string>): Route | undefined {
if (apiPath === 'dnsClient/resolve') {
const flag = (params.import ?? '').trim().toLowerCase()
if (flag !== '' && flag !== 'false') return writeOne('admin')
}
return routeFor(apiPath)
}
/*
The largest body a signed-in caller may send to a path outside `hub/apps/`. The servers read the
rest with the small default, so a long TXT record or a block list import has to be listed here.
*/
const MB = 1024 * 1024
const LARGE_BODIES: Record<string, number> = {
'settings/set': 2 * MB,
'zones/records/add': 2 * MB,
'zones/records/update': 2 * MB,
'zones/options/set': 2 * MB,
'dhcp/scopes/set': 2 * MB,
'allowed/import': 5 * MB,
'blocked/import': 5 * MB,
}
export const largeBodyLimit = (apiPath: string): number | undefined =>
Object.hasOwn(LARGE_BODIES, apiPath) ? LARGE_BODIES[apiPath] : undefined
export const isHubPath = (apiPath: string): boolean => apiPath.startsWith('hub/')
+4 -4
View File
@@ -286,7 +286,7 @@ describe('audit', () => {
expect(adminLogin.body.hubRole).toBe('admin')
const viewer = (await login(base, 'vic', 'password2')).body.token as string
expect((await call(base, 'zones/delete', { zone: 'x', node: 'a' }, admin)).code).toBe(403)
expect((await call(base, 'admin/users/delete', { user: 'x', node: 'a' }, admin)).code).toBe(403)
expect((await call(base, 'hub/audit/list', {}, viewer)).code).toBe(403)
await call(base, 'user/logout', {}, viewer)
@@ -296,7 +296,7 @@ describe('audit', () => {
const actions = entries.map((e) => `${e.action}:${e.outcome}`)
expect(actions).toEqual(expect.arrayContaining(['login:failed', 'login:ok', 'refused-write:refused', 'denied-read:refused', 'logout:ok']))
const refused = entries.find((e) => e.action === 'refused-write')
expect(refused).toMatchObject({ user: 'alice', role: 'admin', target: 'zones/delete', server: 'a' })
expect(refused).toMatchObject({ user: 'alice', role: 'admin', target: 'admin/users/delete', server: 'a' })
const ts = entries.map((e) => e.ts)
expect([...ts].sort().reverse()).toEqual(ts)
@@ -319,11 +319,11 @@ describe('audit', () => {
describe('routes table', () => {
it('lists every old read endpoint as a read and hub/audit/list as audit.read', () => {
for (const p of [
'dashboard/stats/get', 'dashboard/stats/getTop', 'dashboard/metrics/json', 'apps/list', 'logs/query', 'logs/list',
'dashboard/stats/get', 'dashboard/stats/getTop', 'dashboard/metrics/json', 'apps/list', 'logs/query',
'settings/get', 'user/checkForUpdate', 'admin/cluster/state',
]) expect(ROUTES[p]).toEqual({ kind: 'read', capability: 'read' })
expect(routeFor('hub/audit/list')).toEqual({ kind: 'read', capability: 'audit.read' })
expect(routeFor('zones/delete')).toBeUndefined()
expect(routeFor('logs/delete')).toBeUndefined()
expect(routeFor('constructor')).toBeUndefined()
expect(routeFor('hub/nothing')).toBeUndefined()
})
+596
View File
@@ -0,0 +1,596 @@
// @vitest-environment node
/*
Stage 5: managing one chosen server from the hub (zones, cache, blocking, DNS client, settings,
DHCP, logs). The servers are fakes that record how they were called.
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdtempSync, readFileSync, rmSync } 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'
import { addUser, writeUsersFile } from './users.ts'
import { webServiceChanges, redactSettings, safeTarget } from './guards.ts'
import { effectiveRoute, routeFor } from './routes.ts'
interface Call {
path: string
method: string
token: string
/* The URL's query alone. */
query: URLSearchParams
/* The form body, when it is one. */
form: URLSearchParams
contentType: string
body: Buffer
}
interface Fake {
server: http.Server
url: string
calls: Call[]
settings: Record<string, unknown>
down: boolean
failWith: string | null
}
const SETTINGS = {
dnsServerDomain: 'a.example',
webServiceLocalAddresses: ['0.0.0.0', '[::]'],
webServiceHttpPort: 5380,
webServiceEnableTls: false,
webServiceTlsCertificatePath: null,
webServiceTlsCertificatePassword: '************',
tsigKeys: [{ keyName: 'k', sharedSecret: 'TOPSECRET', algorithmName: 'hmac-sha256' }],
proxy: { type: 'Http', address: 'p', port: 3128, username: 'u', password: 'PROXYPASS', bypass: [] },
dnsTlsCertificatePassword: 'CERTPASS',
enableBlocking: true,
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', calls: [], settings: { ...SETTINGS }, down: false, failWith: null }
f.server.on('request', (req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
if (f.down) return req.socket.destroy()
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
const body = Buffer.concat(chunks)
const contentType = String(req.headers['content-type'] ?? '')
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? body.toString('utf8') : '')
f.calls.push({ path: apiPath, method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form, contentType, body })
if (f.failWith !== null && req.method === 'POST') {
res.setHeader('content-type', 'application/json')
return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
}
if (apiPath === 'zones/export' || apiPath === 'logs/download' || apiPath === 'blocked/export') {
if (url.searchParams.get('zone') === 'missing.example') {
res.setHeader('content-type', 'application/json')
return res.end(JSON.stringify({ status: 'error', errorMessage: 'No such zone: missing.example' }))
}
res.writeHead(200, { 'content-type': 'text/plain', 'content-disposition': `attachment; filename="${apiPath.replace('/', '-')}.txt"` })
return res.end(`line one\nline two (${apiPath})\n`)
}
res.setHeader('content-type', 'application/json')
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
if (apiPath === 'settings/get') return ok(f.settings)
if (apiPath === 'zones/list') return ok({ zones: [{ name: 'example.com' }], pageNumber: 1, totalPages: 1 })
ok({ done: apiPath })
})
})
f.server.listen(0, '127.0.0.1', () => {
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
resolve(f)
})
})
}
let a: Fake
let b: Fake
let dataDir: string
let staticDir: string
let hub: http.Server | undefined
let base: string
const tokens: Record<string, string> = {}
const ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
async function start(extra: Record<string, string> = {}): Promise<void> {
hub?.close()
hub = createHub(
parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'read-a',
B_TOKEN: 'read-b',
A_ADMIN_TOKEN: 'admin-a',
B_ADMIN_TOKEN: 'admin-b',
HUB_DATA: dataDir,
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '0',
HUB_TIMEOUT_MS: '2000',
...extra,
}),
)
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
for (const role of ROLES) {
const res = await fetch(`${base}/api/user/login`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: `user=${role}&pass=password-${role}`,
})
tokens[role] = ((await res.json()) as { token: string }).token
}
}
interface Init {
params?: Record<string, string>
method?: string
body?: string | FormData
headers?: Record<string, string>
auth?: boolean
}
async function api(role: string, apiPath: string, init: Init = {}) {
const query = new URLSearchParams(init.params ?? {})
const headers: Record<string, string> = { ...(init.auth === false ? {} : { authorization: `Bearer ${tokens[role]}` }), ...(init.headers ?? {}) }
const res = await fetch(`${base}/api/${apiPath}?${query}`, { method: init.method ?? (init.body === undefined ? 'GET' : 'POST'), headers, body: init.body })
const text = await res.text()
let json: Record<string, any> = {}
try {
json = JSON.parse(text) as Record<string, any>
} catch {
// A file.
}
return { code: res.status, body: json, text, headers: res.headers }
}
const posts = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
const form = (params: Record<string, string>) => ({
body: new URLSearchParams(params).toString(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
})
async function auditEntries(): Promise<Record<string, string>[]> {
return (await api('admin', 'hub/audit/list', { params: { limit: '200' } })).body.response.entries
}
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s5-static-'))
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
users = addUser(users, 'operator', 'operator', 'password-operator')
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
users = addUser(users, 'admin', 'admin', 'password-admin')
writeUsersFile(dataDir, users)
await start()
})
beforeEach(() => {
for (const f of [a, b]) {
f.calls = []
f.down = false
f.failWith = null
f.settings = { ...SETTINGS }
}
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
/* One write of each area, with the lowest capability that may make it. */
const OPERATE = [
['cache/flush', {}],
['cache/delete', { domain: 'x.example' }],
['blocked/add', { domain: 'ads.example' }],
['allowed/delete', { domain: 'ok.example' }],
['settings/temporaryDisableBlocking', { minutes: '5' }],
['settings/forceUpdateBlockLists', {}],
] as const
const ADMIN = [
['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }],
['zones/delete', { zone: 'example.com' }],
['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }],
['dhcp/scopes/enable', { name: 'Default' }],
['settings/set', { enableBlocking: 'true' }],
] as const
describe('who may write what', () => {
it('refuses a viewer on every write, without calling a server', async () => {
for (const [p, params] of [...OPERATE, ...ADMIN]) {
const r = await api('viewer', p, { params: { node: 'a', ...params } })
expect(r.code, p).toBe(403)
expect(r.body.errorMessage).toBe('Your role does not allow this.')
}
expect(posts(a)).toHaveLength(0)
})
it('lets an operator operate (cache, blocking) but not change zones, settings or DHCP', async () => {
for (const [p, params] of OPERATE) expect((await api('operator', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
for (const [p, params] of ADMIN) expect((await api('operator', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
expect(posts(a).map((c) => c.path)).toEqual(OPERATE.map(([p]) => p))
})
it('lets an apps-admin do neither', async () => {
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('apps-admin', p, { params: { node: 'a', ...params } })).code, p).toBe(403)
expect(posts(a)).toHaveLength(0)
})
it('lets an admin do all of it', async () => {
for (const [p, params] of [...OPERATE, ...ADMIN]) expect((await api('admin', p, { params: { node: 'a', ...params } })).body.status, p).toBe('ok')
expect(posts(a)).toHaveLength(OPERATE.length + ADMIN.length)
})
it('tells the console what each role may modify', async () => {
const info = async (role: string) => (await api(role, 'user/session/get')).body.info.permissions
const viewer = await info('viewer')
const operator = await info('operator')
const admin = await info('admin')
expect(viewer.Zones).toEqual({ canView: true, canModify: false, canDelete: false })
expect(operator.Zones.canModify).toBe(false)
expect(operator.Cache).toEqual({ canView: true, canModify: true, canDelete: true })
expect(operator.Blocked.canModify).toBe(true)
expect(operator.Settings.canModify).toBe(false)
expect(admin.Zones).toEqual({ canView: true, canModify: true, canDelete: true })
expect(admin.DhcpServer).toEqual({ canView: true, canModify: true, canDelete: true })
// Backup and restore, deleting logs and stats: nobody.
for (const p of [viewer, operator, admin]) {
expect(p.Logs).toEqual({ canView: true, canModify: false, canDelete: false })
expect(p.Dashboard.canDelete).toBe(false)
expect(p.Settings.canDelete).toBe(false)
expect(p.Administration.canView).toBe(false)
}
})
})
describe('what stays refused', () => {
const REFUSED = [
'admin/users/list', 'admin/users/create', 'admin/sso/set', 'admin/ldap/set', 'admin/groups/list', 'admin/sessions/createToken',
'user/createToken', 'user/changePassword', 'user/profile/get',
'logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll',
'settings/restore', 'settings/backup', 'zones/permissions/get', 'zones/permissions/set',
]
it('answers "read-only" to each, even to an admin, and calls no server', async () => {
for (const p of REFUSED) {
const r = await api('admin', p, { params: { node: 'a' }, method: 'POST', body: '' })
expect(r.code, p).toBe(403)
expect(r.body.errorMessage, p).toBe('The hub is read-only.')
}
expect(a.calls).toHaveLength(0)
const refused = (await auditEntries()).filter((e) => e.action === 'refused-write')
expect(refused.map((e) => e.target)).toEqual(expect.arrayContaining(['logs/delete', 'settings/backup']))
})
})
describe('choosing the server', () => {
it('refuses a write with no node, the aggregate or an unknown node (400) and calls no server', async () => {
for (const node of [undefined, '', 'cluster', 'zzz']) {
const r = await api('admin', 'cache/flush', { params: node === undefined ? {} : { node } })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toBe(node === 'zzz' ? 'Unknown node: zzz' : 'Choose a server: this change is made on one server at a time.')
}
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(0)
})
it('refuses a server the hub has no admin token for (403), and audits it', async () => {
await start({ B_ADMIN_TOKEN: '' })
const r = await api('admin', 'cache/flush', { params: { node: 'b' } })
expect(r.code).toBe(403)
expect(r.body.errorMessage).toMatch(/no admin token/)
expect(b.calls).toHaveLength(0)
expect((await api('admin', 'cache/flush', { params: { node: 'a' } })).body.status).toBe('ok')
expect((await auditEntries()).find((e) => e.detail === 'no admin token')).toMatchObject({ server: 'b', outcome: 'refused' })
await start()
})
it('writes only to the chosen server', async () => {
await api('admin', 'cache/flush', { params: { node: 'b' } })
expect(posts(a)).toHaveLength(0)
expect(posts(b)).toHaveLength(1)
})
it('refuses a read that has no merger when no server is named', async () => {
for (const p of ['zones/list', 'cache/list', 'blocked/list', 'dhcp/scopes/list', 'logs/list', 'dnsClient/resolve', 'zones/export']) {
const r = await api('viewer', p)
expect(r.code, p).toBe(400)
expect(r.body.errorMessage).toBe('Choose a server: this is read from one server at a time.')
expect((await api('viewer', p, { params: { node: 'cluster' } })).code, p).toBe(400)
}
expect(a.calls).toHaveLength(0)
const one = await api('viewer', 'zones/list', { params: { node: 'a', pageNumber: '1' } })
expect(one.body.response.zones[0].name).toBe('example.com')
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
})
})
describe('how the hub calls the server', () => {
it('sends the parameters in a form body and leaves only the token in the query', async () => {
const r = await api('admin', 'zones/records/add', {
params: { node: 'a', zone: 'example.com' },
...form({ domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' }),
})
expect(r.body.status).toBe('ok')
const [call] = posts(a)
expect(call.path).toBe('zones/records/add')
expect(call.token).toBe('admin-a')
expect([...call.query.keys()]).toEqual(['token'])
expect(call.contentType).toContain('application/x-www-form-urlencoded')
expect(Object.fromEntries(call.form)).toEqual({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'v=spf1 secret-value' })
})
it('forwards a GET the console sent as a POST', async () => {
await api('admin', 'zones/enable', { params: { node: 'a', zone: 'example.com' } })
expect(posts(a)[0].form.get('zone')).toBe('example.com')
expect(posts(a)[0].form.has('node')).toBe(false)
})
it('never forwards the caller token or node, and uses the admin token alone', async () => {
await api('admin', 'cache/flush', { params: { node: 'a', token: 'evil' } })
const call = posts(a)[0]
expect(call.token).toBe('admin-a')
expect(call.form.has('token')).toBe(false)
expect(call.form.has('node')).toBe(false)
})
it('relays the server refusal as an error naming the server', async () => {
a.failWith = 'Zone already exists.'
const r = await api('admin', 'zones/create', { params: { node: 'a', zone: 'example.com', type: 'Primary' }, method: 'POST' })
expect(r.code).toBe(200)
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: Zone already exists.' })
})
it('says so when the server does not answer', async () => {
a.down = true
const r = await api('admin', 'cache/flush', { params: { node: 'a' } })
expect(r.body.status).toBe('error')
expect(r.body.errorMessage).toMatch(/^a did not answer/)
})
})
describe('the audit line', () => {
it('has the path, the server, one identifier and the outcome, and no value', async () => {
await api('operator', 'blocked/add', { params: { node: 'a' }, ...form({ domain: 'ads.example', note: 'PRIVATE-NOTE' }) })
await api('admin', 'zones/records/add', { params: { node: 'b' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'SECRET-TXT' }) })
await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ tsigKeys: 'k|TOPSECRET|hmac-sha256', proxyPassword: 'PROXYPASS2' }) })
b.failWith = 'Denied for SECRET-TXT.'
await api('admin', 'zones/delete', { params: { node: 'b', zone: 'example.com' } })
const entries = await auditEntries()
const mine = entries.filter((e) => /^(blocked|zones|settings)\./.test(e.action))
expect(mine.find((e) => e.action === 'blocked.add')).toMatchObject({ user: 'operator', role: 'operator', server: 'a', target: 'ads.example', outcome: 'ok' })
expect(mine.find((e) => e.action === 'zones.records.add')).toMatchObject({ user: 'admin', server: 'b', target: 'example.com', outcome: 'ok' })
expect(mine.find((e) => e.action === 'settings.set')).toMatchObject({ server: 'a', outcome: 'ok' })
expect(mine.find((e) => e.action === 'zones.delete')).toMatchObject({ outcome: 'failed', detail: 'refused by the server' })
expect(mine.find((e) => e.action === 'blocked.add')?.ip).toBeTruthy()
const raw = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
for (const value of ['PRIVATE-NOTE', 'SECRET-TXT', 'TOPSECRET', 'PROXYPASS2', 'admin-a', 'admin-b', 'ads.example"}']) expect(raw).not.toContain(value)
})
})
describe('settings/set', () => {
const unchanged = {
webServiceLocalAddresses: '0.0.0.0,[::]',
webServiceHttpPort: '5380',
webServiceEnableTls: 'false',
webServiceTlsCertificatePath: '',
webServiceTlsCertificatePassword: '************',
}
it('passes a save that carries the web service block unchanged, as the console sends it', async () => {
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, enableBlocking: 'false', dnsServerDomain: 'a2.example' }) })
expect(r.body.status).toBe('ok')
expect(r.body.server).toBe('a')
expect(posts(a)[0].form.get('enableBlocking')).toBe('false')
})
it.each([
['webServiceHttpPort', '5381'],
['webServiceLocalAddresses', '127.0.0.1'],
['webServiceEnableTls', 'true'],
['webServiceTlsCertificatePassword', 'new-pass'],
])('refuses a changed %s and forwards nothing', async (name, value) => {
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ ...unchanged, [name]: value, enableBlocking: 'false' }) })
expect(r.code).toBe(400)
expect(r.body.errorMessage).toBe("Change the web service settings in that server's own console.")
expect(posts(a)).toHaveLength(0)
const entry = (await auditEntries()).find((e) => e.action === 'settings.set' && e.outcome === 'refused')
expect(entry?.detail).toContain(name)
expect(entry?.detail).not.toContain(value)
})
it('refuses user, SSO and LDAP fields whatever their value', async () => {
for (const field of ['ssoEnabled', 'ldapServer', 'userName', 'authSessionTimeout']) {
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ [field]: 'x' }) })
expect(r.code, field).toBe(400)
}
expect(posts(a)).toHaveLength(0)
})
it('does not save when the current settings cannot be read first', async () => {
a.down = true
const r = await api('admin', 'settings/set', { params: { node: 'a' }, ...form({ enableBlocking: 'false' }) })
expect(r.body.status).toBe('error')
expect(r.body.errorMessage).toMatch(/nothing was changed/)
})
it('compares as the console writes (lists, numbers, booleans)', () => {
const current = { webServiceLocalAddresses: ['0.0.0.0', '[::]'], webServiceHttpPort: 5380, webServiceEnableTls: false, webServiceRealIpHeader: 'X-Real-IP', other: 'x' }
expect(webServiceChanges({ webServiceLocalAddresses: '[::]\n0.0.0.0', webServiceHttpPort: '5380', webServiceEnableTls: 'False', webServiceRealIpHeader: 'X-Real-IP', other: 'changed' }, current)).toEqual([])
expect(webServiceChanges({ webServiceHttpPort: '' }, current)).toEqual(['webServiceHttpPort'])
expect(webServiceChanges({ webServiceLocalAddresses: 'false' }, current)).toEqual(['webServiceLocalAddresses'])
expect(webServiceChanges({ webServiceUnknownField: 'x' }, current)).toEqual([])
})
})
describe('settings/get', () => {
it('blanks the secrets for every role but admin', async () => {
for (const role of ['viewer', 'operator', 'apps-admin']) {
const r = await api(role, 'settings/get', { params: { node: 'a' } })
expect(r.body.status).toBe('ok')
const s = r.body.response
expect(s.tsigKeys).toEqual([{ keyName: 'k', sharedSecret: '', algorithmName: 'hmac-sha256' }])
expect(s.proxy).toMatchObject({ address: 'p', username: 'u', password: '' })
expect(s.dnsTlsCertificatePassword).toBe('')
expect(s.webServiceTlsCertificatePassword).toBe('')
expect(s.enableBlocking).toBe(true)
expect(r.text).not.toMatch(/TOPSECRET|PROXYPASS|CERTPASS/)
}
const admin = (await api('admin', 'settings/get', { params: { node: 'a' } })).body.response
expect(admin.tsigKeys[0].sharedSecret).toBe('TOPSECRET')
expect(admin.proxy.password).toBe('PROXYPASS')
})
it('also blanks the answer given without a node (the first server)', async () => {
const r = await api('viewer', 'settings/get')
expect(r.body.response.tsigKeys[0].sharedSecret).toBe('')
})
it('does not change what it is given', () => {
const input = { tsigKeys: [{ sharedSecret: 's' }], proxy: null }
expect(redactSettings(input)).toEqual({ tsigKeys: [{ sharedSecret: '' }], proxy: null })
expect(input.tsigKeys[0].sharedSecret).toBe('s')
expect(redactSettings(null)).toBeNull()
})
})
describe('dnsClient/resolve', () => {
it('is a plain read, but with import it is a zone write needing admin', async () => {
expect(effectiveRoute('dnsClient/resolve', {})).toMatchObject({ kind: 'read', capability: 'read' })
expect(effectiveRoute('dnsClient/resolve', { import: 'false' })?.kind).toBe('read')
for (const flag of ['true', 'True', ' TRUE ', '1', 'yes']) {
expect(effectiveRoute('dnsClient/resolve', { import: flag })).toMatchObject({ kind: 'write-one', capability: 'admin' })
}
expect((await api('viewer', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', type: 'A' } })).body.status).toBe('ok')
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
for (const role of ['viewer', 'operator', 'apps-admin']) {
expect((await api(role, 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).code, role).toBe(403)
}
expect(posts(a)).toHaveLength(0)
expect((await api('admin', 'dnsClient/resolve', { params: { node: 'a', domain: 'example.com', import: 'true' } })).body.status).toBe('ok')
expect(posts(a)[0]).toMatchObject({ token: 'admin-a' })
expect(posts(a)[0].form.get('import')).toBe('true')
})
})
describe('downloads', () => {
it('passes a file through with the reader token', async () => {
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'example.com' } })
expect(r.code).toBe(200)
expect(r.text).toBe('line one\nline two (zones/export)\n')
expect(r.headers.get('content-type')).toBe('text/plain')
expect(r.headers.get('content-disposition')).toContain('attachment')
expect(r.headers.get('x-content-type-options')).toBe('nosniff')
expect(a.calls[0]).toMatchObject({ method: 'GET', token: 'read-a' })
})
it('turns the server error envelope into the hub one', async () => {
const r = await api('viewer', 'zones/export', { params: { node: 'a', zone: 'missing.example' } })
expect(r.body).toEqual({ status: 'error', errorMessage: 'a: No such zone: missing.example' })
})
it('opens a download with a single-use token, once', async () => {
const issued = await api('viewer', 'user/createSingleUseToken')
const once = issued.body.response.token as string
expect(once).toMatch(/^[0-9a-f]{64}$/)
const first = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
expect(first.text).toContain('logs/download')
const second = await api('viewer', 'logs/download', { auth: false, params: { node: 'a', fileName: '2026-01-01', token: once } })
expect(second.body.status).toBe('invalid-token')
})
it('does not accept a single-use token for anything but a download', async () => {
const once = (await api('admin', 'user/createSingleUseToken')).body.response.token as string
const r = await api('admin', 'cache/flush', { auth: false, params: { node: 'a', token: once } })
expect(r.body.status).toBe('invalid-token')
expect(posts(a)).toHaveLength(0)
expect((await api('admin', 'zones/list', { auth: false, params: { node: 'a', token: once } })).body.status).toBe('invalid-token')
})
it('gives no single-use token without a session', async () => {
expect((await api('viewer', 'user/createSingleUseToken', { auth: false })).body.status).toBe('invalid-token')
})
})
describe('uploads and large bodies', () => {
it('forwards a pasted zone file as text/plain, with the options in the query', async () => {
const r = await api('admin', 'zones/import', {
params: { node: 'a', zone: 'example.com', overwrite: 'true' },
body: '$ORIGIN example.com.\n@ IN SOA ns hostmaster 1 2 3 4 5\n',
headers: { 'content-type': 'text/plain' },
})
expect(r.body.status).toBe('ok')
const call = posts(a)[0]
expect(call.token).toBe('admin-a')
expect(call.contentType).toContain('text/plain')
expect(call.body.toString()).toContain('$ORIGIN example.com.')
expect(call.query.get('zone')).toBe('example.com')
expect(call.query.get('overwrite')).toBe('true')
})
it('forwards a zone file sent as multipart, and a create with no body', async () => {
const data = new FormData()
data.append('fileImportZone', new Blob(['zone text'], { type: 'text/plain' }), 'example.com.zone')
const r = await api('admin', 'zones/create', { params: { node: 'b', zone: 'example.com', type: 'Primary' }, body: data })
expect(r.body.status).toBe('ok')
const call = posts(b)[0]
expect(call.contentType).toContain('multipart/form-data')
expect(call.body.toString()).toContain('zone text')
expect(call.body.toString()).toContain('name="fileImportZone"')
expect(call.query.get('zone')).toBe('example.com')
await api('admin', 'zones/create', { params: { node: 'a', zone: 'plain.example', type: 'Primary' }, method: 'POST' })
expect(posts(a)[0].form.get('zone')).toBe('plain.example')
})
it('does not take an upload from a role that may not write zones', async () => {
const r = await api('operator', 'zones/import', { params: { node: 'a', zone: 'example.com' }, body: 'x', headers: { 'content-type': 'text/plain' } })
expect(r.code).toBe(403)
expect(posts(a)).toHaveLength(0)
})
it('takes a long TXT record from a signed-in caller, and a small limit from anyone else', async () => {
const text = 'v=spf1 ' + 'a'.repeat(200_000)
const r = await api('admin', 'zones/records/add', { params: { node: 'a' }, ...form({ zone: 'example.com', domain: 'www.example.com', type: 'TXT', text }) })
expect(r.body.status).toBe('ok')
expect(posts(a)[0].form.get('text')).toBe(text)
// Not signed in: the small default applies (the hub drops the connection), and nothing reaches the server.
const anon = await api('admin', 'zones/records/add', { auth: false, params: { node: 'a' }, ...form({ zone: 'example.com', text }) }).catch(() => null)
expect(anon?.body.status).not.toBe('ok')
expect(posts(a)).toHaveLength(1)
})
it('takes a block list import of a few MB', async () => {
const list = Array.from({ length: 150_000 }, (_, i) => `d${i}.example`).join(',')
const r = await api('operator', 'blocked/import', { params: { node: 'a' }, ...form({ blockedZones: list }) })
expect(r.body.status).toBe('ok')
expect(posts(a)[0].form.get('blockedZones')).toBe(list)
})
})
describe('the table', () => {
it('has every area and none of the refused paths', () => {
expect(routeFor('zones/records/add')).toMatchObject({ kind: 'write-one', capability: 'admin' })
expect(routeFor('cache/flush')).toMatchObject({ kind: 'write-one', capability: 'operate' })
expect(routeFor('settings/temporaryDisableBlocking')).toMatchObject({ kind: 'write-one', capability: 'operate' })
expect(routeFor('settings/get')).toEqual({ kind: 'read', capability: 'read' })
expect(routeFor('zones/export')).toMatchObject({ kind: 'read', raw: true, perServer: true })
for (const p of ['logs/delete', 'logs/deleteAll', 'dashboard/stats/deleteAll', 'settings/backup', 'settings/restore', 'admin/users/list', 'user/createToken']) {
expect(routeFor(p), p).toBeUndefined()
}
expect(safeTarget({ zone: 'example.com', text: 'x' })).toBe('example.com')
expect(safeTarget({ text: 'x' })).toBeUndefined()
})
})
+3 -2
View File
@@ -170,7 +170,8 @@ describe('session', () => {
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 })
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 () => {
@@ -196,7 +197,7 @@ describe('session', () => {
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')
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)
+156 -22
View File
@@ -4,15 +4,19 @@ 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.
It serves the console's static build and a Technitium-compatible `/api/*`. A read aimed at
one server (`node=<id>`) is forwarded to it with that server's token; a read with no node (or
`node=cluster`) asks every server in parallel and merges the answers (see merge.ts), and the
reads that have no merger (`perServer` in routes.ts) are refused without a node. A change
is made on ONE chosen server with its admin token, or by the app and config endpoints; whatever
is not in the table of routes.ts is refused.
*/
import http from 'node:http'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'
import type { ReadableStream as WebStream } from 'node:stream/web'
import { fileURLToPath } from 'node:url'
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
import { Upstream, UpstreamError, type Params } from './upstream.ts'
@@ -20,7 +24,8 @@ import { Sessions } from './session.ts'
import { Users, can } from './users.ts'
import { LoginLimiter, lockedMessage } from './ratelimit.ts'
import { Audit } from './audit.ts'
import { routeFor } from './routes.ts'
import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts'
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts'
import { buildMatrix } from './apps-matrix.ts'
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
@@ -363,8 +368,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
/* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, kind: string, params: Params, who: Who, ip: string): Promise<void> {
if (kind === 'write-all') {
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
if (route.kind === 'write-all') {
if (req.method !== 'POST') {
send(res, 405, fail('Use POST.'))
return
@@ -421,12 +426,12 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
// write-one: an upstream endpoint aimed at ONE server (`node=<id>`).
const action = actionForPath(apiPath)
const node = params.node ?? ''
const server = byId.get(node)
if (action === null) {
send(res, 403, fail('The hub is read-only.'))
await serverWrite(req, res, apiPath, route, params, who, ip)
return
}
const node = params.node ?? ''
const server = byId.get(node)
if (server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose one server: this change cannot be made on every server at once here.' : `Unknown node: ${node}`))
return
@@ -462,12 +467,129 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 200, result.ok ? ok(response) : fail(`${server.id}: ${result.message}`))
}
const textBody = (req: http.IncomingMessage): boolean => /^text\/plain/i.test(String(req.headers['content-type'] ?? ''))
/*
A change to ONE server from the sections it is managed from (zones, cache, blocking, DNS client,
settings, DHCP). The role has been checked against the route. The server is the one named by
`node`, and its admin token does the work. What is forwarded is exactly the parameters that were
looked at here, in a form body (see `Upstream.write`), minus `node` and `token`. The audit line
holds the path, the server, one identifier and the outcome, never a value.
*/
async function serverWrite(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
const node = params.node ?? ''
const server = byId.get(node)
const line = { user: who.user, role: who.role, ip, action: apiPath.replace(/\//g, '.') }
if (server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this change is made on one server at a time.' : `Unknown node: ${node}`))
return
}
if (server.adminToken === undefined) {
audit.log({ ...line, server: server.id, target: safeTarget(params), outcome: 'refused', detail: 'no admin token' })
send(res, 403, fail(`${server.id}: the hub has no admin token for this server, so it cannot change it.`))
return
}
const forward: Params = {}
for (const [k, v] of Object.entries(params)) if (k !== 'node' && k !== 'token') forward[k] = v
// A file or a text body (a zone import). The parameters may also arrive as multipart fields.
let body: FormData | string | undefined
if (route.upload === true && (isMultipart(req) || textBody(req))) {
let data: Buffer
try {
data = await readLimited(req)
} catch (e) {
if (e instanceof TooLarge) {
res.setHeader('connection', 'close')
send(res, 413, fail(e.message))
} else send(res, 400, fail('The upload could not be read.'))
return
}
if (isMultipart(req)) {
let parsed
try {
parsed = parseMultipart(data, String(req.headers['content-type'] ?? ''))
} catch {
send(res, 400, fail('The upload could not be read.'))
return
}
for (const [k, v] of Object.entries(parsed.fields)) if (k !== 'node' && k !== 'token') forward[k] = v
const file = parsed.files[0]
if (file !== undefined) {
const form = new FormData()
form.append(file.field, new Blob([file.data], { type: file.type }), file.filename)
body = form
}
} else body = data.toString('utf8')
}
// settings/set: no change to the web service the hub itself uses, and nothing about users (guards.ts).
if (apiPath === 'settings/set') {
if (Object.keys(forward).some(isForbiddenSettingsField)) {
audit.log({ ...line, server: server.id, outcome: 'refused', detail: 'user, SSO or LDAP field' })
send(res, 400, fail("User, SSO and LDAP settings are changed in that server's own console."))
return
}
try {
upstream.invalidate(server.id)
const changed = webServiceChanges(forward, await upstream.call(server, 'settings/get', {}, server.adminToken))
if (changed.length > 0) {
audit.log({ ...line, server: server.id, outcome: 'refused', detail: `web service: ${changed.join(', ').slice(0, 200)}` })
send(res, 400, fail(WEB_SERVICE_MESSAGE))
return
}
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
audit.log({ ...line, server: server.id, outcome: 'failed', detail: 'could not read the current settings' })
send(res, 200, fail(`${server.id}: the web service settings could not be checked first (${e.message}); nothing was changed.`))
return
}
}
try {
const response = await upstream.write(server, apiPath, forward, body)
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'ok' })
send(res, 200, apiPath === 'settings/set' ? { ...ok(response), server: server.id } : ok(response))
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
audit.log({ ...line, server: server.id, target: safeTarget(forward), outcome: 'failed', detail: e.kind === 'unreachable' ? 'no answer' : 'refused by the server' })
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
}
/* A file from one server (a zone, a log, a block list), passed on as it comes. */
async function download(res: http.ServerResponse, server: HubServer, apiPath: string, params: Params): Promise<void> {
try {
const file = await upstream.raw(server, apiPath, params)
res.writeHead(200, {
'content-type': file.contentType,
...(file.disposition === undefined ? {} : { 'content-disposition': file.disposition }),
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
})
if (file.body === null) {
res.end()
return
}
res.on('close', () => file.abort())
Readable.fromWeb(file.body as WebStream).on('error', () => res.destroy()).pipe(res)
} catch (e) {
if (!(e instanceof UpstreamError)) throw e
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
}
}
async function api(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, url: URL): Promise<void> {
// A multipart body (an app zip) is read later, once the caller is known to be allowed to send it.
// A multipart body (an app zip, a zone file) and a text body (a pasted zone file) are read later,
// once the caller is known to be allowed to send them.
// A config body may be large, but only for a signed-in caller (token in the header or the query).
const large = (apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set')
&& sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const form = isMultipart(req) ? new URLSearchParams() : await readForm(req, !large ? MAX_BODY : apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY)
const own = apiPath.startsWith('hub/apps/') || apiPath === 'apps/config/set'
const wanted = own ? (apiPath === 'hub/apps/import' ? MAX_IMPORT_BODY : MAX_CONFIG_BODY) : largeBodyLimit(apiPath)
const large = wanted !== undefined && sessions.touch(bearer(req) ?? url.searchParams.get('token')) !== null
const deferred = isMultipart(req) || (routeFor(apiPath)?.upload === true && textBody(req))
const form = deferred ? new URLSearchParams() : await readForm(req, large ? (wanted as number) : MAX_BODY)
const params: Params = {}
for (const [k, v] of url.searchParams) params[k] = v
for (const [k, v] of form) params[k] = v
@@ -501,7 +623,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
const token = bearer(req) ?? params.token ?? null
const who = sessions.touch(token)
// A download opened in a new tab carries a single-use token instead of the session's.
const who = sessions.touch(token) ?? (req.method === 'GET' && routeFor(apiPath)?.raw === true ? sessions.takeSingleUse(token) : null)
if (who === null) {
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
return
@@ -516,7 +639,11 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 200, { status: 'ok' })
return
}
const route = routeFor(apiPath)
if (apiPath === 'user/createSingleUseToken') {
send(res, 200, ok({ token: sessions.openSingleUse(token as string) }))
return
}
const route = effectiveRoute(apiPath, params)
if (route === undefined) {
audit.log({
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
@@ -538,7 +665,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
return
}
if (route.kind !== 'read') {
await write(req, res, apiPath, route.kind, params, who, ip)
await write(req, res, apiPath, route, params, who, ip)
return
}
if (apiPath.startsWith('hub/apps/config/') || apiPath.startsWith('hub/apps/history/') || apiPath === 'hub/apps/overrides/current' || apiPath === 'hub/apps/export') {
@@ -591,15 +718,22 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
// 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))
// A read with no merger is never answered by "the first server" for the aggregate: it needs a server.
if (route.perServer === true && server === undefined) {
send(res, 400, fail(node === '' || node === 'cluster' ? 'Choose a server: this is read from one server at a time.' : `Unknown node: ${node}`))
return
}
if (node !== '' && node !== 'cluster') {
send(res, 200, fail(`Unknown node: ${node}`))
if (route.raw === true && server !== undefined) {
await download(res, server, apiPath, forward)
return
}
send(res, 200, await aggregate(apiPath, forward))
let answer: Json
if (server !== undefined) answer = await single(server, apiPath, forward)
else if (node !== '' && node !== 'cluster') answer = fail(`Unknown node: ${node}`)
else answer = await aggregate(apiPath, forward)
// The settings hold secrets (TSIG keys, passwords): only an admin sees them (guards.ts).
if (apiPath === 'settings/get' && !can(who.role, 'admin') && answer.status === 'ok') answer = { ...answer, response: redactSettings(answer.response) }
send(res, 200, answer)
}
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
+56 -9
View File
@@ -6,9 +6,11 @@ the console reads from `user/login` and `user/session/get` (flat, as the real se
*/
import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
import { can, signature, type Role, type UserRecord, type Users } from './users.ts'
import { can, signature, type Capability, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000
/* A download link carries a token that works once, within this time. */
export const SINGLE_USE_MS = 60_000
export interface Identity {
user: string
@@ -20,16 +22,34 @@ interface Session extends Identity {
sig: string
}
interface SingleUse extends Identity {
until: number
sig: string
}
/*
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, the Logs and the Apps; About has no permission and is always there. Only the Apps can be
modified or deleted, and only by a role with `apps.write`.
when `canView === false`, so a missing key would leave it visible. About has no permission
and is always there. Administration (users, SSO, LDAP of each server) is not offered.
What a role may change comes from its capabilities, per key. Settings never get `canDelete`
(that is the backup and restore, which the hub does not forward), nor do the Logs and the
Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs
`admin` to modify because its Import writes a zone. The Settings key also gates the
temporary blocking switch in the console, which the hub itself would let an operator use:
the console is the stricter of the two there.
*/
const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
]
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps'])
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps', 'Zones', 'Cache', 'Allowed', 'Blocked', 'DnsClient', 'Settings', 'DhcpServer'])
const MODIFIES: Record<string, Capability> = {
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate',
DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin',
}
const DELETES: Record<string, Capability> = {
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', DhcpServer: 'admin',
}
export class Sessions {
#config: HubConfig
@@ -37,6 +57,7 @@ export class Sessions {
#now: () => number
#started: string
#seen = new Map<string, Session>()
#single = new Map<string, SingleUse>()
#drift: () => number
@@ -75,9 +96,31 @@ export class Sessions {
this.#seen.delete(token)
}
/* A token for one download (the console opens it in a new tab, which has no Authorization header). */
openSingleUse(token: string): string | null {
const session = this.#seen.get(token)
if (session === undefined) return null
const once = randomBytes(32).toString('hex')
this.#single.set(once, { user: session.user, role: session.role, sig: session.sig, until: this.#now() + SINGLE_USE_MS })
return once
}
/* The identity behind a single-use token, which is spent by asking; null when it is unknown, used,
expired, or its user was removed or edited since. */
takeSingleUse(token: string | null | undefined): Identity | null {
if (!token) return null
const once = this.#single.get(token)
if (once === undefined) return null
this.#single.delete(token)
const record = this.#users.find(once.user)
if (this.#now() > once.until || record === undefined || signature(record) !== once.sig) return null
return { user: once.user, role: once.role }
}
#sweep(): void {
const now = this.#now()
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
for (const [token, once] of this.#single) if (now > once.until) this.#single.delete(token)
}
info(role: Role) {
@@ -87,10 +130,14 @@ export class Sessions {
uptimestamp: this.#started,
dnsServerDomain: domain,
permissions: Object.fromEntries(
PERMISSION_KEYS.map((key) => {
const writes = key === 'Apps' && can(role, 'apps.write')
return [key, { canView: VIEWABLE.has(key), canModify: writes, canDelete: writes }]
}),
PERMISSION_KEYS.map((key) => [
key,
{
canView: VIEWABLE.has(key),
canModify: key in MODIFIES && can(role, MODIFIES[key]),
canDelete: key in DELETES && can(role, DELETES[key]),
},
]),
),
/* The console offers its node selector when the cluster is initialised. The
first server plays the Primary (`primaryNodeName` looks for one). */
+79 -12
View File
@@ -21,6 +21,15 @@ export class UpstreamError extends Error {
export type Params = Record<string, string>
/* What `raw` hands back: the headers worth passing on and the body as a stream. */
export interface RawAnswer {
contentType: string
disposition?: string
body: ReadableStream<Uint8Array> | null
/* Stops the download (the caller went away). */
abort: () => void
}
interface Cached {
until: number
response: unknown
@@ -61,18 +70,73 @@ export class Upstream {
/*
A write: POST with the server's ADMIN token and never the read one, never cached. Whatever
the outcome, what the hub remembers of this server is dropped afterwards, so the next read
shows the state the write left. `body` is a multipart upload (the app zip).
shows the state the write left.
The parameters travel in a form body (`application/x-www-form-urlencoded`) and only `token`
stays in the query, so values (a record's data, a TSIG secret) do not end up in a proxy's
access log. The server reads query or form alike. A `body` that is already a form gets the
parameters added to it. Two bodies cannot be a form, so there the parameters stay in the
query: a `FormData` (a multipart upload, the app zip or a zone file) and a string (a zone
file as `text/plain`).
*/
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams): Promise<unknown> {
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams | string): Promise<unknown> {
if (server.adminToken === undefined) throw new UpstreamError('rejected', 'no admin token configured')
let query = new URLSearchParams()
const init: RequestInit = { method: 'POST' }
if (body === undefined || body instanceof URLSearchParams) {
const form = new URLSearchParams(body)
for (const [k, v] of Object.entries(params)) form.set(k, v)
init.body = form
} else {
query = new URLSearchParams(params)
init.body = body
if (typeof body === 'string') init.headers = { 'content-type': 'text/plain; charset=utf-8' }
}
try {
const init: RequestInit = body === undefined ? { method: 'POST' } : { method: 'POST', body }
return await this.#exec(server, path, new URLSearchParams(params), server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
return await this.#exec(server, path, query, server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
} finally {
this.invalidate(server.id)
}
}
/*
A file the server answers with (a zone export, a log, a block list): read with the READ token,
never cached. A JSON answer is the usual envelope, so an error is raised as for any call; any
other answer is handed back as it is, to be streamed to the caller. The timeout covers the wait
for the headers only: a big log may take longer to arrive.
*/
async raw(server: HubServer, path: string, params: Params = {}): Promise<RawAnswer> {
const query = new URLSearchParams(params)
query.set('token', server.token)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs)
let res: Response
try {
res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, { method: 'GET', signal: controller.signal })
} catch (e) {
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
} finally {
clearTimeout(timer)
}
const type = res.headers.get('content-type') ?? 'application/octet-stream'
if (/json/i.test(type)) {
let envelope: { status?: string; response?: unknown; errorMessage?: string }
try {
envelope = (await res.json()) as typeof envelope
} catch (e) {
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
}
throw envelope?.status === 'ok' ? new UpstreamError('rejected', 'it sent JSON where a file was expected') : this.#rejection(envelope)
}
if (!res.ok) throw new UpstreamError('rejected', `status ${res.status}`)
return {
contentType: type,
disposition: res.headers.get('content-disposition') ?? undefined,
body: res.body,
abort: () => controller.abort(),
}
}
/** Forget every cached answer of one server. */
invalidate(serverId: string): void {
const prefix = `${serverId}\n`
@@ -89,7 +153,7 @@ export class Upstream {
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
...init,
signal: controller.signal,
headers: { accept: 'application/json' },
headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) },
})
envelope = (await res.json()) as typeof envelope
} catch (e) {
@@ -98,16 +162,19 @@ export class Upstream {
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 (envelope?.status !== 'ok') throw this.#rejection(envelope)
return envelope.response
}
/* The error for an envelope that is not `ok`. */
#rejection(envelope: { status?: string; errorMessage?: string } | undefined): UpstreamError {
const why =
envelope?.status === 'invalid-token'
? 'its API token was refused'
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
return new UpstreamError('rejected', why)
}
#prune(): void {
const now = this.#now()
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)