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 <[email protected]>
This commit is contained in:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3118
-101
No files matched your search
@@ -312,6 +312,8 @@ docker compose rm -f technitium-hub # удалить контейнер
|
|||||||
| `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) |
|
| `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) |
|
||||||
| `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс |
|
| `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс |
|
||||||
| `HUB_PORT` | `8080` | Порт внутри контейнера |
|
| `HUB_PORT` | `8080` | Порт внутри контейнера |
|
||||||
|
| `HUB_WEBHOOK_URL` | нет | Адрес http или https, куда хаб шлёт уведомления (см. ниже). Адрес может содержать токен, храните его как секрет |
|
||||||
|
| `HUB_WEBHOOK_SECRET` | нет | Подписывает уведомления (`X-Hub-Signature`). В логи не пишется |
|
||||||
|
|
||||||
Третий сервер добавляется строкой в `HUB_SERVERS` и его токеном:
|
Третий сервер добавляется строкой в `HUB_SERVERS` и его токеном:
|
||||||
```yaml
|
```yaml
|
||||||
@@ -361,8 +363,41 @@ docker compose rm -f technitium-hub # удалить контейнер
|
|||||||
|
|
||||||
- **История.** Вкладка History в окне Config: все прежние версии, которые хаб сохранил (конфиг сервера перед применением или сохранением, прежний эталон). Выберите версию, чтобы прочитать её и увидеть отличие от текущего конфига сервера. Роли `apps-admin` и `admin`: **Roll back…** (выбор серверов, предпросмотр, итог по каждому) и **Restore as baseline** для прежнего эталона. Хранится по 50 версий на приложение.
|
- **История.** Вкладка History в окне Config: все прежние версии, которые хаб сохранил (конфиг сервера перед применением или сохранением, прежний эталон). Выберите версию, чтобы прочитать её и увидеть отличие от текущего конфига сервера. Роли `apps-admin` и `admin`: **Roll back…** (выбор серверов, предпросмотр, итог по каждому) и **Restore as baseline** для прежнего эталона. Хранится по 50 версий на приложение.
|
||||||
- **Контроль расхождений.** Раз в 15 минут хаб читает конфиги и сравнивает их с тем, что должно быть (эталон плюс локальные поля сервера). Если конфиг поменяли прямо в консоли сервера, в шапке появляется «N config drift». В списке у каждого расхождения есть **Open config**, а для `apps-admin` и `admin` ещё **Accept as baseline** (конфиг этого сервера становится эталоном; локальные поля остаются локальными) и **Revert to baseline** (через предпросмотр). Период меняется переменной `HUB_DRIFT_MINUTES`, `0` отключает проверку. Сервер, который не ответил, показывается как «Cannot read» и в счётчик не входит. Последний результат лежит в `/data/drift.json`.
|
- **Контроль расхождений.** Раз в 15 минут хаб читает конфиги и сравнивает их с тем, что должно быть (эталон плюс локальные поля сервера). Если конфиг поменяли прямо в консоли сервера, в шапке появляется «N config drift». В списке у каждого расхождения есть **Open config**, а для `apps-admin` и `admin` ещё **Accept as baseline** (конфиг этого сервера становится эталоном; локальные поля остаются локальными) и **Revert to baseline** (через предпросмотр). Период меняется переменной `HUB_DRIFT_MINUTES`, `0` отключает проверку. Сервер, который не ответил, показывается как «Cannot read» и в счётчик не входит. Последний результат лежит в `/data/drift.json`.
|
||||||
|
- **Что проверяется.** Конфиги приложений, общие поля настроек (против эталона настроек) и списки Blocked/Allowed (против эталона списков). Проверка настроек и списков идёт только если соответствующий эталон принят. Расхождение настроек или списков показывает только названия полей, без значений. В списке у него **Open compare** (окно сравнения Settings или Blocking), **Accept as baseline** и **Revert to baseline** (через тот же предпросмотр); эти действия только для роли `admin`.
|
||||||
- **Перенос эталонов.** Apps › All servers: **Export baselines** скачивает один файл со всеми эталонами и локальными полями, **Import baselines** загружает его в другой хаб: сначала проверка и сводка (New, Changed, Same), запись только после **Import**. На серверы импорт ничего не отправляет. Файл содержит конфиги, храните его как секрет.
|
- **Перенос эталонов.** Apps › All servers: **Export baselines** скачивает один файл со всеми эталонами и локальными полями, **Import baselines** загружает его в другой хаб: сначала проверка и сводка (New, Changed, Same), запись только после **Import**. На серверы импорт ничего не отправляет. Файл содержит конфиги, храните его как секрет.
|
||||||
|
|
||||||
|
## Уведомления (webhook)
|
||||||
|
|
||||||
|
Если задан `HUB_WEBHOOK_URL`, хаб отправляет POST с JSON `{event, at, hub, items}`:
|
||||||
|
|
||||||
|
- `drift.found`: что-то только что разошлось с эталоном (сервер, вид, имя). Шлётся один раз при изменении, а не каждые 15 минут;
|
||||||
|
- `drift.cleared`: расхождение исчезло (сервер, который перестал отвечать, не считается исчезнувшим);
|
||||||
|
- `write.failed`: изменение, записанное в журнал с исходом `failed` (без значений). Сбои одного момента идут одним уведомлением.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
```json
|
||||||
|
{"event":"drift.found","at":"2026-11-01T09:15:00.000Z","hub":"dnss.shnt.top",
|
||||||
|
"items":[{"kind":"settings","server":"ndns","since":"2026-11-01T09:15:00.000Z","fields":["blockingType"]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если задан `HUB_WEBHOOK_SECRET`, заголовок `X-Hub-Signature: sha256=<hex>` содержит HMAC-SHA256 точных байт тела. Проверка на стороне получателя (Node):
|
||||||
|
```js
|
||||||
|
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
|
||||||
|
// сравните с заголовком через timingSafeEqual
|
||||||
|
```
|
||||||
|
Отправка не блокирует работу хаба: таймаут 5 секунд, не больше одного повтора. Ошибка пишется в лог хаба с именем хоста (без параметров адреса). Администратор видит состояние (настроен ли, хост, результат последней отправки) и кнопку **Send test** в окне «Config drift» (оно открывается из шапки, когда есть расхождения).
|
||||||
|
|
||||||
|
## Профили
|
||||||
|
|
||||||
|
Профиль нужен, чтобы быстро привести новый или сброшенный сервер к общему виду. Это именованный набор в `/data/profiles/`: приложения магазина (имя и версия) с эталонным конфигом каждого, общие поля настроек (поля, относящиеся к самому хосту, не входят) и эталон списков Blocked/Allowed. Файл содержит секреты настроек (ключи TSIG), берегите том `/data`.
|
||||||
|
|
||||||
|
Все действия только для роли `admin` (`apps-admin`, `operator` и `viewer` получают отказ). Кнопка **Profiles** на экране Apps › All servers:
|
||||||
|
|
||||||
|
- **Create from current baselines**: профиль собирается из эталонов, которые хаб держит сейчас; можно не брать приложения, настройки или списки. Приложение не из магазина (поставленное из zip) в профиль не входит;
|
||||||
|
- **View**: состав профиля; секреты показаны как `<secret>`, тексты конфигов не показываются;
|
||||||
|
- **Apply to servers…**: выбор серверов, предпросмотр по шагам для каждого сервера, затем итог по каждому шагу. Шаги идут по порядку, ошибка шага останавливает следующие шаги этого сервера: 1) установка недостающих приложений из магазина самого сервера, 2) конфиги приложений (эталон плюс локальные поля сервера, прежний конфиг сохраняется в историю), 3) общие поля настроек (через те же проверки, что и обычное сохранение), 4) списки: недостающие домены добавляются, лишние удаляются только по галочке **Also remove extras**. **Retry failed** начинает заново с шага, который не удался;
|
||||||
|
- **Export** и **Import profiles**: перенос между хабами (файл содержит секреты, храните его как секрет; сначала проверка, запись только после **Import**).
|
||||||
|
|
||||||
## Приложения: установка, обновление, удаление
|
## Приложения: установка, обновление, удаление
|
||||||
|
|
||||||
Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**:
|
Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
|
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
|
||||||
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
|
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
|
||||||
|
|
||||||
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером), 6 (действия на все серверы вне Apps) и 7 (зоны, настройки и блокировка: сравнение и выравнивание) сделаны. Этапы 8 и далее пока планы (`docs/hub-deploy.md`).
|
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером), 6 (действия на все серверы вне Apps), 7 (зоны, настройки и блокировка: сравнение и выравнивание) и 8 (профили, webhook, расхождения настроек и списков) сделаны. Этапы после 8 пока планы (`docs/hub-deploy.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+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_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_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_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. |
|
| `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. |
|
| `<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_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
|
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
|
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),
|
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
|
## 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
|
**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
|
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
|
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ kind, name?, server, since, status, fields? }] }`, where
|
||||||
`differs`, `unreadable` (the server or the read failed; listed, not an alarm) or `not-installed`, and `since` is the first check that
|
`kind` is `app`, `settings` or `lists` (stage 8), `status` is `differs`, `unreadable` (the server or the read failed; listed, not an
|
||||||
saw it. Two checks never run at once.
|
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.
|
- `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
|
- `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.
|
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.
|
- "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.
|
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.
|
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
|
## Run locally
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+5
-4
@@ -286,9 +286,9 @@ export class AppsConfig {
|
|||||||
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
|
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
async preview(name: string, ids: string[]): Promise<{ targets: TargetPreview[] }> {
|
async preview(name: string, ids: string[], overrideText?: string): Promise<{ targets: TargetPreview[] }> {
|
||||||
const targets = this.#targets(ids)
|
const targets = this.#targets(ids)
|
||||||
const baseline = this.getBaseline(name)
|
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
|
||||||
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
||||||
const overrides = this.getOverrides(name)
|
const overrides = this.getOverrides(name)
|
||||||
const live = await this.#liveAll(name, true)
|
const live = await this.#liveAll(name, true)
|
||||||
@@ -368,8 +368,9 @@ export class AppsConfig {
|
|||||||
return { results: done.map((d) => d.result), details }
|
return { results: done.map((d) => d.result), details }
|
||||||
}
|
}
|
||||||
|
|
||||||
async apply(name: string, ids: string[], expect: Record<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
/* `overrideText` is a config that is not the hub's baseline (a profile's). The server's local values still apply. */
|
||||||
const baseline = this.getBaseline(name)
|
async apply(name: string, ids: string[], expect: Record<string, string>, overrideText?: string): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||||
|
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
|
||||||
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
|
||||||
const overrides = this.getOverrides(name)
|
const overrides = this.getOverrides(name)
|
||||||
return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect)
|
return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect)
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ describe('drift over the API', () => {
|
|||||||
|
|
||||||
expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403)
|
expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403)
|
||||||
const checked = await post('apps-admin', 'hub/drift/check', {})
|
const checked = await post('apps-admin', 'hub/drift/check', {})
|
||||||
expect(checked.body.response.apps).toMatchObject([{ app: 'Blocker', server: 'b', kind: 'differs' }])
|
expect(checked.body.response.apps).toMatchObject([{ kind: 'app', app: 'Blocker', name: 'Blocker', server: 'b', status: 'differs' }])
|
||||||
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
|
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
|
||||||
const got = await api('viewer', 'hub/drift/get')
|
const got = await api('viewer', 'hub/drift/get')
|
||||||
expect(got.body.response.apps).toHaveLength(1)
|
expect(got.body.response.apps).toHaveLength(1)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class Audit {
|
|||||||
#ring: AuditEntry[] = []
|
#ring: AuditEntry[] = []
|
||||||
#size = 0
|
#size = 0
|
||||||
#memoryOnly = false
|
#memoryOnly = false
|
||||||
|
#listener: ((entry: AuditEntry) => void) | null = null
|
||||||
|
|
||||||
constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) {
|
constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) {
|
||||||
this.#maxBytes = options.maxBytes ?? MAX_BYTES
|
this.#maxBytes = options.maxBytes ?? MAX_BYTES
|
||||||
@@ -62,6 +63,11 @@ export class Audit {
|
|||||||
this.#memoryOnly = true
|
this.#memoryOnly = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One function is told about every entry as it is written (the webhook uses it for failed writes). */
|
||||||
|
onEntry(listener: (entry: AuditEntry) => void): void {
|
||||||
|
this.#listener = listener
|
||||||
|
}
|
||||||
|
|
||||||
log(input: AuditInput): void {
|
log(input: AuditInput): void {
|
||||||
const entry: AuditEntry = {
|
const entry: AuditEntry = {
|
||||||
ts: new Date(this.#now()).toISOString(),
|
ts: new Date(this.#now()).toISOString(),
|
||||||
@@ -75,6 +81,11 @@ export class Audit {
|
|||||||
...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }),
|
...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }),
|
||||||
}
|
}
|
||||||
this.#ring.push(entry)
|
this.#ring.push(entry)
|
||||||
|
try {
|
||||||
|
this.#listener?.(entry)
|
||||||
|
} catch {
|
||||||
|
// A listener never stops the log.
|
||||||
|
}
|
||||||
if (this.#ring.length > RING) this.#ring.shift()
|
if (this.#ring.length > RING) this.#ring.shift()
|
||||||
if (this.#memoryOnly) return
|
if (this.#memoryOnly) return
|
||||||
const line = JSON.stringify(entry) + '\n'
|
const line = JSON.stringify(entry) + '\n'
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface HubConfig {
|
|||||||
/** How often the drift check runs (HUB_DRIFT_MINUTES); 0 turns it off. */
|
/** How often the drift check runs (HUB_DRIFT_MINUTES); 0 turns it off. */
|
||||||
driftMinutes: number
|
driftMinutes: number
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
|
/** Where drift and failed-write notices are POSTed (HUB_WEBHOOK_URL); null when not set. */
|
||||||
|
webhookUrl: string | null
|
||||||
|
/** Signs the notices with HMAC-SHA256 (HUB_WEBHOOK_SECRET); null when not set. Never logged. */
|
||||||
|
webhookSecret: string | null
|
||||||
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
|
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
|
||||||
domain: string
|
domain: string
|
||||||
/** The console's static build. */
|
/** The console's static build. */
|
||||||
@@ -85,6 +89,17 @@ export function parseConfig(env: Env): HubConfig {
|
|||||||
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
|
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webhookUrl = (env.HUB_WEBHOOK_URL ?? '').trim()
|
||||||
|
if (webhookUrl !== '') {
|
||||||
|
let ok = /^https?:\/\/[^/\s]/i.test(webhookUrl)
|
||||||
|
try {
|
||||||
|
new URL(webhookUrl)
|
||||||
|
} catch {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if (!ok) throw new Error('HUB_WEBHOOK_URL must be an http or https address.')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
servers,
|
servers,
|
||||||
bootstrap: user === '' ? null : { user, password },
|
bootstrap: user === '' ? null : { user, password },
|
||||||
@@ -94,6 +109,8 @@ export function parseConfig(env: Env): HubConfig {
|
|||||||
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
|
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
|
||||||
driftMinutes: number(env, 'HUB_DRIFT_MINUTES', 15, 0),
|
driftMinutes: number(env, 'HUB_DRIFT_MINUTES', 15, 0),
|
||||||
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
|
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
|
||||||
|
webhookUrl: webhookUrl === '' ? null : webhookUrl,
|
||||||
|
webhookSecret: (env.HUB_WEBHOOK_SECRET ?? '') === '' ? null : (env.HUB_WEBHOOK_SECRET as string),
|
||||||
domain: env.HUB_DOMAIN?.trim() || 'hub',
|
domain: env.HUB_DOMAIN?.trim() || 'hub',
|
||||||
staticDir: env.HUB_STATIC?.trim() || './dist',
|
staticDir: env.HUB_STATIC?.trim() || './dist',
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-12
@@ -1,11 +1,12 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { parseConfig } from './config.ts'
|
import { parseConfig } from './config.ts'
|
||||||
import { Drift } from './drift.ts'
|
import { Drift } from './drift.ts'
|
||||||
import { writeBaseline, writeOverrides } from './store.ts'
|
import { writeBaseline, writeListsBaseline, writeOverrides, writeSettingsBaseline } from './store.ts'
|
||||||
|
import { Webhook } from './webhook.ts'
|
||||||
import { Upstream } from './upstream.ts'
|
import { Upstream } from './upstream.ts'
|
||||||
|
|
||||||
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
||||||
@@ -14,6 +15,9 @@ interface FakeServer {
|
|||||||
apps: Record<string, string>
|
apps: Record<string, string>
|
||||||
down: boolean
|
down: boolean
|
||||||
denyConfig: boolean
|
denyConfig: boolean
|
||||||
|
settings: Record<string, unknown>
|
||||||
|
blocked: string[]
|
||||||
|
allowed: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir: string
|
let dir: string
|
||||||
@@ -38,10 +42,14 @@ const fakeFetch = (async (input: string | URL | Request) => {
|
|||||||
if (server.denyConfig) return Response.json({ status: 'error', errorMessage: 'Access was denied.' })
|
if (server.denyConfig) return Response.json({ status: 'error', errorMessage: 'Access was denied.' })
|
||||||
return reply({ config: server.apps[name] ?? null })
|
return reply({ config: server.apps[name] ?? null })
|
||||||
}
|
}
|
||||||
|
if (apiPath === 'settings/get') return reply(server.settings)
|
||||||
|
if (apiPath === 'blocked/export' || apiPath === 'allowed/export') {
|
||||||
|
return new Response((apiPath === 'blocked/export' ? server.blocked : server.allowed).join('\n') + '\n', { headers: { 'content-type': 'text/plain' } })
|
||||||
|
}
|
||||||
return reply({})
|
return reply({})
|
||||||
}) as typeof fetch
|
}) as typeof fetch
|
||||||
|
|
||||||
function make() {
|
function make(webhook?: Webhook) {
|
||||||
const config = parseConfig({
|
const config = parseConfig({
|
||||||
HUB_SERVERS: 'a=http://127.0.0.1:1,b=http://127.0.0.1:2',
|
HUB_SERVERS: 'a=http://127.0.0.1:1,b=http://127.0.0.1:2',
|
||||||
A_TOKEN: 'ra',
|
A_TOKEN: 'ra',
|
||||||
@@ -49,7 +57,7 @@ function make() {
|
|||||||
HUB_DATA: dir,
|
HUB_DATA: dir,
|
||||||
HUB_CACHE_SECONDS: '0',
|
HUB_CACHE_SECONDS: '0',
|
||||||
})
|
})
|
||||||
const drift = new Drift(config, new Upstream(config, fakeFetch, () => clock), () => clock)
|
const drift = new Drift(config, new Upstream(config, fakeFetch, () => clock), () => clock, { webhook })
|
||||||
return { config, drift }
|
return { config, drift }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +66,8 @@ const baseline = (app: string, text: string) =>
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dir = mkdtempSync(path.join(tmpdir(), 'hub-drift-'))
|
dir = mkdtempSync(path.join(tmpdir(), 'hub-drift-'))
|
||||||
servers = { a: { apps: {}, down: false, denyConfig: false }, b: { apps: {}, down: false, denyConfig: false } }
|
const blank = (): FakeServer => ({ apps: {}, down: false, denyConfig: false, settings: {}, blocked: [], allowed: [] })
|
||||||
|
servers = { a: blank(), b: blank() }
|
||||||
calls = []
|
calls = []
|
||||||
gate = null
|
gate = null
|
||||||
clock = Date.parse('2026-10-31T10:00:00Z')
|
clock = Date.parse('2026-10-31T10:00:00Z')
|
||||||
@@ -83,7 +92,7 @@ describe('drift check', () => {
|
|||||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||||
const { drift } = make()
|
const { drift } = make()
|
||||||
const state = await drift.check()
|
const state = await drift.check()
|
||||||
expect(state.apps).toEqual([{ app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', kind: 'differs' }])
|
expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }])
|
||||||
expect(drift.count()).toBe(1)
|
expect(drift.count()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,7 +112,7 @@ describe('drift check', () => {
|
|||||||
expect((await drift.check()).apps).toEqual([])
|
expect((await drift.check()).apps).toEqual([])
|
||||||
// A wrong local value is drift.
|
// A wrong local value is drift.
|
||||||
servers.b.apps.Blocker = json({ ttl: 30, db: '/elsewhere.db' })
|
servers.b.apps.Blocker = json({ ttl: 30, db: '/elsewhere.db' })
|
||||||
expect((await drift.check()).apps.map((e) => [e.server, e.kind])).toEqual([['b', 'differs']])
|
expect((await drift.check()).apps.map((e) => [e.server, e.status])).toEqual([['b', 'differs']])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists a server that does not answer as unreadable, and does not count it', async () => {
|
it('lists a server that does not answer as unreadable, and does not count it', async () => {
|
||||||
@@ -112,7 +121,7 @@ describe('drift check', () => {
|
|||||||
servers.b.down = true
|
servers.b.down = true
|
||||||
const { drift } = make()
|
const { drift } = make()
|
||||||
const state = await drift.check()
|
const state = await drift.check()
|
||||||
expect(state.apps).toEqual([{ app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', kind: 'unreadable' }])
|
expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'unreadable' }])
|
||||||
expect(drift.count()).toBe(0)
|
expect(drift.count()).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,7 +131,7 @@ describe('drift check', () => {
|
|||||||
servers.a.denyConfig = true
|
servers.a.denyConfig = true
|
||||||
const { drift } = make()
|
const { drift } = make()
|
||||||
const state = await drift.check()
|
const state = await drift.check()
|
||||||
expect(state.apps.map((e) => [e.server, e.kind])).toEqual([['a', 'unreadable'], ['b', 'not-installed']])
|
expect(state.apps.map((e) => [e.server, e.status])).toEqual([['a', 'unreadable'], ['b', 'not-installed']])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps `since` across checks and drops the entry once the problem is fixed', async () => {
|
it('keeps `since` across checks and drops the entry once the problem is fixed', async () => {
|
||||||
@@ -147,7 +156,7 @@ describe('drift check', () => {
|
|||||||
expect((await drift.check()).apps[0].since).toBe('2026-10-31T10:45:00.000Z')
|
expect((await drift.check()).apps[0].since).toBe('2026-10-31T10:45:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('changes the kind (and `since`) when the problem changes', async () => {
|
it('changes the status (and `since`) when the problem changes', async () => {
|
||||||
baseline('Blocker', json({ ttl: 30 }))
|
baseline('Blocker', json({ ttl: 30 }))
|
||||||
servers.a.apps.Blocker = json({ ttl: 30 })
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||||
servers.b.apps.Blocker = json({ ttl: 99 })
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||||
@@ -156,7 +165,7 @@ describe('drift check', () => {
|
|||||||
servers.b.down = true
|
servers.b.down = true
|
||||||
clock += 60_000
|
clock += 60_000
|
||||||
const state = await drift.check()
|
const state = await drift.check()
|
||||||
expect(state.apps[0]).toMatchObject({ kind: 'unreadable', since: '2026-10-31T10:01:00.000Z' })
|
expect(state.apps[0]).toMatchObject({ status: 'unreadable', since: '2026-10-31T10:01:00.000Z' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never runs two checks at once', async () => {
|
it('never runs two checks at once', async () => {
|
||||||
@@ -187,10 +196,148 @@ describe('drift check', () => {
|
|||||||
const file = JSON.parse(readFileSync(path.join(dir, 'drift.json'), 'utf8'))
|
const file = JSON.parse(readFileSync(path.join(dir, 'drift.json'), 'utf8'))
|
||||||
expect(file).toEqual({
|
expect(file).toEqual({
|
||||||
checkedAt: '2026-10-31T10:00:00.000Z',
|
checkedAt: '2026-10-31T10:00:00.000Z',
|
||||||
apps: [{ app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', kind: 'differs' }],
|
apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }],
|
||||||
})
|
})
|
||||||
const again = make().drift
|
const again = make().drift
|
||||||
expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false })
|
expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false })
|
||||||
expect(again.count()).toBe(1)
|
expect(again.count()).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const baselineSettings = (settings: Record<string, unknown>) => writeSettingsBaseline(dir, { settings, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
||||||
|
const baselineLists = (blocked: string[], allowed: string[]) => writeListsBaseline(dir, { blocked, allowed, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
||||||
|
|
||||||
|
describe('drift of settings and lists', () => {
|
||||||
|
it('reports shared settings fields that differ from the baseline, by field name only', async () => {
|
||||||
|
baselineSettings({ blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'x.example' })
|
||||||
|
servers.a.settings = { blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'a.example' }
|
||||||
|
servers.b.settings = { blockingType: 'AnyAddress', defaultRecordTtl: 60, dnsServerDomain: 'b.example' }
|
||||||
|
const { drift } = make()
|
||||||
|
const state = await drift.check()
|
||||||
|
// dnsServerDomain is local: server a is fine even though its value differs.
|
||||||
|
expect(state.apps).toEqual([{ kind: 'settings', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] }])
|
||||||
|
expect(JSON.stringify(state)).not.toContain('AnyAddress')
|
||||||
|
expect(drift.count()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports Blocked and Allowed lists that differ, without a domain', async () => {
|
||||||
|
baselineLists(['ads.example'], ['ok.example'])
|
||||||
|
servers.a.blocked = ['ads.example']
|
||||||
|
servers.a.allowed = ['ok.example']
|
||||||
|
servers.b.blocked = ['ads.example', 'extra.example']
|
||||||
|
servers.b.allowed = ['ok.example']
|
||||||
|
const { drift } = make()
|
||||||
|
const state = await drift.check()
|
||||||
|
expect(state.apps).toEqual([{ kind: 'lists', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blocked'] }])
|
||||||
|
expect(JSON.stringify(state)).not.toContain('extra.example')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads no settings and no lists when there is no baseline for them', async () => {
|
||||||
|
const { drift } = make()
|
||||||
|
await drift.check()
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists a server that does not answer as unreadable for each baseline, and does not count it', async () => {
|
||||||
|
baselineSettings({ blockingType: 'NxDomain' })
|
||||||
|
baselineLists([], [])
|
||||||
|
servers.b.down = true
|
||||||
|
const { drift } = make()
|
||||||
|
const state = await drift.check()
|
||||||
|
expect(state.apps.map((e) => [e.kind, e.server, e.status])).toEqual([['lists', 'b', 'unreadable'], ['settings', 'b', 'unreadable']])
|
||||||
|
expect(drift.count()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts every kind in one number', async () => {
|
||||||
|
baseline('Blocker', json({ ttl: 30 }))
|
||||||
|
baselineSettings({ blockingType: 'NxDomain' })
|
||||||
|
baselineLists(['x.example'], [])
|
||||||
|
servers.a.apps.Blocker = json({ ttl: 1 })
|
||||||
|
servers.b.apps.Blocker = json({ ttl: 30 })
|
||||||
|
servers.a.settings = { blockingType: 'AnyAddress' }
|
||||||
|
servers.b.settings = { blockingType: 'NxDomain' }
|
||||||
|
servers.a.blocked = ['x.example']
|
||||||
|
const { drift } = make()
|
||||||
|
await drift.check()
|
||||||
|
// a: app and settings; b: lists (the baseline has x.example, b has none).
|
||||||
|
expect(drift.get().apps.map((e) => `${e.kind}:${e.server}`).sort()).toEqual(['app:a', 'lists:b', 'settings:a'])
|
||||||
|
expect(drift.count()).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads a drift.json written before stage 8, where `kind` held the status', () => {
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, 'drift.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
checkedAt: '2026-10-31T09:00:00.000Z',
|
||||||
|
apps: [
|
||||||
|
{ app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', kind: 'differs' },
|
||||||
|
{ app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', kind: 'not-installed' },
|
||||||
|
{ server: 'a', since: 'x', kind: 'bogus', app: 'Z' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { drift } = make()
|
||||||
|
expect(drift.get().apps).toEqual([
|
||||||
|
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', status: 'differs' },
|
||||||
|
{ kind: 'app', name: 'Other', app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', status: 'not-installed' },
|
||||||
|
])
|
||||||
|
expect(drift.count()).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('drift notices', () => {
|
||||||
|
const sent: { event: string; items: Record<string, unknown>[] }[] = []
|
||||||
|
const hook = () =>
|
||||||
|
new Webhook(
|
||||||
|
{ webhookUrl: 'https://hooks.example/x?token=SECRET', webhookSecret: null, domain: 'hub' },
|
||||||
|
{
|
||||||
|
retryDelayMs: 0,
|
||||||
|
fetch: (async (_url: unknown, init: RequestInit) => {
|
||||||
|
sent.push(JSON.parse(String(init.body)))
|
||||||
|
return new Response('ok')
|
||||||
|
}) as typeof fetch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sent.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tells what newly differs once, and what is cleared, and nothing when nothing changed', async () => {
|
||||||
|
baseline('Blocker', json({ ttl: 30 }))
|
||||||
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||||
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||||
|
const webhook = hook()
|
||||||
|
const { drift } = make(webhook)
|
||||||
|
await drift.check()
|
||||||
|
await webhook.settled()
|
||||||
|
expect(sent.map((s) => s.event)).toEqual(['drift.found'])
|
||||||
|
expect(sent[0].items).toMatchObject([{ kind: 'app', name: 'Blocker', server: 'b' }])
|
||||||
|
|
||||||
|
clock += 15 * 60_000
|
||||||
|
await drift.check()
|
||||||
|
await webhook.settled()
|
||||||
|
expect(sent).toHaveLength(1)
|
||||||
|
|
||||||
|
servers.b.apps.Blocker = json({ ttl: 30 })
|
||||||
|
await drift.check()
|
||||||
|
await webhook.settled()
|
||||||
|
expect(sent.map((s) => s.event)).toEqual(['drift.found', 'drift.cleared'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not repeat a notice after a restart, and does not call an unreadable server cleared', async () => {
|
||||||
|
baseline('Blocker', json({ ttl: 30 }))
|
||||||
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
||||||
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
||||||
|
await make().drift.check()
|
||||||
|
const webhook = hook()
|
||||||
|
const restarted = make(webhook).drift
|
||||||
|
await restarted.check()
|
||||||
|
await webhook.settled()
|
||||||
|
expect(sent).toEqual([])
|
||||||
|
servers.b.down = true
|
||||||
|
await restarted.check()
|
||||||
|
await webhook.settled()
|
||||||
|
expect(sent).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
+101
-32
@@ -1,54 +1,96 @@
|
|||||||
/*
|
/*
|
||||||
Drift: a server whose config is no longer what the hub says it should be (the baseline plus the
|
Drift: a server whose config is no longer what the hub says it should be, usually because someone
|
||||||
server's own local values), usually because someone changed it in that server's own console.
|
changed it in that server's own console. Three kinds:
|
||||||
|
|
||||||
`check` reads every server's config for every app that has a baseline, with the read token, and
|
app an app's config is not the baseline plus the server's own local values
|
||||||
compares it with the effective config as the sync does. The result is kept in memory and in
|
settings the shared settings fields differ from the settings baseline
|
||||||
`${HUB_DATA}/drift.json`; `since` is the first check that saw the same problem, and it is dropped
|
lists the custom Blocked or Allowed list differs from the lists baseline
|
||||||
once the problem is gone. Two checks never run at once. The timer lives in server.ts, so the
|
|
||||||
tests drive `check` themselves.
|
`check` reads every server with the same rules as the compare views and keeps the result in memory
|
||||||
|
and in `${HUB_DATA}/drift.json`; `since` is the first check that saw the same problem, and it is
|
||||||
|
dropped once the problem is gone. An entry carries names and field names only, never a value.
|
||||||
|
Two checks never run at once. The timer lives in server.ts, so the tests drive `check` themselves.
|
||||||
|
|
||||||
|
When a webhook is given, a CHANGE of state is sent to it: `drift.found` for what newly differs and
|
||||||
|
`drift.cleared` for what no longer does (an entry that became unreadable is not cleared: it is not
|
||||||
|
known). The first state is the one loaded from drift.json, so a restart does not repeat notices.
|
||||||
*/
|
*/
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { HubConfig } from './config.ts'
|
import type { HubConfig } from './config.ts'
|
||||||
import { effectiveConfig, sameConfig } from './apps-sync.ts'
|
import { effectiveConfig, sameConfig } from './apps-sync.ts'
|
||||||
import { readConfigText } from './apps-config.ts'
|
import { readConfigText } from './apps-config.ts'
|
||||||
|
import { SettingsConfig } from './settings-config.ts'
|
||||||
import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts'
|
import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts'
|
||||||
import type { Upstream } from './upstream.ts'
|
import type { Upstream } from './upstream.ts'
|
||||||
|
import type { Webhook } from './webhook.ts'
|
||||||
|
|
||||||
/*
|
export type DriftStatus = 'differs' | 'unreadable' | 'not-installed'
|
||||||
Not yet (stage 7): settings are not part of this check yet. A `settings` kind would need `DriftEntry.app`
|
export type DriftType = 'app' | 'settings' | 'lists'
|
||||||
(and `count()`, which feeds the header badge) to stand for something other than an app. For now the
|
|
||||||
Settings and Blocking compare views (settings-config.ts) are the way to see a server that moved away
|
|
||||||
from the settings baseline.
|
|
||||||
*/
|
|
||||||
export type DriftKind = 'differs' | 'unreadable' | 'not-installed'
|
|
||||||
|
|
||||||
export interface DriftEntry {
|
export interface DriftEntry {
|
||||||
app: string
|
kind: DriftType
|
||||||
|
/** The app's name; absent for settings and lists. */
|
||||||
|
name?: string
|
||||||
|
/** Same as `name` for an app (the console's matrix reads it). */
|
||||||
|
app?: string
|
||||||
server: string
|
server: string
|
||||||
since: string
|
since: string
|
||||||
kind: DriftKind
|
status: DriftStatus
|
||||||
|
/** Setting names, or `blocked` / `allowed`. Never values. */
|
||||||
|
fields?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriftState {
|
export interface DriftState {
|
||||||
checkedAt: string | null
|
checkedAt: string | null
|
||||||
|
/** Every entry, whatever its kind (the key is kept from stage 4). */
|
||||||
apps: DriftEntry[]
|
apps: DriftEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const KINDS: DriftKind[] = ['differs', 'unreadable', 'not-installed']
|
const STATUSES: DriftStatus[] = ['differs', 'unreadable', 'not-installed']
|
||||||
|
const TYPES: DriftType[] = ['app', 'settings', 'lists']
|
||||||
|
|
||||||
|
export const keyOf = (e: Pick<DriftEntry, 'kind' | 'name' | 'server'>): string => `${e.kind}\n${e.name ?? ''}\n${e.server}`
|
||||||
|
|
||||||
|
/* An entry from drift.json. Before stage 8 `kind` held the status and `app` the name. */
|
||||||
|
export function readEntry(raw: unknown): DriftEntry | null {
|
||||||
|
if (raw === null || typeof raw !== 'object') return null
|
||||||
|
const e = raw as Record<string, unknown>
|
||||||
|
if (typeof e.server !== 'string' || typeof e.since !== 'string') return null
|
||||||
|
const oldKind = typeof e.kind === 'string' && STATUSES.includes(e.kind as DriftStatus)
|
||||||
|
const status = (oldKind ? e.kind : e.status) as DriftStatus
|
||||||
|
if (!STATUSES.includes(status)) return null
|
||||||
|
const kind = (oldKind || e.kind === undefined ? 'app' : e.kind) as DriftType
|
||||||
|
if (!TYPES.includes(kind)) return null
|
||||||
|
const name = typeof e.name === 'string' ? e.name : typeof e.app === 'string' ? e.app : undefined
|
||||||
|
if (kind === 'app' && name === undefined) return null
|
||||||
|
const fields = Array.isArray(e.fields) ? e.fields.filter((f): f is string => typeof f === 'string').slice(0, 100) : undefined
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
...(name === undefined ? {} : { name }),
|
||||||
|
...(kind === 'app' ? { app: name } : {}),
|
||||||
|
server: e.server,
|
||||||
|
since: e.since,
|
||||||
|
status,
|
||||||
|
...(fields === undefined || fields.length === 0 ? {} : { fields }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Drift {
|
export class Drift {
|
||||||
#config: HubConfig
|
#config: HubConfig
|
||||||
#upstream: Upstream
|
#upstream: Upstream
|
||||||
#now: () => number
|
#now: () => number
|
||||||
|
#settings: SettingsConfig
|
||||||
|
#webhook: Webhook | null
|
||||||
#state: DriftState
|
#state: DriftState
|
||||||
#running: Promise<DriftState> | null = null
|
#running: Promise<DriftState> | null = null
|
||||||
|
|
||||||
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
|
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now, options: { webhook?: Webhook } = {}) {
|
||||||
this.#config = config
|
this.#config = config
|
||||||
this.#upstream = upstream
|
this.#upstream = upstream
|
||||||
this.#now = now
|
this.#now = now
|
||||||
|
this.#settings = new SettingsConfig(config, upstream, now)
|
||||||
|
this.#webhook = options.webhook ?? null
|
||||||
this.#state = this.#load()
|
this.#state = this.#load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +102,7 @@ export class Drift {
|
|||||||
try {
|
try {
|
||||||
if (!existsSync(this.#file())) return { checkedAt: null, apps: [] }
|
if (!existsSync(this.#file())) return { checkedAt: null, apps: [] }
|
||||||
const data = JSON.parse(readFileSync(this.#file(), 'utf8')) as Partial<DriftState>
|
const data = JSON.parse(readFileSync(this.#file(), 'utf8')) as Partial<DriftState>
|
||||||
const apps = (Array.isArray(data.apps) ? data.apps : []).filter(
|
const apps = (Array.isArray(data.apps) ? data.apps : []).map(readEntry).filter((e): e is DriftEntry => e !== null)
|
||||||
(e): e is DriftEntry => typeof e?.app === 'string' && typeof e.server === 'string' && typeof e.since === 'string' && KINDS.includes(e.kind),
|
|
||||||
)
|
|
||||||
return { checkedAt: typeof data.checkedAt === 'string' ? data.checkedAt : null, apps }
|
return { checkedAt: typeof data.checkedAt === 'string' ? data.checkedAt : null, apps }
|
||||||
} catch {
|
} catch {
|
||||||
return { checkedAt: null, apps: [] }
|
return { checkedAt: null, apps: [] }
|
||||||
@@ -73,9 +113,9 @@ export class Drift {
|
|||||||
return { ...this.#state, checking: this.#running !== null }
|
return { ...this.#state, checking: this.#running !== null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many servers differ from their baseline (the other kinds are listed, not counted). */
|
/** How many entries differ from their baseline, of any kind (unreadable ones are listed, not counted). */
|
||||||
count(): number {
|
count(): number {
|
||||||
return this.#state.apps.filter((e) => e.kind === 'differs').length
|
return this.#state.apps.filter((e) => e.status === 'differs').length
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Runs a check, or joins the one that is already running. */
|
/* Runs a check, or joins the one that is already running. */
|
||||||
@@ -94,9 +134,9 @@ export class Drift {
|
|||||||
baseline: readBaseline(this.#config.dataDir, app),
|
baseline: readBaseline(this.#config.dataDir, app),
|
||||||
overrides: readOverrides(this.#config.dataDir, app),
|
overrides: readOverrides(this.#config.dataDir, app),
|
||||||
}))
|
}))
|
||||||
const found: { app: string; server: string; kind: DriftKind }[] = []
|
const found: Omit<DriftEntry, 'since'>[] = []
|
||||||
|
|
||||||
await Promise.all(
|
const appChecks = Promise.all(
|
||||||
this.#config.servers.map(async (server) => {
|
this.#config.servers.map(async (server) => {
|
||||||
if (apps.length === 0) return
|
if (apps.length === 0) return
|
||||||
this.#upstream.invalidate(server.id)
|
this.#upstream.invalidate(server.id)
|
||||||
@@ -105,36 +145,65 @@ export class Drift {
|
|||||||
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string }[] } | null
|
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string }[] } | null
|
||||||
installed = new Set((list?.apps ?? []).map((a) => a.name))
|
installed = new Set((list?.apps ?? []).map((a) => a.name))
|
||||||
} catch {
|
} catch {
|
||||||
for (const a of apps) found.push({ app: a.app, server: server.id, kind: 'unreadable' })
|
for (const a of apps) found.push({ kind: 'app', name: a.app, app: a.app, server: server.id, status: 'unreadable' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
apps.map(async (a) => {
|
apps.map(async (a) => {
|
||||||
if (a.baseline === null) return
|
if (a.baseline === null) return
|
||||||
if (!installed.has(a.app)) return void found.push({ app: a.app, server: server.id, kind: 'not-installed' })
|
const entry = { kind: 'app' as const, name: a.app, app: a.app, server: server.id }
|
||||||
|
if (!installed.has(a.app)) return void found.push({ ...entry, status: 'not-installed' })
|
||||||
try {
|
try {
|
||||||
const text = (await readConfigText(this.#upstream, server, a.app)) ?? ''
|
const text = (await readConfigText(this.#upstream, server, a.app)) ?? ''
|
||||||
const effective = effectiveConfig(a.baseline.text, a.overrides, server.id).text
|
const effective = effectiveConfig(a.baseline.text, a.overrides, server.id).text
|
||||||
if (!sameConfig(text, effective)) found.push({ app: a.app, server: server.id, kind: 'differs' })
|
if (!sameConfig(text, effective)) found.push({ ...entry, status: 'differs' })
|
||||||
} catch {
|
} catch {
|
||||||
found.push({ app: a.app, server: server.id, kind: 'unreadable' })
|
found.push({ ...entry, status: 'unreadable' })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const otherChecks = this.#settings.driftFindings().then(
|
||||||
|
(list) => {
|
||||||
|
for (const f of list) found.push({ kind: f.kind, server: f.server, status: f.status, ...(f.fields.length > 0 ? { fields: f.fields } : {}) })
|
||||||
|
},
|
||||||
|
() => undefined,
|
||||||
|
)
|
||||||
|
await Promise.all([appChecks, otherChecks])
|
||||||
|
|
||||||
const at = new Date(this.#now()).toISOString()
|
const at = new Date(this.#now()).toISOString()
|
||||||
const before = new Map(this.#state.apps.map((e) => [`${e.app}\n${e.server}\n${e.kind}`, e.since]))
|
const before = new Map(this.#state.apps.map((e) => [`${keyOf(e)}\n${e.status}`, e.since]))
|
||||||
const entries = found
|
const entries = found
|
||||||
.map((f): DriftEntry => ({ ...f, since: before.get(`${f.app}\n${f.server}\n${f.kind}`) ?? at }))
|
.map((f): DriftEntry => ({ ...f, since: before.get(`${keyOf(f)}\n${f.status}`) ?? at }))
|
||||||
.sort((x, y) => x.app.localeCompare(y.app) || x.server.localeCompare(y.server))
|
.sort((x, y) => x.kind.localeCompare(y.kind) || (x.name ?? '').localeCompare(y.name ?? '') || x.server.localeCompare(y.server))
|
||||||
|
const previous = this.#state.apps
|
||||||
this.#state = { checkedAt: at, apps: entries }
|
this.#state = { checkedAt: at, apps: entries }
|
||||||
try {
|
try {
|
||||||
writeAtomic(this.#file(), JSON.stringify(this.#state, null, 2) + '\n')
|
writeAtomic(this.#file(), JSON.stringify(this.#state, null, 2) + '\n')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`hub: drift.json could not be written (${e instanceof Error ? e.message : String(e)}).`)
|
console.error(`hub: drift.json could not be written (${e instanceof Error ? e.message : String(e)}).`)
|
||||||
}
|
}
|
||||||
|
this.#notify(previous, entries)
|
||||||
return this.#state
|
return this.#state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A notice for what newly differs and for what is back in line. Names and field names only. */
|
||||||
|
#notify(previous: DriftEntry[], now: DriftEntry[]): void {
|
||||||
|
if (this.#webhook === null || !this.#webhook.configured) return
|
||||||
|
const item = (e: DriftEntry): Record<string, unknown> => ({
|
||||||
|
kind: e.kind,
|
||||||
|
...(e.name === undefined ? {} : { name: e.name }),
|
||||||
|
server: e.server,
|
||||||
|
since: e.since,
|
||||||
|
...(e.fields === undefined ? {} : { fields: e.fields }),
|
||||||
|
})
|
||||||
|
const was = new Set(previous.filter((e) => e.status === 'differs').map(keyOf))
|
||||||
|
const nowDiffers = new Set(now.filter((e) => e.status === 'differs').map(keyOf))
|
||||||
|
const unknown = new Set(now.filter((e) => e.status === 'unreadable').map(keyOf))
|
||||||
|
const newly = now.filter((e) => e.status === 'differs' && !was.has(keyOf(e)))
|
||||||
|
const cleared = previous.filter((e) => e.status === 'differs' && !nowDiffers.has(keyOf(e)) && !unknown.has(keyOf(e)))
|
||||||
|
if (newly.length > 0) this.#webhook.send('drift.found', newly.map(item))
|
||||||
|
if (cleared.length > 0) this.#webhook.send('drift.cleared', cleared.map(item))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
/*
|
||||||
|
Stage 8, profiles: making one from the hub's baselines, what a viewer of it may see, export and
|
||||||
|
import, the preview, and the order and the stop of an apply, on two fake servers that keep their
|
||||||
|
apps, configs, settings and lists.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
|
||||||
|
import { cleanProfile } from './profiles.ts'
|
||||||
|
import { writeBaseline, writeListsBaseline, writeSettingsBaseline } from './store.ts'
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
apps: Record<string, string>
|
||||||
|
configs: Record<string, string>
|
||||||
|
store: { name: string; version: string }[]
|
||||||
|
settings: Record<string, unknown>
|
||||||
|
blocked: string[]
|
||||||
|
allowed: string[]
|
||||||
|
failSettings: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxes = new Map<Fake, Box>()
|
||||||
|
const urlOf = (f: Fake, name: string): string => `${f.url}/store/${name}.zip`
|
||||||
|
|
||||||
|
function install(f: Fake, box: Box): void {
|
||||||
|
boxes.set(f, box)
|
||||||
|
f.on('apps/list', () => ({ response: { apps: Object.entries(box.apps).map(([name, version]) => ({ name, version })) } }))
|
||||||
|
f.on('apps/listStoreApps', () => ({ response: { storeApps: box.store.map((a) => ({ ...a, url: urlOf(f, a.name) })) } }))
|
||||||
|
f.on('apps/downloadAndInstall', (c: Call) => {
|
||||||
|
const name = c.form.get('name') ?? c.query.get('name') ?? ''
|
||||||
|
const url = c.form.get('url') ?? c.query.get('url') ?? ''
|
||||||
|
const entry = box.store.find((a) => a.name === name)
|
||||||
|
if (entry === undefined || url !== urlOf(f, name)) return { error: 'The app was not found in the store.' }
|
||||||
|
box.apps[name] = entry.version
|
||||||
|
return { response: {} }
|
||||||
|
})
|
||||||
|
f.on('apps/config/get', (c: Call) => ({ response: { config: box.configs[c.query.get('name') ?? ''] ?? null } }))
|
||||||
|
f.on('apps/config/set', (c: Call) => {
|
||||||
|
box.configs[c.form.get('name') ?? c.query.get('name') ?? ''] = c.form.get('config') ?? ''
|
||||||
|
return { response: {} }
|
||||||
|
})
|
||||||
|
f.on('settings/get', () => ({ response: box.settings }))
|
||||||
|
f.on('settings/set', (c: Call) => {
|
||||||
|
if (box.failSettings) return { error: 'The settings were refused.' }
|
||||||
|
for (const [k, v] of c.form) {
|
||||||
|
if (k === 'tsigKeys') {
|
||||||
|
const cells = v.split('|')
|
||||||
|
box.settings.tsigKeys = v === 'false' ? [] : [{ keyName: cells[0], sharedSecret: cells[1], algorithmName: cells[2] }]
|
||||||
|
} else if (k in box.settings) box.settings[k] = v
|
||||||
|
}
|
||||||
|
return { response: {} }
|
||||||
|
})
|
||||||
|
for (const list of ['blocked', 'allowed'] as const) {
|
||||||
|
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
|
||||||
|
f.on(`${list}/add`, (c: Call) => {
|
||||||
|
box[list].push(c.form.get('domain') ?? '')
|
||||||
|
return { response: {} }
|
||||||
|
})
|
||||||
|
f.on(`${list}/delete`, (c: Call) => {
|
||||||
|
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
|
||||||
|
return { response: {} }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let a: Fake
|
||||||
|
let b: Fake
|
||||||
|
let hub: TestHub
|
||||||
|
const boxA = (): Box => boxes.get(a)!
|
||||||
|
const boxB = (): Box => boxes.get(b)!
|
||||||
|
const order = (f: Fake): string[] => f.calls.filter((c) => c.method === 'POST').map((c) => c.path)
|
||||||
|
|
||||||
|
const CONFIG = JSON.stringify({ ttl: 30 })
|
||||||
|
const settingsOf = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||||
|
dnsServerDomain: 'a.example',
|
||||||
|
blockingType: 'NxDomain',
|
||||||
|
tsigKeys: [{ keyName: 'k1', sharedSecret: 'REALSECRET', algorithmName: 'hmac-sha256' }],
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
function baselines(): void {
|
||||||
|
writeBaseline(hub.dataDir, { app: 'Blocker', text: CONFIG, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||||
|
writeSettingsBaseline(hub.dataDir, { settings: settingsOf(), updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||||
|
writeListsBaseline(hub.dataDir, { blocked: ['ads.example'], allowed: ['ok.example'], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
a = await fake()
|
||||||
|
b = await fake()
|
||||||
|
hub = await startHub(a, b)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
hub.close()
|
||||||
|
a.server.close()
|
||||||
|
b.server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
a.calls = []
|
||||||
|
b.calls = []
|
||||||
|
install(a, {
|
||||||
|
apps: { Blocker: '2.0', Zipped: '1.0' },
|
||||||
|
configs: { Blocker: CONFIG },
|
||||||
|
store: [{ name: 'Blocker', version: '2.0' }],
|
||||||
|
settings: settingsOf(),
|
||||||
|
blocked: ['ads.example'],
|
||||||
|
allowed: ['ok.example'],
|
||||||
|
failSettings: false,
|
||||||
|
})
|
||||||
|
// b is a fresh server: no apps, different settings, a missing domain, an extra one.
|
||||||
|
install(b, {
|
||||||
|
apps: {},
|
||||||
|
configs: {},
|
||||||
|
store: [{ name: 'Blocker', version: '2.0' }],
|
||||||
|
settings: settingsOf({ dnsServerDomain: 'b.example', blockingType: 'AnyAddress', tsigKeys: [] }),
|
||||||
|
blocked: ['mine.example'],
|
||||||
|
allowed: ['ok.example'],
|
||||||
|
failSettings: false,
|
||||||
|
})
|
||||||
|
baselines()
|
||||||
|
for (const f of readdirSync(path.join(hub.dataDir)).filter((n) => n === 'profiles')) void f
|
||||||
|
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const snapshot = (over: Record<string, string> = {}) =>
|
||||||
|
hub.call('admin', 'hub/profiles/snapshot', { name: 'Fresh', apps: 'Blocker', settings: 'true', lists: 'true', ...over })
|
||||||
|
|
||||||
|
describe('making a profile', () => {
|
||||||
|
it('copies the baselines at that moment: store apps with version and config, shared settings fields, lists', async () => {
|
||||||
|
const done = await snapshot()
|
||||||
|
expect(done.code).toBe(200)
|
||||||
|
expect(done.body.status).toBe('ok')
|
||||||
|
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||||
|
expect(file.apps).toEqual([{ name: 'Blocker', version: '2.0', config: CONFIG }])
|
||||||
|
// The host field stays out; the real secret is kept, as the baseline file has it.
|
||||||
|
expect(file.settings.dnsServerDomain).toBeUndefined()
|
||||||
|
expect(file.settings.tsigKeys[0].sharedSecret).toBe('REALSECRET')
|
||||||
|
expect(file.lists).toEqual({ blocked: ['ads.example'], allowed: ['ok.example'] })
|
||||||
|
expect(file.updatedBy).toBe('admin')
|
||||||
|
// Later changes of the baselines do not change the profile.
|
||||||
|
writeBaseline(hub.dataDir, { app: 'Blocker', text: '{"ttl":1}', updatedAt: 'x', updatedBy: 't' })
|
||||||
|
const again = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||||
|
expect(again.apps[0].config).toBe(CONFIG)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the admin choose the parts, and refuses an empty choice', async () => {
|
||||||
|
expect((await snapshot({ settings: 'false', lists: 'false' })).body.status).toBe('ok')
|
||||||
|
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||||
|
expect(file.settings).toBeNull()
|
||||||
|
expect(file.lists).toBeNull()
|
||||||
|
const none = await snapshot({ apps: '', settings: 'false', lists: 'false' })
|
||||||
|
expect(none.code).toBe(400)
|
||||||
|
expect(none.body.errorMessage).toMatch(/at least one part/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses an app that is a zip, one that is installed nowhere, and a bad name', async () => {
|
||||||
|
const zip = await snapshot({ apps: 'Zipped' })
|
||||||
|
expect(zip.code).toBe(400)
|
||||||
|
expect(zip.body.errorMessage).toMatch(/store listing/)
|
||||||
|
expect((await snapshot({ apps: 'Ghost' })).body.errorMessage).toMatch(/not installed/)
|
||||||
|
expect((await snapshot({ name: '../evil' })).code).toBe(400)
|
||||||
|
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a profile of the same name and lists it with counts only', async () => {
|
||||||
|
await snapshot()
|
||||||
|
await snapshot({ lists: 'false' })
|
||||||
|
const list = await hub.call('admin', 'hub/profiles/list', {}, 'GET')
|
||||||
|
expect(list.body.response.profiles).toMatchObject([{ name: 'Fresh', apps: 1, configs: 1, lists: null }])
|
||||||
|
expect(list.body.response.profiles[0].settings).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('viewing a profile', () => {
|
||||||
|
it('masks secrets and never returns an app config', async () => {
|
||||||
|
await snapshot()
|
||||||
|
const got = await hub.call('admin', 'hub/profiles/get', { name: 'Fresh' }, 'GET')
|
||||||
|
const text = JSON.stringify(got.body)
|
||||||
|
expect(text).not.toContain('REALSECRET')
|
||||||
|
expect(text).toContain('<secret>')
|
||||||
|
expect(text).not.toContain('"ttl"')
|
||||||
|
expect(got.body.response.apps).toEqual([{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: CONFIG.length }])
|
||||||
|
expect(got.body.response.lists).toEqual({ blocked: 1, allowed: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers an unknown profile with an error', async () => {
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/get', { name: 'Nope' }, 'GET')).code).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('roles', () => {
|
||||||
|
it('refuses every profile endpoint to every role but admin, and answers none of them from a server', async () => {
|
||||||
|
const reads = ['list', 'get', 'export', 'preview']
|
||||||
|
const writes = ['snapshot', 'delete', 'import', 'apply']
|
||||||
|
for (const role of ROLES.filter((r) => r !== 'admin')) {
|
||||||
|
for (const p of reads) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b' }, 'GET')).code).toBe(403)
|
||||||
|
for (const p of writes) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b', data: '{}' })).code).toBe(403)
|
||||||
|
}
|
||||||
|
// An unlisted profile path is refused, not forwarded.
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/nothing', {}, 'GET')).code).toBe(403)
|
||||||
|
expect(a.calls).toEqual([])
|
||||||
|
expect(b.calls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('export and import', () => {
|
||||||
|
it('exports with the real secret, and imports it dry first, then for real', async () => {
|
||||||
|
await snapshot()
|
||||||
|
const res = await fetch(`${hub.base}/api/hub/profiles/export`, { headers: { authorization: `Bearer ${hub.tokens.admin}` } })
|
||||||
|
expect(res.headers.get('content-disposition')).toMatch(/hub-profiles-/)
|
||||||
|
const text = await res.text()
|
||||||
|
expect(text).toContain('REALSECRET')
|
||||||
|
const file = JSON.parse(text)
|
||||||
|
expect(file).toMatchObject({ hub: 'technitium-hub', type: 'profiles', version: 1 })
|
||||||
|
|
||||||
|
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
||||||
|
const dry = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'false' })
|
||||||
|
expect(dry.body.response).toMatchObject({ applied: false, profiles: [{ name: 'Fresh', status: 'new', apps: 1, settings: true, lists: true }] })
|
||||||
|
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
||||||
|
const done = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
||||||
|
expect(done.body.response.applied).toBe(true)
|
||||||
|
const again = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
||||||
|
expect(again.body.response.profiles[0].status).toBe('same')
|
||||||
|
expect(JSON.stringify(await hub.audit())).not.toContain('REALSECRET')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a file of another kind, a placeholder where a secret should be, and local fields are dropped', async () => {
|
||||||
|
const bad = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [] }) })
|
||||||
|
expect(bad.code).toBe(400)
|
||||||
|
const masked = { hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'M', apps: [], settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] }, lists: null }] }
|
||||||
|
const refused = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify(masked) })
|
||||||
|
expect(refused.code).toBe(400)
|
||||||
|
expect(refused.body.errorMessage).toMatch(/<secret>/)
|
||||||
|
expect(() => cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).not.toThrow()
|
||||||
|
expect(cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).toEqual({ blockingType: 'NxDomain' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a body above the usual limit for an admin', async () => {
|
||||||
|
const big = JSON.stringify({ hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'Big', apps: [{ name: 'X', version: '1', config: JSON.stringify({ pad: 'x'.repeat(200_000) }) }], settings: null, lists: null }] })
|
||||||
|
expect(big.length).toBeGreaterThan(64 * 1024)
|
||||||
|
const done = await hub.call('admin', 'hub/profiles/import', { data: big, apply: 'false' })
|
||||||
|
expect(done.body.response.profiles[0]).toMatchObject({ name: 'Big', status: 'new' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('preview', () => {
|
||||||
|
it('says what each part would change on a target, and writes nothing', async () => {
|
||||||
|
await snapshot()
|
||||||
|
const got = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b,a' })
|
||||||
|
expect(got.code).toBe(200)
|
||||||
|
const [tb, ta] = got.body.response.targets
|
||||||
|
expect(tb.server).toBe('b')
|
||||||
|
expect(tb.apps).toEqual([{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' }])
|
||||||
|
expect(tb.configs).toEqual([{ name: 'Blocker', state: 'after-install' }])
|
||||||
|
expect(tb.settings.fields).toContain('blockingType')
|
||||||
|
expect(tb.settings.secretFields).toContain('tsigKeys')
|
||||||
|
expect(JSON.stringify(got.body)).not.toContain('REALSECRET')
|
||||||
|
expect(tb.lists.blocked.missing).toBe(1)
|
||||||
|
expect(ta.apps).toEqual([{ name: 'Blocker', state: 'present', profileVersion: '2.0', installedVersion: '2.0' }])
|
||||||
|
expect(ta.configs).toEqual([{ name: 'Blocker', state: 'same' }])
|
||||||
|
expect(ta.settings.unchanged).toBe(true)
|
||||||
|
expect(order(a).concat(order(b))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks an installed app whose version differs, and an app the store does not offer', async () => {
|
||||||
|
await snapshot()
|
||||||
|
boxB().apps.Blocker = '1.0'
|
||||||
|
const one = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
||||||
|
expect(one.body.response.targets[0].apps[0]).toMatchObject({ state: 'version-differs', installedVersion: '1.0', profileVersion: '2.0' })
|
||||||
|
boxB().apps = {}
|
||||||
|
boxB().store = []
|
||||||
|
const two = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
||||||
|
expect(two.body.response.targets[0].apps[0].state).toBe('not-in-store')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('apply', () => {
|
||||||
|
it('runs the steps in order on a fresh server: install, config, settings, lists', async () => {
|
||||||
|
await snapshot()
|
||||||
|
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
||||||
|
expect(done.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
||||||
|
expect(done.body.response.results[0].steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||||
|
['apps', 'ok'],
|
||||||
|
['configs', 'ok'],
|
||||||
|
['settings', 'ok'],
|
||||||
|
['lists', 'ok'],
|
||||||
|
])
|
||||||
|
expect(order(b)).toEqual(['apps/downloadAndInstall', 'apps/config/set', 'settings/set', 'blocked/add'])
|
||||||
|
expect(boxB().apps.Blocker).toBe('2.0')
|
||||||
|
expect(JSON.parse(boxB().configs.Blocker)).toEqual({ ttl: 30 })
|
||||||
|
expect(boxB().settings.blockingType).toBe('NxDomain')
|
||||||
|
// The host field was left alone, and the extra domain stays unless asked.
|
||||||
|
expect(boxB().settings.dnsServerDomain).toBe('b.example')
|
||||||
|
expect(boxB().blocked).toEqual(['mine.example', 'ads.example'])
|
||||||
|
// The old config of a server that had one goes to the history; b had none, so nothing is claimed.
|
||||||
|
expect(order(a)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes the install URL from the target own store, never from the request', async () => {
|
||||||
|
await snapshot()
|
||||||
|
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', url: 'https://evil.example/x.zip' })
|
||||||
|
const install = b.calls.find((c) => c.path === 'apps/downloadAndInstall')!
|
||||||
|
expect(install.query.get('url') ?? install.form.get('url')).toBe(urlOf(b, 'Blocker'))
|
||||||
|
expect(a.calls.some((c) => c.path === 'apps/downloadAndInstall')).toBe(false)
|
||||||
|
// Each server is asked for its own listing, with its own read token.
|
||||||
|
expect(b.calls.find((c) => c.path === 'apps/listStoreApps')!.token).toBe('read-b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes extra domains only when asked', async () => {
|
||||||
|
await snapshot()
|
||||||
|
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', removeExtras: 'true' })
|
||||||
|
expect(boxB().blocked).toEqual(['ads.example'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops the later steps of a server when one fails, says so, and leaves the other server alone', async () => {
|
||||||
|
await snapshot()
|
||||||
|
boxB().failSettings = true
|
||||||
|
boxA().settings.blockingType = 'AnyAddress'
|
||||||
|
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'a,b' })
|
||||||
|
const [ra, rb] = done.body.response.results
|
||||||
|
expect(ra).toMatchObject({ server: 'a', ok: true })
|
||||||
|
expect(rb).toMatchObject({ server: 'b', ok: false })
|
||||||
|
expect(rb.message).toMatch(/Stopped at settings/)
|
||||||
|
expect(rb.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||||
|
['apps', 'ok'],
|
||||||
|
['configs', 'ok'],
|
||||||
|
['settings', 'failed'],
|
||||||
|
['lists', 'not-run'],
|
||||||
|
])
|
||||||
|
expect(order(b)).not.toContain('blocked/add')
|
||||||
|
const audit = await hub.audit()
|
||||||
|
const lines = audit.filter((e) => e.action === 'profiles.apply' && e.server === 'b').slice(0, 3)
|
||||||
|
expect(lines.map((e) => [e.outcome, e.detail?.split(':')[0]])).toEqual([['failed', 'settings'], ['ok', 'configs'], ['ok', 'apps']])
|
||||||
|
expect(JSON.stringify(audit)).not.toContain('REALSECRET')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries from the failed step without redoing the earlier ones', async () => {
|
||||||
|
await snapshot()
|
||||||
|
boxB().failSettings = true
|
||||||
|
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
||||||
|
b.calls = []
|
||||||
|
boxB().failSettings = false
|
||||||
|
const again = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', from: JSON.stringify({ b: 'settings' }) })
|
||||||
|
const r = again.body.response.results[0]
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
expect(r.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||||
|
['apps', 'skipped'],
|
||||||
|
['configs', 'skipped'],
|
||||||
|
['settings', 'ok'],
|
||||||
|
['lists', 'ok'],
|
||||||
|
])
|
||||||
|
expect(order(b)).toEqual(['settings/set', 'blocked/add'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails a server with no admin token on its first step, and refuses an unknown server or profile', async () => {
|
||||||
|
await snapshot()
|
||||||
|
const unknown = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'zzz' })
|
||||||
|
expect(unknown.code).toBe(400)
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Nope', targets: 'b' })).code).toBe(400)
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: '' })).code).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a profile', async () => {
|
||||||
|
await snapshot()
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).body.response).toEqual({ deleted: true })
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).code).toBe(400)
|
||||||
|
expect((await hub.call('admin', 'hub/profiles/list', {}, 'GET')).body.response.profiles).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
+510
@@ -0,0 +1,510 @@
|
|||||||
|
/*
|
||||||
|
Profiles (stage 8): a named bundle of what a server should have, kept in `${HUB_DATA}/profiles/`,
|
||||||
|
to bring a new or reset server in line in one go.
|
||||||
|
|
||||||
|
A profile holds, as the admin chose when it was made from the hub's current baselines:
|
||||||
|
|
||||||
|
apps store apps only (never a zip): the name and the version they had, and for each the hub's
|
||||||
|
baseline config at that moment
|
||||||
|
settings the SHARED fields of the settings baseline (host fields never travel), secrets as the
|
||||||
|
baseline file keeps them
|
||||||
|
lists the custom Blocked and Allowed lists baseline
|
||||||
|
|
||||||
|
Applying a profile to a server runs four steps in order, and a failed step stops the later ones for
|
||||||
|
that server (the other servers go on):
|
||||||
|
|
||||||
|
apps install the apps that are missing, from THAT server's own store listing (apps-write.ts)
|
||||||
|
configs the profile's config of each app plus that server's own local values; the old config
|
||||||
|
goes to the history first (apps-config.ts)
|
||||||
|
settings the changed shared fields, through the same checks as a per-server settings/set
|
||||||
|
lists the missing domains; extras only when asked
|
||||||
|
|
||||||
|
`from` restarts a server at a given step (the retry of a failed run), so what already worked is not
|
||||||
|
done again. Everything here is for admins. A view never carries a secret and never an app config.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { HubConfig, HubServer } from './config.ts'
|
||||||
|
import { MAX_CONFIG, looksLikeJson, parseJson, type Diff } from './apps-sync.ts'
|
||||||
|
import { UserError, compareVersions, type AppsConfig } from './apps-config.ts'
|
||||||
|
import type { AppsWriter } from './apps-write.ts'
|
||||||
|
import { isFieldName, isBuiltInLocal, MAX_LIST, SECRET_PLACEHOLDER, maskSettings } from './settings-sync.ts'
|
||||||
|
import type { SettingsConfig } from './settings-config.ts'
|
||||||
|
import { listBaselineApps, readBaseline, readListsBaseline, readSettingsBaseline, safeName, writeAtomic } from './store.ts'
|
||||||
|
import type { Upstream } from './upstream.ts'
|
||||||
|
|
||||||
|
export const STEPS = ['apps', 'configs', 'settings', 'lists'] as const
|
||||||
|
export type Step = (typeof STEPS)[number]
|
||||||
|
|
||||||
|
export interface ProfileApp {
|
||||||
|
name: string
|
||||||
|
/** The version the app had when the profile was made. */
|
||||||
|
version: string
|
||||||
|
/** The hub's baseline config of the app then; null when it had none. */
|
||||||
|
config: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
updatedBy: string
|
||||||
|
apps: ProfileApp[]
|
||||||
|
settings: Record<string, unknown> | null
|
||||||
|
lists: { blocked: string[]; allowed: string[] } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepResult {
|
||||||
|
step: Step
|
||||||
|
/* `skipped`: done by an earlier run. `not-run`: an earlier step failed. */
|
||||||
|
status: 'ok' | 'failed' | 'skipped' | 'not-run'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyResult {
|
||||||
|
server: string
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
steps: StepResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/
|
||||||
|
const MAX_PROFILES = 50
|
||||||
|
const MAX_APPS = 100
|
||||||
|
const MAX_SETTINGS_BYTES = 1024 * 1024
|
||||||
|
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||||
|
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
|
||||||
|
const hasPlaceholder = (value: unknown): boolean => JSON.stringify(value).includes(JSON.stringify(SECRET_PLACEHOLDER))
|
||||||
|
|
||||||
|
/* The stored shape, checked field by field. Used for files on disk and for imports. */
|
||||||
|
export function cleanProfile(raw: unknown, fallbackName?: string): Profile {
|
||||||
|
if (!isObject(raw)) throw new UserError('A profile must be an object.')
|
||||||
|
const name = typeof raw.name === 'string' ? raw.name : (fallbackName ?? '')
|
||||||
|
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
||||||
|
if (!Array.isArray(raw.apps) || raw.apps.length > MAX_APPS) throw new UserError(`"${name}": the apps must be a list of at most ${MAX_APPS}.`)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const apps = raw.apps.map((a): ProfileApp => {
|
||||||
|
if (!isObject(a) || typeof a.name !== 'string' || a.name === '' || a.name.length > 200 || seen.has(a.name)) throw new UserError(`"${name}": an app has no valid, unique name.`)
|
||||||
|
seen.add(a.name)
|
||||||
|
const version = typeof a.version === 'string' ? a.version.slice(0, 40) : ''
|
||||||
|
const config = typeof a.config === 'string' ? a.config : null
|
||||||
|
if (config !== null) {
|
||||||
|
if (config.length > MAX_CONFIG) throw new UserError(`"${name}": the config of "${a.name}" is larger than 1 MB.`)
|
||||||
|
if (looksLikeJson(config) && !parseJson(config).ok) throw new UserError(`"${name}": the config of "${a.name}" is not valid JSON.`)
|
||||||
|
}
|
||||||
|
return { name: a.name, version, config }
|
||||||
|
})
|
||||||
|
let settings: Profile['settings'] = null
|
||||||
|
if (raw.settings !== null && raw.settings !== undefined) {
|
||||||
|
if (!isObject(raw.settings)) throw new UserError(`"${name}": the settings must be an object.`)
|
||||||
|
if (JSON.stringify(raw.settings).length > MAX_SETTINGS_BYTES) throw new UserError(`"${name}": the settings are larger than 1 MB.`)
|
||||||
|
if (hasPlaceholder(raw.settings)) throw new UserError(`"${name}": the settings hold ${SECRET_PLACEHOLDER} where a secret should be. Export the profile from the hub that has it.`)
|
||||||
|
settings = {}
|
||||||
|
for (const [k, v] of Object.entries(raw.settings)) {
|
||||||
|
if (!isFieldName(k)) throw new UserError(`"${name}": "${k.slice(0, 40)}" is not a setting name.`)
|
||||||
|
// Host fields never travel.
|
||||||
|
if (!isBuiltInLocal(k)) settings[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lists: Profile['lists'] = null
|
||||||
|
if (raw.lists !== null && raw.lists !== undefined) {
|
||||||
|
const l = raw.lists as { blocked?: unknown; allowed?: unknown }
|
||||||
|
if (!isObject(raw.lists) || !Array.isArray(l.blocked) || !Array.isArray(l.allowed)) throw new UserError(`"${name}": the lists need a blocked and an allowed array.`)
|
||||||
|
const clean = (v: unknown[]): string[] => {
|
||||||
|
if (v.length > MAX_LIST || v.some((d) => typeof d !== 'string' || d.length > 253)) throw new UserError(`"${name}": a list is longer than ${MAX_LIST} domains or holds a bad entry.`)
|
||||||
|
return [...new Set(v as string[])].sort()
|
||||||
|
}
|
||||||
|
lists = { blocked: clean(l.blocked), allowed: clean(l.allowed) }
|
||||||
|
}
|
||||||
|
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||||
|
return { name, createdAt: text(raw.createdAt), updatedAt: text(raw.updatedAt), updatedBy: text(raw.updatedBy), apps, settings, lists }
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = (p: Profile): string => JSON.stringify({ apps: p.apps, settings: p.settings, lists: p.lists })
|
||||||
|
|
||||||
|
export class Profiles {
|
||||||
|
#config: HubConfig
|
||||||
|
#upstream: Upstream
|
||||||
|
#appsConfig: AppsConfig
|
||||||
|
#settings: SettingsConfig
|
||||||
|
#writer: AppsWriter
|
||||||
|
#now: () => number
|
||||||
|
|
||||||
|
constructor(config: HubConfig, upstream: Upstream, appsConfig: AppsConfig, settings: SettingsConfig, writer: AppsWriter, now: () => number = Date.now) {
|
||||||
|
this.#config = config
|
||||||
|
this.#upstream = upstream
|
||||||
|
this.#appsConfig = appsConfig
|
||||||
|
this.#settings = settings
|
||||||
|
this.#writer = writer
|
||||||
|
this.#now = now
|
||||||
|
}
|
||||||
|
|
||||||
|
#dir(): string {
|
||||||
|
return path.join(this.#config.dataDir, 'profiles')
|
||||||
|
}
|
||||||
|
|
||||||
|
#file(name: string): string {
|
||||||
|
return path.join(this.#dir(), `${safeName(name)}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
#read(name: string): Profile | null {
|
||||||
|
const file = this.#file(name)
|
||||||
|
if (!existsSync(file)) return null
|
||||||
|
try {
|
||||||
|
const p = cleanProfile(JSON.parse(readFileSync(file, 'utf8')))
|
||||||
|
return p.name === name ? p : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#all(): Profile[] {
|
||||||
|
if (!existsSync(this.#dir())) return []
|
||||||
|
const out: Profile[] = []
|
||||||
|
for (const f of readdirSync(this.#dir())) {
|
||||||
|
if (!f.endsWith('.json')) continue
|
||||||
|
try {
|
||||||
|
out.push(cleanProfile(JSON.parse(readFileSync(path.join(this.#dir(), f), 'utf8'))))
|
||||||
|
} catch {
|
||||||
|
// A file that is not a profile is left alone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#require(name: string): Profile {
|
||||||
|
const found = this.#read(name)
|
||||||
|
if (found === null) throw new UserError(`There is no profile named "${name.slice(0, 60)}".`)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
#write(p: Profile): void {
|
||||||
|
writeAtomic(this.#file(p.name), JSON.stringify(p, null, 2) + '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the console lists: counts, no config and no value. */
|
||||||
|
list(): { name: string; apps: number; configs: number; settings: number | null; lists: { blocked: number; allowed: number } | null; updatedAt: string; updatedBy: string }[] {
|
||||||
|
return this.#all().map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
apps: p.apps.length,
|
||||||
|
configs: p.apps.filter((a) => a.config !== null).length,
|
||||||
|
settings: p.settings === null ? null : Object.keys(p.settings).length,
|
||||||
|
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
updatedBy: p.updatedBy,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One profile for the console: secrets masked, no app config text. */
|
||||||
|
get(name: string): unknown {
|
||||||
|
const p = this.#require(name)
|
||||||
|
return {
|
||||||
|
name: p.name,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
updatedBy: p.updatedBy,
|
||||||
|
apps: p.apps.map((a) => ({ name: a.name, version: a.version, hasConfig: a.config !== null, bytes: a.config === null ? 0 : a.config.length })),
|
||||||
|
settings: p.settings === null ? null : maskSettings(p.settings),
|
||||||
|
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(name: string): void {
|
||||||
|
this.#require(name)
|
||||||
|
rmSync(this.#file(name), { force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Making one from the hub's current baselines ---- */
|
||||||
|
|
||||||
|
async #installedVersions(): Promise<Map<string, string>> {
|
||||||
|
const versions = new Map<string, string>()
|
||||||
|
let answered = 0
|
||||||
|
await Promise.all(
|
||||||
|
this.#config.servers.map(async (s) => {
|
||||||
|
try {
|
||||||
|
this.#upstream.invalidate(s.id)
|
||||||
|
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||||
|
answered++
|
||||||
|
for (const a of list?.apps ?? []) {
|
||||||
|
const have = versions.get(a.name)
|
||||||
|
const v = typeof a.version === 'string' ? a.version : ''
|
||||||
|
if (have === undefined || compareVersions(v, have) > 0) versions.set(a.name, v)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A server that does not answer adds nothing.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (answered === 0) throw new UserError('No server answered, so the installed apps cannot be read.')
|
||||||
|
return versions
|
||||||
|
}
|
||||||
|
|
||||||
|
async #storeNames(): Promise<Set<string>> {
|
||||||
|
const names = new Set<string>()
|
||||||
|
let answered = 0
|
||||||
|
await Promise.all(
|
||||||
|
this.#config.servers.map(async (s) => {
|
||||||
|
try {
|
||||||
|
const r = (await this.#upstream.call(s, 'apps/listStoreApps')) as { storeApps?: { name: string }[] } | null
|
||||||
|
answered++
|
||||||
|
for (const a of r?.storeApps ?? []) names.add(a.name)
|
||||||
|
} catch {
|
||||||
|
// Same.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (answered === 0) throw new UserError('No server answered, so the store listing cannot be read.')
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Makes or replaces a profile from the hub's current baselines. `apps`: the names to take (undefined
|
||||||
|
takes every app that has a baseline). Settings and lists are taken whole when asked.
|
||||||
|
*/
|
||||||
|
async snapshot(name: string, parts: { apps?: string[]; settings: boolean; lists: boolean }, user: string): Promise<{ replaced: boolean; detail: string }> {
|
||||||
|
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
||||||
|
const appNames = parts.apps ?? listBaselineApps(this.#config.dataDir)
|
||||||
|
if (appNames.length === 0 && !parts.settings && !parts.lists) throw new UserError('Choose at least one part: apps, settings or lists.')
|
||||||
|
if (appNames.length > MAX_APPS) throw new UserError(`At most ${MAX_APPS} apps in a profile.`)
|
||||||
|
const existing = this.#read(name)
|
||||||
|
if (existing === null && this.#all().length >= MAX_PROFILES) throw new UserError(`At most ${MAX_PROFILES} profiles.`)
|
||||||
|
|
||||||
|
let apps: ProfileApp[] = []
|
||||||
|
if (appNames.length > 0) {
|
||||||
|
const [versions, store] = await Promise.all([this.#installedVersions(), this.#storeNames()])
|
||||||
|
apps = [...new Set(appNames)].map((app) => {
|
||||||
|
const version = versions.get(app)
|
||||||
|
if (version === undefined) throw new UserError(`"${app}" is not installed on any server, so its version is not known.`)
|
||||||
|
if (!store.has(app)) throw new UserError(`"${app}" is not in the store listing. A profile holds store apps only, not apps installed from a file.`)
|
||||||
|
return { name: app, version, config: readBaseline(this.#config.dataDir, app)?.text ?? null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let settings: Profile['settings'] = null
|
||||||
|
if (parts.settings) {
|
||||||
|
const baseline = readSettingsBaseline(this.#config.dataDir)
|
||||||
|
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||||
|
const extra = this.#settings.extraLocal()
|
||||||
|
settings = Object.fromEntries(Object.entries(baseline.settings).filter(([k]) => !isBuiltInLocal(k) && !extra.includes(k)))
|
||||||
|
}
|
||||||
|
let lists: Profile['lists'] = null
|
||||||
|
if (parts.lists) {
|
||||||
|
const baseline = readListsBaseline(this.#config.dataDir)
|
||||||
|
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||||
|
lists = { blocked: [...baseline.blocked], allowed: [...baseline.allowed] }
|
||||||
|
}
|
||||||
|
const at = new Date(this.#now()).toISOString()
|
||||||
|
this.#write({ name, createdAt: existing?.createdAt || at, updatedAt: at, updatedBy: user, apps, settings, lists })
|
||||||
|
return {
|
||||||
|
replaced: existing !== null,
|
||||||
|
detail: `${existing === null ? 'created' : 'replaced'}: ${apps.length} app(s), settings ${settings === null ? 'no' : Object.keys(settings).length + ' fields'}, lists ${lists === null ? 'no' : 'yes'}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Export and import ---- */
|
||||||
|
|
||||||
|
exportFile(name?: string): { hub: string; type: string; version: number; exportedAt: string; profiles: Profile[] } {
|
||||||
|
const profiles = name === undefined || name === '' ? this.#all() : [this.#require(name)]
|
||||||
|
return { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt: new Date(this.#now()).toISOString(), profiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
importFile(raw: string, apply: boolean, user: string): { profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]; applied: boolean } {
|
||||||
|
const parsed = parseJson(raw)
|
||||||
|
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
|
||||||
|
const file = parsed.value as { hub?: unknown; type?: unknown; version?: unknown; profiles?: unknown }
|
||||||
|
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub' || file.type !== 'profiles') throw new UserError('This is not a profiles file of the hub.')
|
||||||
|
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
|
||||||
|
if (!Array.isArray(file.profiles) || file.profiles.length > MAX_PROFILES) throw new UserError(`The file has no list of profiles, or has more than ${MAX_PROFILES}.`)
|
||||||
|
const plan: { profile: Profile; status: 'new' | 'changed' | 'same' }[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of file.profiles) {
|
||||||
|
const profile = cleanProfile(item)
|
||||||
|
if (seen.has(profile.name)) throw new UserError(`"${profile.name}" is in the file twice.`)
|
||||||
|
seen.add(profile.name)
|
||||||
|
const old = this.#read(profile.name)
|
||||||
|
plan.push({ profile, status: old === null ? 'new' : shape(old) === shape(profile) ? 'same' : 'changed' })
|
||||||
|
}
|
||||||
|
const total = this.#all().filter((p) => !seen.has(p.name)).length + plan.length
|
||||||
|
if (total > MAX_PROFILES) throw new UserError(`That would make more than ${MAX_PROFILES} profiles.`)
|
||||||
|
if (apply) {
|
||||||
|
const at = new Date(this.#now()).toISOString()
|
||||||
|
for (const p of plan) {
|
||||||
|
if (p.status === 'same') continue
|
||||||
|
const old = this.#read(p.profile.name)
|
||||||
|
this.#write({ ...p.profile, createdAt: old?.createdAt || at, updatedAt: at, updatedBy: user })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { profiles: plan.map((p) => ({ name: p.profile.name, status: p.status, apps: p.profile.apps.length, settings: p.profile.settings !== null, lists: p.profile.lists !== null })), applied: apply }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Preview ---- */
|
||||||
|
|
||||||
|
#server(id: string): HubServer {
|
||||||
|
const found = this.#config.servers.find((s) => s.id === id)
|
||||||
|
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
#targets(ids: string[]): HubServer[] {
|
||||||
|
const unique = [...new Set(ids)]
|
||||||
|
if (unique.length === 0) throw new UserError('Choose at least one server.')
|
||||||
|
return unique.map((id) => this.#server(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async #look(server: HubServer): Promise<{ installed: Map<string, string>; store: Map<string, string> }> {
|
||||||
|
this.#upstream.invalidate(server.id)
|
||||||
|
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||||
|
const store = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; version?: string }[] } | null
|
||||||
|
return {
|
||||||
|
installed: new Map((list?.apps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
||||||
|
store: new Map((store?.storeApps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What applying the profile would change on each target. Nothing is written. */
|
||||||
|
async preview(name: string, ids: string[], removeExtras: boolean): Promise<{ profile: string; targets: unknown[] }> {
|
||||||
|
const profile = this.#require(name)
|
||||||
|
const targets = this.#targets(ids)
|
||||||
|
const out = await Promise.all(
|
||||||
|
targets.map(async (s) => {
|
||||||
|
const writable = s.adminToken !== undefined
|
||||||
|
const base = { server: s.id, writable }
|
||||||
|
try {
|
||||||
|
const { installed, store } = await this.#look(s)
|
||||||
|
const apps = profile.apps.map((a) => {
|
||||||
|
const have = installed.get(a.name)
|
||||||
|
if (have === undefined) {
|
||||||
|
const offered = store.get(a.name)
|
||||||
|
return offered === undefined
|
||||||
|
? { name: a.name, state: 'not-in-store' as const, profileVersion: a.version }
|
||||||
|
: { name: a.name, state: 'install' as const, profileVersion: a.version, storeVersion: offered }
|
||||||
|
}
|
||||||
|
return { name: a.name, state: have === a.version ? ('present' as const) : ('version-differs' as const), profileVersion: a.version, installedVersion: have }
|
||||||
|
})
|
||||||
|
const configs = await Promise.all(
|
||||||
|
profile.apps
|
||||||
|
.filter((a) => a.config !== null)
|
||||||
|
.map(async (a) => {
|
||||||
|
if (!installed.has(a.name)) return { name: a.name, state: 'after-install' as const }
|
||||||
|
try {
|
||||||
|
const p = (await this.#appsConfig.preview(a.name, [s.id], a.config as string)).targets[0]
|
||||||
|
if (!p.ok) return { name: a.name, state: 'unreadable' as const, error: p.error }
|
||||||
|
return p.unchanged ? { name: a.name, state: 'same' as const } : { name: a.name, state: 'differs' as const, changes: p.changes as Diff }
|
||||||
|
} catch (e) {
|
||||||
|
return { name: a.name, state: 'unreadable' as const, error: reason(e) }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
let settings: unknown = null
|
||||||
|
if (profile.settings !== null) {
|
||||||
|
const p = (await this.#settings.preview([s.id], undefined, { settings: profile.settings, updatedAt: '', updatedBy: '' })).targets[0]
|
||||||
|
settings = p.ok ? { fields: p.changes.changes.map((c) => c.path), secretFields: p.secretFields, unchanged: p.unchanged, changes: p.changes, warnings: p.warnings } : { error: p.error }
|
||||||
|
}
|
||||||
|
let lists: unknown = null
|
||||||
|
if (profile.lists !== null) {
|
||||||
|
const p = (await this.#settings.listsPreview([s.id], removeExtras, { ...profile.lists, updatedAt: '', updatedBy: '' })).targets[0]
|
||||||
|
lists = p.ok ? { blocked: p.blocked, allowed: p.allowed, status: p.status, tooMany: p.tooMany } : { error: p.error }
|
||||||
|
}
|
||||||
|
return { ...base, ok: true, apps, configs, settings, lists, warnings: writable ? [] : ['The hub has no admin token for this server, so it cannot write to it.'] }
|
||||||
|
} catch (e) {
|
||||||
|
return { ...base, ok: false, error: reason(e), apps: [], configs: [], settings: null, lists: null, warnings: [] }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { profile: profile.name, targets: out }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Apply ---- */
|
||||||
|
|
||||||
|
async #run(profile: Profile, s: HubServer, step: Step, removeExtras: boolean): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const failed = (message: string) => ({ ok: false, message })
|
||||||
|
if (step === 'apps') {
|
||||||
|
this.#upstream.invalidate(s.id)
|
||||||
|
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string }[] } | null
|
||||||
|
const have = new Set((list?.apps ?? []).map((a) => a.name))
|
||||||
|
const installed: string[] = []
|
||||||
|
for (const a of profile.apps) {
|
||||||
|
if (have.has(a.name)) continue
|
||||||
|
const outcome = await this.#writer.runMany('install-store', a.name, [s.id])
|
||||||
|
if ('error' in outcome) return failed(`${a.name}: ${outcome.error}`)
|
||||||
|
const r = outcome.results[0]
|
||||||
|
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'could not be installed'}`)
|
||||||
|
installed.push(a.name)
|
||||||
|
}
|
||||||
|
return { ok: true, message: installed.length === 0 ? 'Every app is already installed.' : `Installed ${installed.join(', ')}.` }
|
||||||
|
}
|
||||||
|
if (step === 'configs') {
|
||||||
|
let written = 0
|
||||||
|
let same = 0
|
||||||
|
for (const a of profile.apps) {
|
||||||
|
if (a.config === null) continue
|
||||||
|
const done = await this.#appsConfig.apply(a.name, [s.id], {}, a.config)
|
||||||
|
const r = done.results[0]
|
||||||
|
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'failed'}`)
|
||||||
|
if (/nothing was written/.test(r.message ?? '')) same++
|
||||||
|
else written++
|
||||||
|
}
|
||||||
|
return { ok: true, message: `${written} config(s) applied, ${same} already in sync.` }
|
||||||
|
}
|
||||||
|
if (step === 'settings') {
|
||||||
|
const done = await this.#settings.apply([s.id], {}, undefined, { settings: profile.settings ?? {}, updatedAt: '', updatedBy: '' })
|
||||||
|
const r = done.results[0]
|
||||||
|
return { ok: r.ok, message: r.message ?? '' }
|
||||||
|
}
|
||||||
|
const done = await this.#settings.listsApply([s.id], removeExtras, { ...(profile.lists ?? { blocked: [], allowed: [] }), updatedAt: '', updatedBy: '' })
|
||||||
|
const r = done.results[0]
|
||||||
|
return { ok: r.ok, message: r.message ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
#stepsOf(profile: Profile): Step[] {
|
||||||
|
return STEPS.filter((step) => (step === 'apps' ? profile.apps.length > 0 : step === 'configs' ? profile.apps.some((a) => a.config !== null) : step === 'settings' ? profile.settings !== null : profile.lists !== null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async #applyOne(profile: Profile, s: HubServer, from: Step | undefined, removeExtras: boolean): Promise<ApplyResult> {
|
||||||
|
const steps: StepResult[] = []
|
||||||
|
const plan = this.#stepsOf(profile)
|
||||||
|
if (plan.length === 0) return { server: s.id, ok: true, message: 'The profile has nothing to apply.', steps }
|
||||||
|
if (s.adminToken === undefined) {
|
||||||
|
return { server: s.id, ok: false, message: 'no admin token configured', steps: plan.map((step, i) => ({ step, status: i === 0 ? ('failed' as const) : ('not-run' as const), message: i === 0 ? 'no admin token configured' : 'An earlier step failed.' })) }
|
||||||
|
}
|
||||||
|
const start = from === undefined ? 0 : Math.max(0, plan.indexOf(from))
|
||||||
|
let stopped: Step | null = null
|
||||||
|
for (const [i, step] of plan.entries()) {
|
||||||
|
if (stopped !== null) {
|
||||||
|
steps.push({ step, status: 'not-run', message: 'An earlier step failed.' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (i < start) {
|
||||||
|
steps.push({ step, status: 'skipped', message: 'Done by an earlier run.' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let outcome: { ok: boolean; message: string }
|
||||||
|
try {
|
||||||
|
outcome = await this.#run(profile, s, step, removeExtras)
|
||||||
|
} catch (e) {
|
||||||
|
outcome = { ok: false, message: reason(e) }
|
||||||
|
}
|
||||||
|
steps.push({ step, status: outcome.ok ? 'ok' : 'failed', message: outcome.message })
|
||||||
|
if (!outcome.ok) stopped = step
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
server: s.id,
|
||||||
|
ok: stopped === null,
|
||||||
|
message: stopped === null ? 'Profile applied.' : `Stopped at ${stopped}: ${steps.find((x) => x.step === stopped)?.message ?? 'failed'}`.slice(0, 300),
|
||||||
|
steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Several servers go on in parallel; the steps of one server go one after the other. */
|
||||||
|
async apply(name: string, ids: string[], options: { removeExtras: boolean; from: Record<string, string> }): Promise<{ results: ApplyResult[] }> {
|
||||||
|
const profile = this.#require(name)
|
||||||
|
const targets = this.#targets(ids)
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map((s) => {
|
||||||
|
const from = (STEPS as readonly string[]).includes(options.from[s.id]) ? (options.from[s.id] as Step) : undefined
|
||||||
|
return this.#applyOne(profile, s, from, options.removeExtras)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { results }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,6 +150,17 @@ export const ROUTES: Record<string, Route> = {
|
|||||||
'hub/apps/history/rollback': writeAll('apps.write'),
|
'hub/apps/history/rollback': writeAll('apps.write'),
|
||||||
'hub/apps/baseline/restore': writeAll('apps.write'),
|
'hub/apps/baseline/restore': writeAll('apps.write'),
|
||||||
'hub/apps/drift/accept': writeAll('apps.write'),
|
'hub/apps/drift/accept': writeAll('apps.write'),
|
||||||
|
// Profiles (profiles.ts): bundles of apps, settings and lists. They hold settings secrets, so everything needs admin.
|
||||||
|
'hub/profiles/list': read('admin'),
|
||||||
|
'hub/profiles/get': read('admin'),
|
||||||
|
'hub/profiles/export': read('admin'),
|
||||||
|
'hub/profiles/preview': read('admin'),
|
||||||
|
'hub/profiles/snapshot': writeAll('admin'),
|
||||||
|
'hub/profiles/delete': writeAll('admin'),
|
||||||
|
'hub/profiles/import': writeAll('admin'),
|
||||||
|
'hub/profiles/apply': writeAll('admin'),
|
||||||
|
'hub/webhook/status': read('admin'),
|
||||||
|
'hub/webhook/test': writeAll('admin'),
|
||||||
'hub/drift/get': read(),
|
'hub/drift/get': read(),
|
||||||
'hub/drift/check': writeAll('apps.write'),
|
'hub/drift/check': writeAll('apps.write'),
|
||||||
// Configs can hold secrets, so the file of all baselines needs apps.write even to read.
|
// Configs can hold secrets, so the file of all baselines needs apps.write even to read.
|
||||||
@@ -220,6 +231,7 @@ const LARGE_BODIES: Record<string, number> = {
|
|||||||
'dhcp/scopes/set': 2 * MB,
|
'dhcp/scopes/set': 2 * MB,
|
||||||
'hub/settings/baseline/save': 2 * MB,
|
'hub/settings/baseline/save': 2 * MB,
|
||||||
'hub/settings/overrides/save': 2 * MB,
|
'hub/settings/overrides/save': 2 * MB,
|
||||||
|
'hub/profiles/import': 5 * MB,
|
||||||
'allowed/import': 5 * MB,
|
'allowed/import': 5 * MB,
|
||||||
'blocked/import': 5 * MB,
|
'blocked/import': 5 * MB,
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-2
@@ -32,6 +32,8 @@ import { AppsWriter, actionForPath, type FileAction, type TargetResult } from '.
|
|||||||
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
|
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
|
||||||
import { AppsConfig, UserError } from './apps-config.ts'
|
import { AppsConfig, UserError } from './apps-config.ts'
|
||||||
import { Drift } from './drift.ts'
|
import { Drift } from './drift.ts'
|
||||||
|
import { Webhook, type WebhookOptions } from './webhook.ts'
|
||||||
|
import { Profiles } from './profiles.ts'
|
||||||
import { SettingsConfig } from './settings-config.ts'
|
import { SettingsConfig } from './settings-config.ts'
|
||||||
import { BLOCKING_FIELDS } from './settings-sync.ts'
|
import { BLOCKING_FIELDS } from './settings-sync.ts'
|
||||||
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
|
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
|
||||||
@@ -141,17 +143,31 @@ function clientIp(req: http.IncomingMessage, trustProxy: boolean): string {
|
|||||||
return req.socket.remoteAddress ?? 'unknown'
|
return req.socket.remoteAddress ?? 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server {
|
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap), options: { webhook?: WebhookOptions } = {}): http.Server {
|
||||||
const upstream = new Upstream(config)
|
const upstream = new Upstream(config)
|
||||||
const drift = new Drift(config, upstream)
|
const webhook = new Webhook(config, options.webhook)
|
||||||
|
const drift = new Drift(config, upstream, Date.now, { webhook })
|
||||||
const sessions = new Sessions(config, users, Date.now, () => drift.count())
|
const sessions = new Sessions(config, users, Date.now, () => drift.count())
|
||||||
const limiter = new LoginLimiter()
|
const limiter = new LoginLimiter()
|
||||||
const audit = new Audit(config.dataDir)
|
const audit = new Audit(config.dataDir)
|
||||||
|
// A failed write is told to the webhook: who, what, where and the audited reason (never a value). Logins and the test itself are left out.
|
||||||
|
audit.onEntry((e) => {
|
||||||
|
if (e.outcome !== 'failed' || e.action === 'login' || e.action.startsWith('webhook.')) return
|
||||||
|
webhook.queueFailed({
|
||||||
|
action: e.action,
|
||||||
|
...(e.server === undefined ? {} : { server: e.server }),
|
||||||
|
...(e.target === undefined ? {} : { target: e.target }),
|
||||||
|
user: e.user,
|
||||||
|
...(e.detail === undefined ? {} : { detail: e.detail.slice(0, 200) }),
|
||||||
|
at: e.ts,
|
||||||
|
})
|
||||||
|
})
|
||||||
const writer = new AppsWriter(config, upstream)
|
const writer = new AppsWriter(config, upstream)
|
||||||
const runner = new ServersRunner(config, upstream)
|
const runner = new ServersRunner(config, upstream)
|
||||||
const appsConfig = new AppsConfig(config, upstream)
|
const appsConfig = new AppsConfig(config, upstream)
|
||||||
const zonesSync = new ZonesSync(config, upstream)
|
const zonesSync = new ZonesSync(config, upstream)
|
||||||
const settingsConfig = new SettingsConfig(config, upstream)
|
const settingsConfig = new SettingsConfig(config, upstream)
|
||||||
|
const profiles = new Profiles(config, upstream, appsConfig, settingsConfig, writer)
|
||||||
const byId = new Map(config.servers.map((s) => [s.id, s]))
|
const byId = new Map(config.servers.map((s) => [s.id, s]))
|
||||||
|
|
||||||
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
|
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
|
||||||
@@ -488,6 +504,71 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Profiles (profiles.ts): list, view, export and the preview. No change is made. */
|
||||||
|
async function profilesRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
|
||||||
|
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.') }
|
||||||
|
try {
|
||||||
|
if (apiPath === 'hub/profiles/list') send(res, 200, ok({ profiles: profiles.list() }))
|
||||||
|
else if (apiPath === 'hub/profiles/get') send(res, 200, ok(profiles.get(params.name ?? '')))
|
||||||
|
else if (apiPath === 'hub/profiles/preview') {
|
||||||
|
const preview = await profiles.preview(params.name ?? '', csv(params.targets), params.removeExtras === 'true')
|
||||||
|
audit.log({ ...line, target: params.name, outcome: 'ok', detail: csv(params.targets).join(',') })
|
||||||
|
send(res, 200, ok(preview))
|
||||||
|
} else if (apiPath === 'hub/profiles/export') {
|
||||||
|
const file = profiles.exportFile(params.name)
|
||||||
|
audit.log({ ...line, target: params.name || undefined, outcome: 'ok', detail: `${file.profiles.length} profile(s)` })
|
||||||
|
const text = JSON.stringify(file, null, 2) + '\n'
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-disposition': `attachment; filename="hub-profiles-${file.exportedAt.slice(0, 10)}.json"`,
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
'content-length': Buffer.byteLength(text),
|
||||||
|
})
|
||||||
|
res.end(text)
|
||||||
|
} else send(res, 403, fail('The hub is read-only.'))
|
||||||
|
} catch (e) {
|
||||||
|
const message = userMessage(e)
|
||||||
|
if (message === null) throw e
|
||||||
|
send(res, 400, fail(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make, delete, import and apply a profile. Audit lines carry names, counts and the outcome of each step, never a value. */
|
||||||
|
async function profilesWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
|
||||||
|
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.'), target: (params.name ?? '').slice(0, 60) || undefined }
|
||||||
|
try {
|
||||||
|
if (apiPath === 'hub/profiles/snapshot') {
|
||||||
|
const done = await profiles.snapshot(params.name ?? '', { apps: params.apps === undefined ? undefined : csv(params.apps), settings: params.settings === 'true', lists: params.lists === 'true' }, who.user)
|
||||||
|
audit.log({ ...line, outcome: 'ok', detail: done.detail })
|
||||||
|
send(res, 200, ok({ profile: profiles.get(params.name ?? ''), replaced: done.replaced }))
|
||||||
|
} else if (apiPath === 'hub/profiles/delete') {
|
||||||
|
profiles.delete(params.name ?? '')
|
||||||
|
audit.log({ ...line, outcome: 'ok' })
|
||||||
|
send(res, 200, ok({ deleted: true }))
|
||||||
|
} else if (apiPath === 'hub/profiles/import') {
|
||||||
|
const done = profiles.importFile(params.data ?? '', params.apply === 'true', who.user)
|
||||||
|
const count = (status: string) => done.profiles.filter((p) => p.status === status).length
|
||||||
|
audit.log({ ...line, target: undefined, outcome: 'ok', detail: `${done.applied ? 'applied' : 'dry run'}: ${count('new')} new, ${count('changed')} changed, ${count('same')} same` })
|
||||||
|
send(res, 200, ok(done))
|
||||||
|
} else if (apiPath === 'hub/profiles/apply') {
|
||||||
|
const from = jsonParam(params.from, {}) as Record<string, string>
|
||||||
|
const done = await profiles.apply(params.name ?? '', csv(params.targets), { removeExtras: params.removeExtras === 'true', from: from !== null && typeof from === 'object' ? from : {} })
|
||||||
|
for (const r of done.results) {
|
||||||
|
for (const s of r.steps.length > 0 ? r.steps : [{ step: 'apps', status: r.ok ? 'ok' : 'failed', message: r.message }]) {
|
||||||
|
if (s.status === 'not-run' || s.status === 'skipped') continue
|
||||||
|
audit.log({ ...line, server: r.server, outcome: s.status === 'ok' ? 'ok' : 'failed', detail: `${s.step}: ${s.message}`.slice(0, 300) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send(res, 200, ok({ results: done.results }))
|
||||||
|
} else send(res, 403, fail('The hub is read-only.'))
|
||||||
|
} catch (e) {
|
||||||
|
const message = userMessage(e)
|
||||||
|
if (message === null) throw e
|
||||||
|
audit.log({ ...line, outcome: 'refused', detail: message.slice(0, 120) })
|
||||||
|
send(res, 400, fail(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* The write endpoints. The role has already been checked against the route's capability. */
|
/* The write endpoints. The role has already been checked against the route's capability. */
|
||||||
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
|
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
|
||||||
if (route.kind === 'write-all') {
|
if (route.kind === 'write-all') {
|
||||||
@@ -495,6 +576,12 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
|||||||
send(res, 405, fail('Use POST.'))
|
send(res, 405, fail('Use POST.'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (apiPath === 'hub/webhook/test') {
|
||||||
|
const sent = await webhook.deliver('webhook.test', [{ message: 'A test notice from the hub.', by: who.user }])
|
||||||
|
audit.log({ user: who.user, role: who.role, ip, action: 'webhook.test', outcome: sent.ok ? 'ok' : 'failed', detail: sent.ok ? undefined : sent.error })
|
||||||
|
send(res, 200, ok(webhook.status()))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (apiPath === 'hub/drift/check') {
|
if (apiPath === 'hub/drift/check') {
|
||||||
const state = await drift.check()
|
const state = await drift.check()
|
||||||
audit.log({ user: who.user, role: who.role, ip, action: 'drift.check', outcome: 'ok', detail: `${state.apps.length} finding(s), ${drift.count()} differ` })
|
audit.log({ user: who.user, role: who.role, ip, action: 'drift.check', outcome: 'ok', detail: `${state.apps.length} finding(s), ${drift.count()} differ` })
|
||||||
@@ -505,6 +592,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
|||||||
await configWrite(res, apiPath, params, who, ip)
|
await configWrite(res, apiPath, params, who, ip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (apiPath.startsWith('hub/profiles/')) {
|
||||||
|
await profilesWrite(res, apiPath, params, who, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
|
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
|
||||||
await settingsWrite(res, apiPath, params, who, ip)
|
await settingsWrite(res, apiPath, params, who, ip)
|
||||||
return
|
return
|
||||||
@@ -822,6 +913,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
|||||||
await configRead(res, apiPath, params, who, ip)
|
await configRead(res, apiPath, params, who, ip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (apiPath.startsWith('hub/profiles/')) {
|
||||||
|
await profilesRead(res, apiPath, params, who, ip)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
|
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
|
||||||
await settingsRead(res, apiPath, params, who, ip)
|
await settingsRead(res, apiPath, params, who, ip)
|
||||||
return
|
return
|
||||||
@@ -830,6 +925,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
|||||||
await zonesRead(res, apiPath, params)
|
await zonesRead(res, apiPath, params)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (apiPath === 'hub/webhook/status') {
|
||||||
|
send(res, 200, ok(webhook.status()))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (apiPath === 'hub/drift/get') {
|
if (apiPath === 'hub/drift/get') {
|
||||||
send(res, 200, ok(drift.get()))
|
send(res, 200, ok(drift.get()))
|
||||||
return
|
return
|
||||||
|
|||||||
+51
-8
@@ -254,9 +254,9 @@ export class SettingsConfig {
|
|||||||
return hashOf(shared)
|
return hashOf(shared)
|
||||||
}
|
}
|
||||||
|
|
||||||
async preview(ids: string[], only?: readonly string[]): Promise<{ targets: SettingsTargetPreview[] }> {
|
async preview(ids: string[], only?: readonly string[], override?: SettingsBaseline): Promise<{ targets: SettingsTargetPreview[] }> {
|
||||||
const targets = this.#targets(ids)
|
const targets = this.#targets(ids)
|
||||||
const baseline = this.getBaseline()
|
const baseline = override ?? this.getBaseline()
|
||||||
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||||
const extra = this.#extraLocal()
|
const extra = this.#extraLocal()
|
||||||
const base = normalize(baseline.settings)
|
const base = normalize(baseline.settings)
|
||||||
@@ -341,9 +341,10 @@ export class SettingsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
/* `override` is a baseline that is not the hub's own (a profile's settings). */
|
||||||
|
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[], override?: SettingsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||||
const targets = this.#targets(ids)
|
const targets = this.#targets(ids)
|
||||||
const baseline = this.getBaseline()
|
const baseline = override ?? this.getBaseline()
|
||||||
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||||
const extra = this.#extraLocal()
|
const extra = this.#extraLocal()
|
||||||
const done = await Promise.all(targets.map((s) => this.#applyOne(s, baseline, extra, expect[s.id], only)))
|
const done = await Promise.all(targets.map((s) => this.#applyOne(s, baseline, extra, expect[s.id], only)))
|
||||||
@@ -352,6 +353,48 @@ export class SettingsConfig {
|
|||||||
return { results: done.map((d) => d.result), details }
|
return { results: done.map((d) => d.result), details }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The extra local field names (for the profiles, which keep shared fields only). */
|
||||||
|
extraLocal(): string[] {
|
||||||
|
return this.#extraLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
For the drift check: which servers differ from the settings baseline and from the lists baseline.
|
||||||
|
Nothing is read for a baseline that does not exist. Field names only, never a value.
|
||||||
|
*/
|
||||||
|
async driftFindings(): Promise<{ kind: 'settings' | 'lists'; server: string; status: 'differs' | 'unreadable'; fields: string[] }[]> {
|
||||||
|
const out: { kind: 'settings' | 'lists'; server: string; status: 'differs' | 'unreadable'; fields: string[] }[] = []
|
||||||
|
const baseline = this.getBaseline()
|
||||||
|
const lists = this.getListsBaseline()
|
||||||
|
if (baseline === null && lists === null) return out
|
||||||
|
const extra = this.#extraLocal()
|
||||||
|
await Promise.all(
|
||||||
|
this.#config.servers.map(async (s) => {
|
||||||
|
if (baseline !== null) {
|
||||||
|
try {
|
||||||
|
const fields = changedFields(baseline.settings, await this.#read(s), extra)
|
||||||
|
if (fields.length > 0) out.push({ kind: 'settings', server: s.id, status: 'differs', fields })
|
||||||
|
} catch {
|
||||||
|
out.push({ kind: 'settings', server: s.id, status: 'unreadable', fields: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lists !== null) {
|
||||||
|
try {
|
||||||
|
const live = await this.#readLists(s)
|
||||||
|
const fields = (['blocked', 'allowed'] as const).filter((l) => {
|
||||||
|
const d = diffSets(lists[l], live[l])
|
||||||
|
return d.missing.length + d.extra.length > 0
|
||||||
|
})
|
||||||
|
if (fields.length > 0) out.push({ kind: 'lists', server: s.id, status: 'differs', fields })
|
||||||
|
} catch {
|
||||||
|
out.push({ kind: 'lists', server: s.id, status: 'unreadable', fields: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- History: list and view (no rollback; apply the baseline again, or accept an old one by hand) ---- */
|
/* ---- History: list and view (no rollback; apply the baseline again, or accept an old one by hand) ---- */
|
||||||
|
|
||||||
historyList(): HistoryEntry[] {
|
historyList(): HistoryEntry[] {
|
||||||
@@ -447,9 +490,9 @@ export class SettingsConfig {
|
|||||||
return { detail: `from ${serverId}: ${lists.blocked.length} blocked, ${lists.allowed.length} allowed` }
|
return { detail: `from ${serverId}: ${lists.blocked.length} blocked, ${lists.allowed.length} allowed` }
|
||||||
}
|
}
|
||||||
|
|
||||||
async listsPreview(ids: string[], removeExtras: boolean): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> {
|
async listsPreview(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> {
|
||||||
const targets = this.#targets(ids)
|
const targets = this.#targets(ids)
|
||||||
const baseline = this.getListsBaseline()
|
const baseline = override ?? this.getListsBaseline()
|
||||||
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||||
const out = await Promise.all(
|
const out = await Promise.all(
|
||||||
targets.map(async (s) => {
|
targets.map(async (s) => {
|
||||||
@@ -530,9 +573,9 @@ export class SettingsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listsApply(ids: string[], removeExtras: boolean): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
async listsApply(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
|
||||||
const targets = this.#targets(ids)
|
const targets = this.#targets(ids)
|
||||||
const baseline = this.getListsBaseline()
|
const baseline = override ?? this.getListsBaseline()
|
||||||
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||||
const done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
|
const done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
|
||||||
const details: Record<string, string> = {}
|
const details: Record<string, string> = {}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
/*
|
||||||
|
Stage 8, notices to the outside: the signature, the timeout and the single retry, what is logged,
|
||||||
|
and the two endpoints (status and test) on a hub that has a webhook.
|
||||||
|
*/
|
||||||
|
import { createHmac } from 'node:crypto'
|
||||||
|
import http from 'node:http'
|
||||||
|
import type { AddressInfo } from 'node:net'
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { parseConfig } from './config.ts'
|
||||||
|
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
|
||||||
|
import { Webhook, sign } from './webhook.ts'
|
||||||
|
|
||||||
|
const urlConfig = (webhookUrl: string | null, webhookSecret: string | null = null) => ({ webhookUrl, webhookSecret, domain: 'hub.example' })
|
||||||
|
|
||||||
|
describe('Webhook', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('posts {event, at, hub, items} and signs the exact body when there is a secret', async () => {
|
||||||
|
const seen: { url: string; init: RequestInit }[] = []
|
||||||
|
const hook = new Webhook(urlConfig('https://hooks.example/in?token=abc', 's3cret'), {
|
||||||
|
now: () => Date.parse('2026-11-01T00:00:00Z'),
|
||||||
|
fetch: (async (url: string, init: RequestInit) => {
|
||||||
|
seen.push({ url, init })
|
||||||
|
return new Response('ok')
|
||||||
|
}) as typeof fetch,
|
||||||
|
})
|
||||||
|
hook.send('drift.found', [{ kind: 'settings', server: 'a', fields: ['blockingType'] }])
|
||||||
|
await hook.settled()
|
||||||
|
expect(seen).toHaveLength(1)
|
||||||
|
const body = String(seen[0].init.body)
|
||||||
|
expect(JSON.parse(body)).toEqual({ event: 'drift.found', at: '2026-11-01T00:00:00.000Z', hub: 'hub.example', items: [{ kind: 'settings', server: 'a', fields: ['blockingType'] }] })
|
||||||
|
const headers = seen[0].init.headers as Record<string, string>
|
||||||
|
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`)
|
||||||
|
expect(headers['x-hub-signature']).toBe(sign('s3cret', body))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no signature without a secret and nothing at all without a URL', async () => {
|
||||||
|
const seen: RequestInit[] = []
|
||||||
|
const fetcher = (async (_u: string, init: RequestInit) => {
|
||||||
|
seen.push(init)
|
||||||
|
return new Response('ok')
|
||||||
|
}) as typeof fetch
|
||||||
|
const hook = new Webhook(urlConfig('https://hooks.example/in'), { fetch: fetcher })
|
||||||
|
hook.send('drift.cleared', [{ kind: 'app' }])
|
||||||
|
await hook.settled()
|
||||||
|
expect((seen[0].headers as Record<string, string>)['x-hub-signature']).toBeUndefined()
|
||||||
|
const none = new Webhook(urlConfig(null), { fetch: fetcher })
|
||||||
|
none.send('drift.found', [{ kind: 'app' }])
|
||||||
|
await none.settled()
|
||||||
|
expect(seen).toHaveLength(1)
|
||||||
|
expect(none.status()).toEqual({ configured: false, host: null, signed: false, last: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries once and logs the host only, never the query string or the error text', async () => {
|
||||||
|
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
const out = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||||
|
let tries = 0
|
||||||
|
const hook = new Webhook(urlConfig('https://hooks.example:8443/in?token=TOPSECRET'), {
|
||||||
|
retryDelayMs: 0,
|
||||||
|
fetch: (async () => {
|
||||||
|
tries++
|
||||||
|
throw new TypeError('fetch failed https://hooks.example:8443/in?token=TOPSECRET', { cause: { code: 'ECONNREFUSED' } })
|
||||||
|
}) as typeof fetch,
|
||||||
|
})
|
||||||
|
hook.send('drift.found', [{ kind: 'app' }])
|
||||||
|
await hook.settled()
|
||||||
|
expect(tries).toBe(2)
|
||||||
|
const text = [...log.mock.calls, ...out.mock.calls].flat().join(' ')
|
||||||
|
expect(text).toContain('hooks.example:8443')
|
||||||
|
expect(text).toContain('ECONNREFUSED')
|
||||||
|
expect(text).not.toContain('TOPSECRET')
|
||||||
|
expect(text).not.toContain('token=')
|
||||||
|
expect(hook.status()).toMatchObject({ configured: true, host: 'hooks.example:8443', last: { event: 'drift.found', ok: false, error: 'ECONNREFUSED' } })
|
||||||
|
expect(JSON.stringify(hook.status())).not.toContain('TOPSECRET')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a non-2xx answer as a failure and stops after the one retry; a second success is ok', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
const answers = [500, 204]
|
||||||
|
let tries = 0
|
||||||
|
const hook = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: answers[tries++] ?? 500 })) as typeof fetch })
|
||||||
|
hook.send('drift.found', [{ kind: 'app' }])
|
||||||
|
await hook.settled()
|
||||||
|
expect(tries).toBe(2)
|
||||||
|
expect(hook.status().last).toMatchObject({ ok: true })
|
||||||
|
const bad = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: 503 })) as typeof fetch })
|
||||||
|
expect(await bad.deliver('webhook.test', [{}])).toMatchObject({ ok: false, error: 'HTTP 503' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not wait for a hanging receiver when sending, and gives up after the timeout', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
|
||||||
|
timeoutMs: 40,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
fetch: ((_u: string, init: RequestInit) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
init.signal?.addEventListener('abort', () => reject((init.signal as AbortSignal).reason))
|
||||||
|
})) as typeof fetch,
|
||||||
|
})
|
||||||
|
const started = Date.now()
|
||||||
|
hook.send('drift.found', [{ kind: 'app' }])
|
||||||
|
expect(Date.now() - started).toBeLessThan(30)
|
||||||
|
await hook.settled()
|
||||||
|
expect(hook.status().last).toMatchObject({ ok: false, error: 'TimeoutError' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects failed writes of one moment into a single notice', async () => {
|
||||||
|
const bodies: { event: string; items: unknown[] }[] = []
|
||||||
|
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
|
||||||
|
batchMs: 5,
|
||||||
|
fetch: (async (_u: string, init: RequestInit) => {
|
||||||
|
bodies.push(JSON.parse(String(init.body)))
|
||||||
|
return new Response('ok')
|
||||||
|
}) as typeof fetch,
|
||||||
|
})
|
||||||
|
hook.queueFailed({ action: 'a', server: 'x' })
|
||||||
|
hook.queueFailed({ action: 'a', server: 'y' })
|
||||||
|
await hook.settled()
|
||||||
|
expect(bodies).toHaveLength(1)
|
||||||
|
expect(bodies[0]).toMatchObject({ event: 'write.failed', items: [{ server: 'x' }, { server: 'y' }] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HUB_WEBHOOK_URL', () => {
|
||||||
|
const base = { HUB_SERVERS: 'a=http://a.example', A_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' }
|
||||||
|
it('accepts http and https and refuses anything else', () => {
|
||||||
|
expect(parseConfig({ ...base, HUB_WEBHOOK_URL: 'https://h.example/x?y=1', HUB_WEBHOOK_SECRET: 's' })).toMatchObject({ webhookUrl: 'https://h.example/x?y=1', webhookSecret: 's' })
|
||||||
|
expect(parseConfig(base)).toMatchObject({ webhookUrl: null, webhookSecret: null })
|
||||||
|
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'ftp://h.example' })).toThrow(/HUB_WEBHOOK_URL/)
|
||||||
|
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'h.example' })).toThrow(/HUB_WEBHOOK_URL/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the webhook on a hub', () => {
|
||||||
|
let a: Fake
|
||||||
|
let b: Fake
|
||||||
|
let hub: TestHub
|
||||||
|
let receiver: http.Server
|
||||||
|
let received: { headers: http.IncomingHttpHeaders; body: string }[]
|
||||||
|
let status = 200
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
a = await fake()
|
||||||
|
b = await fake()
|
||||||
|
receiver = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
req.on('data', (c: Buffer) => chunks.push(c))
|
||||||
|
req.on('end', () => {
|
||||||
|
received.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') })
|
||||||
|
res.writeHead(status).end('ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r))
|
||||||
|
hub = await startHub(a, b, {
|
||||||
|
HUB_WEBHOOK_URL: `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/in?token=QUERYSECRET`,
|
||||||
|
HUB_WEBHOOK_SECRET: 'shared-secret',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
hub.close()
|
||||||
|
a.server.close()
|
||||||
|
b.server.close()
|
||||||
|
receiver.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
received = []
|
||||||
|
status = 200
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows its status to an admin only, with the host and never the query', async () => {
|
||||||
|
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/status', {}, 'GET')).code).toBe(403)
|
||||||
|
const got = await hub.call('admin', 'hub/webhook/status', {}, 'GET')
|
||||||
|
expect(got.code).toBe(200)
|
||||||
|
expect(got.body.response).toMatchObject({ configured: true, signed: true, last: null })
|
||||||
|
expect(got.body.response.host).toMatch(/^127\.0\.0\.1:\d+$/)
|
||||||
|
expect(JSON.stringify(got.body)).not.toContain('QUERYSECRET')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses the test to every role but admin, and refuses GET', async () => {
|
||||||
|
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/test')).code).toBe(403)
|
||||||
|
expect((await hub.call('admin', 'hub/webhook/test', {}, 'GET')).code).toBe(405)
|
||||||
|
expect(received).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a signed test notice, records the result and audits it', async () => {
|
||||||
|
const done = await hub.call('admin', 'hub/webhook/test')
|
||||||
|
expect(done.code).toBe(200)
|
||||||
|
expect(done.body.response.last).toMatchObject({ event: 'webhook.test', ok: true })
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
const { headers, body } = received[0]
|
||||||
|
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 'shared-secret').update(body).digest('hex')}`)
|
||||||
|
expect(JSON.parse(body)).toMatchObject({ event: 'webhook.test', hub: 'hub' })
|
||||||
|
const audit = await hub.audit()
|
||||||
|
expect(audit.find((e) => e.action === 'webhook.test')).toMatchObject({ user: 'admin', outcome: 'ok' })
|
||||||
|
expect(JSON.stringify(audit)).not.toContain('QUERYSECRET')
|
||||||
|
expect(JSON.stringify(audit)).not.toContain('shared-secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a failing receiver in the answer without failing the request', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
status = 500
|
||||||
|
const done = await hub.call('admin', 'hub/webhook/test')
|
||||||
|
expect(done.code).toBe(200)
|
||||||
|
expect(done.body.status).toBe('ok')
|
||||||
|
expect(done.body.response.last).toMatchObject({ ok: false, error: 'HTTP 500' })
|
||||||
|
expect(received).toHaveLength(2)
|
||||||
|
expect((await hub.audit()).find((e) => e.action === 'webhook.test' && e.outcome === 'failed')).toBeTruthy()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends write.failed for a failed audited write, without the values', async () => {
|
||||||
|
a.on('zones/records/add', () => ({ error: 'The record already exists.' }))
|
||||||
|
const done = await hub.call('admin', 'zones/records/add', { node: 'a', zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'TOPVALUE' })
|
||||||
|
expect(done.body.status).toBe('error')
|
||||||
|
for (let i = 0; i < 40 && received.length === 0; i++) await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
const sent = JSON.parse(received[0].body)
|
||||||
|
expect(sent.event).toBe('write.failed')
|
||||||
|
expect(sent.items[0]).toMatchObject({ action: 'zones.records.add', server: 'a', user: 'admin' })
|
||||||
|
expect(received[0].body).not.toContain('TOPVALUE')
|
||||||
|
// A failed login is not a write.
|
||||||
|
received = []
|
||||||
|
await fetch(`${hub.base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'user=admin&pass=wrong' })
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
expect(received).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body
|
||||||
|
`{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited
|
||||||
|
write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed:
|
||||||
|
`X-Hub-Signature: sha256=<hex hmac>`.
|
||||||
|
|
||||||
|
A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and
|
||||||
|
at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its
|
||||||
|
query string) and never the error text. Items carry names and field names, never values.
|
||||||
|
*/
|
||||||
|
import { createHmac } from 'node:crypto'
|
||||||
|
import type { HubConfig } from './config.ts'
|
||||||
|
|
||||||
|
export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test'
|
||||||
|
|
||||||
|
export interface Delivery {
|
||||||
|
event: WebhookEvent
|
||||||
|
at: string
|
||||||
|
ok: boolean
|
||||||
|
/** `HTTP 500`, or an error code or name. Never a URL. */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookStatus {
|
||||||
|
configured: boolean
|
||||||
|
/** Host and port only. */
|
||||||
|
host: string | null
|
||||||
|
signed: boolean
|
||||||
|
last: Delivery | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookOptions {
|
||||||
|
fetch?: typeof fetch
|
||||||
|
timeoutMs?: number
|
||||||
|
retryDelayMs?: number
|
||||||
|
/** How long write.failed items are collected into one notice. */
|
||||||
|
batchMs?: number
|
||||||
|
now?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
||||||
|
|
||||||
|
export class Webhook {
|
||||||
|
#url: string | null
|
||||||
|
#secret: string | null
|
||||||
|
#domain: string
|
||||||
|
#host: string | null
|
||||||
|
#fetch: typeof fetch
|
||||||
|
#timeoutMs: number
|
||||||
|
#retryDelayMs: number
|
||||||
|
#batchMs: number
|
||||||
|
#now: () => number
|
||||||
|
#last: Delivery | null = null
|
||||||
|
#pending = new Set<Promise<unknown>>()
|
||||||
|
#queued: Record<string, unknown>[] = []
|
||||||
|
#timer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
constructor(config: Pick<HubConfig, 'webhookUrl' | 'webhookSecret' | 'domain'>, options: WebhookOptions = {}) {
|
||||||
|
this.#url = config.webhookUrl
|
||||||
|
this.#secret = config.webhookSecret
|
||||||
|
this.#domain = config.domain
|
||||||
|
this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host
|
||||||
|
this.#fetch = options.fetch ?? fetch
|
||||||
|
this.#timeoutMs = options.timeoutMs ?? 5000
|
||||||
|
this.#retryDelayMs = options.retryDelayMs ?? 1000
|
||||||
|
this.#batchMs = options.batchMs ?? 250
|
||||||
|
this.#now = options.now ?? Date.now
|
||||||
|
}
|
||||||
|
|
||||||
|
get configured(): boolean {
|
||||||
|
return this.#url !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): WebhookStatus {
|
||||||
|
return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves when every delivery started so far has ended (for the tests). */
|
||||||
|
async settled(): Promise<void> {
|
||||||
|
if (this.#timer !== null) this.#flush()
|
||||||
|
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending])
|
||||||
|
}
|
||||||
|
|
||||||
|
#body(event: WebhookEvent, items: Record<string, unknown>[]): string {
|
||||||
|
return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items })
|
||||||
|
}
|
||||||
|
|
||||||
|
async #attempt(body: string): Promise<string | null> {
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||||
|
if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body)
|
||||||
|
try {
|
||||||
|
const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) })
|
||||||
|
await res.body?.cancel().catch(() => undefined)
|
||||||
|
return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}`
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code
|
||||||
|
return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delivers one notice and records the result. Returns it. */
|
||||||
|
async deliver(event: WebhookEvent, items: Record<string, unknown>[]): Promise<Delivery> {
|
||||||
|
if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' }
|
||||||
|
const body = this.#body(event, items)
|
||||||
|
let error = await this.#attempt(body)
|
||||||
|
if (error !== null) {
|
||||||
|
if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs))
|
||||||
|
error = await this.#attempt(body)
|
||||||
|
}
|
||||||
|
const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) }
|
||||||
|
this.#last = result
|
||||||
|
if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fire and forget: never throws, never waits. */
|
||||||
|
send(event: WebhookEvent, items: Record<string, unknown>[]): void {
|
||||||
|
if (this.#url === null || items.length === 0) return
|
||||||
|
const run = this.deliver(event, items).catch(() => undefined)
|
||||||
|
this.#pending.add(run)
|
||||||
|
void run.finally(() => this.#pending.delete(run))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collects `write.failed` items for a moment, so ten failed servers make one notice. */
|
||||||
|
queueFailed(item: Record<string, unknown>): void {
|
||||||
|
if (this.#url === null) return
|
||||||
|
this.#queued.push(item)
|
||||||
|
if (this.#timer === null) {
|
||||||
|
this.#timer = setTimeout(() => this.#flush(), this.#batchMs)
|
||||||
|
this.#timer.unref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#flush(): void {
|
||||||
|
if (this.#timer !== null) clearTimeout(this.#timer)
|
||||||
|
this.#timer = null
|
||||||
|
const items = this.#queued.splice(0, 50)
|
||||||
|
this.#queued = []
|
||||||
|
this.send('write.failed', items)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -236,10 +236,18 @@ server's own store; the client never sends one.
|
|||||||
*/
|
*/
|
||||||
export type HubRunAction = 'install-store' | 'update-store' | 'uninstall'
|
export type HubRunAction = 'install-store' | 'update-store' | 'uninstall'
|
||||||
|
|
||||||
|
export interface StepResult {
|
||||||
|
step: 'apps' | 'configs' | 'settings' | 'lists'
|
||||||
|
status: 'ok' | 'failed' | 'skipped' | 'not-run'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TargetResult {
|
export interface TargetResult {
|
||||||
server: string
|
server: string
|
||||||
ok: boolean
|
ok: boolean
|
||||||
message?: string
|
message?: string
|
||||||
|
/* Only a profile apply: what each step did on this server. */
|
||||||
|
steps?: StepResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }>
|
export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }>
|
||||||
@@ -384,13 +392,20 @@ export interface HistoryDiff {
|
|||||||
unchanged: boolean
|
unchanged: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DriftKind = 'differs' | 'unreadable' | 'not-installed'
|
export type DriftStatus = 'differs' | 'unreadable' | 'not-installed'
|
||||||
|
/* What drifted: an app's config, the settings, or the Blocked and Allowed lists. */
|
||||||
|
export type DriftKind = 'app' | 'settings' | 'lists'
|
||||||
|
|
||||||
export interface DriftEntry {
|
export interface DriftEntry {
|
||||||
app: string
|
kind: DriftKind
|
||||||
|
/* The app's name; absent for settings and lists. */
|
||||||
|
name?: string
|
||||||
|
app?: string
|
||||||
server: string
|
server: string
|
||||||
since: string
|
since: string
|
||||||
kind: DriftKind
|
status: DriftStatus
|
||||||
|
/* Setting names, or blocked and allowed. Never values. */
|
||||||
|
fields?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriftState {
|
export interface DriftState {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function refreshHubDrift(token: string | null): Promise<void> {
|
|||||||
const outcome = await getDrift(token)
|
const outcome = await getDrift(token)
|
||||||
if (outcome.kind !== 'ok') return
|
if (outcome.kind !== 'ok') return
|
||||||
const entries = Array.isArray(outcome.data.response?.apps) ? outcome.data.response.apps : []
|
const entries = Array.isArray(outcome.data.response?.apps) ? outcome.data.response.apps : []
|
||||||
set({ count: entries.filter((e) => e.kind === 'differs').length, entries })
|
set({ count: entries.filter((e) => e.status === 'differs').length, entries })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetHubDrift(): void {
|
export function resetHubDrift(): void {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { urlApi } from '../app/base'
|
||||||
|
import { apiRequest, envelopeOutcome, type ApiOutcome } from './client'
|
||||||
|
import type { ConfigDiff, RunOutcome } from './apps'
|
||||||
|
|
||||||
|
/*
|
||||||
|
Profiles and the webhook, behind a hub (hub/profiles.ts, hub/webhook.ts). Every endpoint needs the
|
||||||
|
hub's admin role. A profile holds settings secrets, so no answer shows one: they appear as
|
||||||
|
`<secret>`, and an app's config text is never sent, only whether there is one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProfileSummary {
|
||||||
|
name: string
|
||||||
|
apps: number
|
||||||
|
configs: number
|
||||||
|
settings: number | null
|
||||||
|
lists: { blocked: number; allowed: number } | null
|
||||||
|
updatedAt: string
|
||||||
|
updatedBy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileView {
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
updatedBy: string
|
||||||
|
apps: { name: string; version: string; hasConfig: boolean; bytes: number }[]
|
||||||
|
settings: Record<string, unknown> | null
|
||||||
|
lists: { blocked: number; allowed: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listProfiles = (token: string | null): Promise<ApiOutcome<{ response: { profiles: ProfileSummary[] } }>> => apiRequest('hub/profiles/list', { token })
|
||||||
|
|
||||||
|
export const getProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: ProfileView }>> => apiRequest('hub/profiles/get', { token, body: { name } })
|
||||||
|
|
||||||
|
export interface SnapshotParts {
|
||||||
|
/* Undefined takes every app that has a baseline. */
|
||||||
|
apps?: string[]
|
||||||
|
settings: boolean
|
||||||
|
lists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapshotProfile = (token: string | null, name: string, parts: SnapshotParts): Promise<ApiOutcome<{ response: { profile: ProfileView; replaced: boolean } }>> =>
|
||||||
|
apiRequest('hub/profiles/snapshot', {
|
||||||
|
token,
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, settings: String(parts.settings), lists: String(parts.lists), ...(parts.apps === undefined ? {} : { apps: parts.apps.join(',') }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: { deleted: boolean } }>> =>
|
||||||
|
apiRequest('hub/profiles/delete', { token, method: 'POST', body: { name } })
|
||||||
|
|
||||||
|
export interface ProfileImportPlan {
|
||||||
|
profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]
|
||||||
|
applied: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importProfiles = (token: string | null, data: string, apply: boolean): Promise<ApiOutcome<{ response: ProfileImportPlan }>> =>
|
||||||
|
apiRequest('hub/profiles/import', { token, method: 'POST', body: { data, apply: apply ? 'true' : 'false' } })
|
||||||
|
|
||||||
|
/* The file of profiles (it holds settings secrets). A download is not the usual envelope. */
|
||||||
|
export async function exportProfiles(token: string | null, name?: string): Promise<ApiOutcome<{ filename: string; text: string }>> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
let res: Response
|
||||||
|
let text: string
|
||||||
|
try {
|
||||||
|
res = await fetch(urlApi(`api/hub/profiles/export${name === undefined ? '' : `?name=${encodeURIComponent(name)}`}`), { headers })
|
||||||
|
text = await res.text()
|
||||||
|
} catch {
|
||||||
|
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const env = JSON.parse(text) as { status?: string }
|
||||||
|
if (env.status !== undefined && env.status !== 'ok') return envelopeOutcome<{ filename: string; text: string }>(env, token)
|
||||||
|
} catch {
|
||||||
|
return { kind: 'error', message: 'The hub did not send a file.' }
|
||||||
|
}
|
||||||
|
const filename = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'hub-profiles.json'
|
||||||
|
return { kind: 'ok', data: { filename, text } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAppState {
|
||||||
|
name: string
|
||||||
|
state: 'install' | 'present' | 'version-differs' | 'not-in-store'
|
||||||
|
profileVersion: string
|
||||||
|
storeVersion?: string
|
||||||
|
installedVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileConfigState {
|
||||||
|
name: string
|
||||||
|
state: 'same' | 'differs' | 'after-install' | 'unreadable'
|
||||||
|
error?: string
|
||||||
|
changes?: ConfigDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileTargetPreview {
|
||||||
|
server: string
|
||||||
|
writable: boolean
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
apps: ProfileAppState[]
|
||||||
|
configs: ProfileConfigState[]
|
||||||
|
settings: { fields: string[]; secretFields: string[]; unchanged: boolean; changes: ConfigDiff; warnings: string[]; error?: string } | null
|
||||||
|
lists: { blocked: { missing: number; extra: number }; allowed: { missing: number; extra: number }; status: string; tooMany?: string; error?: string } | null
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const previewProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { profile: string; targets: ProfileTargetPreview[] } }>> =>
|
||||||
|
apiRequest('hub/profiles/preview', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras) } })
|
||||||
|
|
||||||
|
/* `from` says, per server, the step to start at: the retry of a failed run. */
|
||||||
|
export const applyProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean, from: Record<string, string>): Promise<RunOutcome> =>
|
||||||
|
apiRequest('hub/profiles/apply', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras), from: JSON.stringify(from) } })
|
||||||
|
|
||||||
|
export interface WebhookStatus {
|
||||||
|
configured: boolean
|
||||||
|
/* Host and port only, never the address. */
|
||||||
|
host: string | null
|
||||||
|
signed: boolean
|
||||||
|
last: { event: string; at: string; ok: boolean; error?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWebhookStatus = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/status', { token })
|
||||||
|
|
||||||
|
export const testWebhook = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/test', { token, method: 'POST', body: {} })
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { DriftStatus } from './DriftStatus'
|
||||||
|
import { refreshHubDrift, resetHubDrift } from '../api/hub-drift'
|
||||||
|
import * as apps from '../api/apps'
|
||||||
|
import * as settings from '../api/hub-settings'
|
||||||
|
import * as profiles from '../api/hub-profiles'
|
||||||
|
import type { DriftEntry } from '../api/apps'
|
||||||
|
|
||||||
|
/*
|
||||||
|
The drift list with the kinds added in stage 8: settings and the Blocked and Allowed lists. Their
|
||||||
|
actions are for an admin and reuse the compare views and the apply flows of Settings and Blocking;
|
||||||
|
an entry names fields, never values. The webhook line is the admin's.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => resetHubDrift())
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const ENTRIES: DriftEntry[] = [
|
||||||
|
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
|
||||||
|
{ kind: 'settings', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] },
|
||||||
|
{ kind: 'lists', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blocked'] },
|
||||||
|
{ kind: 'settings', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function load(entries: DriftEntry[] = ENTRIES) {
|
||||||
|
vi.spyOn(apps, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: entries } } } as never)
|
||||||
|
await act(async () => refreshHubDrift('t'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_SET = { configured: false, host: null, signed: false, last: null }
|
||||||
|
|
||||||
|
describe('DriftStatus kinds', () => {
|
||||||
|
it('counts every kind that differs and lists settings and lists with their field names', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
expect(await screen.findByRole('button', { name: 'Config drift' })).toHaveTextContent('3 config drifts')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Config drift' }))
|
||||||
|
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
|
||||||
|
expect(within(settingsRow).getByText('blockingType, defaultRecordTtl')).toBeInTheDocument()
|
||||||
|
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByText('blocked')).toBeInTheDocument()
|
||||||
|
expect(within(screen.getByRole('listitem', { name: 'Settings on mdns' })).getByText('Cannot read')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives a non-admin no action on settings or lists, but keeps the app actions of a writer', async () => {
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
|
||||||
|
expect(within(settingsRow).queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
expect(within(screen.getByRole('listitem', { name: 'Blocker on ndns' })).getByRole('button', { name: 'Accept as baseline' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Webhook')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the settings compare, and the blocking compare for lists', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
const compare = vi.spyOn(settings, 'getSettingsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
|
||||||
|
const lists = vi.spyOn(settings, 'getListsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
|
||||||
|
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: ENTRIES } } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Open compare' }))
|
||||||
|
await waitFor(() => expect(compare).toHaveBeenCalledTimes(1))
|
||||||
|
expect(lists).not.toHaveBeenCalled()
|
||||||
|
expect(await screen.findByRole('dialog', { name: 'Compare settings across servers' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts the server settings as the baseline after a confirmation, then checks again', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
const accept = vi.spyOn(settings, 'importSettingsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: { settings: {}, updatedAt: '', updatedBy: '' } } } } as never)
|
||||||
|
const check = vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
|
||||||
|
expect(accept).toHaveBeenCalledWith('t', 'ndns')
|
||||||
|
await waitFor(() => expect(check).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts the lists of a server as the baseline', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
const accept = vi.spyOn(settings, 'importListsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { imported: true } } } as never)
|
||||||
|
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
|
||||||
|
expect(accept).toHaveBeenCalledWith('t', 'ndns')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reverts settings to the baseline through the preview', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
const preview = vi.spyOn(settings, 'previewSettings').mockResolvedValue({
|
||||||
|
kind: 'ok',
|
||||||
|
data: { response: { targets: [{ server: 'ndns', ok: true, writable: true, currentHash: 'h1', changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] }, secretFields: [], unchanged: false, warnings: [] }] } },
|
||||||
|
} as never)
|
||||||
|
const apply = vi.spyOn(settings, 'applySettings').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Applied and verified (1 field).' }] } } } as never)
|
||||||
|
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
|
||||||
|
const picker = await screen.findByRole('dialog', { name: 'Apply the settings baseline' })
|
||||||
|
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||||
|
expect(preview).toHaveBeenCalledWith('t', ['ndns'], undefined)
|
||||||
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the settings baseline: review' })).getByRole('button', { name: 'Apply' }))
|
||||||
|
expect(apply).toHaveBeenCalledWith('t', ['ndns'], { ndns: 'h1' }, undefined)
|
||||||
|
expect(await screen.findByText('Applied and verified (1 field).')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reverts the lists through the lists preview', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
|
||||||
|
vi.spyOn(settings, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [{ id: 'ndns', ok: true, writable: true, status: 'differs', blocked: { ...none, extra: 1 }, allowed: none }] } } } as never)
|
||||||
|
const apply = vi.spyOn(settings, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added 0, removed 0.' }] } } } as never)
|
||||||
|
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists' })).getByRole('button', { name: 'Preview' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists: review' })).getByRole('button', { name: 'Apply' }))
|
||||||
|
expect(apply).toHaveBeenCalledWith('t', ['ndns'], false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('webhook line in the drift dialog', () => {
|
||||||
|
it('shows the host and the last delivery, and sends a test', async () => {
|
||||||
|
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({
|
||||||
|
kind: 'ok',
|
||||||
|
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'drift.found', at: '2026-11-01T00:00:00Z', ok: false, error: 'HTTP 500' } } },
|
||||||
|
} as never)
|
||||||
|
const test = vi.spyOn(profiles, 'testWebhook').mockResolvedValue({
|
||||||
|
kind: 'ok',
|
||||||
|
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'webhook.test', at: '2026-11-01T00:01:00Z', ok: true } } },
|
||||||
|
} as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
expect(await screen.findByText('hooks.example:8443')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Last delivery failed (HTTP 500)')).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
|
||||||
|
expect(test).toHaveBeenCalledWith('t')
|
||||||
|
expect(await screen.findByText('Last delivery ok')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says it is not configured, with no test button, and shows an error with Retry', async () => {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(profiles, 'getWebhookStatus')
|
||||||
|
.mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never)
|
||||||
|
.mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
|
||||||
|
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
|
||||||
|
await load()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
|
||||||
|
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
expect(await screen.findByText('Not configured')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Send test' })).not.toBeInTheDocument()
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
+78
-19
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { acceptDrift, checkDrift, type DriftEntry } from '../api/apps'
|
import { acceptDrift, checkDrift, type DriftEntry } from '../api/apps'
|
||||||
import { refreshHubDrift, useHubDrift } from '../api/hub-drift'
|
import { refreshHubDrift, useHubDrift } from '../api/hub-drift'
|
||||||
|
import { importListsBaseline, importSettingsBaseline } from '../api/hub-settings'
|
||||||
import { AppConfigSync } from '../screens/apps/AppConfigSync'
|
import { AppConfigSync } from '../screens/apps/AppConfigSync'
|
||||||
import { applyRequest } from '../screens/apps/applyRequest'
|
import { applyRequest } from '../screens/apps/applyRequest'
|
||||||
import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow'
|
import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow'
|
||||||
|
import { SettingsCompare, listsApplyRequest, settingsApplyRequest, type Scope } from '../screens/settings/SettingsCompare'
|
||||||
import { Alert } from '../ui/Alert'
|
import { Alert } from '../ui/Alert'
|
||||||
import { Button } from '../ui/Button'
|
import { Button } from '../ui/Button'
|
||||||
import { Confirm } from '../ui/Confirm'
|
import { Confirm } from '../ui/Confirm'
|
||||||
@@ -11,20 +13,27 @@ import { Dialog } from '../ui/Dialog'
|
|||||||
import { Empty } from '../ui/Empty'
|
import { Empty } from '../ui/Empty'
|
||||||
import { Tag } from '../ui/Tag'
|
import { Tag } from '../ui/Tag'
|
||||||
import styles from './Shell.module.css'
|
import styles from './Shell.module.css'
|
||||||
|
import { WebhookStatus } from './WebhookStatus'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Behind a hub: a mark in the header when a server no longer has the config the hub says it should
|
Behind a hub: a mark in the header when a server no longer has what the hub says it should (someone
|
||||||
(someone changed it in that server's own console), and the list of them. Each can be looked at in
|
changed it in that server's own console), and the list of them. Three kinds: an app's config, the
|
||||||
the app's config view; a writer can accept the server's config as the new baseline, or revert the
|
settings, and the Blocked and Allowed lists. Each can be looked at (the app's config view, or the
|
||||||
server to the baseline after a preview.
|
compare view of Settings or Blocking); a writer can accept the server's state as the new baseline,
|
||||||
|
or revert the server to the baseline after a preview. Settings and lists are for an admin, as their
|
||||||
|
compare views are. An entry names fields, never values.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const KIND = { differs: 'Differs', unreadable: 'Cannot read', 'not-installed': 'Not installed' } as const
|
const STATUS = { differs: 'Differs', unreadable: 'Cannot read', 'not-installed': 'Not installed' } as const
|
||||||
|
|
||||||
export function DriftStatus({ token, canWrite, writable }: { token: string | null; canWrite: boolean; writable: string[] }) {
|
const label = (e: DriftEntry): string => (e.kind === 'app' ? (e.name ?? e.app ?? '') : e.kind === 'settings' ? 'Settings' : 'Blocked and Allowed lists')
|
||||||
|
const keyOf = (e: DriftEntry): string => `${e.kind}/${e.name ?? e.app ?? ''}/${e.server}`
|
||||||
|
|
||||||
|
export function DriftStatus({ token, canWrite, writable, isAdmin = false }: { token: string | null; canWrite: boolean; writable: string[]; isAdmin?: boolean }) {
|
||||||
const { count, entries } = useHubDrift()
|
const { count, entries } = useHubDrift()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [configApp, setConfigApp] = useState<string | null>(null)
|
const [configApp, setConfigApp] = useState<string | null>(null)
|
||||||
|
const [compare, setCompare] = useState<Scope | null>(null)
|
||||||
const [request, setRequest] = useState<RunRequest | null>(null)
|
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||||
const [accepting, setAccepting] = useState<DriftEntry | null>(null)
|
const [accepting, setAccepting] = useState<DriftEntry | null>(null)
|
||||||
const [problem, setProblem] = useState<string | null>(null)
|
const [problem, setProblem] = useState<string | null>(null)
|
||||||
@@ -32,13 +41,25 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
|
|||||||
|
|
||||||
if (count === 0 && !open) return null
|
if (count === 0 && !open) return null
|
||||||
|
|
||||||
|
async function recheck() {
|
||||||
|
await checkDrift(token)
|
||||||
|
await refreshHubDrift(token)
|
||||||
|
}
|
||||||
|
|
||||||
async function accept(entry: DriftEntry) {
|
async function accept(entry: DriftEntry) {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setProblem(null)
|
setProblem(null)
|
||||||
const outcome = await acceptDrift(token, entry.app, entry.server)
|
const outcome =
|
||||||
setBusy(false)
|
entry.kind === 'app'
|
||||||
|
? await acceptDrift(token, entry.name ?? entry.app ?? '', entry.server)
|
||||||
|
: entry.kind === 'settings'
|
||||||
|
? await importSettingsBaseline(token, entry.server)
|
||||||
|
: await importListsBaseline(token, entry.server)
|
||||||
if (outcome.kind !== 'ok') setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
if (outcome.kind !== 'ok') setProblem(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||||
await refreshHubDrift(token)
|
// The hub checks again after an app is accepted; for settings and lists it is asked to.
|
||||||
|
if (entry.kind === 'app') await refreshHubDrift(token)
|
||||||
|
else await recheck()
|
||||||
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNow() {
|
async function checkNow() {
|
||||||
@@ -51,11 +72,19 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
function revert(entry: DriftEntry) {
|
function revert(entry: DriftEntry) {
|
||||||
|
const servers = pickerServers([{ id: entry.server, writable: writable.includes(entry.server) }])
|
||||||
setRequest(
|
setRequest(
|
||||||
applyRequest(token, entry.app, pickerServers([{ id: entry.server, writable: writable.includes(entry.server) }]), [entry.server]),
|
entry.kind === 'app'
|
||||||
|
? applyRequest(token, entry.name ?? entry.app ?? '', servers, [entry.server])
|
||||||
|
: entry.kind === 'settings'
|
||||||
|
? settingsApplyRequest(token, servers, [entry.server], 'settings')
|
||||||
|
: listsApplyRequest(token, servers, [entry.server]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings and lists are admin-only; an app's config follows the Apps right. */
|
||||||
|
const mayAct = (e: DriftEntry): boolean => (e.kind === 'app' ? canWrite : isAdmin)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className={styles.status} data-attention="true" aria-label="Config drift" onClick={() => setOpen(true)}>
|
<button type="button" className={styles.status} data-attention="true" aria-label="Config drift" onClick={() => setOpen(true)}>
|
||||||
@@ -77,24 +106,36 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="m-0 text-sm text-muted-foreground">
|
<p className="m-0 text-sm text-muted-foreground">
|
||||||
These servers no longer have the config the hub expects: the baseline plus the server's own local fields.
|
These servers no longer have what the hub expects: an app's baseline plus the server's own local fields, or the shared settings and the Blocked and Allowed lists of their baselines.
|
||||||
</p>
|
</p>
|
||||||
|
{isAdmin && <WebhookStatus token={token} />}
|
||||||
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<Empty title="No drift">Every server has the config the hub expects.</Empty>
|
<Empty title="No drift">Every server has what the hub expects.</Empty>
|
||||||
) : (
|
) : (
|
||||||
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Drifts">
|
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Drifts">
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<li key={`${e.app}/${e.server}`} aria-label={`${e.app} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
|
<li key={keyOf(e)} aria-label={`${label(e)} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
|
||||||
<span className="font-medium">{e.app}</span>
|
<span className="font-medium">{label(e)}</span>
|
||||||
<span>{e.server}</span>
|
<span>{e.server}</span>
|
||||||
<Tag tone={e.kind === 'differs' ? 'warn' : 'neutral'}>{KIND[e.kind]}</Tag>
|
<Tag tone={e.status === 'differs' ? 'warn' : 'neutral'}>{STATUS[e.status]}</Tag>
|
||||||
<span className="text-xs text-muted-foreground">since {new Date(e.since).toLocaleString()}</span>
|
<span className="text-xs text-muted-foreground">since {new Date(e.since).toLocaleString()}</span>
|
||||||
|
{e.fields !== undefined && e.fields.length > 0 && (
|
||||||
|
<span className="basis-full font-mono text-xs text-muted-foreground">{e.fields.slice(0, 8).join(', ')}{e.fields.length > 8 ? ` and ${e.fields.length - 8} more` : ''}</span>
|
||||||
|
)}
|
||||||
<span className="ml-auto flex flex-wrap gap-2">
|
<span className="ml-auto flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={() => setConfigApp(e.app)}>
|
{e.kind === 'app' ? (
|
||||||
|
<Button size="sm" onClick={() => setConfigApp(e.name ?? e.app ?? '')}>
|
||||||
Open config
|
Open config
|
||||||
</Button>
|
</Button>
|
||||||
{canWrite && e.kind === 'differs' && (
|
) : (
|
||||||
|
isAdmin && (
|
||||||
|
<Button size="sm" onClick={() => setCompare(e.kind === 'settings' ? 'settings' : 'blocking')}>
|
||||||
|
Open compare
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{mayAct(e) && e.status === 'differs' && (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" disabled={busy} onClick={() => setAccepting(e)}>
|
<Button size="sm" disabled={busy} onClick={() => setAccepting(e)}>
|
||||||
Accept as baseline
|
Accept as baseline
|
||||||
@@ -114,7 +155,15 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
|
|||||||
<Confirm
|
<Confirm
|
||||||
open={accepting !== null}
|
open={accepting !== null}
|
||||||
title="Accept as baseline"
|
title="Accept as baseline"
|
||||||
text={`Make the config of ${accepting?.app ?? ''} on ${accepting?.server ?? ''} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`}
|
text={
|
||||||
|
accepting === null
|
||||||
|
? ''
|
||||||
|
: accepting.kind === 'app'
|
||||||
|
? `Make the config of ${label(accepting)} on ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`
|
||||||
|
: accepting.kind === 'settings'
|
||||||
|
? `Make the settings of ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the settings history.`
|
||||||
|
: `Make the Blocked and Allowed lists of ${accepting.server} the baseline? The other servers will then differ from it.`
|
||||||
|
}
|
||||||
label="Accept"
|
label="Accept"
|
||||||
onClose={() => setAccepting(null)}
|
onClose={() => setAccepting(null)}
|
||||||
onConfirm={() => accepting && accept(accepting)}
|
onConfirm={() => accepting && accept(accepting)}
|
||||||
@@ -123,12 +172,22 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
|
|||||||
{configApp !== null && (
|
{configApp !== null && (
|
||||||
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void refreshHubDrift(token)} />
|
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void refreshHubDrift(token)} />
|
||||||
)}
|
)}
|
||||||
|
{compare !== null && (
|
||||||
|
<SettingsCompare
|
||||||
|
token={token}
|
||||||
|
scope={compare}
|
||||||
|
onClose={() => {
|
||||||
|
setCompare(null)
|
||||||
|
void recheck()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{request !== null && (
|
{request !== null && (
|
||||||
<RunFlow
|
<RunFlow
|
||||||
request={request}
|
request={request}
|
||||||
onClose={(changed) => {
|
onClose={(changed) => {
|
||||||
setRequest(null)
|
setRequest(null)
|
||||||
if (changed) void checkDrift(token).then(() => refreshHubDrift(token))
|
if (changed) void recheck()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('Shell on a hub', () => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
vi.spyOn(client, 'apiRequest').mockImplementation((async (path: string) =>
|
vi.spyOn(client, 'apiRequest').mockImplementation((async (path: string) =>
|
||||||
path === 'hub/drift/get'
|
path === 'hub/drift/get'
|
||||||
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', kind: 'differs' }] } } }
|
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' }] } } }
|
||||||
: { kind: 'ok', data: { status: 'ok', response: {} } }) as never)
|
: { kind: 'ok', data: { status: 'ok', response: {} } }) as never)
|
||||||
const hubSession = session(true)
|
const hubSession = session(true)
|
||||||
hubSession.info = { ...hubSession.info!, hubDrift: 1 }
|
hubSession.info = { ...hubSession.info!, hubDrift: 1 }
|
||||||
|
|||||||
+4
-3
@@ -41,6 +41,7 @@ import { useBlockingMark } from '../screens/blocking/blocking-state'
|
|||||||
import { useHubWarnings } from '../api/hub-warnings'
|
import { useHubWarnings } from '../api/hub-warnings'
|
||||||
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
|
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
|
||||||
import { DriftStatus } from './DriftStatus'
|
import { DriftStatus } from './DriftStatus'
|
||||||
|
import { WebhookStatus } from './WebhookStatus'
|
||||||
import { Confirm } from '../ui/Confirm'
|
import { Confirm } from '../ui/Confirm'
|
||||||
import { Notifier } from '../ui/Notifier'
|
import { Notifier } from '../ui/Notifier'
|
||||||
import type { Notice } from '../lib/notice'
|
import type { Notice } from '../lib/notice'
|
||||||
@@ -613,7 +614,7 @@ export function Shell({
|
|||||||
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
|
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />}
|
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
|
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
|
||||||
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
|
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
|
||||||
@@ -679,9 +680,9 @@ export function Shell({
|
|||||||
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
|
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
|
||||||
/>
|
/>
|
||||||
) : current?.id === 'about' ? (
|
) : current?.id === 'about' ? (
|
||||||
<About info={info} />
|
<About info={info} hubPanel={hub && permissions?.Settings?.canModify === true ? <WebhookStatus token={session.token} /> : undefined} />
|
||||||
) : current?.id === 'apps' ? (
|
) : current?.id === 'apps' ? (
|
||||||
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />
|
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />
|
||||||
) : current?.id === 'cache' ? (
|
) : current?.id === 'cache' ? (
|
||||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
|
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
|
||||||
) : current?.id === 'blocking' ? (
|
) : current?.id === 'blocking' ? (
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { getWebhookStatus, testWebhook, type WebhookStatus as Status } from '../api/hub-profiles'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
|
import { Tag } from '../ui/Tag'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
|
/*
|
||||||
|
For the hub's admin: whether the hub sends notices to a webhook (HUB_WEBHOOK_URL), the host it
|
||||||
|
sends them to (never the address, which may carry a token), how the last delivery went, and a
|
||||||
|
button that sends a test notice. Read-only: the address and the secret are set on the hub.
|
||||||
|
*/
|
||||||
|
export function WebhookStatus({ token }: { token: string | null }) {
|
||||||
|
const [status, setStatus] = useState<Status | null>(null)
|
||||||
|
const [failure, setFailure] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const outcome = await getWebhookStatus(token)
|
||||||
|
if (outcome.kind === 'ok') {
|
||||||
|
setStatus(outcome.data.response)
|
||||||
|
setFailure(null)
|
||||||
|
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
setBusy(true)
|
||||||
|
const outcome = await testWebhook(token)
|
||||||
|
setBusy(false)
|
||||||
|
if (outcome.kind === 'ok') {
|
||||||
|
setStatus(outcome.data.response)
|
||||||
|
setFailure(null)
|
||||||
|
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure !== null && status === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm" role="alert">
|
||||||
|
<span>Could not read the webhook status. {failure}</span>
|
||||||
|
<Button size="sm" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === null) return <Skeleton className="h-6 w-2/3" role="status" aria-label="Loading the webhook status" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2" aria-label="Webhook">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">Webhook</span>
|
||||||
|
{status.configured ? <Tag tone="ok">Configured</Tag> : <Tag tone="neutral">Not configured</Tag>}
|
||||||
|
{status.configured && <span className="text-muted-foreground">{status.host}</span>}
|
||||||
|
{status.configured && status.last === null && <span className="text-xs text-muted-foreground">Nothing sent yet.</span>}
|
||||||
|
{status.last !== null && (
|
||||||
|
<>
|
||||||
|
<Tag tone={status.last.ok ? 'ok' : 'dan'}>{status.last.ok ? 'Last delivery ok' : `Last delivery failed${status.last.error === undefined ? '' : ` (${status.last.error})`}`}</Tag>
|
||||||
|
<span className="text-xs text-muted-foreground">{new Date(status.last.at).toLocaleString()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status.configured && (
|
||||||
|
<Button size="sm" className="ml-auto" disabled={busy} onClick={() => void test()}>
|
||||||
|
Send test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!status.configured && <p className="m-0 text-xs text-muted-foreground">Set HUB_WEBHOOK_URL on the hub to get a notice when a server drifts or a write fails.</p>}
|
||||||
|
{failure !== null && (
|
||||||
|
<p className="m-0 text-xs text-muted-foreground" role="alert">
|
||||||
|
{failure}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,7 +61,8 @@ four literals the product does not say.
|
|||||||
What stays is the `Server` panel: version and uptime are facts upstream's own
|
What stays is the `Server` panel: version and uptime are facts upstream's own
|
||||||
About shows, right under the title.
|
About shows, right under the title.
|
||||||
*/
|
*/
|
||||||
export function About({ info }: { info?: Info }) {
|
/* `hubPanel`: behind a hub, the hub's own status for its admin (the webhook), under Server. */
|
||||||
|
export function About({ info, hubPanel }: { info?: Info; hubPanel?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
|
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
|
||||||
@@ -166,6 +167,12 @@ export function About({ info }: { info?: Info }) {
|
|||||||
</Body>
|
</Body>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{hubPanel != null && (
|
||||||
|
<Panel title="Hub notifications" description="Where the hub sends drift and failure notices">
|
||||||
|
<Body>{hubPanel}</Body>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
This console's own panels (deviation 9 of CONVENTIONS.md, approved on
|
This console's own panels (deviation 9 of CONVENTIONS.md, approved on
|
||||||
2026-10-16), under Server. Technitium's card on the left stays literal.
|
2026-10-16), under Server. Technitium's card on the left stays literal.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { AppsMatrix } from './AppsMatrix'
|
|||||||
import { HubStore } from './HubStore'
|
import { HubStore } from './HubStore'
|
||||||
import { HubInstallFile } from './HubInstallFile'
|
import { HubInstallFile } from './HubInstallFile'
|
||||||
import { ImportBaselines } from './ImportBaselines'
|
import { ImportBaselines } from './ImportBaselines'
|
||||||
|
import { Profiles } from './Profiles'
|
||||||
import { RunFlow, type RunRequest } from './RunFlow'
|
import { RunFlow, type RunRequest } from './RunFlow'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -70,6 +71,7 @@ export function Apps({
|
|||||||
clusterInitialised = false,
|
clusterInitialised = false,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
writable = [],
|
writable = [],
|
||||||
|
isAdmin = false,
|
||||||
}: {
|
}: {
|
||||||
token: string | null
|
token: string | null
|
||||||
/** Only used behind a hub: its servers. */
|
/** Only used behind a hub: its servers. */
|
||||||
@@ -79,6 +81,8 @@ export function Apps({
|
|||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
/** Behind a hub: the servers the hub holds an admin token for. */
|
/** Behind a hub: the servers the hub holds an admin token for. */
|
||||||
writable?: string[]
|
writable?: string[]
|
||||||
|
/** Behind a hub: the role is the hub's admin (profiles are for admins). */
|
||||||
|
isAdmin?: boolean
|
||||||
}) {
|
}) {
|
||||||
/*
|
/*
|
||||||
Behind a hub (`info.hub`) this screen is read-only and has a server selector: "All
|
Behind a hub (`info.hub`) this screen is read-only and has a server selector: "All
|
||||||
@@ -102,6 +106,7 @@ export function Apps({
|
|||||||
const [hubView, setHubView] = useState<'matrix' | 'store'>('matrix')
|
const [hubView, setHubView] = useState<'matrix' | 'store'>('matrix')
|
||||||
const [fileDialog, setFileDialog] = useState(false)
|
const [fileDialog, setFileDialog] = useState(false)
|
||||||
const [importDialog, setImportDialog] = useState(false)
|
const [importDialog, setImportDialog] = useState(false)
|
||||||
|
const [profilesDialog, setProfilesDialog] = useState(false)
|
||||||
const [fileRequest, setFileRequest] = useState<RunRequest | null>(null)
|
const [fileRequest, setFileRequest] = useState<RunRequest | null>(null)
|
||||||
const [refresh, setRefresh] = useState(0)
|
const [refresh, setRefresh] = useState(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -263,6 +268,7 @@ export function Apps({
|
|||||||
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
|
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
|
||||||
<Button onClick={() => void exportFile()}>Export baselines</Button>
|
<Button onClick={() => void exportFile()}>Export baselines</Button>
|
||||||
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
|
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
|
||||||
|
{isAdmin && <Button onClick={() => setProfilesDialog(true)}>Profiles</Button>}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -272,6 +278,7 @@ export function Apps({
|
|||||||
) : (
|
) : (
|
||||||
<AppsMatrix token={token} canWrite={canWrite} refresh={refresh} />
|
<AppsMatrix token={token} canWrite={canWrite} refresh={refresh} />
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && <Profiles open={profilesDialog} onOpenChange={setProfilesDialog} token={token} servers={hubServers} />}
|
||||||
<ImportBaselines open={importDialog} onOpenChange={setImportDialog} token={token} onImported={() => setRefresh((n) => n + 1)} />
|
<ImportBaselines open={importDialog} onOpenChange={setImportDialog} token={token} onImported={() => setRefresh((n) => n + 1)} />
|
||||||
<HubInstallFile open={fileDialog} onOpenChange={setFileDialog} token={token} servers={hubServers} onRequest={setFileRequest} />
|
<HubInstallFile open={fileDialog} onOpenChange={setFileDialog} token={token} servers={hubServers} onRequest={setFileRequest} />
|
||||||
{fileRequest !== null && (
|
{fileRequest !== null && (
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ describe('History tab', () => {
|
|||||||
|
|
||||||
describe('DriftStatus in the header', () => {
|
describe('DriftStatus in the header', () => {
|
||||||
const DRIFTS: DriftEntry[] = [
|
const DRIFTS: DriftEntry[] = [
|
||||||
{ app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', kind: 'differs' },
|
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
|
||||||
{ app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', kind: 'unreadable' },
|
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function load(entries: DriftEntry[]) {
|
async function load(entries: DriftEntry[]) {
|
||||||
@@ -200,7 +200,7 @@ describe('Drift badge in the matrix', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never)
|
} as never)
|
||||||
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: [{ app: 'Blocker', server: 'ndns', since: 'z', kind: 'differs' }, { app: 'Blocker', server: 'mdns', since: 'z', kind: 'unreadable' }] } } } as never)
|
vi.spyOn(api, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: 'z', status: 'differs' }, { kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: 'z', status: 'unreadable' }] } } } as never)
|
||||||
render(<AppsMatrix token="t" />)
|
render(<AppsMatrix token="t" />)
|
||||||
await screen.findByText('Blocker')
|
await screen.findByText('Blocker')
|
||||||
await waitFor(() => expect(screen.getAllByText('Drift')).toHaveLength(1))
|
await waitFor(() => expect(screen.getAllByText('Drift')).toHaveLength(1))
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
|||||||
void getDrift(token)
|
void getDrift(token)
|
||||||
.then((o) => {
|
.then((o) => {
|
||||||
if (o.kind === 'ok' && Array.isArray(o.data.response?.apps)) {
|
if (o.kind === 'ok' && Array.isArray(o.data.response?.apps)) {
|
||||||
setDrifted(new Set(o.data.response.apps.filter((e) => e.kind === 'differs').map((e) => `${e.app}\n${e.server}`)))
|
setDrifted(new Set(o.data.response.apps.filter((e) => e.kind === 'app' && e.status === 'differs').map((e) => `${e.name ?? e.app}\n${e.server}`)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { Profiles } from './Profiles'
|
||||||
|
import * as api from '../../api/hub-profiles'
|
||||||
|
import type { ProfileSummary, ProfileTargetPreview } from '../../api/hub-profiles'
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
const SERVERS = [
|
||||||
|
{ id: 'mdns', writable: true },
|
||||||
|
{ id: 'ndns', writable: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FRESH: ProfileSummary = { name: 'Fresh', apps: 2, configs: 1, settings: 12, lists: { blocked: 3, allowed: 1 }, updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin' }
|
||||||
|
|
||||||
|
const listOf = (profiles: ProfileSummary[]) => vi.spyOn(api, 'listProfiles').mockResolvedValue({ kind: 'ok', data: { response: { profiles } } } as never)
|
||||||
|
|
||||||
|
const PREVIEW: ProfileTargetPreview = {
|
||||||
|
server: 'ndns',
|
||||||
|
writable: true,
|
||||||
|
ok: true,
|
||||||
|
apps: [
|
||||||
|
{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' },
|
||||||
|
{ name: 'Logs', state: 'version-differs', profileVersion: '2.0', installedVersion: '1.0' },
|
||||||
|
],
|
||||||
|
configs: [{ name: 'Blocker', state: 'after-install' }],
|
||||||
|
settings: { fields: ['blockingType'], secretFields: ['tsigKeys'], unchanged: false, warnings: [], changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] } },
|
||||||
|
lists: { blocked: { missing: 2, extra: 1 }, allowed: { missing: 0, extra: 0 }, status: 'differs' },
|
||||||
|
warnings: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (steps: { step: 'apps' | 'configs' | 'settings' | 'lists'; status: 'ok' | 'failed' | 'skipped' | 'not-run'; message: string }[], ok: boolean) =>
|
||||||
|
({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok, message: ok ? 'Profile applied.' : 'Stopped at settings: refused', steps }] } } }) as never
|
||||||
|
|
||||||
|
describe('Profiles list', () => {
|
||||||
|
it('shows a skeleton while it loads, then each profile with its parts', async () => {
|
||||||
|
listOf([FRESH])
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
expect(screen.getByRole('status', { name: 'Loading the profiles' })).toBeInTheDocument()
|
||||||
|
const row = await screen.findByRole('listitem', { name: 'Fresh' })
|
||||||
|
expect(within(row).getByText('2 apps')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('1 config')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('12 settings')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('4 list domains')).toBeInTheDocument()
|
||||||
|
for (const name of ['View', 'Apply to servers…', 'Export', 'Delete']) expect(within(row).getByRole('button', { name })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has an empty state whose action creates a profile from the current baselines', async () => {
|
||||||
|
listOf([])
|
||||||
|
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Create from current baselines' }))
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
|
||||||
|
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Fresh')
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||||
|
expect(snapshot).toHaveBeenCalledWith('t', 'Fresh', { apps: undefined, settings: true, lists: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the admin leave a part out', async () => {
|
||||||
|
listOf([])
|
||||||
|
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Create from current baselines' }))
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
|
||||||
|
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Light')
|
||||||
|
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Apps' }))
|
||||||
|
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Blocked and Allowed lists' }))
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||||
|
expect(snapshot).toHaveBeenCalledWith('t', 'Light', { apps: [], settings: true, lists: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error with Retry', async () => {
|
||||||
|
const spy = vi.spyOn(api, 'listProfiles').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: { profiles: [FRESH] } } } as never)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
|
expect(await screen.findByRole('listitem', { name: 'Fresh' })).toBeInTheDocument()
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('views a profile with the secrets masked and no config text', async () => {
|
||||||
|
listOf([FRESH])
|
||||||
|
vi.spyOn(api, 'getProfile').mockResolvedValue({
|
||||||
|
kind: 'ok',
|
||||||
|
data: {
|
||||||
|
response: {
|
||||||
|
name: 'Fresh', createdAt: '', updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin',
|
||||||
|
apps: [{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: 12 }],
|
||||||
|
settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] },
|
||||||
|
lists: { blocked: 3, allowed: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'View' }))
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Profile: Fresh' })
|
||||||
|
expect(await within(dialog).findByText('With config')).toBeInTheDocument()
|
||||||
|
expect((within(dialog).getByLabelText('Shared settings (JSON)') as HTMLTextAreaElement).value).toContain('<secret>')
|
||||||
|
expect(within(dialog).getByText('3 blocked and 1 allowed domains.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes only after a confirmation', async () => {
|
||||||
|
const list = listOf([FRESH])
|
||||||
|
const removed = vi.spyOn(api, 'deleteProfile').mockResolvedValue({ kind: 'ok', data: { response: { deleted: true } } } as never)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(removed).not.toHaveBeenCalled()
|
||||||
|
list.mockResolvedValue({ kind: 'ok', data: { response: { profiles: [] } } } as never)
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }))
|
||||||
|
expect(removed).toHaveBeenCalledWith('t', 'Fresh')
|
||||||
|
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applying a profile', () => {
|
||||||
|
it('previews the steps per server, applies, and shows the steps of each result', async () => {
|
||||||
|
listOf([FRESH])
|
||||||
|
const preview = vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
|
||||||
|
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(
|
||||||
|
result(
|
||||||
|
[
|
||||||
|
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
|
||||||
|
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
|
||||||
|
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
|
||||||
|
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||||
|
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
|
||||||
|
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
|
||||||
|
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||||
|
expect(preview).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false)
|
||||||
|
|
||||||
|
const review = await screen.findByRole('dialog', { name: 'Apply the profile Fresh: review' })
|
||||||
|
const steps = within(review).getByRole('list', { name: 'Steps on ndns' })
|
||||||
|
expect(within(steps).getByText('1. Apps')).toBeInTheDocument()
|
||||||
|
expect(within(steps).getByText('Will be installed')).toBeInTheDocument()
|
||||||
|
expect(within(steps).getByText('Other version installed')).toBeInTheDocument()
|
||||||
|
expect(within(steps).getByText('2. App configs')).toBeInTheDocument()
|
||||||
|
expect(within(steps).getByText(/tsigKeys: the secret values are sent but never shown/)).toBeInTheDocument()
|
||||||
|
expect(within(steps).getByText(/Blocked: 2 to add, 1 extra/)).toBeInTheDocument()
|
||||||
|
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
|
||||||
|
|
||||||
|
expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false, {})
|
||||||
|
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
|
||||||
|
expect(within(list).getByText('Installed Blocker.')).toBeInTheDocument()
|
||||||
|
expect(within(list).getAllByText('Done')).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes list extras only when ticked in the review', async () => {
|
||||||
|
listOf([FRESH])
|
||||||
|
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
|
||||||
|
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(result([{ step: 'lists', status: 'ok', message: 'done' }], true))
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||||
|
await userEvent.click(await screen.findByRole('checkbox', { name: 'Also remove extras' }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
||||||
|
await waitFor(() => expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], true, {}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says which step failed and retries from that step', async () => {
|
||||||
|
listOf([FRESH])
|
||||||
|
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
|
||||||
|
const apply = vi
|
||||||
|
.spyOn(api, 'applyProfile')
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
result(
|
||||||
|
[
|
||||||
|
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
|
||||||
|
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
|
||||||
|
{ step: 'settings', status: 'failed', message: 'refused' },
|
||||||
|
{ step: 'lists', status: 'not-run', message: 'An earlier step failed.' },
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(
|
||||||
|
result(
|
||||||
|
[
|
||||||
|
{ step: 'apps', status: 'skipped', message: 'Done by an earlier run.' },
|
||||||
|
{ step: 'configs', status: 'skipped', message: 'Done by an earlier run.' },
|
||||||
|
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
|
||||||
|
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||||
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }))
|
||||||
|
|
||||||
|
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
|
||||||
|
expect(within(list).getByText('Failed')).toBeInTheDocument()
|
||||||
|
expect(within(list).getByText('Not run')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Stopped at settings/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Retry failed' }))
|
||||||
|
await waitFor(() => expect(apply).toHaveBeenCalledTimes(2))
|
||||||
|
expect(apply).toHaveBeenLastCalledWith('t', 'Fresh', ['ndns'], false, { ndns: 'settings' })
|
||||||
|
await waitFor(() => expect(within(screen.getByRole('list', { name: 'Steps on ndns' })).getAllByText('Done earlier')).toHaveLength(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists a server with no admin token disabled, and shows a preview error', async () => {
|
||||||
|
listOf([FRESH])
|
||||||
|
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'error', message: 'unreachable' } as never)
|
||||||
|
render(<Profiles open onOpenChange={() => {}} token="t" servers={[{ id: 'mdns', writable: false }, { id: 'ndns', writable: true }]} />)
|
||||||
|
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
|
||||||
|
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
|
||||||
|
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
|
||||||
|
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
|
||||||
|
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
||||||
|
expect(await screen.findByText('unreachable')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
applyProfile,
|
||||||
|
deleteProfile,
|
||||||
|
exportProfiles,
|
||||||
|
getProfile,
|
||||||
|
importProfiles,
|
||||||
|
listProfiles,
|
||||||
|
previewProfile,
|
||||||
|
snapshotProfile,
|
||||||
|
type ProfileImportPlan,
|
||||||
|
type ProfileSummary,
|
||||||
|
type ProfileTargetPreview,
|
||||||
|
type ProfileView,
|
||||||
|
} from '../../api/hub-profiles'
|
||||||
|
import type { RunOutcome } from '../../api/apps'
|
||||||
|
import { Alert } from '../../ui/Alert'
|
||||||
|
import { Button } from '../../ui/Button'
|
||||||
|
import { Check } from '../../ui/Check'
|
||||||
|
import { Confirm } from '../../ui/Confirm'
|
||||||
|
import { Dialog } from '../../ui/Dialog'
|
||||||
|
import { Empty, Failure } from '../../ui/Empty'
|
||||||
|
import { LabeledInput, LabeledTextarea } from '../../ui/Field'
|
||||||
|
import { Tag } from '../../ui/Tag'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { DiffView } from './ConfigDiffView'
|
||||||
|
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
|
||||||
|
|
||||||
|
/*
|
||||||
|
Behind a hub, for an admin: profiles, named bundles of store apps (with the hub's baseline config of
|
||||||
|
each), the shared settings and the Blocked and Allowed lists. One is made from the hub's current
|
||||||
|
baselines, can be exported to another hub, and is applied to one or several servers through the
|
||||||
|
same picker, review and results as every other change here. A server's steps run in order (apps,
|
||||||
|
app configs, settings, lists) and a failed one stops the rest for that server; "Retry failed" starts
|
||||||
|
again at the failed step. Nothing here shows a secret or an app's config text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
|
||||||
|
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
|
||||||
|
|
||||||
|
const APP_STATE = {
|
||||||
|
install: ['warn', 'Will be installed'],
|
||||||
|
present: ['ok', 'Already installed'],
|
||||||
|
'version-differs': ['info', 'Other version installed'],
|
||||||
|
'not-in-store': ['dan', 'Not in this store'],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const CONFIG_STATE = {
|
||||||
|
same: ['ok', 'Same'],
|
||||||
|
differs: ['warn', 'Will change'],
|
||||||
|
'after-install': ['info', 'Set after install'],
|
||||||
|
unreadable: ['dan', 'Cannot read'],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/* ---- Apply: the review of what each server would get ---- */
|
||||||
|
|
||||||
|
function PreviewBody({ targets, opts }: { targets: ProfileTargetPreview[]; opts: { remove: boolean } }) {
|
||||||
|
const [remove, setRemove] = useState(opts.remove)
|
||||||
|
const extras = targets.reduce((n, t) => n + (t.lists?.blocked.extra ?? 0) + (t.lists?.allowed.extra ?? 0), 0)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{targets.map((t) => (
|
||||||
|
<section key={t.server} aria-label={`Preview ${t.server}`} className="flex flex-col gap-3">
|
||||||
|
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
|
||||||
|
{t.server}
|
||||||
|
{!t.ok && <Tag tone="dan">Cannot apply</Tag>}
|
||||||
|
</h3>
|
||||||
|
{t.error !== undefined && <Alert type="danger" title="Error">{t.error}</Alert>}
|
||||||
|
{t.warnings.map((w) => (
|
||||||
|
<Alert key={w} type="warning" title="Warning">
|
||||||
|
{w}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
{t.ok && (
|
||||||
|
<ol className="m-0 flex list-none flex-col gap-3 p-0" aria-label={`Steps on ${t.server}`}>
|
||||||
|
{t.apps.length > 0 && (
|
||||||
|
<li className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">1. Apps</span>
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4">
|
||||||
|
{t.apps.map((a) => (
|
||||||
|
<li key={a.name} className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium">{a.name}</span>
|
||||||
|
<Tag tone={APP_STATE[a.state][0]}>{APP_STATE[a.state][1]}</Tag>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{a.state === 'version-differs' ? `${a.installedVersion} here, ${a.profileVersion} in the profile; it is not updated` : a.state === 'install' ? `Store offers ${a.storeVersion}` : `Version ${a.profileVersion}`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{t.configs.length > 0 && (
|
||||||
|
<li className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">2. App configs</span>
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-2 p-0 pl-4">
|
||||||
|
{t.configs.map((c) => (
|
||||||
|
<li key={c.name} className="flex flex-col gap-1 text-xs">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
<Tag tone={CONFIG_STATE[c.state][0]}>{CONFIG_STATE[c.state][1]}</Tag>
|
||||||
|
{c.error !== undefined && <span className="text-muted-foreground">{c.error}</span>}
|
||||||
|
</span>
|
||||||
|
{c.changes !== undefined && <DiffView diff={c.changes} from="Now" to="After apply" />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{t.settings !== null && (
|
||||||
|
<li className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
3. Settings
|
||||||
|
{t.settings.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.settings.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">{`${t.settings.fields.length} field(s) will change`}</Tag>}
|
||||||
|
</span>
|
||||||
|
{t.settings.error !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.settings.error}</span>}
|
||||||
|
{t.settings.secretFields.length > 0 && <span className="pl-4 text-xs text-muted-foreground">{t.settings.secretFields.join(', ')}: the secret values are sent but never shown.</span>}
|
||||||
|
{t.settings.error === undefined && !t.settings.unchanged && (
|
||||||
|
<div className="pl-4">
|
||||||
|
<DiffView diff={t.settings.changes} from="Now" to="After apply" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{t.lists !== null && (
|
||||||
|
<li className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
4. Blocked and Allowed lists
|
||||||
|
{t.lists.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.lists.status === 'same' ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
|
||||||
|
</span>
|
||||||
|
{t.lists.error !== undefined ? (
|
||||||
|
<span className="pl-4 text-xs text-muted-foreground">{t.lists.error}</span>
|
||||||
|
) : (
|
||||||
|
<span className="pl-4 text-xs text-muted-foreground">
|
||||||
|
Blocked: {t.lists.blocked.missing} to add, {t.lists.blocked.extra} extra. Allowed: {t.lists.allowed.missing} to add, {t.lists.allowed.extra} extra.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.lists.tooMany !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.lists.tooMany}</span>}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{/* Only when a server has list domains the profile does not: otherwise there is nothing to remove. */}
|
||||||
|
{extras > 0 && (
|
||||||
|
<Check
|
||||||
|
label="Also remove extras"
|
||||||
|
help={`Deletes the ${extras} list domain(s) a server has that the profile does not. The server's lists are saved to the hub's history first.`}
|
||||||
|
checked={remove}
|
||||||
|
onChange={(on) => {
|
||||||
|
opts.remove = on
|
||||||
|
setRemove(on)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The picker, the review and the results of applying one profile. */
|
||||||
|
function applyRequestFor(token: string | null, name: string, servers: { id: string; writable: boolean }[]): RunRequest {
|
||||||
|
const opts = { remove: false }
|
||||||
|
// Where each server should start next time: the step that failed on it.
|
||||||
|
const from: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
title: `Apply the profile ${name}`,
|
||||||
|
intro: 'Each server goes through the steps in order: install missing apps from its own store, apply the app configs, the settings, then the lists. A step that fails stops the later ones for that server.',
|
||||||
|
confirmLabel: 'Preview',
|
||||||
|
servers: pickerServers(servers),
|
||||||
|
preselected: [],
|
||||||
|
review: async (targets) => {
|
||||||
|
opts.remove = false
|
||||||
|
const outcome = await previewProfile(token, name, targets, false)
|
||||||
|
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
|
||||||
|
return { kind: 'ok', confirmLabel: 'Apply', context: undefined, body: <PreviewBody targets={outcome.data.response.targets} opts={opts} /> }
|
||||||
|
},
|
||||||
|
run: async (targets): Promise<RunOutcome> => {
|
||||||
|
const outcome = await applyProfile(token, name, targets, opts.remove, { ...from })
|
||||||
|
if (outcome.kind === 'ok') {
|
||||||
|
for (const r of outcome.data.response.results) {
|
||||||
|
const failed = r.steps?.find((s) => s.status === 'failed')
|
||||||
|
if (r.ok || failed === undefined) delete from[r.server]
|
||||||
|
else from[r.server] = failed.step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Make one ---- */
|
||||||
|
|
||||||
|
function CreateProfile({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [apps, setApps] = useState(true)
|
||||||
|
const [settings, setSettings] = useState(true)
|
||||||
|
const [lists, setLists] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [problem, setProblem] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
setBusy(true)
|
||||||
|
setProblem(null)
|
||||||
|
const outcome = await snapshotProfile(token, name.trim(), { apps: apps ? undefined : [], settings, lists })
|
||||||
|
setBusy(false)
|
||||||
|
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose()
|
||||||
|
}}
|
||||||
|
title="Create a profile"
|
||||||
|
close="Cancel"
|
||||||
|
actions={
|
||||||
|
<Button variant="primary" disabled={busy || name.trim() === '' || (!apps && !settings && !lists)} onClick={() => void create()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="m-0 text-sm text-muted-foreground">
|
||||||
|
A profile is a copy of the hub's baselines as they are now. An existing profile with the same name is replaced.
|
||||||
|
</p>
|
||||||
|
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||||
|
<LabeledInput label="Profile name" value={name} maxLength={60} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<div className="flex flex-col gap-3" role="group" aria-label="Parts">
|
||||||
|
<Check label="Apps" help="Every app that has a baseline, with its store version and its baseline config. Store apps only." checked={apps} onChange={setApps} />
|
||||||
|
<Check label="Settings" help="The shared fields of the settings baseline. Host fields stay out." checked={settings} onChange={setSettings} />
|
||||||
|
<Check label="Blocked and Allowed lists" help="The lists baseline." checked={lists} onChange={setLists} />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- View one ---- */
|
||||||
|
|
||||||
|
function ViewProfile({ token, name, onClose }: { token: string | null; name: string; onClose: () => void }) {
|
||||||
|
const [view, setView] = useState<ProfileView | null>(null)
|
||||||
|
const [failure, setFailure] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const outcome = await getProfile(token, name)
|
||||||
|
if (outcome.kind === 'ok') {
|
||||||
|
setView(outcome.data.response)
|
||||||
|
setFailure(null)
|
||||||
|
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
}, [token, name])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose()
|
||||||
|
}}
|
||||||
|
size="wide"
|
||||||
|
title={`Profile: ${name}`}
|
||||||
|
>
|
||||||
|
{failure !== null ? (
|
||||||
|
<Failure>
|
||||||
|
<span className="flex flex-col items-center gap-3">
|
||||||
|
<span>Could not read the profile. {failure}</span>
|
||||||
|
<Button variant="primary" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Failure>
|
||||||
|
) : view === null ? (
|
||||||
|
<Skeleton className="h-24 w-full" role="status" aria-label="Loading the profile" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="m-0 text-xs text-muted-foreground">
|
||||||
|
Made by {view.updatedBy} at {when(view.updatedAt)}. Secrets show as {'<secret>'}; an app's config text is not shown.
|
||||||
|
</p>
|
||||||
|
<section aria-label="Apps" className="flex flex-col gap-2">
|
||||||
|
<h3 className="m-0 text-sm font-semibold">Apps</h3>
|
||||||
|
{view.apps.length === 0 ? (
|
||||||
|
<Empty compact>No apps.</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
{view.apps.map((a) => (
|
||||||
|
<li key={a.name} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">{a.name}</span>
|
||||||
|
<span className="text-muted-foreground">{a.version}</span>
|
||||||
|
<Tag tone={a.hasConfig ? 'info' : 'neutral'}>{a.hasConfig ? 'With config' : 'No config'}</Tag>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section aria-label="Settings" className="flex flex-col gap-2">
|
||||||
|
<h3 className="m-0 text-sm font-semibold">Settings</h3>
|
||||||
|
{view.settings === null ? <Empty compact>Not part of this profile.</Empty> : <LabeledTextarea label="Shared settings (JSON)" mono rows={10} readOnly spellCheck={false} value={JSON.stringify(view.settings, null, 2)} onChange={() => {}} />}
|
||||||
|
</section>
|
||||||
|
<section aria-label="Lists" className="flex flex-col gap-2">
|
||||||
|
<h3 className="m-0 text-sm font-semibold">Blocked and Allowed lists</h3>
|
||||||
|
{view.lists === null ? <Empty compact>Not part of this profile.</Empty> : <p className="m-0 text-sm">{view.lists.blocked} blocked and {view.lists.allowed} allowed domains.</p>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Import ---- */
|
||||||
|
|
||||||
|
function ImportProfiles({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
|
||||||
|
const [text, setText] = useState<string | null>(null)
|
||||||
|
const [plan, setPlan] = useState<ProfileImportPlan | null>(null)
|
||||||
|
const [problem, setProblem] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const file = fileRef.current?.files?.[0]
|
||||||
|
if (!file) return setProblem('Please select a profiles file.')
|
||||||
|
setBusy(true)
|
||||||
|
setProblem(null)
|
||||||
|
const content = await file.text()
|
||||||
|
const outcome = await importProfiles(token, content, false)
|
||||||
|
setBusy(false)
|
||||||
|
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
setText(content)
|
||||||
|
setPlan(outcome.data.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (text === null) return
|
||||||
|
setBusy(true)
|
||||||
|
const outcome = await importProfiles(token, text, true)
|
||||||
|
setBusy(false)
|
||||||
|
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
setPlan(outcome.data.response)
|
||||||
|
setDone(true)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = plan?.profiles.filter((p) => p.status !== 'same').length ?? 0
|
||||||
|
const TONE = { new: 'info', changed: 'warn', same: 'ok' } as const
|
||||||
|
const LABEL = { new: 'New', changed: 'Changed', same: 'Same' } as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose()
|
||||||
|
}}
|
||||||
|
title="Import profiles"
|
||||||
|
actions={
|
||||||
|
plan === null ? (
|
||||||
|
<Button variant="primary" disabled={busy} onClick={() => void check()}>
|
||||||
|
Check file
|
||||||
|
</Button>
|
||||||
|
) : !done ? (
|
||||||
|
<Button variant="primary" disabled={busy || changes === 0} onClick={() => void commit()}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||||
|
{plan === null ? (
|
||||||
|
<>
|
||||||
|
<p className="m-0 text-sm text-muted-foreground">Choose a file made by Export. It is checked first; nothing is changed until you press Import, and no server is touched.</p>
|
||||||
|
<LabeledInput label="Profiles file" type="file" accept="application/json,.json" ref={fileRef} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{done && <Alert type="success" title="Imported.">The profiles were saved.</Alert>}
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Profiles in the file">
|
||||||
|
{plan.profiles.map((p) => (
|
||||||
|
<li key={p.name} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">{p.name}</span>
|
||||||
|
<Tag tone={TONE[p.status]}>{LABEL[p.status]}</Tag>
|
||||||
|
<span className="text-xs text-muted-foreground">{p.apps} app(s){p.settings ? ', settings' : ''}{p.lists ? ', lists' : ''}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- The list ---- */
|
||||||
|
|
||||||
|
export function Profiles({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
token,
|
||||||
|
servers,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
token: string | null
|
||||||
|
/** The servers of the hub, and whether it holds an admin token for each. */
|
||||||
|
servers: { id: string; writable: boolean }[]
|
||||||
|
}) {
|
||||||
|
const [profiles, setProfiles] = useState<ProfileSummary[] | null>(null)
|
||||||
|
const [failure, setFailure] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [viewing, setViewing] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null)
|
||||||
|
const [request, setRequest] = useState<RunRequest | null>(null)
|
||||||
|
const [problem, setProblem] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const outcome = await listProfiles(token)
|
||||||
|
if (outcome.kind === 'ok') {
|
||||||
|
setProfiles(outcome.data.response.profiles)
|
||||||
|
setFailure(null)
|
||||||
|
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void load()
|
||||||
|
}, [open, load])
|
||||||
|
|
||||||
|
async function exportFile(name: string) {
|
||||||
|
setProblem(null)
|
||||||
|
const outcome = await exportProfiles(token, name)
|
||||||
|
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
const url = URL.createObjectURL(new Blob([outcome.data.text], { type: 'application/json' }))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = outcome.data.filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(name: string) {
|
||||||
|
const outcome = await deleteProfile(token, name)
|
||||||
|
if (outcome.kind !== 'ok') setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = (
|
||||||
|
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||||
|
Create from current baselines
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
size="wide"
|
||||||
|
title="Profiles"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setImporting(true)}>Import profiles</Button>
|
||||||
|
{profiles !== null && profiles.length > 0 && create}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="m-0 text-sm text-muted-foreground">
|
||||||
|
A profile brings a new or reset server in line: it installs the apps from that server's own store and applies their configs, the shared settings and the Blocked and Allowed lists.
|
||||||
|
</p>
|
||||||
|
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
|
||||||
|
{failure !== null && profiles === null ? (
|
||||||
|
<Failure>
|
||||||
|
<span className="flex flex-col items-center gap-3">
|
||||||
|
<span>Could not read the profiles. {failure}</span>
|
||||||
|
<Button variant="primary" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Failure>
|
||||||
|
) : profiles === null ? (
|
||||||
|
<div className="flex flex-col gap-3" role="status" aria-label="Loading the profiles">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
) : profiles.length === 0 ? (
|
||||||
|
<Empty title="No profiles yet" actions={create}>
|
||||||
|
Make one from the baselines the hub holds now, or import a file from another hub.
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Profiles">
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<li key={p.name} aria-label={p.name} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
|
||||||
|
<span className="font-medium">{p.name}</span>
|
||||||
|
<Tag tone="neutral">{`${p.apps} app${p.apps === 1 ? '' : 's'}`}</Tag>
|
||||||
|
{p.configs > 0 && <Tag tone="neutral">{`${p.configs} config${p.configs === 1 ? '' : 's'}`}</Tag>}
|
||||||
|
{p.settings !== null && <Tag tone="neutral">{`${p.settings} settings`}</Tag>}
|
||||||
|
{p.lists !== null && <Tag tone="neutral">{`${p.lists.blocked + p.lists.allowed} list domains`}</Tag>}
|
||||||
|
<span className="text-xs text-muted-foreground">{p.updatedBy} · {when(p.updatedAt)}</span>
|
||||||
|
<span className="ml-auto flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" onClick={() => setViewing(p.name)}>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setRequest(applyRequestFor(token, p.name, servers))}>
|
||||||
|
Apply to servers…
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void exportFile(p.name)}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => setDeleting(p.name)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreateProfile
|
||||||
|
token={token}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setCreating(false)
|
||||||
|
void load()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{importing && (
|
||||||
|
<ImportProfiles
|
||||||
|
token={token}
|
||||||
|
onClose={() => setImporting(false)}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{viewing !== null && <ViewProfile token={token} name={viewing} onClose={() => setViewing(null)} />}
|
||||||
|
<Confirm
|
||||||
|
open={deleting !== null}
|
||||||
|
title="Delete profile"
|
||||||
|
text={`Delete the profile ${deleting ?? ''}? Servers that already had it applied keep what they got.`}
|
||||||
|
label="Delete"
|
||||||
|
onClose={() => setDeleting(null)}
|
||||||
|
onConfirm={() => deleting !== null && remove(deleting)}
|
||||||
|
/>
|
||||||
|
{request !== null && <RunFlow request={request} onClose={() => setRequest(null)} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,7 +45,11 @@ export function pickerServers(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stage = 'pick' | 'reviewing' | 'review' | 'running' | 'results'
|
/* The steps of a profile apply, drawn under the server they ran on. */
|
||||||
|
const STEP_LABEL = { apps: 'Apps', configs: 'App configs', settings: 'Settings', lists: 'Lists' } as const
|
||||||
|
const STEP_STATUS = { ok: 'Done', failed: 'Failed', skipped: 'Done earlier', 'not-run': 'Not run' } as const
|
||||||
|
|
||||||
|
type Stage ='pick' | 'reviewing' | 'review' | 'running' | 'results'
|
||||||
|
|
||||||
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
|
export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (changed: boolean) => void }) {
|
||||||
const [stage, setStage] = useState<Stage>('pick')
|
const [stage, setStage] = useState<Stage>('pick')
|
||||||
@@ -185,10 +189,23 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
|
|||||||
) : (
|
) : (
|
||||||
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Results">
|
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Results">
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
<li key={r.server} className="flex flex-wrap items-center gap-2 text-sm">
|
<li key={r.server} className="flex flex-col gap-2 text-sm">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-medium">{r.server}</span>
|
<span className="font-medium">{r.server}</span>
|
||||||
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
|
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
|
||||||
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
|
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
|
||||||
|
</span>
|
||||||
|
{r.steps !== undefined && r.steps.length > 0 && (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4" aria-label={`Steps on ${r.server}`}>
|
||||||
|
{r.steps.map((s) => (
|
||||||
|
<li key={s.step} className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium">{STEP_LABEL[s.step]}</span>
|
||||||
|
<Tag tone={s.status === 'ok' ? 'ok' : s.status === 'failed' ? 'dan' : 'neutral'}>{STEP_STATUS[s.status]}</Tag>
|
||||||
|
<span className="text-muted-foreground">{s.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function StatusTag({ status, differing }: { status: string; differing: number })
|
|||||||
|
|
||||||
/* ---- Requests for the picker, the review and the results ---- */
|
/* ---- Requests for the picker, the review and the results ---- */
|
||||||
|
|
||||||
function settingsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[], scope: Scope): RunRequest {
|
export function settingsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[], scope: Scope): RunRequest {
|
||||||
const group = scope === 'blocking' ? 'blocking' : undefined
|
const group = scope === 'blocking' ? 'blocking' : undefined
|
||||||
return {
|
return {
|
||||||
title: scope === 'blocking' ? 'Apply the blocking settings' : 'Apply the settings baseline',
|
title: scope === 'blocking' ? 'Apply the blocking settings' : 'Apply the settings baseline',
|
||||||
@@ -145,7 +145,7 @@ function ListsReview({ targets, opts }: { targets: ListsServer[]; opts: { remove
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function listsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[]): RunRequest {
|
export function listsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[]): RunRequest {
|
||||||
const opts = { remove: false }
|
const opts = { remove: false }
|
||||||
return {
|
return {
|
||||||
title: 'Apply the Blocked and Allowed lists',
|
title: 'Apply the Blocked and Allowed lists',
|
||||||
|
|||||||
Reference in new issue
Block a user