Hub stage 5: manage each server from one console
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
+128
-3
@@ -3,8 +3,19 @@ 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`.
|
||||
|
||||
Not in the table: `status`, `user/login`, `user/session/get` and `user/logout`, which the
|
||||
server answers itself before the table is consulted.
|
||||
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'
|
||||
|
||||
@@ -13,11 +24,22 @@ 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(),
|
||||
@@ -25,10 +47,81 @@ export const ROUTES: Record<string, Route> = {
|
||||
'dashboard/metrics/json': read(),
|
||||
'apps/list': read(),
|
||||
'logs/query': read(),
|
||||
'logs/list': 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(),
|
||||
@@ -69,4 +162,36 @@ 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/')
|
||||
Reference in new issue
Block a user