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]>
129 lines
4.4 KiB
TypeScript
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),
|
|
}
|
|
}
|
|
}
|