Files
technitium-console/hub/session.ts
T
DenozordecandClaude Opus 5.5 6acd2d656d Hub stage 2: install, update and uninstall apps on several servers
Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability.
Store URLs are resolved from each server's own listing. The config is saved
to history before an uninstall. Zip uploads are fanned out to the servers.
Every target's result is audited. The console gets a row menu on the
matrix, a target picker, a results dialog with Retry failed, the store and
install from file in All servers mode, and single-server actions for writers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:11:40 +07:00

124 lines
4.2 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>()
constructor(config: HubConfig, users: Users, now: () => number = Date.now) {
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. */
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),
}
}
}