Hub stage 2: read-only Technitium-compatible server over several servers
node:http server that serves the console build and a /api/* the console accepts: its own login and session (clusterInitialized, the servers as nodes, hub flag, Dashboard and Logs only), node=<id> forwarded with that server's token, no node or cluster merged (stats, top lists, query logs, apps), every write refused with 403, a short response cache, and an unreachable server reported as hubWarnings. The dev mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9df2c2b908
commit
5f29a438c9
8 files changed
+980
-7
No files matched your search
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
|
||||
export interface HubConfig {
|
||||
servers: HubServer[]
|
||||
user: string
|
||||
password: string
|
||||
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`
|
||||
}
|
||||
|
||||
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 {
|
||||
const user = env.HUB_USER ?? ''
|
||||
const password = env.HUB_PASSWORD ?? ''
|
||||
if (user === '' || password === '') throw new Error('HUB_USER and HUB_PASSWORD are required.')
|
||||
|
||||
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}".`)
|
||||
servers.push({ id, url, token })
|
||||
}
|
||||
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,
|
||||
user,
|
||||
password,
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user