- Status, sidebar mark and header treat a server that blocks with the Advanced Blocking app as blocking, per server behind the hub. - Overview panel summarising the app's groups; Lists and Rules show and edit the app's list URLs and rules (read, change one field, write back). - Block List Domains adds the app's list domains, counted by the hub (hub/blocking/app-lists/count, public hosts only unless HUB_LISTS_ALLOW_PRIVATE=1) or by the browser where CORS allows. - Header and KPI cards follow the Dashboard: selector in the chrome slot, Compare servers in the header actions, period under the title, StatCard tiles without sparklines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.9 KiB
TypeScript
125 lines
4.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
|
|
/** 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<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),
|
|
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)
|
|
}
|