Files
technitium-console/hub/session.ts
T
DenozordecandClaude Opus 5.5 5f29a438c9 Hub stage 2: read-only Technitium-compatible server over several servers
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]>
2026-10-30 19:04:41 +07:00

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(),
}
}
}