Files
technitium-console/hub/config.ts
T
DenozordecandClaude Opus 5.5 0ef2d53a65 Hub stage 0: own users with roles, login limit, audit log, permission table
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>
2026-10-31 15:25:38 +07:00

102 lines
3.8 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
/** Optional write-capable token (`<ID>_ADMIN_TOKEN`). Not used yet; never logged. */
adminToken?: string
}
/** The single admin from HUB_USER and HUB_PASSWORD, used only while there is no users file. */
export interface Bootstrap {
user: string
password: string
}
export interface HubConfig {
servers: HubServer[]
/** From HUB_USER and HUB_PASSWORD; null when they are not set. Whether it is used depends on the users file. */
bootstrap: Bootstrap | null
/** Where users.json and audit.log live (HUB_DATA). */
dataDir: string
/** Believe the first X-Forwarded-For entry as the client address (HUB_TRUST_PROXY=1). */
trustProxy: boolean
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`
}
/* `ndns` -> `NDNS_ADMIN_TOKEN`. */
export function adminTokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_ADMIN_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 {
// The users file decides whether these are needed; the config does not read the file.
const user = env.HUB_USER ?? ''
const password = env.HUB_PASSWORD ?? ''
if ((user === '') !== (password === '')) throw new Error('HUB_USER and HUB_PASSWORD must be set together.')
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}".`)
const adminToken = env[adminTokenVariable(id)] ?? ''
servers.push(adminToken === '' ? { id, url, token } : { id, url, token, adminToken })
}
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,
bootstrap: user === '' ? null : { user, password },
dataDir: env.HUB_DATA?.trim() || '/data',
trustProxy: ['1', 'true'].includes((env.HUB_TRUST_PROXY ?? '').trim().toLowerCase()),
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)
}