One endpoint, hub/servers/run (operate), runs a fixed set of actions on the chosen servers in parallel with each server's admin token: add or remove a domain in Blocked or Allowed, disable blocking for a time or enable it, update block lists, replace the block list URLs, flush the cache or delete a domain from it. Each server gets its own result and the console offers Retry failed. In "All servers", Blocking, Cache and Query Logs rows open this flow instead of being disabled. The hub validates domains, minutes and the server's list grammar (allow lists with !, switched-off lines with #) and logs no URL list. Operators can now use the blocking switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
6.7 KiB
TypeScript
176 lines
6.7 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 blocking switch and the block list update are blocking,
|
|
so behind a hub the console asks for Blocked/Allowed Modify there (which an operator has), not for Settings Modify (which
|
|
only an admin has). Saving the list URLs of one server stays a settings change.
|
|
*/
|
|
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),
|
|
}
|
|
}
|
|
}
|