Files
technitium-console/hub/routes.ts
T
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:19 +07:00

243 lines
11 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`. `hub/servers/run` (blocking, Blocked/Allowed and cache on several
servers at once) needs `operate`.
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'), // see `effectiveRoute` for the blocking switch
'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'),
// Profiles (profiles.ts): bundles of apps, settings and lists. They hold settings secrets, so everything needs admin.
'hub/profiles/list': read('admin'),
'hub/profiles/get': read('admin'),
'hub/profiles/export': read('admin'),
'hub/profiles/preview': read('admin'),
'hub/profiles/snapshot': writeAll('admin'),
'hub/profiles/delete': writeAll('admin'),
'hub/profiles/import': writeAll('admin'),
'hub/profiles/apply': writeAll('admin'),
'hub/webhook/status': read('admin'),
'hub/webhook/test': writeAll('admin'),
'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'),
// The same change on several servers, outside Apps (servers-run.ts). One endpoint with a fixed list of actions.
'hub/servers/run': writeAll('operate'),
// Zones across the servers (zones-sync.ts). The merged list is zone names and serials only.
'hub/zones/matrix': read(),
'hub/zones/copy/preview': read('admin'),
'hub/zones/copy': writeAll('admin'),
'hub/zones/secondary/preview': read('admin'),
'hub/zones/secondary': writeAll('admin'),
// Settings and blocking lists across the servers (settings-config.ts). Settings carry secrets, so even reads need admin.
'hub/settings/compare': read('admin'),
'hub/settings/preview': read('admin'),
'hub/settings/history/list': read('admin'),
'hub/settings/history/get': read('admin'),
'hub/settings/baseline/import': writeAll('admin'),
'hub/settings/baseline/save': writeAll('admin'),
'hub/settings/overrides/save': writeAll('admin'),
'hub/settings/apply': writeAll('admin'),
'hub/blocking/lists/compare': read('admin'),
'hub/blocking/lists/preview': read('admin'),
'hub/blocking/lists/baseline/import': writeAll('admin'),
'hub/blocking/lists/apply': writeAll('admin'),
}
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')
}
/*
The blocking switch is blocking, not server settings: a `settings/set` that carries nothing but
`enableBlocking` needs `operate`, as the temporary switch does. Any other field in the call
makes it the usual `admin` write. (`node` and `token` are the hub's own.)
*/
if (apiPath === 'settings/set') {
const fields = Object.keys(params).filter((k) => k !== 'node' && k !== 'token')
if (fields.length === 1 && fields[0] === 'enableBlocking') return writeOne('operate')
}
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,
'hub/settings/baseline/save': 2 * MB,
'hub/settings/overrides/save': 2 * MB,
'hub/profiles/import': 5 * 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/')