The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
367 lines
31 KiB
Markdown
367 lines
31 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_WEBHOOK_URL` | none | An http or https address that gets a signed JSON notice on a change of drift and on a failed write (see Webhook notices). Treat it like a secret: it may carry a token. |
|
|
| `HUB_WEBHOOK_SECRET` | none | Signs each notice: `X-Hub-Signature: sha256=<HMAC-SHA256 of the body>`. Never logged or returned. |
|
|
| `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. |
|
|
|
|
## Zones, settings and blocking across the servers (stage 7)
|
|
|
|
| Endpoint | Kind | Needs | Does |
|
|
|---|---|---|---|
|
|
| `hub/zones/matrix` | read | `read` | Every zone of every server, one row per name: per server `present`, `absent` or `unknown` with type, SOA serial, disabled, DNSSEC and last modified. Row `status`: `same`, `serial-differs` (Primary zones, different serials), `type-differs`, `only-on` (`onlyOn`), `missing-on`, `unknown`. A Primary with a Secondary of it is the intended setup: it stays `same` with `note: primary-secondary`, or `secondary-behind` when the Secondary lags. A server that did not answer gives `unknown` cells and a `hubWarnings` line; a zone is never called `only-on` while a server that might have it is silent. Pages of 500, internal zones left out. |
|
|
| `hub/zones/copy/preview` | read | `admin` | `{source, zone, targets}`: the source zone (type, serial, about N record lines) and per target whether it exists (type, serial, `needsOverwrite`). |
|
|
| `hub/zones/copy` | write-all | `admin` | `{source, zone, targets, overwrite}`. Reads `zones/export` from the source with the read token (cap 5 MB, 30 s), then per target `zones/create` (the source's type; a Forwarder with `initializeForwarder=false`) when the zone is missing and `zones/import` (`overwrite`, `overwriteZone`, `overwriteSoaSerial` all true, the text as `text/plain`: what the console's Import zone sends). A target that has the zone is touched only if its id is in `overwrite` (checked in the hub). Only Primary and Forwarder zones; Secondary, Stub, Catalog and DNSSEC-signed zones are refused with a reason; a target with the zone as another type is refused; a copy to the source is refused. If the import fails after the create, the result says an empty zone was left. |
|
|
| `hub/zones/secondary/preview` | read | `admin` | `{source, target, zone, primaryAddress, targetAddress, tsigKeyName?}`: the two steps in words. |
|
|
| `hub/zones/secondary` | write-all | `admin` | Step 1 on the source: `zones/options/set` with only `zoneTransfer`, `zoneTransferNetworkACL`, `zoneTransferTsigKeyNames`, `notify`, `notifyNameServers`; what is listed stays, the target address is appended once (so it can run again), a mode that cannot hold a list is widened (Deny to UseSpecifiedNetworkACL, AllowOnlyZoneNameServers to AllowZoneNameServersAndUseSpecifiedNetworkACL, notify None to SpecifiedNameServers, ZoneNameServers to BothZoneAndSpecifiedNameServers). Step 2 on the target: `zones/create` type Secondary with `primaryNameServerAddresses`, `zoneTransferProtocol=Tcp`, `tsigKeyName`. The source goes first; if the target step fails the source change is kept and the result says so. The TSIG key must exist on both servers (`settings/getTsigKeyNames`). Results are two rows, one per server. |
|
|
| `hub/settings/compare` | read | `admin` | Per server `same`, `differs` (N fields) or `local-only`, and the differing fields with each server's value (secrets as `<secret>`, a row only says `secretChanged`). Without a baseline the servers are compared with the first. |
|
|
| `hub/settings/baseline/import` | write-all | `admin` | `{server, group?}`: the server's settings (read with its admin token) become the baseline; `group=blocking` replaces only the blocking fields. The old baseline goes to the history. |
|
|
| `hub/settings/baseline/save` | write-all | `admin` | `{text}`: the baseline from the editor (JSON, up to 2 MB). A secret shown as `<secret>` keeps the stored one; a placeholder with nothing to keep is refused. |
|
|
| `hub/settings/overrides/save` | write-all | `admin` | `{paths}`: extra local field names (up to 50). |
|
|
| `hub/settings/preview` | read | `admin` | `{targets, group?}`: per target the fields an apply would change, as `{kind:'json', changes}`, `secretFields`, `currentHash`. |
|
|
| `hub/settings/apply` | write-all | `admin` | `{targets, expect?, group?}`. Per server: read now, refuse if it changed since the preview, send ONLY the shared fields that differ in one `settings/set` (through the checks of `guards.ts`: never `webService*`, never user, SSO or LDAP fields), after saving the server's settings to the history; read back and compare the fields sent. `enableBlocking` is left alone while blocking is paused. |
|
|
| `hub/settings/history/list`, `hub/settings/history/get` | read | `admin` | The settings kept before an apply, the lists kept before extras were removed, and old baselines. View only, secrets masked: there is no rollback for settings (put values back by accepting a server as baseline, or by hand). |
|
|
| `hub/blocking/lists/compare` | read | `admin` | The custom Blocked and Allowed lists (`blocked/export`, `allowed/export`, read as text with a size cap) as sets: per server `missing N, extra M` with samples. |
|
|
| `hub/blocking/lists/baseline/import` | write-all | `admin` | `{server}`: both lists of that server become the lists baseline (up to 5000 domains each). |
|
|
| `hub/blocking/lists/preview`, `hub/blocking/lists/apply` | read, write-all | `admin` | `{targets, removeExtras}`. Adds the missing domains, one `add` call each (at most 1000); deletes the extras only when `removeExtras=true` (at most 500, after saving the server's lists to the history); reads back. |
|
|
|
|
**What is local.** Never sent by an apply, listed for information: `dnsServerDomain`, `dnsServerLocalEndPoints`,
|
|
`dnsServerIPv4SourceAddresses`, `dnsServerIPv6SourceAddresses` (the host's identity and bind addresses), every `webService*`
|
|
(how the hub reaches the server), `dnsTlsCertificatePath`, `dnsTlsCertificatePassword` (certificate), `dnsOverHttpUnixSocket`,
|
|
`dnsOverHttpsUnixSocket`, `enableDnsOverHttpUnixSocket`, `enableDnsOverHttpsUnixSocket`, `dnsReverseProxyNetworkACL` (this host's sockets
|
|
and proxy) and `logFolder` (a path). User, SSO and LDAP fields are always local. More can be added in the Local fields tab
|
|
(`${HUB_DATA}/settings/overrides.json`); the built-in ones cannot be removed. **Never compared:** `version`, `uptimestamp`,
|
|
`clusterInitialized`, `clusterNodes`, `temporaryDisableBlockingTill`, `blockListNextUpdatedOn`. Fields only one side reports are skipped.
|
|
|
|
**Secrets.** TSIG shared secrets and the proxy password are compared inside the hub by their real value and are stored in the
|
|
baseline and the history (mode 0600, protect the data folder like the users file). No answer carries a secret or a hash of one, to
|
|
any role; the endpoints are `admin` only anyway. A changed secret is shown as "secret changed". The audit log has field names, counts
|
|
and outcomes, never values. Applying a baseline with TSIG keys does send the secrets to the servers (that is the point), over the
|
|
connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overrides}.json`, `settings/history/` (50 files),
|
|
`${HUB_DATA}/blocking/baseline.json`. Settings and the lists are part of the drift check (see Drift).
|
|
|
|
## 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: [{ kind, name?, server, since, status, fields? }] }`, where
|
|
`kind` is `app`, `settings` or `lists` (stage 8), `status` 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. See "Webhook notices and
|
|
profiles" for the settings and lists kinds.
|
|
|
|
- `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 of every kind; 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.
|
|
|
|
## Webhook notices and profiles (stage 8)
|
|
|
|
**Webhook.** With `HUB_WEBHOOK_URL` (http or https) the hub POSTs a small JSON body when something changes. `HUB_WEBHOOK_SECRET`
|
|
is optional and signs the body. Events:
|
|
|
|
| Event | When |
|
|
|---|---|
|
|
| `drift.found` | A server/kind/name newly differs from its baseline. Sent once, not at every check. |
|
|
| `drift.cleared` | An entry that differed is back in line. An entry that became unreadable is not cleared: it is not known. |
|
|
| `write.failed` | An audited write ended `failed` (not `refused`, not a failed login). Failures of the same moment are one notice. |
|
|
| `webhook.test` | The admin pressed "Send test" (`POST hub/webhook/test`). |
|
|
|
|
```json
|
|
{
|
|
"event": "drift.found",
|
|
"at": "2026-11-01T09:15:00.000Z",
|
|
"hub": "hub.example.net",
|
|
"items": [
|
|
{ "kind": "settings", "server": "ndns", "since": "2026-11-01T09:15:00.000Z", "fields": ["blockingType", "defaultRecordTtl"] },
|
|
{ "kind": "app", "name": "Advanced Blocking", "server": "ndns", "since": "2026-11-01T09:15:00.000Z" }
|
|
]
|
|
}
|
|
```
|
|
|
|
Items carry names and field names, never a value. A `write.failed` item has `action`, `server`, `target`, `user`, `at` and the
|
|
audited `detail`. The first state is the one in `drift.json`, so a restart does not repeat notices.
|
|
|
|
With a secret, the header `X-Hub-Signature: sha256=<hex>` is the HMAC-SHA256 of the exact request body. Verify it before parsing:
|
|
|
|
```js
|
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
|
|
const ok = expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))
|
|
```
|
|
|
|
Delivery is fire-and-forget: a 5 second timeout, at most one retry (after a second), redirects are not followed, and it never
|
|
blocks or fails the request that caused it. A failure goes to the server log with the HOST only (never the query string, which may
|
|
carry a token) and the reason (`HTTP 500`, `ECONNREFUSED`, `TimeoutError`).
|
|
|
|
| Endpoint | Method | Needs | Does |
|
|
|---|---|---|---|
|
|
| `hub/webhook/status` | GET | `admin` | `{ configured, host, signed, last: { event, at, ok, error? } }`. The host and port only, never the address. |
|
|
| `hub/webhook/test` | POST | `admin` | Sends a `webhook.test` notice now and waits for it; answers the same status. Audited as `webhook.test`. |
|
|
|
|
**Drift kinds.** `GET hub/drift/get` answers `{ checkedAt, apps: [entry] }` (the key is kept) with
|
|
`entry = { kind: 'app' | 'settings' | 'lists', name?, app?, server, since, status, fields? }`. `status` is `differs`, `unreadable`
|
|
or `not-installed`; `fields` are setting names, or `blocked` / `allowed` for the lists; `name` (and `app`) are the app. A
|
|
`drift.json` from before stage 8, where `kind` held the status and `app` the name, still loads. Settings are compared like the
|
|
Settings compare view (shared fields against the settings baseline), lists as sets against the lists baseline, and only when that
|
|
baseline exists. The header count is the number of `differs` entries of every kind.
|
|
|
|
**Profiles.** A profile is a named bundle in `${HUB_DATA}/profiles/<name>.json` (mode 0600; it holds settings secrets as the
|
|
settings baseline does, so protect the folder): store apps with the version they had and the hub's baseline config of each at that
|
|
moment, the shared fields of the settings baseline (host fields never travel), and the Blocked/Allowed lists baseline. Every
|
|
profile endpoint needs `admin`; viewer, operator and apps-admin are refused.
|
|
|
|
| Endpoint | Method | Does |
|
|
|---|---|---|
|
|
| `hub/profiles/list` | GET | Names with counts. |
|
|
| `hub/profiles/get?name=` | GET | One profile with secrets as `<secret>` and no app config text (only whether there is one). |
|
|
| `hub/profiles/snapshot {name, apps?, settings, lists}` | POST | Makes or replaces a profile from the current baselines. `apps` is a comma list (absent: every app with a baseline; empty: none). A zip app, or one installed nowhere, is refused. |
|
|
| `hub/profiles/delete {name}` | POST | Deletes it. |
|
|
| `hub/profiles/export?name=` | GET | `{ hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt, profiles }`, with the real secrets: keep the file as a secret. |
|
|
| `hub/profiles/import {data, apply}` | POST | Same size limit as the baselines import (5 MB, signed-in callers only). Dry run first (`new`, `changed`, `same`); `apply=true` writes. A `<secret>` placeholder or a host field in the file is refused or dropped. |
|
|
| `hub/profiles/preview {name, targets, removeExtras}` | GET/POST | Per target: apps to install, already there, or another version; app configs that would change; settings fields that would change; lists missing and extra. Writes nothing. |
|
|
| `hub/profiles/apply {name, targets, removeExtras, from}` | POST | Runs the steps on each target. |
|
|
|
|
**Apply.** The targets go in parallel; the steps of one server go in order, and a failed step stops the later ones for that server:
|
|
|
|
1. `apps`: the missing apps are installed from THAT server's own `apps/listStoreApps` (the URL never comes from the request; an
|
|
installed app is not updated, a version difference is only shown).
|
|
2. `configs`: the profile's config of each app plus that server's own local values, with the old config saved to the history first
|
|
and a read-back check (the same as the config apply).
|
|
3. `settings`: the changed shared fields only, through the checks of `guards.ts` (no `webService*`, user, SSO or LDAP field), the
|
|
old settings in the settings history, and a read-back check.
|
|
4. `lists`: the missing domains are added; extras are removed only with `removeExtras`.
|
|
|
|
The answer is `{ results: [{ server, ok, message, steps: [{ step, status, message }] }] }` with `status` one of `ok`, `failed`,
|
|
`skipped` (done by an earlier run) or `not-run`. `from` is `{ "<server>": "<step>" }`: the step to start at, so "Retry failed" does
|
|
not repeat what worked. The audit has one `profiles.apply` line per server and step (names, counts and the outcome, no values).
|
|
|
|
## 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`.
|