/* 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 (`_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 /** Lets the block list counter fetch from private addresses (HUB_LISTS_ALLOW_PRIVATE=1), for lists kept on the LAN. */ listsAllowPrivate: boolean /** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */ domain: string /** The console's static build. */ staticDir: string } type Env = Record /* `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), listsAllowPrivate: ['1', 'true'].includes((env.HUB_LISTS_ALLOW_PRIVATE ?? '').trim().toLowerCase()), domain: env.HUB_DOMAIN?.trim() || 'hub', staticDir: env.HUB_STATIC?.trim() || './dist', } } export function loadConfig(): HubConfig { return parseConfig(process.env) }