Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.8 KiB
Technitium hub
A small, dependency-free Node program that looks like ONE Technitium DNS Server to the
console and answers from several real ones. It serves the console's static build and a
read-only, Technitium-compatible /api/*:
node=<id>forwards the call to that server, with that server's token.- No
node(ornode=cluster) asks every server in parallel and merges the answers (statistics are summed, top lists and query logs are merged). If a server does not answer, the others are shown and the response carrieshubWarnings. - Reads go with each server's read token. The only writes are the app changes below, which use a separate admin token and a role with
apps.write. Anything else that would change data is refused with403(and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
The servers themselves are not changed: each only needs a read-only API token. Create it for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging apps Query Logs reads from) and Settings (the blocking state).
Configuration
| Variable | Default | Meaning |
|---|---|---|
HUB_SERVERS |
required | Ordered id=url list, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net. The first server is shown as the Primary node. |
<ID>_TOKEN |
required | API token of each server; the id is uppercased with non-alphanumerics as _ (dns gives DNS_TOKEN, my-dns gives MY_DNS_TOKEN). |
HUB_USER, HUB_PASSWORD |
see Users | Bootstrap: one admin, used only while ${HUB_DATA}/users.json does not exist. Set both or neither. |
HUB_DATA |
/data |
Folder of users.json and audit.log. Mount a volume on it. |
HUB_TRUST_PROXY |
off | 1 believes the first X-Forwarded-For entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
<ID>_ADMIN_TOKEN |
optional | A write-capable token per server (MDNS_ADMIN_TOKEN). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. |
HUB_PORT |
8080 |
Listening port. |
HUB_CACHE_SECONDS |
15 |
How long successful answers of the servers are cached. |
HUB_TIMEOUT_MS |
8000 |
Per-request timeout towards a server. |
HUB_DOMAIN |
hub |
Shown as the server's domain in the console. |
HUB_STATIC |
./dist |
The console's build to serve. |
Users and roles
The hub has its own accounts in ${HUB_DATA}/users.json ({ "users": [{ "name", "role", "hash", "disabled"? }] },
hash scrypt$N$r$p$salt$key). Names are compared case-insensitively.
- If the file exists, it is the only source;
HUB_USER/HUB_PASSWORDare ignored (a line says so at start). - If it does not exist and
HUB_USER/HUB_PASSWORDare set, that one user signs in asadmin. The file is not written. - If neither exists, the hub refuses to start.
Manage the file with the CLI (npm run hub:user -- <command> locally):
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
add <name> <role>, passwd <name>, role <name> <role>, disable <name>, enable <name>, list. The password is asked
twice without echo; for scripts use HUB_NEW_PASSWORD=... users-cli.ts add ... (minimum 8 characters). The file is written
atomically with mode 0600. The running hub notices a change on the next request and ends the sessions of the users it touched
(disabled, removed, new role or password).
| Role | Capabilities |
|---|---|
viewer |
read |
operator |
read, operate (blocking, cache) |
apps-admin |
read, apps.write |
admin |
everything, including audit.read |
apps.write is what the app writes below need; operate and the rest are still unused. Every response also carries
hubRole (and info.hubRole).
Login protection
After 5 failed logins within 15 minutes from one client address, or for one user name, further attempts are refused for 15
minutes with HTTP 429 and Too many failed logins. Try again in N minutes. (the console shows that message). A good login
resets the user's counter. The state is in memory.
Audit log
${HUB_DATA}/audit.log is JSON Lines: {ts, user, role, ip, action, server?, target?, outcome, detail?}. It records login
(ok, failed, locked), logout, and every refused request outside the permission table as refused-write with its api path.
Passwords, tokens and request bodies are never written. At 10 MB it rotates to audit.log.1 (one kept). If the folder is not
writable the hub says so once on stderr and keeps the last 1000 entries in memory. Admins read it with
GET /api/hub/audit/list?limit=200&before=<ISO time>, newest first.
Endpoints
Public: status, user/login. After signing in: user/session/get, user/logout,
user/checkForUpdate (always "no update") and these reads:
dashboard/stats/get, dashboard/stats/getTop, dashboard/metrics/json, apps/list,
logs/query, logs/list, settings/get, admin/cluster/state (synthesized), and the hub's own hub/audit/list (role with
audit.read). They are listed in routes.ts with a kind (read, write-one, write-all) and the capability they need.
Everything else answers 403 The hub is read-only. and is audited. apps/config/get and apps/config/set are not available yet.
App writes
Needs the apps.write role (apps-admin, admin); others get 403 Your role does not allow this. and a denied-write audit line.
Every write goes to a server with its <ID>_ADMIN_TOKEN; a server without one answers "no admin token configured" and is not called.
POST hub/apps/run(form or JSON):action=install-store|update-store|uninstall,name,targets(list or comma-separated server ids). Runs on every target in parallel and answers{ results: [{ server, ok, message? }] }. The download URL is taken from each target's ownapps/listStoreApps; aurlfield is only accepted when it matches.POST hub/apps/upload(multipart, at most 20 MB, kept in memory only):action=install|update,name,targets(comma-separated), file fieldfileAppZip. The same zip is forwarded to every target.apps/downloadAndInstall,apps/downloadAndUpdate,apps/uninstall,apps/install,apps/updatewithnode=<id>: the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and withnode=cluster.apps/listStoreApps: the first server's store, each app tagged withinstalledOn.
Before an uninstall the app's config is saved to ${HUB_DATA}/apps/<app>/history/<time>__<server>__before-uninstall.txt
(50 files per app are kept). If that cannot be done the uninstall does not happen. Every target's result is written to the
audit log as apps.<action>; the history files hold configs, so protect the data folder like the users file.
After any write the hub forgets its cached answers of that server.
Run locally
npm run build
node dev/mock-api.mjs --port 5380 &
node dev/mock-api.mjs --port 5381 --scale 2 &
HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \
HUB_USER=admin HUB_PASSWORD=admin HUB_DATA=./hub-data HUB_PORT=5390 npm run hub
Then open http://localhost:5390. Tests: npx vitest run hub.
Docker
docker run --rm -p 8080:8080 \
-e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
git.shx.one/denozord/technitium-hub:latest
The image is built by docker/Dockerfile.hub and published by .gitea/workflows/hub.yaml
(latest from main, sha-<7>, and X.Y.Z / X.Y from a hub-vX.Y.Z tag). A compose
example with Traefik is in docs/compose.hub.example.yaml.