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:
DenozordecandClaude Opus 5.5 committed 2026-10-31 22:06:37 +07:00
1 parent 57e492cceb
commit 8bb1e01936
45 files changed
+1851 -190

No files matched your search

+56 -9
View File
@@ -6,9 +6,11 @@ the console reads from `user/login` and `user/session/get` (flat, as the real se
*/
import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
import { can, signature, type Role, type UserRecord, type Users } from './users.ts'
import { can, signature, type Capability, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000
/* A download link carries a token that works once, within this time. */
export const SINGLE_USE_MS = 60_000
export interface Identity {
user: string
@@ -20,16 +22,34 @@ interface Session extends Identity {
sig: string
}
interface SingleUse extends Identity {
until: number
sig: string
}
/*
Every key `src/app/sections.ts` reads, spelled out: the console hides a section only
when `canView === false`, so a missing key would leave it visible. The hub shows the
Dashboard, the Logs and the Apps; About has no permission and is always there. Only the Apps can be
modified or deleted, and only by a role with `apps.write`.
when `canView === false`, so a missing key would leave it visible. About has no permission
and is always there. Administration (users, SSO, LDAP of each server) is not offered.
What a role may change comes from its capabilities, per key. Settings never get `canDelete`
(that is the backup and restore, which the hub does not forward), nor do the Logs and the
Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs
`admin` to modify because its Import writes a zone. The Settings key also gates the
temporary blocking switch in the console, which the hub itself would let an operator use:
the console is the stricter of the two there.
*/
const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
]
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps'])
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps', 'Zones', 'Cache', 'Allowed', 'Blocked', 'DnsClient', 'Settings', 'DhcpServer'])
const MODIFIES: Record<string, Capability> = {
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate',
DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin',
}
const DELETES: Record<string, Capability> = {
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', DhcpServer: 'admin',
}
export class Sessions {
#config: HubConfig
@@ -37,6 +57,7 @@ export class Sessions {
#now: () => number
#started: string
#seen = new Map<string, Session>()
#single = new Map<string, SingleUse>()
#drift: () => number
@@ -75,9 +96,31 @@ export class Sessions {
this.#seen.delete(token)
}
/* A token for one download (the console opens it in a new tab, which has no Authorization header). */
openSingleUse(token: string): string | null {
const session = this.#seen.get(token)
if (session === undefined) return null
const once = randomBytes(32).toString('hex')
this.#single.set(once, { user: session.user, role: session.role, sig: session.sig, until: this.#now() + SINGLE_USE_MS })
return once
}
/* The identity behind a single-use token, which is spent by asking; null when it is unknown, used,
expired, or its user was removed or edited since. */
takeSingleUse(token: string | null | undefined): Identity | null {
if (!token) return null
const once = this.#single.get(token)
if (once === undefined) return null
this.#single.delete(token)
const record = this.#users.find(once.user)
if (this.#now() > once.until || record === undefined || signature(record) !== once.sig) return null
return { user: once.user, role: once.role }
}
#sweep(): void {
const now = this.#now()
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
for (const [token, once] of this.#single) if (now > once.until) this.#single.delete(token)
}
info(role: Role) {
@@ -87,10 +130,14 @@ export class Sessions {
uptimestamp: this.#started,
dnsServerDomain: domain,
permissions: Object.fromEntries(
PERMISSION_KEYS.map((key) => {
const writes = key === 'Apps' && can(role, 'apps.write')
return [key, { canView: VIEWABLE.has(key), canModify: writes, canDelete: writes }]
}),
PERMISSION_KEYS.map((key) => [
key,
{
canView: VIEWABLE.has(key),
canModify: key in MODIFIES && can(role, MODIFIES[key]),
canDelete: key in DELETES && can(role, DELETES[key]),
},
]),
),
/* The console offers its node selector when the cluster is initialised. The
first server plays the Primary (`primaryNodeName` looks for one). */