Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator, apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock the address or the name for 15 minutes. Logins, logouts and every refused request go to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are listed in routes.ts with their kind and capability; writes stay refused for now. Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan is in docs/hub-management-plan.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
3.9 KiB
TypeScript
119 lines
3.9 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 { 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 and the Logs; About has no permission and is always there. The hub is
|
|
read-only, so nothing can be modified or deleted.
|
|
*/
|
|
const PERMISSION_KEYS = [
|
|
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
|
|
]
|
|
const VIEWABLE = new Set(['Dashboard', 'Logs'])
|
|
|
|
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) => [key, { canView: VIEWABLE.has(key), canModify: false, canDelete: false }]),
|
|
),
|
|
/* 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 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),
|
|
}
|
|
}
|
|
}
|