Files
technitium-console/hub/config.ts
T
DenozordecandClaude Opus 5.5 5f29a438c9 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 <[email protected]>
2026-10-30 19:04:41 +07:00

82 lines
2.7 KiB
TypeScript

/*
The hub's configuration, read from the environment. `parseConfig` is pure so the
tests can feed it any environment; `loadConfig` is the one that reads `process.env`.
*/
export const HUB_VERSION = '1.0.0'
export interface HubServer {
id: string
url: string
token: string
}
export interface HubConfig {
servers: HubServer[]
user: string
password: string
port: number
cacheSeconds: number
timeoutMs: number
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
domain: string
/** The console's static build. */
staticDir: string
}
type Env = Record<string, string | undefined>
/* `ndns` -> `NDNS_TOKEN`, `my-dns.1` -> `MY_DNS_1_TOKEN`. */
export function tokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN`
}
function number(env: Env, name: string, fallback: number, min: number): number {
const raw = env[name]
if (raw === undefined || raw.trim() === '') return fallback
const value = Number(raw)
if (!Number.isFinite(value) || value < min) throw new Error(`${name} must be a number of at least ${min}.`)
return value
}
export function parseConfig(env: Env): HubConfig {
const user = env.HUB_USER ?? ''
const password = env.HUB_PASSWORD ?? ''
if (user === '' || password === '') throw new Error('HUB_USER and HUB_PASSWORD are required.')
const servers: HubServer[] = []
for (const part of (env.HUB_SERVERS ?? '').split(',')) {
const entry = part.trim()
if (entry === '') continue
const at = entry.indexOf('=')
const id = at < 0 ? '' : entry.slice(0, at).trim()
const url = at < 0 ? '' : entry.slice(at + 1).trim().replace(/\/+$/, '')
if (!/^[A-Za-z0-9._-]+$/.test(id) || id === 'cluster' || !/^https?:\/\/[^/]/.test(url)) {
throw new Error(`HUB_SERVERS entry "${entry}" is not of the form id=https://host (id: letters, digits, . _ -).`)
}
if (servers.some((s) => s.id === id)) throw new Error(`HUB_SERVERS lists "${id}" twice.`)
const variable = tokenVariable(id)
const token = env[variable] ?? ''
if (token === '') throw new Error(`${variable} is required for the server "${id}".`)
servers.push({ id, url, token })
}
if (servers.length === 0) {
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
}
return {
servers,
user,
password,
port: number(env, 'HUB_PORT', 8080, 0),
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
domain: env.HUB_DOMAIN?.trim() || 'hub',
staticDir: env.HUB_STATIC?.trim() || './dist',
}
}
export function loadConfig(): HubConfig {
return parseConfig(process.env)
}