/* 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 Role, type UserRecord, type Users } from './users.ts' export const IDLE_MS = 30 * 60_000 export interface Identity { user: string role: Role } interface Session extends Identity { last: 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`. */ const PERMISSION_KEYS = [ 'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs', ] const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps']) export class Sessions { #config: HubConfig #users: Users #now: () => number #started: string #seen = 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) } #sweep(): void { const now = this.#now() for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.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) => { const writes = key === 'Apps' && can(role, 'apps.write') return [key, { canView: VIEWABLE.has(key), canModify: writes, canDelete: writes }] }), ), /* 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), } } }