One endpoint, hub/servers/run (operate), runs a fixed set of actions on the chosen servers in parallel with each server's admin token: add or remove a domain in Blocked or Allowed, disable blocking for a time or enable it, update block lists, replace the block list URLs, flush the cache or delete a domain from it. Each server gets its own result and the console offers Retry failed. In "All servers", Blocking, Cache and Query Logs rows open this flow instead of being disabled. The hub validates domains, minutes and the server's list grammar (allow lists with !, switched-off lines with #) and logs no URL list. Operators can now use the blocking switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
9.4 KiB
TypeScript
210 lines
9.4 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'),
|
|
'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'),
|
|
}
|
|
|
|
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,
|
|
'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/')
|