Hub stage 8: settings drift, webhook notices, profiles
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3123
-106
No files matched your search
+89
-5
@@ -23,6 +23,8 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
||||
| `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. |
|
||||
@@ -214,7 +216,7 @@ baseline and the history (mode 0600, protect the data folder like the users file
|
||||
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 are not part of the drift check yet (a note in `drift.ts`).
|
||||
`${HUB_DATA}/blocking/baseline.json`. Settings and the lists are part of the drift check (see Drift).
|
||||
|
||||
## History, rollback, drift, export
|
||||
|
||||
@@ -232,12 +234,13 @@ connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overr
|
||||
|
||||
**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.
|
||||
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; the console shows it in the header.
|
||||
- `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.
|
||||
@@ -256,6 +259,87 @@ Before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user