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>
176 lines
6.6 KiB
TypeScript
176 lines
6.6 KiB
TypeScript
/*
|
|
The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who
|
|
signed in and what that user looked like (role, hash, disabled flag) at that moment, so a
|
|
change to the users file ends the sessions of the users it touched. `info` is the object
|
|
the console reads from `user/login` and `user/session/get` (flat, as the real server sends it).
|
|
*/
|
|
import { randomBytes } from 'node:crypto'
|
|
import { HUB_VERSION, type HubConfig } from './config.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
|
|
role: Role
|
|
}
|
|
|
|
interface Session extends Identity {
|
|
last: number
|
|
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. 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', '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
|
|
#users: Users
|
|
#now: () => number
|
|
#started: string
|
|
#seen = new Map<string, Session>()
|
|
#single = new Map<string, SingleUse>()
|
|
|
|
#drift: () => number
|
|
|
|
constructor(config: HubConfig, users: Users, now: () => number = Date.now, drift: () => number = () => 0) {
|
|
this.#drift = drift
|
|
this.#config = config
|
|
this.#users = users
|
|
this.#now = now
|
|
this.#started = new Date(now()).toISOString()
|
|
}
|
|
|
|
/** Opens a session for a user already authenticated by `Users.authenticate`. */
|
|
open(user: UserRecord): string {
|
|
this.#sweep()
|
|
const token = randomBytes(32).toString('hex')
|
|
this.#seen.set(token, { user: user.name, role: user.role, last: this.#now(), sig: signature(user) })
|
|
return token
|
|
}
|
|
|
|
/* The session's identity while it is valid (extending it), else null. A user who was removed,
|
|
disabled or edited since sign-in ends the session. */
|
|
touch(token: string | null | undefined): Identity | null {
|
|
if (!token) return null
|
|
const session = this.#seen.get(token)
|
|
if (session === undefined) return null
|
|
const record = this.#users.find(session.user)
|
|
if (this.#now() - session.last > IDLE_MS || record === undefined || signature(record) !== session.sig) {
|
|
this.#seen.delete(token)
|
|
return null
|
|
}
|
|
session.last = this.#now()
|
|
return { user: session.user, role: session.role }
|
|
}
|
|
|
|
logout(token: string): void {
|
|
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) {
|
|
const { servers, domain } = this.#config
|
|
return {
|
|
version: HUB_VERSION,
|
|
uptimestamp: this.#started,
|
|
dnsServerDomain: domain,
|
|
permissions: Object.fromEntries(
|
|
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). */
|
|
clusterInitialized: true,
|
|
clusterDomain: domain,
|
|
clusterNodes: servers.map((s, i) => ({ name: s.id, type: i === 0 ? 'Primary' : 'Secondary' })),
|
|
defaultRecordTtl: 3600,
|
|
defaultNsRecordTtl: 3600,
|
|
defaultSoaRecordTtl: 900,
|
|
useSoaSerialDateScheme: true,
|
|
dnssecValidation: true,
|
|
hub: true,
|
|
hubRole: role,
|
|
/* The servers the hub holds an admin token for: the only ones a write can reach. */
|
|
/* How many servers differ from their baseline now: the console marks the header with it. */
|
|
hubDrift: this.#drift(),
|
|
hubWritable: servers.filter((s) => s.adminToken !== undefined).map((s) => s.id),
|
|
}
|
|
}
|
|
|
|
/** The flat body of `user/login` and `user/session/get`. */
|
|
body(token: string, identity: Identity) {
|
|
return {
|
|
status: 'ok',
|
|
token,
|
|
username: identity.user,
|
|
displayName: identity.user,
|
|
type: 'Local',
|
|
isSsoUser: false,
|
|
totpEnabled: false,
|
|
hubRole: identity.role,
|
|
info: this.info(identity.role),
|
|
}
|
|
}
|
|
}
|