From 9742f0fe694d7dc4241cf691d7df05a55022833f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 1 Nov 2026 02:11:19 +0700 Subject: [PATCH] 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 --- docs/hub-deploy.md | 35 ++ docs/hub-management-plan.md | 2 +- hub/README.md | 94 +++- hub/apps-config.ts | 9 +- hub/apps-history.test.ts | 2 +- hub/audit.ts | 11 + hub/config.ts | 17 + hub/drift.test.ts | 171 ++++++- hub/drift.ts | 133 ++++-- hub/profiles.test.ts | 372 +++++++++++++++ hub/profiles.ts | 510 +++++++++++++++++++++ hub/routes.ts | 12 + hub/server.ts | 103 ++++- hub/settings-config.ts | 59 ++- hub/webhook.test.ts | 230 ++++++++++ hub/webhook.ts | 141 ++++++ src/api/apps.ts | 21 +- src/api/hub-drift.ts | 2 +- src/api/hub-profiles.ts | 126 ++++++ src/app/DriftStatus.kinds.test.tsx | 167 +++++++ src/app/DriftStatus.tsx | 101 ++++- src/app/Shell.hub.test.tsx | 2 +- src/app/Shell.tsx | 7 +- src/app/WebhookStatus.tsx | 78 ++++ src/screens/about/About.tsx | 9 +- src/screens/apps/Apps.tsx | 7 + src/screens/apps/AppsHistory.test.tsx | 6 +- src/screens/apps/AppsMatrix.tsx | 2 +- src/screens/apps/Profiles.test.tsx | 223 +++++++++ src/screens/apps/Profiles.tsx | 546 +++++++++++++++++++++++ src/screens/apps/RunFlow.tsx | 27 +- src/screens/settings/SettingsCompare.tsx | 4 +- 32 files changed, 3123 insertions(+), 106 deletions(-) create mode 100644 hub/profiles.test.ts create mode 100644 hub/profiles.ts create mode 100644 hub/webhook.test.ts create mode 100644 hub/webhook.ts create mode 100644 src/api/hub-profiles.ts create mode 100644 src/app/DriftStatus.kinds.test.tsx create mode 100644 src/app/WebhookStatus.tsx create mode 100644 src/screens/apps/Profiles.test.tsx create mode 100644 src/screens/apps/Profiles.tsx diff --git a/docs/hub-deploy.md b/docs/hub-deploy.md index 9a650e8..a537a9d 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -312,6 +312,8 @@ docker compose rm -f technitium-hub # удалить контейнер | `HUB_CACHE_SECONDS` | `15` | Сколько секунд хранить ответы серверов (0 выключает кеш) | | `HUB_TIMEOUT_MS` | `8000` | Сколько ждать ответа сервера, мс | | `HUB_PORT` | `8080` | Порт внутри контейнера | +| `HUB_WEBHOOK_URL` | нет | Адрес http или https, куда хаб шлёт уведомления (см. ниже). Адрес может содержать токен, храните его как секрет | +| `HUB_WEBHOOK_SECRET` | нет | Подписывает уведомления (`X-Hub-Signature`). В логи не пишется | Третий сервер добавляется строкой в `HUB_SERVERS` и его токеном: ```yaml @@ -361,8 +363,41 @@ docker compose rm -f technitium-hub # удалить контейнер - **История.** Вкладка 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`. +- **Что проверяется.** Конфиги приложений, общие поля настроек (против эталона настроек) и списки Blocked/Allowed (против эталона списков). Проверка настроек и списков идёт только если соответствующий эталон принят. Расхождение настроек или списков показывает только названия полей, без значений. В списке у него **Open compare** (окно сравнения Settings или Blocking), **Accept as baseline** и **Revert to baseline** (через тот же предпросмотр); эти действия только для роли `admin`. - **Перенос эталонов.** 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=` содержит 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**: состав профиля; секреты показаны как ``, тексты конфигов не показываются; +- **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**: diff --git a/docs/hub-management-plan.md b/docs/hub-management-plan.md index 528ffdf..a1cc127 100644 --- a/docs/hub-management-plan.md +++ b/docs/hub-management-plan.md @@ -4,7 +4,7 @@ - **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание; - **упор на 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`). --- diff --git a/hub/README.md b/hub/README.md index bb66e1e..5f0bc30 100644 --- a/hub/README.md +++ b/hub/README.md @@ -23,6 +23,8 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants them. | `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. | | `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. | | `HUB_DRIFT_MINUTES` | `15` | How often the hub checks that every server still has the config it should (see Drift). `0` turns the check off. | +| `HUB_WEBHOOK_URL` | none | An http or https address that gets a signed JSON notice on a change of drift and on a failed write (see Webhook notices). Treat it like a secret: it may carry a token. | +| `HUB_WEBHOOK_SECRET` | none | Signs each notice: `X-Hub-Signature: sha256=`. 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. | | `_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. | | `HUB_PORT` | `8080` | Listening port. | @@ -214,7 +216,7 @@ baseline and the history (mode 0600, protect the data folder like the users file any role; the endpoints are `admin` only anyway. A changed secret is shown as "secret changed". The audit log has field names, counts and outcomes, never values. Applying a baseline with TSIG keys does send the secrets to the servers (that is the point), over the connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overrides}.json`, `settings/history/` (50 files), -`${HUB_DATA}/blocking/baseline.json`. Settings are not part of the drift check yet (a note in `drift.ts`). +`${HUB_DATA}/blocking/baseline.json`. Settings and the lists are part of the drift check (see Drift). ## History, rollback, drift, export @@ -232,12 +234,13 @@ connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overr **Drift.** Every `HUB_DRIFT_MINUTES` (and once shortly after start) the hub reads each server's config of every app that has a baseline, with the read token, and compares it by meaning with the server's effective config (baseline plus its local values). The -result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ app, server, since, kind }] }`, where `kind` is -`differs`, `unreadable` (the server or the read failed; listed, not an alarm) or `not-installed`, and `since` is the first check that -saw it. Two checks never run at once. +result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ kind, name?, server, since, status, fields? }] }`, where +`kind` is `app`, `settings` or `lists` (stage 8), `status` is `differs`, `unreadable` (the server or the read failed; listed, not an +alarm) or `not-installed`, and `since` is the first check that saw it. Two checks never run at once. See "Webhook notices and +profiles" for the settings and lists kinds. - `GET hub/drift/get` (read): the state. `POST hub/drift/check` (`apps.write`): check now. -- `user/session/get` carries `info.hubDrift`, the number of `differs` entries; the console shows it in the header. +- `user/session/get` carries `info.hubDrift`, the number of `differs` entries of every kind; the console shows it in the header. - `POST hub/apps/drift/accept {name, server}` (`apps.write`): "Accept as baseline". The server's config becomes the baseline; if the app has local fields, they keep the baseline's old values and the server's own values go to the overrides instead. - "Revert to baseline" is the normal apply on that server. @@ -256,6 +259,87 @@ Before an uninstall the app's config is saved to `${HUB_DATA}/apps//history audit log as `apps.`; the history files hold configs, so protect the data folder like the users file. After any write the hub forgets its cached answers of that server. +## Webhook notices and profiles (stage 8) + +**Webhook.** With `HUB_WEBHOOK_URL` (http or https) the hub POSTs a small JSON body when something changes. `HUB_WEBHOOK_SECRET` +is optional and signs the body. Events: + +| Event | When | +|---|---| +| `drift.found` | A server/kind/name newly differs from its baseline. Sent once, not at every check. | +| `drift.cleared` | An entry that differed is back in line. An entry that became unreadable is not cleared: it is not known. | +| `write.failed` | An audited write ended `failed` (not `refused`, not a failed login). Failures of the same moment are one notice. | +| `webhook.test` | The admin pressed "Send test" (`POST hub/webhook/test`). | + +```json +{ + "event": "drift.found", + "at": "2026-11-01T09:15:00.000Z", + "hub": "hub.example.net", + "items": [ + { "kind": "settings", "server": "ndns", "since": "2026-11-01T09:15:00.000Z", "fields": ["blockingType", "defaultRecordTtl"] }, + { "kind": "app", "name": "Advanced Blocking", "server": "ndns", "since": "2026-11-01T09:15:00.000Z" } + ] +} +``` + +Items carry names and field names, never a value. A `write.failed` item has `action`, `server`, `target`, `user`, `at` and the +audited `detail`. The first state is the one in `drift.json`, so a restart does not repeat notices. + +With a secret, the header `X-Hub-Signature: sha256=` 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/.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 `` 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 `` 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 `{ "": "" }`: the step to start at, so "Retry failed" does +not repeat what worked. The audit has one `profiles.apply` line per server and step (names, counts and the outcome, no values). + ## Run locally ```sh diff --git a/hub/apps-config.ts b/hub/apps-config.ts index 844801e..4c72f74 100644 --- a/hub/apps-config.ts +++ b/hub/apps-config.ts @@ -286,9 +286,9 @@ export class AppsConfig { 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 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.') const overrides = this.getOverrides(name) const live = await this.#liveAll(name, true) @@ -368,8 +368,9 @@ export class AppsConfig { return { results: done.map((d) => d.result), details } } - async apply(name: string, ids: string[], expect: Record): Promise<{ results: TargetResult[]; details: Record }> { - const baseline = this.getBaseline(name) + /* `overrideText` is a config that is not the hub's baseline (a profile's). The server's local values still apply. */ + async apply(name: string, ids: string[], expect: Record, overrideText?: string): Promise<{ results: TargetResult[]; details: Record }> { + 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.') const overrides = this.getOverrides(name) return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect) diff --git a/hub/apps-history.test.ts b/hub/apps-history.test.ts index bbc7d15..2e87b76 100644 --- a/hub/apps-history.test.ts +++ b/hub/apps-history.test.ts @@ -309,7 +309,7 @@ describe('drift over the API', () => { expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403) 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) const got = await api('viewer', 'hub/drift/get') expect(got.body.response.apps).toHaveLength(1) diff --git a/hub/audit.ts b/hub/audit.ts index 97312eb..5af84ce 100644 --- a/hub/audit.ts +++ b/hub/audit.ts @@ -35,6 +35,7 @@ export class Audit { #ring: AuditEntry[] = [] #size = 0 #memoryOnly = false + #listener: ((entry: AuditEntry) => void) | null = null constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) { this.#maxBytes = options.maxBytes ?? MAX_BYTES @@ -62,6 +63,11 @@ export class Audit { 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 { const entry: AuditEntry = { ts: new Date(this.#now()).toISOString(), @@ -75,6 +81,11 @@ export class Audit { ...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }), } 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.#memoryOnly) return const line = JSON.stringify(entry) + '\n' diff --git a/hub/config.ts b/hub/config.ts index 4283d0e..5112ca5 100644 --- a/hub/config.ts +++ b/hub/config.ts @@ -32,6 +32,10 @@ export interface HubConfig { /** How often the drift check runs (HUB_DRIFT_MINUTES); 0 turns it off. */ driftMinutes: 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. */ domain: string /** 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.') } + 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 { servers, bootstrap: user === '' ? null : { user, password }, @@ -94,6 +109,8 @@ export function parseConfig(env: Env): HubConfig { cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0), driftMinutes: number(env, 'HUB_DRIFT_MINUTES', 15, 0), 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', staticDir: env.HUB_STATIC?.trim() || './dist', } diff --git a/hub/drift.test.ts b/hub/drift.test.ts index 92c9584..63082e6 100644 --- a/hub/drift.test.ts +++ b/hub/drift.test.ts @@ -1,11 +1,12 @@ // @vitest-environment node -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseConfig } from './config.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' const json = (o: unknown): string => JSON.stringify(o, null, 2) @@ -14,6 +15,9 @@ interface FakeServer { apps: Record down: boolean denyConfig: boolean + settings: Record + blocked: string[] + allowed: 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.' }) 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({}) }) as typeof fetch -function make() { +function make(webhook?: Webhook) { const config = parseConfig({ HUB_SERVERS: 'a=http://127.0.0.1:1,b=http://127.0.0.1:2', A_TOKEN: 'ra', @@ -49,7 +57,7 @@ function make() { HUB_DATA: dir, 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 } } @@ -58,7 +66,8 @@ const baseline = (app: string, text: string) => beforeEach(() => { 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 = [] gate = null clock = Date.parse('2026-10-31T10:00:00Z') @@ -83,7 +92,7 @@ describe('drift check', () => { servers.b.apps.Blocker = json({ ttl: 99 }) const { drift } = make() 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) }) @@ -103,7 +112,7 @@ describe('drift check', () => { expect((await drift.check()).apps).toEqual([]) // A wrong local value is drift. 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 () => { @@ -112,7 +121,7 @@ describe('drift check', () => { servers.b.down = true const { drift } = make() 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) }) @@ -122,7 +131,7 @@ describe('drift check', () => { servers.a.denyConfig = true const { drift } = make() 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 () => { @@ -147,7 +156,7 @@ describe('drift check', () => { 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 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) @@ -156,7 +165,7 @@ describe('drift check', () => { servers.b.down = true clock += 60_000 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 () => { @@ -187,10 +196,148 @@ describe('drift check', () => { const file = JSON.parse(readFileSync(path.join(dir, 'drift.json'), 'utf8')) expect(file).toEqual({ 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 expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false }) expect(again.count()).toBe(1) }) }) + +const baselineSettings = (settings: Record) => 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[] }[] = [] + 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([]) + }) +}) diff --git a/hub/drift.ts b/hub/drift.ts index 603f569..4ccb7e3 100644 --- a/hub/drift.ts +++ b/hub/drift.ts @@ -1,54 +1,96 @@ /* -Drift: a server whose config is no longer what the hub says it should be (the baseline plus the -server's own local values), usually because someone changed it in that server's own console. +Drift: a server whose config is no longer what the hub says it should be, usually because someone +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 -compares it with the effective config as the sync does. The result is kept 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. Two checks never run at once. The timer lives in server.ts, so the -tests drive `check` themselves. + app an app's config is not the baseline plus the server's own local values + settings the shared settings fields differ from the settings baseline + lists the custom Blocked or Allowed list differs from the lists baseline + +`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 path from 'node:path' import type { HubConfig } from './config.ts' import { effectiveConfig, sameConfig } from './apps-sync.ts' import { readConfigText } from './apps-config.ts' +import { SettingsConfig } from './settings-config.ts' import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts' import type { Upstream } from './upstream.ts' +import type { Webhook } from './webhook.ts' -/* -Not yet (stage 7): settings are not part of this check yet. A `settings` kind would need `DriftEntry.app` -(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 type DriftStatus = 'differs' | 'unreadable' | 'not-installed' +export type DriftType = 'app' | 'settings' | 'lists' 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 since: string - kind: DriftKind + status: DriftStatus + /** Setting names, or `blocked` / `allowed`. Never values. */ + fields?: string[] } export interface DriftState { checkedAt: string | null + /** Every entry, whatever its kind (the key is kept from stage 4). */ 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): 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 + 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 { #config: HubConfig #upstream: Upstream #now: () => number + #settings: SettingsConfig + #webhook: Webhook | null #state: DriftState #running: Promise | 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.#upstream = upstream this.#now = now + this.#settings = new SettingsConfig(config, upstream, now) + this.#webhook = options.webhook ?? null this.#state = this.#load() } @@ -60,9 +102,7 @@ export class Drift { try { if (!existsSync(this.#file())) return { checkedAt: null, apps: [] } const data = JSON.parse(readFileSync(this.#file(), 'utf8')) as Partial - const apps = (Array.isArray(data.apps) ? data.apps : []).filter( - (e): e is DriftEntry => typeof e?.app === 'string' && typeof e.server === 'string' && typeof e.since === 'string' && KINDS.includes(e.kind), - ) + const apps = (Array.isArray(data.apps) ? data.apps : []).map(readEntry).filter((e): e is DriftEntry => e !== null) return { checkedAt: typeof data.checkedAt === 'string' ? data.checkedAt : null, apps } } catch { return { checkedAt: null, apps: [] } @@ -73,9 +113,9 @@ export class Drift { 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 { - 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. */ @@ -94,9 +134,9 @@ export class Drift { baseline: readBaseline(this.#config.dataDir, app), overrides: readOverrides(this.#config.dataDir, app), })) - const found: { app: string; server: string; kind: DriftKind }[] = [] + const found: Omit[] = [] - await Promise.all( + const appChecks = Promise.all( this.#config.servers.map(async (server) => { if (apps.length === 0) return 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 installed = new Set((list?.apps ?? []).map((a) => a.name)) } 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 } await Promise.all( apps.map(async (a) => { 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 { const text = (await readConfigText(this.#upstream, server, a.app)) ?? '' 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 { - 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 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 - .map((f): DriftEntry => ({ ...f, since: before.get(`${f.app}\n${f.server}\n${f.kind}`) ?? at })) - .sort((x, y) => x.app.localeCompare(y.app) || x.server.localeCompare(y.server)) + .map((f): DriftEntry => ({ ...f, since: before.get(`${keyOf(f)}\n${f.status}`) ?? at })) + .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 } try { writeAtomic(this.#file(), JSON.stringify(this.#state, null, 2) + '\n') } catch (e) { console.error(`hub: drift.json could not be written (${e instanceof Error ? e.message : String(e)}).`) } + this.#notify(previous, entries) 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 => ({ + 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)) + } } diff --git a/hub/profiles.test.ts b/hub/profiles.test.ts new file mode 100644 index 0000000..0606f50 --- /dev/null +++ b/hub/profiles.test.ts @@ -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 + configs: Record + store: { name: string; version: string }[] + settings: Record + blocked: string[] + allowed: string[] + failSettings: boolean +} + +const boxes = new Map() +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 = {}): Record => ({ + 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 = {}) => + 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('') + 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: '' }] }, 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(//) + 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) => [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) => [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) => [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([]) + }) +}) diff --git a/hub/profiles.ts b/hub/profiles.ts new file mode 100644 index 0000000..255fa66 --- /dev/null +++ b/hub/profiles.ts @@ -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 | 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 => 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() + 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> { + const versions = new Map() + 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> { + const names = new Set() + 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() + 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; store: Map }> { + 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 { + 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 }): 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 } + } +} diff --git a/hub/routes.ts b/hub/routes.ts index 8634dcd..2e7c101 100644 --- a/hub/routes.ts +++ b/hub/routes.ts @@ -150,6 +150,17 @@ export const ROUTES: Record = { 'hub/apps/history/rollback': writeAll('apps.write'), 'hub/apps/baseline/restore': 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/check': writeAll('apps.write'), // Configs can hold secrets, so the file of all baselines needs apps.write even to read. @@ -220,6 +231,7 @@ const LARGE_BODIES: Record = { 'dhcp/scopes/set': 2 * MB, 'hub/settings/baseline/save': 2 * MB, 'hub/settings/overrides/save': 2 * MB, + 'hub/profiles/import': 5 * MB, 'allowed/import': 5 * MB, 'blocked/import': 5 * MB, } diff --git a/hub/server.ts b/hub/server.ts index 4809498..866403d 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -32,6 +32,8 @@ import { AppsWriter, actionForPath, type FileAction, type TargetResult } from '. import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts' import { AppsConfig, UserError } from './apps-config.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 { BLOCKING_FIELDS } from './settings-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' } -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 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 limiter = new LoginLimiter() 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 runner = new ServersRunner(config, upstream) const appsConfig = new AppsConfig(config, upstream) const zonesSync = new ZonesSync(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])) /* 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 { + 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 { + 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 + 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. */ async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise { 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.')) 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') { 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` }) @@ -505,6 +592,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da await configWrite(res, apiPath, params, who, ip) return } + if (apiPath.startsWith('hub/profiles/')) { + await profilesWrite(res, apiPath, params, who, ip) + return + } if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) { await settingsWrite(res, apiPath, params, who, ip) return @@ -822,6 +913,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da await configRead(res, apiPath, params, who, ip) return } + if (apiPath.startsWith('hub/profiles/')) { + await profilesRead(res, apiPath, params, who, ip) + return + } if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) { await settingsRead(res, apiPath, params, who, ip) return @@ -830,6 +925,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da await zonesRead(res, apiPath, params) return } + if (apiPath === 'hub/webhook/status') { + send(res, 200, ok(webhook.status())) + return + } if (apiPath === 'hub/drift/get') { send(res, 200, ok(drift.get())) return diff --git a/hub/settings-config.ts b/hub/settings-config.ts index a1f89a5..e178a1c 100644 --- a/hub/settings-config.ts +++ b/hub/settings-config.ts @@ -254,9 +254,9 @@ export class SettingsConfig { 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 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.') const extra = this.#extraLocal() const base = normalize(baseline.settings) @@ -341,9 +341,10 @@ export class SettingsConfig { } } - async apply(ids: string[], expect: Record, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record }> { + /* `override` is a baseline that is not the hub's own (a profile's settings). */ + async apply(ids: string[], expect: Record, only?: readonly string[], override?: SettingsBaseline): Promise<{ results: TargetResult[]; details: Record }> { 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.') const extra = this.#extraLocal() 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 } } + /* 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) ---- */ historyList(): HistoryEntry[] { @@ -447,9 +490,9 @@ export class SettingsConfig { 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 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.') const out = await Promise.all( targets.map(async (s) => { @@ -530,9 +573,9 @@ export class SettingsConfig { } } - async listsApply(ids: string[], removeExtras: boolean): Promise<{ results: TargetResult[]; details: Record }> { + async listsApply(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ results: TargetResult[]; details: Record }> { 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.') const done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras))) const details: Record = {} diff --git a/hub/webhook.test.ts b/hub/webhook.test.ts new file mode 100644 index 0000000..40909af --- /dev/null +++ b/hub/webhook.test.ts @@ -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 + 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)['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((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([]) + }) +}) diff --git a/hub/webhook.ts b/hub/webhook.ts new file mode 100644 index 0000000..fd49fa3 --- /dev/null +++ b/hub/webhook.ts @@ -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=`. + +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>() + #queued: Record[] = [] + #timer: NodeJS.Timeout | null = null + + constructor(config: Pick, 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 { + if (this.#timer !== null) this.#flush() + while (this.#pending.size > 0) await Promise.allSettled([...this.#pending]) + } + + #body(event: WebhookEvent, items: Record[]): string { + return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items }) + } + + async #attempt(body: string): Promise { + const headers: Record = { '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[]): Promise { + 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[]): 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): 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) + } +} diff --git a/src/api/apps.ts b/src/api/apps.ts index 0de2c5a..a03a75e 100644 --- a/src/api/apps.ts +++ b/src/api/apps.ts @@ -236,10 +236,18 @@ server's own store; the client never sends one. */ 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 { server: string ok: boolean message?: string + /* Only a profile apply: what each step did on this server. */ + steps?: StepResult[] } export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }> @@ -384,13 +392,20 @@ export interface HistoryDiff { 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 { - app: string + kind: DriftKind + /* The app's name; absent for settings and lists. */ + name?: string + app?: string server: string since: string - kind: DriftKind + status: DriftStatus + /* Setting names, or blocked and allowed. Never values. */ + fields?: string[] } export interface DriftState { diff --git a/src/api/hub-drift.ts b/src/api/hub-drift.ts index 07191a2..08987d0 100644 --- a/src/api/hub-drift.ts +++ b/src/api/hub-drift.ts @@ -29,7 +29,7 @@ export async function refreshHubDrift(token: string | null): Promise { const outcome = await getDrift(token) if (outcome.kind !== 'ok') return 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 { diff --git a/src/api/hub-profiles.ts b/src/api/hub-profiles.ts new file mode 100644 index 0000000..b082dd8 --- /dev/null +++ b/src/api/hub-profiles.ts @@ -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 +``, 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 | null + lists: { blocked: number; allowed: number } | null +} + +export const listProfiles = (token: string | null): Promise> => apiRequest('hub/profiles/list', { token }) + +export const getProfile = (token: string | null, name: string): Promise> => 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> => + 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> => + 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> => + 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> { + const headers: Record = {} + 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> => + 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): Promise => + 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> => apiRequest('hub/webhook/status', { token }) + +export const testWebhook = (token: string | null): Promise> => apiRequest('hub/webhook/test', { token, method: 'POST', body: {} }) diff --git a/src/app/DriftStatus.kinds.test.tsx b/src/app/DriftStatus.kinds.test.tsx new file mode 100644 index 0000000..9d8a09c --- /dev/null +++ b/src/app/DriftStatus.kinds.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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) + }) +}) diff --git a/src/app/DriftStatus.tsx b/src/app/DriftStatus.tsx index 2d95362..b0f250d 100644 --- a/src/app/DriftStatus.tsx +++ b/src/app/DriftStatus.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import { acceptDrift, checkDrift, type DriftEntry } from '../api/apps' import { refreshHubDrift, useHubDrift } from '../api/hub-drift' +import { importListsBaseline, importSettingsBaseline } from '../api/hub-settings' import { AppConfigSync } from '../screens/apps/AppConfigSync' import { applyRequest } from '../screens/apps/applyRequest' 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 { Button } from '../ui/Button' import { Confirm } from '../ui/Confirm' @@ -11,20 +13,27 @@ import { Dialog } from '../ui/Dialog' import { Empty } from '../ui/Empty' import { Tag } from '../ui/Tag' 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 -(someone changed it in that server's own console), and the list of them. Each can be looked at in -the app's config view; a writer can accept the server's config as the new baseline, or revert the -server to the baseline after a preview. +Behind a hub: a mark in the header when a server no longer has what the hub says it should (someone +changed it in that server's own console), and the list of them. Three kinds: an app's config, the +settings, and the Blocked and Allowed lists. Each can be looked at (the app's config view, or the +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 [open, setOpen] = useState(false) const [configApp, setConfigApp] = useState(null) + const [compare, setCompare] = useState(null) const [request, setRequest] = useState(null) const [accepting, setAccepting] = useState(null) const [problem, setProblem] = useState(null) @@ -32,13 +41,25 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul if (count === 0 && !open) return null + async function recheck() { + await checkDrift(token) + await refreshHubDrift(token) + } + async function accept(entry: DriftEntry) { setBusy(true) setProblem(null) - const outcome = await acceptDrift(token, entry.app, entry.server) - setBusy(false) + const outcome = + 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.') - 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() { @@ -51,11 +72,19 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul } function revert(entry: DriftEntry) { + const servers = pickerServers([{ id: entry.server, writable: writable.includes(entry.server) }]) 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 ( <> - {canWrite && e.kind === 'differs' && ( + {e.kind === 'app' ? ( + + ) : ( + isAdmin && ( + + ) + )} + {mayAct(e) && e.status === 'differs' && ( <> + + ) + } + if (status === null) return + + return ( +
+
+ Webhook + {status.configured ? Configured : Not configured} + {status.configured && {status.host}} + {status.configured && status.last === null && Nothing sent yet.} + {status.last !== null && ( + <> + {status.last.ok ? 'Last delivery ok' : `Last delivery failed${status.last.error === undefined ? '' : ` (${status.last.error})`}`} + {new Date(status.last.at).toLocaleString()} + + )} + {status.configured && ( + + )} +
+ {!status.configured &&

Set HUB_WEBHOOK_URL on the hub to get a notice when a server drifts or a write fails.

} + {failure !== null && ( +

+ {failure} +

+ )} +
+ ) +} diff --git a/src/screens/about/About.tsx b/src/screens/about/About.tsx index 3cfb617..e9279bd 100644 --- a/src/screens/about/About.tsx +++ b/src/screens/about/About.tsx @@ -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 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 ( <> @@ -166,6 +167,12 @@ export function About({ info }: { info?: Info }) { + {hubPanel != null && ( + + {hubPanel} + + )} + {/* 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. diff --git a/src/screens/apps/Apps.tsx b/src/screens/apps/Apps.tsx index 5fcd96d..f1daf0e 100644 --- a/src/screens/apps/Apps.tsx +++ b/src/screens/apps/Apps.tsx @@ -32,6 +32,7 @@ import { AppsMatrix } from './AppsMatrix' import { HubStore } from './HubStore' import { HubInstallFile } from './HubInstallFile' import { ImportBaselines } from './ImportBaselines' +import { Profiles } from './Profiles' import { RunFlow, type RunRequest } from './RunFlow' /* @@ -70,6 +71,7 @@ export function Apps({ clusterInitialised = false, canWrite = false, writable = [], + isAdmin = false, }: { token: string | null /** Only used behind a hub: its servers. */ @@ -79,6 +81,8 @@ export function Apps({ canWrite?: boolean /** Behind a hub: the servers the hub holds an admin token for. */ 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 @@ -102,6 +106,7 @@ export function Apps({ const [hubView, setHubView] = useState<'matrix' | 'store'>('matrix') const [fileDialog, setFileDialog] = useState(false) const [importDialog, setImportDialog] = useState(false) + const [profilesDialog, setProfilesDialog] = useState(false) const [fileRequest, setFileRequest] = useState(null) const [refresh, setRefresh] = useState(0) useEffect(() => { @@ -263,6 +268,7 @@ export function Apps({ + {isAdmin && } ) : undefined } @@ -272,6 +278,7 @@ export function Apps({ ) : ( )} + {isAdmin && } setRefresh((n) => n + 1)} /> {fileRequest !== null && ( diff --git a/src/screens/apps/AppsHistory.test.tsx b/src/screens/apps/AppsHistory.test.tsx index 0c10509..e28717b 100644 --- a/src/screens/apps/AppsHistory.test.tsx +++ b/src/screens/apps/AppsHistory.test.tsx @@ -125,8 +125,8 @@ describe('History tab', () => { describe('DriftStatus in the header', () => { const DRIFTS: DriftEntry[] = [ - { app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', kind: 'differs' }, - { app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', kind: 'unreadable' }, + { kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' }, + { kind: 'app', name: 'Blocker', app: 'Blocker', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' }, ] async function load(entries: DriftEntry[]) { @@ -200,7 +200,7 @@ describe('Drift badge in the matrix', () => { }, }, } 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() await screen.findByText('Blocker') await waitFor(() => expect(screen.getAllByText('Drift')).toHaveLength(1)) diff --git a/src/screens/apps/AppsMatrix.tsx b/src/screens/apps/AppsMatrix.tsx index 21af54b..93f5ed0 100644 --- a/src/screens/apps/AppsMatrix.tsx +++ b/src/screens/apps/AppsMatrix.tsx @@ -114,7 +114,7 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st void getDrift(token) .then((o) => { 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(() => {}) diff --git a/src/screens/apps/Profiles.test.tsx b/src/screens/apps/Profiles.test.tsx new file mode 100644 index 0000000..4c9bd21 --- /dev/null +++ b/src/screens/apps/Profiles.test.tsx @@ -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( {}} 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( {}} 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( {}} 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( {}} 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: '' }] }, + lists: { blocked: 3, allowed: 1 }, + }, + }, + } as never) + render( {}} 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('') + 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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() + }) +}) diff --git a/src/screens/apps/Profiles.tsx b/src/screens/apps/Profiles.tsx new file mode 100644 index 0000000..06847da --- /dev/null +++ b/src/screens/apps/Profiles.tsx @@ -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 ( +
+ {targets.map((t) => ( +
+

+ {t.server} + {!t.ok && Cannot apply} +

+ {t.error !== undefined && {t.error}} + {t.warnings.map((w) => ( + + {w} + + ))} + {t.ok && ( +
    + {t.apps.length > 0 && ( +
  1. + 1. Apps +
      + {t.apps.map((a) => ( +
    • + {a.name} + {APP_STATE[a.state][1]} + + {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}`} + +
    • + ))} +
    +
  2. + )} + {t.configs.length > 0 && ( +
  3. + 2. App configs +
      + {t.configs.map((c) => ( +
    • + + {c.name} + {CONFIG_STATE[c.state][1]} + {c.error !== undefined && {c.error}} + + {c.changes !== undefined && } +
    • + ))} +
    +
  4. + )} + {t.settings !== null && ( +
  5. + + 3. Settings + {t.settings.error !== undefined ? Cannot read : t.settings.unchanged ? Already in sync : {`${t.settings.fields.length} field(s) will change`}} + + {t.settings.error !== undefined && {t.settings.error}} + {t.settings.secretFields.length > 0 && {t.settings.secretFields.join(', ')}: the secret values are sent but never shown.} + {t.settings.error === undefined && !t.settings.unchanged && ( +
    + +
    + )} +
  6. + )} + {t.lists !== null && ( +
  7. + + 4. Blocked and Allowed lists + {t.lists.error !== undefined ? Cannot read : t.lists.status === 'same' ? Already in sync : Will change} + + {t.lists.error !== undefined ? ( + {t.lists.error} + ) : ( + + Blocked: {t.lists.blocked.missing} to add, {t.lists.blocked.extra} extra. Allowed: {t.lists.allowed.missing} to add, {t.lists.allowed.extra} extra. + + )} + {t.lists.tooMany !== undefined && {t.lists.tooMany}} +
  8. + )} +
+ )} +
+ ))} + {/* Only when a server has list domains the profile does not: otherwise there is nothing to remove. */} + {extras > 0 && ( + { + opts.remove = on + setRemove(on) + }} + /> + )} +
+ ) +} + +/* 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 = {} + 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: } + }, + run: async (targets): Promise => { + 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(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 ( + { + if (!o) onClose() + }} + title="Create a profile" + close="Cancel" + actions={ + + } + > +

+ A profile is a copy of the hub's baselines as they are now. An existing profile with the same name is replaced. +

+ {problem !== null && {problem}} + setName(e.target.value)} /> +
+ + + +
+
+ ) +} + +/* ---- View one ---- */ + +function ViewProfile({ token, name, onClose }: { token: string | null; name: string; onClose: () => void }) { + const [view, setView] = useState(null) + const [failure, setFailure] = useState(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 ( + { + if (!o) onClose() + }} + size="wide" + title={`Profile: ${name}`} + > + {failure !== null ? ( + + + Could not read the profile. {failure} + + + + ) : view === null ? ( + + ) : ( +
+

+ Made by {view.updatedBy} at {when(view.updatedAt)}. Secrets show as {''}; an app's config text is not shown. +

+
+

Apps

+ {view.apps.length === 0 ? ( + No apps. + ) : ( +
    + {view.apps.map((a) => ( +
  • + {a.name} + {a.version} + {a.hasConfig ? 'With config' : 'No config'} +
  • + ))} +
+ )} +
+
+

Settings

+ {view.settings === null ? Not part of this profile. : {}} />} +
+
+

Blocked and Allowed lists

+ {view.lists === null ? Not part of this profile. :

{view.lists.blocked} blocked and {view.lists.allowed} allowed domains.

} +
+
+ )} +
+ ) +} + +/* ---- Import ---- */ + +function ImportProfiles({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) { + const [text, setText] = useState(null) + const [plan, setPlan] = useState(null) + const [problem, setProblem] = useState(null) + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + const fileRef = useRef(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 ( + { + if (!o) onClose() + }} + title="Import profiles" + actions={ + plan === null ? ( + + ) : !done ? ( + + ) : undefined + } + > + {problem !== null && {problem}} + {plan === null ? ( + <> +

Choose a file made by Export. It is checked first; nothing is changed until you press Import, and no server is touched.

+ + + ) : ( + <> + {done && The profiles were saved.} +
    + {plan.profiles.map((p) => ( +
  • + {p.name} + {LABEL[p.status]} + {p.apps} app(s){p.settings ? ', settings' : ''}{p.lists ? ', lists' : ''} +
  • + ))} +
+ + )} +
+ ) +} + +/* ---- 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(null) + const [failure, setFailure] = useState(null) + const [creating, setCreating] = useState(false) + const [importing, setImporting] = useState(false) + const [viewing, setViewing] = useState(null) + const [deleting, setDeleting] = useState(null) + const [request, setRequest] = useState(null) + const [problem, setProblem] = useState(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 = ( + + ) + + return ( + <> + + + {profiles !== null && profiles.length > 0 && create} + + } + > +

+ 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. +

+ {problem !== null && {problem}} + {failure !== null && profiles === null ? ( + + + Could not read the profiles. {failure} + + + + ) : profiles === null ? ( +
+ + +
+ ) : profiles.length === 0 ? ( + + Make one from the baselines the hub holds now, or import a file from another hub. + + ) : ( +
    + {profiles.map((p) => ( +
  • + {p.name} + {`${p.apps} app${p.apps === 1 ? '' : 's'}`} + {p.configs > 0 && {`${p.configs} config${p.configs === 1 ? '' : 's'}`}} + {p.settings !== null && {`${p.settings} settings`}} + {p.lists !== null && {`${p.lists.blocked + p.lists.allowed} list domains`}} + {p.updatedBy} · {when(p.updatedAt)} + + + + + + +
  • + ))} +
+ )} +
+ + {creating && ( + setCreating(false)} + onDone={() => { + setCreating(false) + void load() + }} + /> + )} + {importing && ( + setImporting(false)} + onDone={() => void load()} + /> + )} + {viewing !== null && setViewing(null)} />} + setDeleting(null)} + onConfirm={() => deleting !== null && remove(deleting)} + /> + {request !== null && setRequest(null)} />} + + ) +} diff --git a/src/screens/apps/RunFlow.tsx b/src/screens/apps/RunFlow.tsx index 7159a8c..0f51502 100644 --- a/src/screens/apps/RunFlow.tsx +++ b/src/screens/apps/RunFlow.tsx @@ -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 }) { const [stage, setStage] = useState('pick') @@ -185,10 +189,23 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c ) : (
    {results.map((r) => ( -
  • - {r.server} - {r.ok ? 'Done' : 'Failed'} - {r.message !== undefined && {r.message}} +
  • + + {r.server} + {r.ok ? 'Done' : 'Failed'} + {r.message !== undefined && {r.message}} + + {r.steps !== undefined && r.steps.length > 0 && ( +
      + {r.steps.map((s) => ( +
    • + {STEP_LABEL[s.step]} + {STEP_STATUS[s.status]} + {s.message} +
    • + ))} +
    + )}
  • ))}
diff --git a/src/screens/settings/SettingsCompare.tsx b/src/screens/settings/SettingsCompare.tsx index a7deabd..01406a9 100644 --- a/src/screens/settings/SettingsCompare.tsx +++ b/src/screens/settings/SettingsCompare.tsx @@ -54,7 +54,7 @@ function StatusTag({ status, differing }: { status: string; differing: number }) /* ---- 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 return { 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 } return { title: 'Apply the Blocked and Allowed lists',