Files
technitium-console/hub/config.ts
T
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:11:19 +07:00

122 lines
4.6 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
/** How often the drift check runs (HUB_DRIFT_MINUTES); 0 turns it off. */
driftMinutes: number
timeoutMs: number
/** Where drift and failed-write notices are POSTed (HUB_WEBHOOK_URL); null when not set. */
webhookUrl: string | null
/** Signs the notices with HMAC-SHA256 (HUB_WEBHOOK_SECRET); null when not set. Never logged. */
webhookSecret: string | null
/** 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.')
}
const webhookUrl = (env.HUB_WEBHOOK_URL ?? '').trim()
if (webhookUrl !== '') {
let ok = /^https?:\/\/[^/\s]/i.test(webhookUrl)
try {
new URL(webhookUrl)
} catch {
ok = false
}
if (!ok) throw new Error('HUB_WEBHOOK_URL must be an http or https address.')
}
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),
driftMinutes: number(env, 'HUB_DRIFT_MINUTES', 15, 0),
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
webhookUrl: webhookUrl === '' ? null : webhookUrl,
webhookSecret: (env.HUB_WEBHOOK_SECRET ?? '') === '' ? null : (env.HUB_WEBHOOK_SECRET as string),
domain: env.HUB_DOMAIN?.trim() || 'hub',
staticDir: env.HUB_STATIC?.trim() || './dist',
}
}
export function loadConfig(): HubConfig {
return parseConfig(process.env)
}