Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be changed on one chosen server through the hub, with that server's admin token. Operators manage cache and blocking; admins also zones, settings and DHCP. Under "All servers", or on a server without an admin token, the controls are disabled with a hint. The hub refuses settings/set changes to a server's web service fields and anything about users, SSO or LDAP; backup, restore, log and stats deletion and admin/* stay refused. Non-admins get settings with secrets blanked. Write params travel in the POST body, downloads pass through with a single-use token, and the audit log names no values. Co-Authored-By: Claude Opus 5.5 <[email protected]>
223 lines
16 KiB
Markdown
223 lines
16 KiB
Markdown
# 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` (or `node=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 carries `hubWarnings`.
|
|
- Reads go with each server's read token. Writes use a separate admin token: the app changes below (role with `apps.write`) and the per-server changes of "Managing one server" (roles with `operate` or `admin`). Anything else that would change data is refused with `403` (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 needs a read-only API token (View, and nothing else, on Dashboard, Logs,
|
|
Apps, Settings, Zones, Cache, Allowed, Blocked, DnsClient and DhcpServer) and, for writes, an admin token whose group has
|
|
Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
|
|
|
## 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_DRIFT_MINUTES` | `15` | How often the hub checks that every server still has the config it should (see Drift). `0` turns the check off. |
|
|
| `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_PASSWORD` are ignored (a line says so at start).
|
|
- If it does not exist and `HUB_USER` / `HUB_PASSWORD` are set, that one user signs in as `admin`. 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):
|
|
|
|
```sh
|
|
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.
|
|
|
|
## Managing one server
|
|
|
|
The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a
|
|
time. `routes.ts` lists exactly the paths the console calls; an `operate` role (`operator`, `admin`) may change the cache,
|
|
Blocked and Allowed, the temporary blocking switch and the list update; zones (records, DNSSEC, import), settings and DHCP
|
|
need `admin`. `viewer` and `apps-admin` only read.
|
|
|
|
- A write needs `node=<id>` of a server with an admin token. No node, `cluster` or an unknown id answers `400`
|
|
("Choose a server: this change is made on one server at a time."); a server without an admin token answers `403`.
|
|
- The parameters travel to the server in a form body (only `token` stays in the query). A zone import goes on as
|
|
`multipart/form-data` or `text/plain` (up to 20 MB); `settings/set`, record and scope writes take up to 2 MB and the
|
|
Blocked/Allowed imports up to 5 MB, for a signed-in caller only.
|
|
- `dnsClient/resolve` is a read, unless `import` is set: then it writes into a zone and needs `admin`.
|
|
- `settings/set` is refused when it changes a `webService*` field (compared with the server's current `settings/get`; the
|
|
console sends the block unchanged with every save) or carries a user, SSO or LDAP field. `settings/get` blanks the TSIG
|
|
shared secrets and the passwords for every role but `admin`.
|
|
- Reads that have no merger (`zones/list`, `cache/list`, `allowed|blocked/list`, `dhcp/*`, `logs/list`, `dnsClient/resolve`,
|
|
the exports) are refused without a node instead of being answered by the first server. `settings/get` is the exception:
|
|
the header's blocking mark reads it without a node.
|
|
- Downloads (`zones/export`, `logs/download`, `logs/export`, `allowed|blocked/export`) are passed through (type, file name,
|
|
body) with the read token. A new tab has no Authorization header, so `user/createSingleUseToken` gives a token that works
|
|
once, for 60 seconds, for these paths only.
|
|
- Refused, even for `admin`: `admin/*`, `user/*` (except the hub's own sign-in), `logs/delete`, `logs/deleteAll`,
|
|
`dashboard/stats/deleteAll`, `settings/backup`, `settings/restore` and `zones/permissions/*`.
|
|
- The audit line holds the user, role, address, path (dots for slashes), server, one identifier (zone, domain, name or scope)
|
|
and the outcome. Never a value.
|
|
|
|
## 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 own `apps/listStoreApps`; a `url` field 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 field `fileAppZip`. The same zip is forwarded to every target.
|
|
- `apps/downloadAndInstall`, `apps/downloadAndUpdate`, `apps/uninstall`, `apps/install`, `apps/update` with `node=<id>`:
|
|
the console's own single-server calls, forwarded to that server (store URLs checked as above). Refused without a node, and
|
|
with `node=cluster`.
|
|
- `apps/listStoreApps`: the first server's store, each app tagged with `installedOn`.
|
|
- `apps/config/get` (read, `node` required) and `apps/config/set` (`apps.write`, `node` required; the old config is saved to the
|
|
history first and the call is refused if that fails): the console's own per-server Config dialog.
|
|
|
|
## Config sync
|
|
|
|
Keeps one app's config the same on every server, except for the fields that must differ. Per app the hub stores, under
|
|
`${HUB_DATA}/apps/<app>/`:
|
|
|
|
- `baseline.json`: `{ app, text, importedFrom?, updatedAt, updatedBy }`. The config as TEXT, so non-JSON configs work too.
|
|
- `overrides.json`: `{ paths: [...], values: { <server>: { <path>: <value> } } }`. Paths are dotted (`db.path`,
|
|
`networks.0.cidr`); at most 50; only for a JSON baseline.
|
|
- `history/<time>__<server>__before-apply.txt` (and `before-uninstall`): the config a server had before the hub changed it
|
|
(50 files per app kept).
|
|
|
|
A server's effective config is the baseline with that server's local values put in (JSON, pretty-printed with 2 spaces). A local
|
|
path with no value for a server keeps the baseline value and is reported as a warning. Comparisons are by meaning (key order and
|
|
spacing do not count) and leave the local fields out, so two servers that differ only there are "Same" in the matrix, and the
|
|
matrix hash ignores them too.
|
|
|
|
| Endpoint | Method | Needs | Does |
|
|
|---|---|---|---|
|
|
| `hub/apps/config/compare?name=` | GET | read | Each server's live config, the baseline, the local fields, `inSync`, and the differences against the baseline and against the other servers. Config texts are returned here, to roles that can read. |
|
|
| `hub/apps/overrides/current` | POST `{name, paths}` | `apps.write` | The servers' current values of those paths. |
|
|
| `hub/apps/baseline/import` | POST `{name, server}` | `apps.write` | Baseline := that server's live config. |
|
|
| `hub/apps/baseline/save` | POST `{name, text}` | `apps.write` | Saves the baseline (1 MB at most). Text that starts with `{` or `[` must be valid JSON; the parse error is returned. |
|
|
| `hub/apps/overrides/save` | POST `{name, paths, values}` | `apps.write` | Saves the local fields. |
|
|
| `hub/apps/config/preview` | POST `{name, targets}` | `apps.write` | Per target: current and effective text, the diff, warnings (a missing local value, a version older than the newest installed, no admin token) and `currentHash`. |
|
|
| `hub/apps/config/apply` | POST `{name, targets, expect?}` | `apps.write` | Per target: re-reads the config; refuses if its hash differs from `expect[server]`; saves the old config to the history; `apps/config/set` with the admin token; reads it back and checks it. A server that rejects the config keeps its old one. |
|
|
|
|
## History, rollback, drift, export
|
|
|
|
**History.** Everything the hub replaces is kept in `${HUB_DATA}/apps/<app>/history/` (50 files per app):
|
|
`<time>__<server>__before-apply.txt`, `before-set` (a single-server config save), `before-uninstall`, and
|
|
`<time>__baseline__before-save.txt` for the baseline's earlier text on every baseline save, import or restore.
|
|
|
|
| Endpoint | Method | Needs | Does |
|
|
|---|---|---|---|
|
|
| `hub/apps/history/list?name=` | GET | read | Newest first: `{ id, at, server, kind, size, hash }`, never the text. |
|
|
| `hub/apps/history/get?name=&id=` | GET | read | The text. `id` has to be a name the listing returns, so nothing outside the folder can be read. |
|
|
| `hub/apps/history/diff?name=&id=&server=` | GET | read | A version against the server's config now (`changes`, `currentHash`, `unchanged`). |
|
|
| `hub/apps/history/rollback` | POST `{name, id, targets, expect?}` | `apps.write` | Puts that version on the targets through the same machinery as apply (old config to the history, `apps/config/set` with the admin token, read back and verify, a result per target, audit). Not for baseline entries. |
|
|
| `hub/apps/baseline/restore` | POST `{name, id}` | `apps.write` | An old baseline entry becomes the baseline again (the current text goes to the history). |
|
|
|
|
**Drift.** Every `HUB_DRIFT_MINUTES` (and once shortly after start) the hub reads each server's config of every app that has a
|
|
baseline, with the read token, and compares it by meaning with the server's effective config (baseline plus its local values). The
|
|
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ app, server, since, kind }] }`, where `kind` is
|
|
`differs`, `unreadable` (the server or the read failed; listed, not an alarm) or `not-installed`, and `since` is the first check that
|
|
saw it. Two checks never run at once.
|
|
|
|
- `GET hub/drift/get` (read): the state. `POST hub/drift/check` (`apps.write`): check now.
|
|
- `user/session/get` carries `info.hubDrift`, the number of `differs` entries; the console shows it in the header.
|
|
- `POST hub/apps/drift/accept {name, server}` (`apps.write`): "Accept as baseline". The server's config becomes the baseline; if the
|
|
app has local fields, they keep the baseline's old values and the server's own values go to the overrides instead.
|
|
- "Revert to baseline" is the normal apply on that server.
|
|
|
|
**Export and import.** `GET hub/apps/export` (`apps.write`, because configs can hold secrets) downloads
|
|
`{ hub: 'technitium-hub', version: 1, exportedAt, apps: [{ app, baseline, overrides }] }`. `POST hub/apps/import {data, apply}`
|
|
(`apps.write`, up to 5 MB, signed-in callers only) validates the file and answers per app `new`, `changed` or `same`; with
|
|
`apply=true` it writes the baselines and overrides (the replaced baselines go to the history). Local values for servers this hub
|
|
does not have are ignored and reported. It never writes to a server.
|
|
|
|
Reading configs needs View on Apps for the `hub-reader` user (and `*_ADMIN_TOKEN` is used as a fallback if a server refuses the
|
|
read token). The audit log records who did what, with the size and a short hash of a config, never the text.
|
|
|
|
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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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`.
|