/* 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 = { Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin', } const DELETES: Record = { 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() #single = new Map() #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), } } }