Zones gets "All servers" in hub mode: one table of every zone on every server with its type and serial, marking zones that are on one server only or whose serials or types differ. A zone can be copied to another server (export, create, import; Overwrite must be ticked for a zone that is already there) or made a Secondary there (the source's zone transfer and notify lists gain the target, then the target zone is created). Settings and Blocking can be compared across servers against a baseline kept in the hub. Host fields stay local, secrets are compared inside the hub only and never shown or logged, and an apply sends only the changed shared fields after saving each server's settings to history. Blocked and Allowed lists are compared as sets; extras are removed only when asked. Co-Authored-By: Claude Opus 5.5 <[email protected]>
231 lines
10 KiB
TypeScript
231 lines
10 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'),
|
|
// 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,
|
|
'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/')
|