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]>
198 lines
8.6 KiB
TypeScript
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/')
|