Hub stage 2: read-only Technitium-compatible server over several servers
node:http server that serves the console build and a /api/* the console accepts: its own login and session (clusterInitialized, the servers as nodes, hub flag, Dashboard and Logs only), node=<id> forwarded with that server's token, no node or cluster merged (stats, top lists, query logs, apps), every write refused with 403, a short response cache, and an unreachable server reported as hubWarnings. The dev mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9df2c2b908
commit
5f29a438c9
8 files changed
+980
-7
No files matched your search
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
The hub's own sign-in. One user from the environment, sessions kept in memory with an
|
||||
idle expiry, and the `info` object the console reads from `user/login` and
|
||||
`user/session/get` (flat, as the real server sends it).
|
||||
*/
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { HUB_VERSION, type HubConfig } from './config.ts'
|
||||
|
||||
export const IDLE_MS = 30 * 60_000
|
||||
|
||||
/* Compared as hashes so the lengths always match and the time does not depend on the content. */
|
||||
function sameSecret(a: string, b: string): boolean {
|
||||
const ha = createHash('sha256').update(a).digest()
|
||||
const hb = createHash('sha256').update(b).digest()
|
||||
return timingSafeEqual(ha, hb)
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
#now: () => number
|
||||
#started: string
|
||||
#seen = new Map<string, number>()
|
||||
|
||||
constructor(config: HubConfig, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#now = now
|
||||
this.#started = new Date(now()).toISOString()
|
||||
}
|
||||
|
||||
/** A new session token, or null when the credentials are wrong. */
|
||||
login(user: string, password: string): string | null {
|
||||
// Both are always compared, so a wrong user and a wrong password cost the same.
|
||||
const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase())
|
||||
const passOk = sameSecret(password, this.#config.password)
|
||||
if (!(userOk && passOk)) return null
|
||||
this.#sweep()
|
||||
const token = randomBytes(32).toString('hex')
|
||||
this.#seen.set(token, this.#now())
|
||||
return token
|
||||
}
|
||||
|
||||
/** True while the token is valid; each use extends it. */
|
||||
touch(token: string | null | undefined): boolean {
|
||||
if (!token) return false
|
||||
const last = this.#seen.get(token)
|
||||
if (last === undefined) return false
|
||||
if (this.#now() - last > IDLE_MS) {
|
||||
this.#seen.delete(token)
|
||||
return false
|
||||
}
|
||||
this.#seen.set(token, this.#now())
|
||||
return true
|
||||
}
|
||||
|
||||
logout(token: string): void {
|
||||
this.#seen.delete(token)
|
||||
}
|
||||
|
||||
#sweep(): void {
|
||||
const now = this.#now()
|
||||
for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token)
|
||||
}
|
||||
|
||||
info() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/** The flat body of `user/login` and `user/session/get`. */
|
||||
body(token: string) {
|
||||
return {
|
||||
status: 'ok',
|
||||
token,
|
||||
username: this.#config.user,
|
||||
displayName: this.#config.user,
|
||||
type: 'Local',
|
||||
isSsoUser: false,
|
||||
totpEnabled: false,
|
||||
info: this.info(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user