Files
technitium-console/hub/session.ts
T
DenozordecandClaude Opus 5.5 8bb1e01936 Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:37 +07:00

176 lines
6.6 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 Capability, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000
/* A download link carries a token that works once, within this time. */
export const SINGLE_USE_MS = 60_000
export interface Identity {
user: string
role: Role
}
interface Session extends Identity {
last: number
sig: string
}
interface SingleUse extends Identity {
until: 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. About has no permission
and is always there. Administration (users, SSO, LDAP of each server) is not offered.
What a role may change comes from its capabilities, per key. Settings never get `canDelete`
(that is the backup and restore, which the hub does not forward), nor do the Logs and the
Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs
`admin` to modify because its Import writes a zone. The Settings key also gates the
temporary blocking switch in the console, which the hub itself would let an operator use:
the console is the stricter of the two there.
*/
const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
]
const VIEWABLE = new Set(['Dashboard', 'Logs', 'Apps', 'Zones', 'Cache', 'Allowed', 'Blocked', 'DnsClient', 'Settings', 'DhcpServer'])
const MODIFIES: Record<string, Capability> = {
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate',
DnsClient: 'admin', Settings: 'admin', DhcpServer: 'admin',
}
const DELETES: Record<string, Capability> = {
Apps: 'apps.write', Zones: 'admin', Cache: 'operate', Allowed: 'operate', Blocked: 'operate', DhcpServer: 'admin',
}
export class Sessions {
#config: HubConfig
#users: Users
#now: () => number
#started: string
#seen = new Map<string, Session>()
#single = new Map<string, SingleUse>()
#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)
}
/* A token for one download (the console opens it in a new tab, which has no Authorization header). */
openSingleUse(token: string): string | null {
const session = this.#seen.get(token)
if (session === undefined) return null
const once = randomBytes(32).toString('hex')
this.#single.set(once, { user: session.user, role: session.role, sig: session.sig, until: this.#now() + SINGLE_USE_MS })
return once
}
/* The identity behind a single-use token, which is spent by asking; null when it is unknown, used,
expired, or its user was removed or edited since. */
takeSingleUse(token: string | null | undefined): Identity | null {
if (!token) return null
const once = this.#single.get(token)
if (once === undefined) return null
this.#single.delete(token)
const record = this.#users.find(once.user)
if (this.#now() > once.until || record === undefined || signature(record) !== once.sig) return null
return { user: once.user, role: once.role }
}
#sweep(): void {
const now = this.#now()
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
for (const [token, once] of this.#single) if (now > once.until) this.#single.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) => [
key,
{
canView: VIEWABLE.has(key),
canModify: key in MODIFIES && can(role, MODIFIES[key]),
canDelete: key in DELETES && can(role, DELETES[key]),
},
]),
),
/* 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),
}
}
}