/* The hub's own sign-in. One user from the environment, sessions kept in memory with an idle expiry, and the `info` object the console reads from `user/login` and `user/session/get` (flat, as the real server sends it). */ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { HUB_VERSION, type HubConfig } from './config.ts' export const IDLE_MS = 30 * 60_000 /* Compared as hashes so the lengths always match and the time does not depend on the content. */ function sameSecret(a: string, b: string): boolean { const ha = createHash('sha256').update(a).digest() const hb = createHash('sha256').update(b).digest() return timingSafeEqual(ha, hb) } /* 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 and the Logs; About has no permission and is always there. The hub is read-only, so nothing can be modified or deleted. */ const PERMISSION_KEYS = [ 'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs', ] const VIEWABLE = new Set(['Dashboard', 'Logs']) export class Sessions { #config: HubConfig #now: () => number #started: string #seen = new Map() constructor(config: HubConfig, now: () => number = Date.now) { this.#config = config this.#now = now this.#started = new Date(now()).toISOString() } /** A new session token, or null when the credentials are wrong. */ login(user: string, password: string): string | null { // Both are always compared, so a wrong user and a wrong password cost the same. const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase()) const passOk = sameSecret(password, this.#config.password) if (!(userOk && passOk)) return null this.#sweep() const token = randomBytes(32).toString('hex') this.#seen.set(token, this.#now()) return token } /** True while the token is valid; each use extends it. */ touch(token: string | null | undefined): boolean { if (!token) return false const last = this.#seen.get(token) if (last === undefined) return false if (this.#now() - last > IDLE_MS) { this.#seen.delete(token) return false } this.#seen.set(token, this.#now()) return true } logout(token: string): void { this.#seen.delete(token) } #sweep(): void { const now = this.#now() for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token) } info() { 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: false, canDelete: false }]), ), /* 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, } } /** The flat body of `user/login` and `user/session/get`. */ body(token: string) { return { status: 'ok', token, username: this.#config.user, displayName: this.#config.user, type: 'Local', isSsoUser: false, totpEnabled: false, info: this.info(), } } }