Files
technitium-console/hub/session.ts
T
DenozordecandClaude Opus 5.5 57e492cceb Hub stage 4: config history, rollback, drift check, baseline export and import
Every saved config, and every earlier baseline, is listed per app. You
can view it, diff it against a server's current config, and roll it back
through preview and apply. Earlier baselines can be restored.

A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with
its effective config. Drift shows in the header and the matrix, with
Accept as baseline and Revert to baseline. All baselines export to one
file. Importing that file shows a dry run before it writes.

Rolling back preselects, and diffs against, the server the version came
from. The line-diff op "del" is renamed to "remove" for the language lint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:33 +07:00

129 lines
4.4 KiB
TypeScript

/*
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<string, Session>()
#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),
}
}
}