Files
technitium-console/hub/routes.ts
T
DenozordecandClaude Opus 5.5 8bb1e01936 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 <[email protected]>
2026-10-31 22:06:37 +07:00

198 lines
8.6 KiB
TypeScript

/*
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`.
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'
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(),
'dashboard/stats/getTop': read(),
'dashboard/metrics/json': read(),
'apps/list': read(),
'logs/query': 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(),
'apps/listStoreApps': read(),
// Writes. They go to a server's ADMIN token only; see apps-write.ts.
'apps/downloadAndInstall': writeOne('apps.write'),
'apps/downloadAndUpdate': writeOne('apps.write'),
'apps/uninstall': writeOne('apps.write'),
'apps/install': writeOne('apps.write'),
'apps/update': writeOne('apps.write'),
// Config sync (apps-config.ts). Compare shows live configs to anyone who can read; the rest needs apps.write.
'apps/config/get': read(),
'apps/config/set': writeOne('apps.write'),
'hub/apps/config/compare': read(),
'hub/apps/config/preview': read('apps.write'),
'hub/apps/overrides/current': read('apps.write'),
'hub/apps/baseline/import': writeAll('apps.write'),
'hub/apps/baseline/save': writeAll('apps.write'),
'hub/apps/overrides/save': writeAll('apps.write'),
'hub/apps/config/apply': writeAll('apps.write'),
// History, drift, and moving baselines between hubs (apps-config.ts, drift.ts).
'hub/apps/history/list': read(),
'hub/apps/history/get': read(),
'hub/apps/history/diff': read(),
'hub/apps/history/rollback': writeAll('apps.write'),
'hub/apps/baseline/restore': writeAll('apps.write'),
'hub/apps/drift/accept': writeAll('apps.write'),
'hub/drift/get': read(),
'hub/drift/check': writeAll('apps.write'),
// Configs can hold secrets, so the file of all baselines needs apps.write even to read.
'hub/apps/export': read('apps.write'),
'hub/apps/import': writeAll('apps.write'),
'hub/apps/run': writeAll('apps.write'),
'hub/apps/upload': writeAll('apps.write'),
}
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/')