/* What each `/api/` 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=`) 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=` 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 = { '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'), // The domains in an Advanced Blocking app's block lists, counted by the hub (app-lists.ts). One server's app. 'hub/blocking/app-lists/count': readOne(), '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): 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 = { '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/')