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
+56
-9
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user