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 <[email protected]>
124 lines
4.2 KiB
TypeScript
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),
|
|
}
|
|
}
|
|
}
|