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:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
+31
-4
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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/')
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user