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

+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> {