Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
105 lines
3.9 KiB
TypeScript
105 lines
3.9 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
|
|
/** 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),
|
|
driftMinutes: number(env, 'HUB_DRIFT_MINUTES', 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)
|
|
}
|