One endpoint, hub/servers/run (operate), runs a fixed set of actions on the chosen servers in parallel with each server's admin token: add or remove a domain in Blocked or Allowed, disable blocking for a time or enable it, update block lists, replace the block list URLs, flush the cache or delete a domain from it. Each server gets its own result and the console offers Retry failed. In "All servers", Blocking, Cache and Query Logs rows open this flow instead of being disabled. The hub validates domains, minutes and the server's list grammar (allow lists with !, switched-off lines with #) and logs no URL list. Operators can now use the blocking switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
18 KiB
Markdown
248 lines
18 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 `admin` are what the per-server and several-server changes need. 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.
|
|
|
|
## Changing several servers at once
|
|
|
|
`POST hub/servers/run` (`write-all`, role with `operate`) runs one fixed action on the chosen servers, in parallel, each with
|
|
that server's admin token. Parameters: `action`, `targets` (comma separated server ids) and the action's own `domain`,
|
|
`minutes` or `urls` (lines in the grammar of the block lists, up to 300: `<url>`, `!<url>` for an allow list, `#...` for a switched-off list or a heading; http, https or file URLs; a line with a comma is refused). The answer is `{results: [{server, ok, message}]}`,
|
|
one per server; a server with no admin token or one that does not answer fails on its own and the others still run. Empty
|
|
targets or an unknown server are refused with `400`, and so is a bad parameter, before any server is called.
|
|
|
|
| action | Technitium call | parameters sent |
|
|
|---|---|---|
|
|
| `blocked.add`, `blocked.delete` | `blocked/add`, `blocked/delete` | `domain` |
|
|
| `allowed.add`, `allowed.delete` | `allowed/add`, `allowed/delete` | `domain` |
|
|
| `blocking.disable` | `settings/temporaryDisableBlocking` | `minutes` (1 to 1440) |
|
|
| `blocking.enable` | `settings/set` | `enableBlocking=true` only |
|
|
| `blocking.updateLists` | `settings/forceUpdateBlockLists` | none |
|
|
| `blocking.setListUrls` | `settings/set` | `blockListUrls` only (comma joined) |
|
|
| `cache.flush` | `cache/flush` | none |
|
|
| `cache.delete` | `cache/delete` | `domain` |
|
|
|
|
The block list URLs are part of blocking, so `blocking.setListUrls` needs `operate` like the rest (the per-server
|
|
`settings/set` of any other field still needs `admin`). `settings/set` with `enableBlocking` as its only field is `operate`
|
|
too, so an operator can use the blocking switch on one server. The audit log gets one line per server with the action, the
|
|
server, the domain or `minutes=N` or `urls=N` and the outcome, never the URL list. On the servers, the hub-admin needs
|
|
Blocked and Allowed Modify and Delete, Cache Delete, and Settings Modify (list URLs, blocking switch).
|
|
|
|
## 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`.
|