node:http server that serves the console build and a /api/* the console accepts: its own login and session (clusterInitialized, the servers as nodes, hub flag, Dashboard and Logs only), node=<id> forwarded with that server's token, no node or cluster merged (stats, top lists, query logs, apps), every write refused with 403, a short response cache, and an unreachable server reported as hubWarnings. The dev mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees. Co-Authored-By: Claude Opus 5.5 <[email protected]>
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
/*
|
|
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<string, number>()
|
|
|
|
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(),
|
|
}
|
|
}
|
|
}
|