Hub stage 8: settings drift, webhook notices, profiles
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s

The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 02:11:19 +07:00
1 parent 392b644faa
commit 9742f0fe69
32 files changed
+3118 -101

No files matched your search

+35
View File
@@ -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=<hex>` содержит HMAC-SHA256 точных байт тела. Проверка на стороне получателя (Node):
```js
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
// сравните с заголовком через timingSafeEqual
```
Отправка не блокирует работу хаба: таймаут 5 секунд, не больше одного повтора. Ошибка пишется в лог хаба с именем хоста (без параметров адреса). Администратор видит состояние (настроен ли, хост, результат последней отправки) и кнопку **Send test** в окне «Config drift» (оно открывается из шапки, когда есть расхождения).
## Профили
Профиль нужен, чтобы быстро привести новый или сброшенный сервер к общему виду. Это именованный набор в `/data/profiles/`: приложения магазина (имя и версия) с эталонным конфигом каждого, общие поля настроек (поля, относящиеся к самому хосту, не входят) и эталон списков Blocked/Allowed. Файл содержит секреты настроек (ключи TSIG), берегите том `/data`.
Все действия только для роли `admin` (`apps-admin`, `operator` и `viewer` получают отказ). Кнопка **Profiles** на экране Apps › All servers:
- **Create from current baselines**: профиль собирается из эталонов, которые хаб держит сейчас; можно не брать приложения, настройки или списки. Приложение не из магазина (поставленное из zip) в профиль не входит;
- **View**: состав профиля; секреты показаны как `<secret>`, тексты конфигов не показываются;
- **Apply to servers…**: выбор серверов, предпросмотр по шагам для каждого сервера, затем итог по каждому шагу. Шаги идут по порядку, ошибка шага останавливает следующие шаги этого сервера: 1) установка недостающих приложений из магазина самого сервера, 2) конфиги приложений (эталон плюс локальные поля сервера, прежний конфиг сохраняется в историю), 3) общие поля настроек (через те же проверки, что и обычное сохранение), 4) списки: недостающие домены добавляются, лишние удаляются только по галочке **Also remove extras**. **Retry failed** начинает заново с шага, который не удался;
- **Export** и **Import profiles**: перенос между хабами (файл содержит секреты, храните его как секрет; сначала проверка, запись только после **Import**).
## Приложения: установка, обновление, удаление
Роль `apps-admin` (или `admin`) видит в разделе Apps при «All servers» меню действий в каждой строке и кнопки **App Store** и **Install from file**:
+1 -1
View File
@@ -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`).
---
+89 -5
View File
@@ -23,6 +23,8 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
| `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. |
| `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. |
| `HUB_DRIFT_MINUTES` | `15` | How often the hub checks that every server still has the config it should (see Drift). `0` turns the check off. |
| `HUB_WEBHOOK_URL` | none | An http or https address that gets a signed JSON notice on a change of drift and on a failed write (see Webhook notices). Treat it like a secret: it may carry a token. |
| `HUB_WEBHOOK_SECRET` | none | Signs each notice: `X-Hub-Signature: sha256=<HMAC-SHA256 of the body>`. Never logged or returned. |
| `HUB_TRUST_PROXY` | off | `1` believes the first `X-Forwarded-For` entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
| `<ID>_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. |
| `HUB_PORT` | `8080` | Listening port. |
@@ -214,7 +216,7 @@ baseline and the history (mode 0600, protect the data folder like the users file
any role; the endpoints are `admin` only anyway. A changed secret is shown as "secret changed". The audit log has field names, counts
and outcomes, never values. Applying a baseline with TSIG keys does send the secrets to the servers (that is the point), over the
connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overrides}.json`, `settings/history/` (50 files),
`${HUB_DATA}/blocking/baseline.json`. Settings are not part of the drift check yet (a note in `drift.ts`).
`${HUB_DATA}/blocking/baseline.json`. Settings and the lists are part of the drift check (see Drift).
## History, rollback, drift, export
@@ -232,12 +234,13 @@ connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overr
**Drift.** Every `HUB_DRIFT_MINUTES` (and once shortly after start) the hub reads each server's config of every app that has a
baseline, with the read token, and compares it by meaning with the server's effective config (baseline plus its local values). The
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ app, server, since, kind }] }`, where `kind` is
`differs`, `unreadable` (the server or the read failed; listed, not an alarm) or `not-installed`, and `since` is the first check that
saw it. Two checks never run at once.
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ kind, name?, server, since, status, fields? }] }`, where
`kind` is `app`, `settings` or `lists` (stage 8), `status` is `differs`, `unreadable` (the server or the read failed; listed, not an
alarm) or `not-installed`, and `since` is the first check that saw it. Two checks never run at once. See "Webhook notices and
profiles" for the settings and lists kinds.
- `GET hub/drift/get` (read): the state. `POST hub/drift/check` (`apps.write`): check now.
- `user/session/get` carries `info.hubDrift`, the number of `differs` entries; the console shows it in the header.
- `user/session/get` carries `info.hubDrift`, the number of `differs` entries of every kind; the console shows it in the header.
- `POST hub/apps/drift/accept {name, server}` (`apps.write`): "Accept as baseline". The server's config becomes the baseline; if the
app has local fields, they keep the baseline's old values and the server's own values go to the overrides instead.
- "Revert to baseline" is the normal apply on that server.
@@ -256,6 +259,87 @@ Before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history
audit log as `apps.<action>`; the history files hold configs, so protect the data folder like the users file.
After any write the hub forgets its cached answers of that server.
## Webhook notices and profiles (stage 8)
**Webhook.** With `HUB_WEBHOOK_URL` (http or https) the hub POSTs a small JSON body when something changes. `HUB_WEBHOOK_SECRET`
is optional and signs the body. Events:
| Event | When |
|---|---|
| `drift.found` | A server/kind/name newly differs from its baseline. Sent once, not at every check. |
| `drift.cleared` | An entry that differed is back in line. An entry that became unreadable is not cleared: it is not known. |
| `write.failed` | An audited write ended `failed` (not `refused`, not a failed login). Failures of the same moment are one notice. |
| `webhook.test` | The admin pressed "Send test" (`POST hub/webhook/test`). |
```json
{
"event": "drift.found",
"at": "2026-11-01T09:15:00.000Z",
"hub": "hub.example.net",
"items": [
{ "kind": "settings", "server": "ndns", "since": "2026-11-01T09:15:00.000Z", "fields": ["blockingType", "defaultRecordTtl"] },
{ "kind": "app", "name": "Advanced Blocking", "server": "ndns", "since": "2026-11-01T09:15:00.000Z" }
]
}
```
Items carry names and field names, never a value. A `write.failed` item has `action`, `server`, `target`, `user`, `at` and the
audited `detail`. The first state is the one in `drift.json`, so a restart does not repeat notices.
With a secret, the header `X-Hub-Signature: sha256=<hex>` is the HMAC-SHA256 of the exact request body. Verify it before parsing:
```js
import { createHmac, timingSafeEqual } from 'node:crypto'
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
const ok = expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))
```
Delivery is fire-and-forget: a 5 second timeout, at most one retry (after a second), redirects are not followed, and it never
blocks or fails the request that caused it. A failure goes to the server log with the HOST only (never the query string, which may
carry a token) and the reason (`HTTP 500`, `ECONNREFUSED`, `TimeoutError`).
| Endpoint | Method | Needs | Does |
|---|---|---|---|
| `hub/webhook/status` | GET | `admin` | `{ configured, host, signed, last: { event, at, ok, error? } }`. The host and port only, never the address. |
| `hub/webhook/test` | POST | `admin` | Sends a `webhook.test` notice now and waits for it; answers the same status. Audited as `webhook.test`. |
**Drift kinds.** `GET hub/drift/get` answers `{ checkedAt, apps: [entry] }` (the key is kept) with
`entry = { kind: 'app' | 'settings' | 'lists', name?, app?, server, since, status, fields? }`. `status` is `differs`, `unreadable`
or `not-installed`; `fields` are setting names, or `blocked` / `allowed` for the lists; `name` (and `app`) are the app. A
`drift.json` from before stage 8, where `kind` held the status and `app` the name, still loads. Settings are compared like the
Settings compare view (shared fields against the settings baseline), lists as sets against the lists baseline, and only when that
baseline exists. The header count is the number of `differs` entries of every kind.
**Profiles.** A profile is a named bundle in `${HUB_DATA}/profiles/<name>.json` (mode 0600; it holds settings secrets as the
settings baseline does, so protect the folder): store apps with the version they had and the hub's baseline config of each at that
moment, the shared fields of the settings baseline (host fields never travel), and the Blocked/Allowed lists baseline. Every
profile endpoint needs `admin`; viewer, operator and apps-admin are refused.
| Endpoint | Method | Does |
|---|---|---|
| `hub/profiles/list` | GET | Names with counts. |
| `hub/profiles/get?name=` | GET | One profile with secrets as `<secret>` and no app config text (only whether there is one). |
| `hub/profiles/snapshot {name, apps?, settings, lists}` | POST | Makes or replaces a profile from the current baselines. `apps` is a comma list (absent: every app with a baseline; empty: none). A zip app, or one installed nowhere, is refused. |
| `hub/profiles/delete {name}` | POST | Deletes it. |
| `hub/profiles/export?name=` | GET | `{ hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt, profiles }`, with the real secrets: keep the file as a secret. |
| `hub/profiles/import {data, apply}` | POST | Same size limit as the baselines import (5 MB, signed-in callers only). Dry run first (`new`, `changed`, `same`); `apply=true` writes. A `<secret>` placeholder or a host field in the file is refused or dropped. |
| `hub/profiles/preview {name, targets, removeExtras}` | GET/POST | Per target: apps to install, already there, or another version; app configs that would change; settings fields that would change; lists missing and extra. Writes nothing. |
| `hub/profiles/apply {name, targets, removeExtras, from}` | POST | Runs the steps on each target. |
**Apply.** The targets go in parallel; the steps of one server go in order, and a failed step stops the later ones for that server:
1. `apps`: the missing apps are installed from THAT server's own `apps/listStoreApps` (the URL never comes from the request; an
installed app is not updated, a version difference is only shown).
2. `configs`: the profile's config of each app plus that server's own local values, with the old config saved to the history first
and a read-back check (the same as the config apply).
3. `settings`: the changed shared fields only, through the checks of `guards.ts` (no `webService*`, user, SSO or LDAP field), the
old settings in the settings history, and a read-back check.
4. `lists`: the missing domains are added; extras are removed only with `removeExtras`.
The answer is `{ results: [{ server, ok, message, steps: [{ step, status, message }] }] }` with `status` one of `ok`, `failed`,
`skipped` (done by an earlier run) or `not-run`. `from` is `{ "<server>": "<step>" }`: the step to start at, so "Retry failed" does
not repeat what worked. The audit has one `profiles.apply` line per server and step (names, counts and the outcome, no values).
## Run locally
```sh
+5 -4
View File
@@ -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<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
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<string, string>, overrideText?: string): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline)
if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.')
const overrides = this.getOverrides(name)
return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect)
+1 -1
View File
@@ -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)
+11
View File
@@ -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'
+17
View File
@@ -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',
}
+159 -12
View File
@@ -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<string, string>
down: boolean
denyConfig: boolean
settings: Record<string, unknown>
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<string, unknown>) => writeSettingsBaseline(dir, { settings, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
const baselineLists = (blocked: string[], allowed: string[]) => writeListsBaseline(dir, { blocked, allowed, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
describe('drift of settings and lists', () => {
it('reports shared settings fields that differ from the baseline, by field name only', async () => {
baselineSettings({ blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'x.example' })
servers.a.settings = { blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'a.example' }
servers.b.settings = { blockingType: 'AnyAddress', defaultRecordTtl: 60, dnsServerDomain: 'b.example' }
const { drift } = make()
const state = await drift.check()
// dnsServerDomain is local: server a is fine even though its value differs.
expect(state.apps).toEqual([{ kind: 'settings', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] }])
expect(JSON.stringify(state)).not.toContain('AnyAddress')
expect(drift.count()).toBe(1)
})
it('reports Blocked and Allowed lists that differ, without a domain', async () => {
baselineLists(['ads.example'], ['ok.example'])
servers.a.blocked = ['ads.example']
servers.a.allowed = ['ok.example']
servers.b.blocked = ['ads.example', 'extra.example']
servers.b.allowed = ['ok.example']
const { drift } = make()
const state = await drift.check()
expect(state.apps).toEqual([{ kind: 'lists', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blocked'] }])
expect(JSON.stringify(state)).not.toContain('extra.example')
})
it('reads no settings and no lists when there is no baseline for them', async () => {
const { drift } = make()
await drift.check()
expect(calls).toEqual([])
})
it('lists a server that does not answer as unreadable for each baseline, and does not count it', async () => {
baselineSettings({ blockingType: 'NxDomain' })
baselineLists([], [])
servers.b.down = true
const { drift } = make()
const state = await drift.check()
expect(state.apps.map((e) => [e.kind, e.server, e.status])).toEqual([['lists', 'b', 'unreadable'], ['settings', 'b', 'unreadable']])
expect(drift.count()).toBe(0)
})
it('counts every kind in one number', async () => {
baseline('Blocker', json({ ttl: 30 }))
baselineSettings({ blockingType: 'NxDomain' })
baselineLists(['x.example'], [])
servers.a.apps.Blocker = json({ ttl: 1 })
servers.b.apps.Blocker = json({ ttl: 30 })
servers.a.settings = { blockingType: 'AnyAddress' }
servers.b.settings = { blockingType: 'NxDomain' }
servers.a.blocked = ['x.example']
const { drift } = make()
await drift.check()
// a: app and settings; b: lists (the baseline has x.example, b has none).
expect(drift.get().apps.map((e) => `${e.kind}:${e.server}`).sort()).toEqual(['app:a', 'lists:b', 'settings:a'])
expect(drift.count()).toBe(3)
})
it('loads a drift.json written before stage 8, where `kind` held the status', () => {
writeFileSync(
path.join(dir, 'drift.json'),
JSON.stringify({
checkedAt: '2026-10-31T09:00:00.000Z',
apps: [
{ app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', kind: 'differs' },
{ app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', kind: 'not-installed' },
{ server: 'a', since: 'x', kind: 'bogus', app: 'Z' },
],
}),
)
const { drift } = make()
expect(drift.get().apps).toEqual([
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', status: 'differs' },
{ kind: 'app', name: 'Other', app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', status: 'not-installed' },
])
expect(drift.count()).toBe(1)
})
})
describe('drift notices', () => {
const sent: { event: string; items: Record<string, unknown>[] }[] = []
const hook = () =>
new Webhook(
{ webhookUrl: 'https://hooks.example/x?token=SECRET', webhookSecret: null, domain: 'hub' },
{
retryDelayMs: 0,
fetch: (async (_url: unknown, init: RequestInit) => {
sent.push(JSON.parse(String(init.body)))
return new Response('ok')
}) as typeof fetch,
},
)
beforeEach(() => {
sent.length = 0
})
it('tells what newly differs once, and what is cleared, and nothing when nothing changed', async () => {
baseline('Blocker', json({ ttl: 30 }))
servers.a.apps.Blocker = json({ ttl: 30 })
servers.b.apps.Blocker = json({ ttl: 99 })
const webhook = hook()
const { drift } = make(webhook)
await drift.check()
await webhook.settled()
expect(sent.map((s) => s.event)).toEqual(['drift.found'])
expect(sent[0].items).toMatchObject([{ kind: 'app', name: 'Blocker', server: 'b' }])
clock += 15 * 60_000
await drift.check()
await webhook.settled()
expect(sent).toHaveLength(1)
servers.b.apps.Blocker = json({ ttl: 30 })
await drift.check()
await webhook.settled()
expect(sent.map((s) => s.event)).toEqual(['drift.found', 'drift.cleared'])
})
it('does not repeat a notice after a restart, and does not call an unreadable server cleared', async () => {
baseline('Blocker', json({ ttl: 30 }))
servers.a.apps.Blocker = json({ ttl: 30 })
servers.b.apps.Blocker = json({ ttl: 99 })
await make().drift.check()
const webhook = hook()
const restarted = make(webhook).drift
await restarted.check()
await webhook.settled()
expect(sent).toEqual([])
servers.b.down = true
await restarted.check()
await webhook.settled()
expect(sent).toEqual([])
})
})
+101 -32
View File
@@ -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<DriftEntry, 'kind' | 'name' | 'server'>): string => `${e.kind}\n${e.name ?? ''}\n${e.server}`
/* An entry from drift.json. Before stage 8 `kind` held the status and `app` the name. */
export function readEntry(raw: unknown): DriftEntry | null {
if (raw === null || typeof raw !== 'object') return null
const e = raw as Record<string, unknown>
if (typeof e.server !== 'string' || typeof e.since !== 'string') return null
const oldKind = typeof e.kind === 'string' && STATUSES.includes(e.kind as DriftStatus)
const status = (oldKind ? e.kind : e.status) as DriftStatus
if (!STATUSES.includes(status)) return null
const kind = (oldKind || e.kind === undefined ? 'app' : e.kind) as DriftType
if (!TYPES.includes(kind)) return null
const name = typeof e.name === 'string' ? e.name : typeof e.app === 'string' ? e.app : undefined
if (kind === 'app' && name === undefined) return null
const fields = Array.isArray(e.fields) ? e.fields.filter((f): f is string => typeof f === 'string').slice(0, 100) : undefined
return {
kind,
...(name === undefined ? {} : { name }),
...(kind === 'app' ? { app: name } : {}),
server: e.server,
since: e.since,
status,
...(fields === undefined || fields.length === 0 ? {} : { fields }),
}
}
export class Drift {
#config: HubConfig
#upstream: Upstream
#now: () => number
#settings: SettingsConfig
#webhook: Webhook | null
#state: DriftState
#running: Promise<DriftState> | null = null
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now, options: { webhook?: Webhook } = {}) {
this.#config = config
this.#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<DriftState>
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<DriftEntry, 'since'>[] = []
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<string, unknown> => ({
kind: e.kind,
...(e.name === undefined ? {} : { name: e.name }),
server: e.server,
since: e.since,
...(e.fields === undefined ? {} : { fields: e.fields }),
})
const was = new Set(previous.filter((e) => e.status === 'differs').map(keyOf))
const nowDiffers = new Set(now.filter((e) => e.status === 'differs').map(keyOf))
const unknown = new Set(now.filter((e) => e.status === 'unreadable').map(keyOf))
const newly = now.filter((e) => e.status === 'differs' && !was.has(keyOf(e)))
const cleared = previous.filter((e) => e.status === 'differs' && !nowDiffers.has(keyOf(e)) && !unknown.has(keyOf(e)))
if (newly.length > 0) this.#webhook.send('drift.found', newly.map(item))
if (cleared.length > 0) this.#webhook.send('drift.cleared', cleared.map(item))
}
}
+372
View File
@@ -0,0 +1,372 @@
// @vitest-environment node
/*
Stage 8, profiles: making one from the hub's baselines, what a viewer of it may see, export and
import, the preview, and the order and the stop of an apply, on two fake servers that keep their
apps, configs, settings and lists.
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
import { cleanProfile } from './profiles.ts'
import { writeBaseline, writeListsBaseline, writeSettingsBaseline } from './store.ts'
interface Box {
apps: Record<string, string>
configs: Record<string, string>
store: { name: string; version: string }[]
settings: Record<string, unknown>
blocked: string[]
allowed: string[]
failSettings: boolean
}
const boxes = new Map<Fake, Box>()
const urlOf = (f: Fake, name: string): string => `${f.url}/store/${name}.zip`
function install(f: Fake, box: Box): void {
boxes.set(f, box)
f.on('apps/list', () => ({ response: { apps: Object.entries(box.apps).map(([name, version]) => ({ name, version })) } }))
f.on('apps/listStoreApps', () => ({ response: { storeApps: box.store.map((a) => ({ ...a, url: urlOf(f, a.name) })) } }))
f.on('apps/downloadAndInstall', (c: Call) => {
const name = c.form.get('name') ?? c.query.get('name') ?? ''
const url = c.form.get('url') ?? c.query.get('url') ?? ''
const entry = box.store.find((a) => a.name === name)
if (entry === undefined || url !== urlOf(f, name)) return { error: 'The app was not found in the store.' }
box.apps[name] = entry.version
return { response: {} }
})
f.on('apps/config/get', (c: Call) => ({ response: { config: box.configs[c.query.get('name') ?? ''] ?? null } }))
f.on('apps/config/set', (c: Call) => {
box.configs[c.form.get('name') ?? c.query.get('name') ?? ''] = c.form.get('config') ?? ''
return { response: {} }
})
f.on('settings/get', () => ({ response: box.settings }))
f.on('settings/set', (c: Call) => {
if (box.failSettings) return { error: 'The settings were refused.' }
for (const [k, v] of c.form) {
if (k === 'tsigKeys') {
const cells = v.split('|')
box.settings.tsigKeys = v === 'false' ? [] : [{ keyName: cells[0], sharedSecret: cells[1], algorithmName: cells[2] }]
} else if (k in box.settings) box.settings[k] = v
}
return { response: {} }
})
for (const list of ['blocked', 'allowed'] as const) {
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
f.on(`${list}/add`, (c: Call) => {
box[list].push(c.form.get('domain') ?? '')
return { response: {} }
})
f.on(`${list}/delete`, (c: Call) => {
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
return { response: {} }
})
}
}
let a: Fake
let b: Fake
let hub: TestHub
const boxA = (): Box => boxes.get(a)!
const boxB = (): Box => boxes.get(b)!
const order = (f: Fake): string[] => f.calls.filter((c) => c.method === 'POST').map((c) => c.path)
const CONFIG = JSON.stringify({ ttl: 30 })
const settingsOf = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
dnsServerDomain: 'a.example',
blockingType: 'NxDomain',
tsigKeys: [{ keyName: 'k1', sharedSecret: 'REALSECRET', algorithmName: 'hmac-sha256' }],
...over,
})
function baselines(): void {
writeBaseline(hub.dataDir, { app: 'Blocker', text: CONFIG, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
writeSettingsBaseline(hub.dataDir, { settings: settingsOf(), updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
writeListsBaseline(hub.dataDir, { blocked: ['ads.example'], allowed: ['ok.example'], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
}
beforeAll(async () => {
a = await fake()
b = await fake()
hub = await startHub(a, b)
})
afterAll(() => {
hub.close()
a.server.close()
b.server.close()
})
beforeEach(async () => {
a.calls = []
b.calls = []
install(a, {
apps: { Blocker: '2.0', Zipped: '1.0' },
configs: { Blocker: CONFIG },
store: [{ name: 'Blocker', version: '2.0' }],
settings: settingsOf(),
blocked: ['ads.example'],
allowed: ['ok.example'],
failSettings: false,
})
// b is a fresh server: no apps, different settings, a missing domain, an extra one.
install(b, {
apps: {},
configs: {},
store: [{ name: 'Blocker', version: '2.0' }],
settings: settingsOf({ dnsServerDomain: 'b.example', blockingType: 'AnyAddress', tsigKeys: [] }),
blocked: ['mine.example'],
allowed: ['ok.example'],
failSettings: false,
})
baselines()
for (const f of readdirSync(path.join(hub.dataDir)).filter((n) => n === 'profiles')) void f
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
})
const snapshot = (over: Record<string, string> = {}) =>
hub.call('admin', 'hub/profiles/snapshot', { name: 'Fresh', apps: 'Blocker', settings: 'true', lists: 'true', ...over })
describe('making a profile', () => {
it('copies the baselines at that moment: store apps with version and config, shared settings fields, lists', async () => {
const done = await snapshot()
expect(done.code).toBe(200)
expect(done.body.status).toBe('ok')
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
expect(file.apps).toEqual([{ name: 'Blocker', version: '2.0', config: CONFIG }])
// The host field stays out; the real secret is kept, as the baseline file has it.
expect(file.settings.dnsServerDomain).toBeUndefined()
expect(file.settings.tsigKeys[0].sharedSecret).toBe('REALSECRET')
expect(file.lists).toEqual({ blocked: ['ads.example'], allowed: ['ok.example'] })
expect(file.updatedBy).toBe('admin')
// Later changes of the baselines do not change the profile.
writeBaseline(hub.dataDir, { app: 'Blocker', text: '{"ttl":1}', updatedAt: 'x', updatedBy: 't' })
const again = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
expect(again.apps[0].config).toBe(CONFIG)
})
it('lets the admin choose the parts, and refuses an empty choice', async () => {
expect((await snapshot({ settings: 'false', lists: 'false' })).body.status).toBe('ok')
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
expect(file.settings).toBeNull()
expect(file.lists).toBeNull()
const none = await snapshot({ apps: '', settings: 'false', lists: 'false' })
expect(none.code).toBe(400)
expect(none.body.errorMessage).toMatch(/at least one part/)
})
it('refuses an app that is a zip, one that is installed nowhere, and a bad name', async () => {
const zip = await snapshot({ apps: 'Zipped' })
expect(zip.code).toBe(400)
expect(zip.body.errorMessage).toMatch(/store listing/)
expect((await snapshot({ apps: 'Ghost' })).body.errorMessage).toMatch(/not installed/)
expect((await snapshot({ name: '../evil' })).code).toBe(400)
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
})
it('replaces a profile of the same name and lists it with counts only', async () => {
await snapshot()
await snapshot({ lists: 'false' })
const list = await hub.call('admin', 'hub/profiles/list', {}, 'GET')
expect(list.body.response.profiles).toMatchObject([{ name: 'Fresh', apps: 1, configs: 1, lists: null }])
expect(list.body.response.profiles[0].settings).toBeGreaterThan(0)
})
})
describe('viewing a profile', () => {
it('masks secrets and never returns an app config', async () => {
await snapshot()
const got = await hub.call('admin', 'hub/profiles/get', { name: 'Fresh' }, 'GET')
const text = JSON.stringify(got.body)
expect(text).not.toContain('REALSECRET')
expect(text).toContain('<secret>')
expect(text).not.toContain('"ttl"')
expect(got.body.response.apps).toEqual([{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: CONFIG.length }])
expect(got.body.response.lists).toEqual({ blocked: 1, allowed: 1 })
})
it('answers an unknown profile with an error', async () => {
expect((await hub.call('admin', 'hub/profiles/get', { name: 'Nope' }, 'GET')).code).toBe(400)
})
})
describe('roles', () => {
it('refuses every profile endpoint to every role but admin, and answers none of them from a server', async () => {
const reads = ['list', 'get', 'export', 'preview']
const writes = ['snapshot', 'delete', 'import', 'apply']
for (const role of ROLES.filter((r) => r !== 'admin')) {
for (const p of reads) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b' }, 'GET')).code).toBe(403)
for (const p of writes) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b', data: '{}' })).code).toBe(403)
}
// An unlisted profile path is refused, not forwarded.
expect((await hub.call('admin', 'hub/profiles/nothing', {}, 'GET')).code).toBe(403)
expect(a.calls).toEqual([])
expect(b.calls).toEqual([])
})
})
describe('export and import', () => {
it('exports with the real secret, and imports it dry first, then for real', async () => {
await snapshot()
const res = await fetch(`${hub.base}/api/hub/profiles/export`, { headers: { authorization: `Bearer ${hub.tokens.admin}` } })
expect(res.headers.get('content-disposition')).toMatch(/hub-profiles-/)
const text = await res.text()
expect(text).toContain('REALSECRET')
const file = JSON.parse(text)
expect(file).toMatchObject({ hub: 'technitium-hub', type: 'profiles', version: 1 })
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
const dry = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'false' })
expect(dry.body.response).toMatchObject({ applied: false, profiles: [{ name: 'Fresh', status: 'new', apps: 1, settings: true, lists: true }] })
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
const done = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
expect(done.body.response.applied).toBe(true)
const again = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
expect(again.body.response.profiles[0].status).toBe('same')
expect(JSON.stringify(await hub.audit())).not.toContain('REALSECRET')
})
it('refuses a file of another kind, a placeholder where a secret should be, and local fields are dropped', async () => {
const bad = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [] }) })
expect(bad.code).toBe(400)
const masked = { hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'M', apps: [], settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] }, lists: null }] }
const refused = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify(masked) })
expect(refused.code).toBe(400)
expect(refused.body.errorMessage).toMatch(/<secret>/)
expect(() => cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).not.toThrow()
expect(cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).toEqual({ blockingType: 'NxDomain' })
})
it('accepts a body above the usual limit for an admin', async () => {
const big = JSON.stringify({ hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'Big', apps: [{ name: 'X', version: '1', config: JSON.stringify({ pad: 'x'.repeat(200_000) }) }], settings: null, lists: null }] })
expect(big.length).toBeGreaterThan(64 * 1024)
const done = await hub.call('admin', 'hub/profiles/import', { data: big, apply: 'false' })
expect(done.body.response.profiles[0]).toMatchObject({ name: 'Big', status: 'new' })
})
})
describe('preview', () => {
it('says what each part would change on a target, and writes nothing', async () => {
await snapshot()
const got = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b,a' })
expect(got.code).toBe(200)
const [tb, ta] = got.body.response.targets
expect(tb.server).toBe('b')
expect(tb.apps).toEqual([{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' }])
expect(tb.configs).toEqual([{ name: 'Blocker', state: 'after-install' }])
expect(tb.settings.fields).toContain('blockingType')
expect(tb.settings.secretFields).toContain('tsigKeys')
expect(JSON.stringify(got.body)).not.toContain('REALSECRET')
expect(tb.lists.blocked.missing).toBe(1)
expect(ta.apps).toEqual([{ name: 'Blocker', state: 'present', profileVersion: '2.0', installedVersion: '2.0' }])
expect(ta.configs).toEqual([{ name: 'Blocker', state: 'same' }])
expect(ta.settings.unchanged).toBe(true)
expect(order(a).concat(order(b))).toEqual([])
})
it('marks an installed app whose version differs, and an app the store does not offer', async () => {
await snapshot()
boxB().apps.Blocker = '1.0'
const one = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
expect(one.body.response.targets[0].apps[0]).toMatchObject({ state: 'version-differs', installedVersion: '1.0', profileVersion: '2.0' })
boxB().apps = {}
boxB().store = []
const two = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
expect(two.body.response.targets[0].apps[0].state).toBe('not-in-store')
})
})
describe('apply', () => {
it('runs the steps in order on a fresh server: install, config, settings, lists', async () => {
await snapshot()
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
expect(done.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
expect(done.body.response.results[0].steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
['apps', 'ok'],
['configs', 'ok'],
['settings', 'ok'],
['lists', 'ok'],
])
expect(order(b)).toEqual(['apps/downloadAndInstall', 'apps/config/set', 'settings/set', 'blocked/add'])
expect(boxB().apps.Blocker).toBe('2.0')
expect(JSON.parse(boxB().configs.Blocker)).toEqual({ ttl: 30 })
expect(boxB().settings.blockingType).toBe('NxDomain')
// The host field was left alone, and the extra domain stays unless asked.
expect(boxB().settings.dnsServerDomain).toBe('b.example')
expect(boxB().blocked).toEqual(['mine.example', 'ads.example'])
// The old config of a server that had one goes to the history; b had none, so nothing is claimed.
expect(order(a)).toEqual([])
})
it('takes the install URL from the target own store, never from the request', async () => {
await snapshot()
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', url: 'https://evil.example/x.zip' })
const install = b.calls.find((c) => c.path === 'apps/downloadAndInstall')!
expect(install.query.get('url') ?? install.form.get('url')).toBe(urlOf(b, 'Blocker'))
expect(a.calls.some((c) => c.path === 'apps/downloadAndInstall')).toBe(false)
// Each server is asked for its own listing, with its own read token.
expect(b.calls.find((c) => c.path === 'apps/listStoreApps')!.token).toBe('read-b')
})
it('removes extra domains only when asked', async () => {
await snapshot()
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', removeExtras: 'true' })
expect(boxB().blocked).toEqual(['ads.example'])
})
it('stops the later steps of a server when one fails, says so, and leaves the other server alone', async () => {
await snapshot()
boxB().failSettings = true
boxA().settings.blockingType = 'AnyAddress'
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'a,b' })
const [ra, rb] = done.body.response.results
expect(ra).toMatchObject({ server: 'a', ok: true })
expect(rb).toMatchObject({ server: 'b', ok: false })
expect(rb.message).toMatch(/Stopped at settings/)
expect(rb.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
['apps', 'ok'],
['configs', 'ok'],
['settings', 'failed'],
['lists', 'not-run'],
])
expect(order(b)).not.toContain('blocked/add')
const audit = await hub.audit()
const lines = audit.filter((e) => e.action === 'profiles.apply' && e.server === 'b').slice(0, 3)
expect(lines.map((e) => [e.outcome, e.detail?.split(':')[0]])).toEqual([['failed', 'settings'], ['ok', 'configs'], ['ok', 'apps']])
expect(JSON.stringify(audit)).not.toContain('REALSECRET')
})
it('retries from the failed step without redoing the earlier ones', async () => {
await snapshot()
boxB().failSettings = true
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
b.calls = []
boxB().failSettings = false
const again = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', from: JSON.stringify({ b: 'settings' }) })
const r = again.body.response.results[0]
expect(r.ok).toBe(true)
expect(r.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
['apps', 'skipped'],
['configs', 'skipped'],
['settings', 'ok'],
['lists', 'ok'],
])
expect(order(b)).toEqual(['settings/set', 'blocked/add'])
})
it('fails a server with no admin token on its first step, and refuses an unknown server or profile', async () => {
await snapshot()
const unknown = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'zzz' })
expect(unknown.code).toBe(400)
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Nope', targets: 'b' })).code).toBe(400)
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: '' })).code).toBe(400)
})
it('deletes a profile', async () => {
await snapshot()
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).body.response).toEqual({ deleted: true })
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).code).toBe(400)
expect((await hub.call('admin', 'hub/profiles/list', {}, 'GET')).body.response.profiles).toEqual([])
})
})
+510
View File
@@ -0,0 +1,510 @@
/*
Profiles (stage 8): a named bundle of what a server should have, kept in `${HUB_DATA}/profiles/`,
to bring a new or reset server in line in one go.
A profile holds, as the admin chose when it was made from the hub's current baselines:
apps store apps only (never a zip): the name and the version they had, and for each the hub's
baseline config at that moment
settings the SHARED fields of the settings baseline (host fields never travel), secrets as the
baseline file keeps them
lists the custom Blocked and Allowed lists baseline
Applying a profile to a server runs four steps in order, and a failed step stops the later ones for
that server (the other servers go on):
apps install the apps that are missing, from THAT server's own store listing (apps-write.ts)
configs the profile's config of each app plus that server's own local values; the old config
goes to the history first (apps-config.ts)
settings the changed shared fields, through the same checks as a per-server settings/set
lists the missing domains; extras only when asked
`from` restarts a server at a given step (the retry of a failed run), so what already worked is not
done again. Everything here is for admins. A view never carries a secret and never an app config.
*/
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import type { HubConfig, HubServer } from './config.ts'
import { MAX_CONFIG, looksLikeJson, parseJson, type Diff } from './apps-sync.ts'
import { UserError, compareVersions, type AppsConfig } from './apps-config.ts'
import type { AppsWriter } from './apps-write.ts'
import { isFieldName, isBuiltInLocal, MAX_LIST, SECRET_PLACEHOLDER, maskSettings } from './settings-sync.ts'
import type { SettingsConfig } from './settings-config.ts'
import { listBaselineApps, readBaseline, readListsBaseline, readSettingsBaseline, safeName, writeAtomic } from './store.ts'
import type { Upstream } from './upstream.ts'
export const STEPS = ['apps', 'configs', 'settings', 'lists'] as const
export type Step = (typeof STEPS)[number]
export interface ProfileApp {
name: string
/** The version the app had when the profile was made. */
version: string
/** The hub's baseline config of the app then; null when it had none. */
config: string | null
}
export interface Profile {
name: string
createdAt: string
updatedAt: string
updatedBy: string
apps: ProfileApp[]
settings: Record<string, unknown> | null
lists: { blocked: string[]; allowed: string[] } | null
}
export interface StepResult {
step: Step
/* `skipped`: done by an earlier run. `not-run`: an earlier step failed. */
status: 'ok' | 'failed' | 'skipped' | 'not-run'
message: string
}
export interface ApplyResult {
server: string
ok: boolean
message: string
steps: StepResult[]
}
const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/
const MAX_PROFILES = 50
const MAX_APPS = 100
const MAX_SETTINGS_BYTES = 1024 * 1024
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
const hasPlaceholder = (value: unknown): boolean => JSON.stringify(value).includes(JSON.stringify(SECRET_PLACEHOLDER))
/* The stored shape, checked field by field. Used for files on disk and for imports. */
export function cleanProfile(raw: unknown, fallbackName?: string): Profile {
if (!isObject(raw)) throw new UserError('A profile must be an object.')
const name = typeof raw.name === 'string' ? raw.name : (fallbackName ?? '')
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
if (!Array.isArray(raw.apps) || raw.apps.length > MAX_APPS) throw new UserError(`"${name}": the apps must be a list of at most ${MAX_APPS}.`)
const seen = new Set<string>()
const apps = raw.apps.map((a): ProfileApp => {
if (!isObject(a) || typeof a.name !== 'string' || a.name === '' || a.name.length > 200 || seen.has(a.name)) throw new UserError(`"${name}": an app has no valid, unique name.`)
seen.add(a.name)
const version = typeof a.version === 'string' ? a.version.slice(0, 40) : ''
const config = typeof a.config === 'string' ? a.config : null
if (config !== null) {
if (config.length > MAX_CONFIG) throw new UserError(`"${name}": the config of "${a.name}" is larger than 1 MB.`)
if (looksLikeJson(config) && !parseJson(config).ok) throw new UserError(`"${name}": the config of "${a.name}" is not valid JSON.`)
}
return { name: a.name, version, config }
})
let settings: Profile['settings'] = null
if (raw.settings !== null && raw.settings !== undefined) {
if (!isObject(raw.settings)) throw new UserError(`"${name}": the settings must be an object.`)
if (JSON.stringify(raw.settings).length > MAX_SETTINGS_BYTES) throw new UserError(`"${name}": the settings are larger than 1 MB.`)
if (hasPlaceholder(raw.settings)) throw new UserError(`"${name}": the settings hold ${SECRET_PLACEHOLDER} where a secret should be. Export the profile from the hub that has it.`)
settings = {}
for (const [k, v] of Object.entries(raw.settings)) {
if (!isFieldName(k)) throw new UserError(`"${name}": "${k.slice(0, 40)}" is not a setting name.`)
// Host fields never travel.
if (!isBuiltInLocal(k)) settings[k] = v
}
}
let lists: Profile['lists'] = null
if (raw.lists !== null && raw.lists !== undefined) {
const l = raw.lists as { blocked?: unknown; allowed?: unknown }
if (!isObject(raw.lists) || !Array.isArray(l.blocked) || !Array.isArray(l.allowed)) throw new UserError(`"${name}": the lists need a blocked and an allowed array.`)
const clean = (v: unknown[]): string[] => {
if (v.length > MAX_LIST || v.some((d) => typeof d !== 'string' || d.length > 253)) throw new UserError(`"${name}": a list is longer than ${MAX_LIST} domains or holds a bad entry.`)
return [...new Set(v as string[])].sort()
}
lists = { blocked: clean(l.blocked), allowed: clean(l.allowed) }
}
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
return { name, createdAt: text(raw.createdAt), updatedAt: text(raw.updatedAt), updatedBy: text(raw.updatedBy), apps, settings, lists }
}
const shape = (p: Profile): string => JSON.stringify({ apps: p.apps, settings: p.settings, lists: p.lists })
export class Profiles {
#config: HubConfig
#upstream: Upstream
#appsConfig: AppsConfig
#settings: SettingsConfig
#writer: AppsWriter
#now: () => number
constructor(config: HubConfig, upstream: Upstream, appsConfig: AppsConfig, settings: SettingsConfig, writer: AppsWriter, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#appsConfig = appsConfig
this.#settings = settings
this.#writer = writer
this.#now = now
}
#dir(): string {
return path.join(this.#config.dataDir, 'profiles')
}
#file(name: string): string {
return path.join(this.#dir(), `${safeName(name)}.json`)
}
#read(name: string): Profile | null {
const file = this.#file(name)
if (!existsSync(file)) return null
try {
const p = cleanProfile(JSON.parse(readFileSync(file, 'utf8')))
return p.name === name ? p : null
} catch {
return null
}
}
#all(): Profile[] {
if (!existsSync(this.#dir())) return []
const out: Profile[] = []
for (const f of readdirSync(this.#dir())) {
if (!f.endsWith('.json')) continue
try {
out.push(cleanProfile(JSON.parse(readFileSync(path.join(this.#dir(), f), 'utf8'))))
} catch {
// A file that is not a profile is left alone.
}
}
return out.sort((a, b) => a.name.localeCompare(b.name))
}
#require(name: string): Profile {
const found = this.#read(name)
if (found === null) throw new UserError(`There is no profile named "${name.slice(0, 60)}".`)
return found
}
#write(p: Profile): void {
writeAtomic(this.#file(p.name), JSON.stringify(p, null, 2) + '\n')
}
/* What the console lists: counts, no config and no value. */
list(): { name: string; apps: number; configs: number; settings: number | null; lists: { blocked: number; allowed: number } | null; updatedAt: string; updatedBy: string }[] {
return this.#all().map((p) => ({
name: p.name,
apps: p.apps.length,
configs: p.apps.filter((a) => a.config !== null).length,
settings: p.settings === null ? null : Object.keys(p.settings).length,
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
updatedAt: p.updatedAt,
updatedBy: p.updatedBy,
}))
}
/* One profile for the console: secrets masked, no app config text. */
get(name: string): unknown {
const p = this.#require(name)
return {
name: p.name,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
updatedBy: p.updatedBy,
apps: p.apps.map((a) => ({ name: a.name, version: a.version, hasConfig: a.config !== null, bytes: a.config === null ? 0 : a.config.length })),
settings: p.settings === null ? null : maskSettings(p.settings),
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
}
}
delete(name: string): void {
this.#require(name)
rmSync(this.#file(name), { force: true })
}
/* ---- Making one from the hub's current baselines ---- */
async #installedVersions(): Promise<Map<string, string>> {
const versions = new Map<string, string>()
let answered = 0
await Promise.all(
this.#config.servers.map(async (s) => {
try {
this.#upstream.invalidate(s.id)
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
answered++
for (const a of list?.apps ?? []) {
const have = versions.get(a.name)
const v = typeof a.version === 'string' ? a.version : ''
if (have === undefined || compareVersions(v, have) > 0) versions.set(a.name, v)
}
} catch {
// A server that does not answer adds nothing.
}
}),
)
if (answered === 0) throw new UserError('No server answered, so the installed apps cannot be read.')
return versions
}
async #storeNames(): Promise<Set<string>> {
const names = new Set<string>()
let answered = 0
await Promise.all(
this.#config.servers.map(async (s) => {
try {
const r = (await this.#upstream.call(s, 'apps/listStoreApps')) as { storeApps?: { name: string }[] } | null
answered++
for (const a of r?.storeApps ?? []) names.add(a.name)
} catch {
// Same.
}
}),
)
if (answered === 0) throw new UserError('No server answered, so the store listing cannot be read.')
return names
}
/*
Makes or replaces a profile from the hub's current baselines. `apps`: the names to take (undefined
takes every app that has a baseline). Settings and lists are taken whole when asked.
*/
async snapshot(name: string, parts: { apps?: string[]; settings: boolean; lists: boolean }, user: string): Promise<{ replaced: boolean; detail: string }> {
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
const appNames = parts.apps ?? listBaselineApps(this.#config.dataDir)
if (appNames.length === 0 && !parts.settings && !parts.lists) throw new UserError('Choose at least one part: apps, settings or lists.')
if (appNames.length > MAX_APPS) throw new UserError(`At most ${MAX_APPS} apps in a profile.`)
const existing = this.#read(name)
if (existing === null && this.#all().length >= MAX_PROFILES) throw new UserError(`At most ${MAX_PROFILES} profiles.`)
let apps: ProfileApp[] = []
if (appNames.length > 0) {
const [versions, store] = await Promise.all([this.#installedVersions(), this.#storeNames()])
apps = [...new Set(appNames)].map((app) => {
const version = versions.get(app)
if (version === undefined) throw new UserError(`"${app}" is not installed on any server, so its version is not known.`)
if (!store.has(app)) throw new UserError(`"${app}" is not in the store listing. A profile holds store apps only, not apps installed from a file.`)
return { name: app, version, config: readBaseline(this.#config.dataDir, app)?.text ?? null }
})
}
let settings: Profile['settings'] = null
if (parts.settings) {
const baseline = readSettingsBaseline(this.#config.dataDir)
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
const extra = this.#settings.extraLocal()
settings = Object.fromEntries(Object.entries(baseline.settings).filter(([k]) => !isBuiltInLocal(k) && !extra.includes(k)))
}
let lists: Profile['lists'] = null
if (parts.lists) {
const baseline = readListsBaseline(this.#config.dataDir)
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
lists = { blocked: [...baseline.blocked], allowed: [...baseline.allowed] }
}
const at = new Date(this.#now()).toISOString()
this.#write({ name, createdAt: existing?.createdAt || at, updatedAt: at, updatedBy: user, apps, settings, lists })
return {
replaced: existing !== null,
detail: `${existing === null ? 'created' : 'replaced'}: ${apps.length} app(s), settings ${settings === null ? 'no' : Object.keys(settings).length + ' fields'}, lists ${lists === null ? 'no' : 'yes'}`,
}
}
/* ---- Export and import ---- */
exportFile(name?: string): { hub: string; type: string; version: number; exportedAt: string; profiles: Profile[] } {
const profiles = name === undefined || name === '' ? this.#all() : [this.#require(name)]
return { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt: new Date(this.#now()).toISOString(), profiles }
}
importFile(raw: string, apply: boolean, user: string): { profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]; applied: boolean } {
const parsed = parseJson(raw)
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
const file = parsed.value as { hub?: unknown; type?: unknown; version?: unknown; profiles?: unknown }
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub' || file.type !== 'profiles') throw new UserError('This is not a profiles file of the hub.')
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
if (!Array.isArray(file.profiles) || file.profiles.length > MAX_PROFILES) throw new UserError(`The file has no list of profiles, or has more than ${MAX_PROFILES}.`)
const plan: { profile: Profile; status: 'new' | 'changed' | 'same' }[] = []
const seen = new Set<string>()
for (const item of file.profiles) {
const profile = cleanProfile(item)
if (seen.has(profile.name)) throw new UserError(`"${profile.name}" is in the file twice.`)
seen.add(profile.name)
const old = this.#read(profile.name)
plan.push({ profile, status: old === null ? 'new' : shape(old) === shape(profile) ? 'same' : 'changed' })
}
const total = this.#all().filter((p) => !seen.has(p.name)).length + plan.length
if (total > MAX_PROFILES) throw new UserError(`That would make more than ${MAX_PROFILES} profiles.`)
if (apply) {
const at = new Date(this.#now()).toISOString()
for (const p of plan) {
if (p.status === 'same') continue
const old = this.#read(p.profile.name)
this.#write({ ...p.profile, createdAt: old?.createdAt || at, updatedAt: at, updatedBy: user })
}
}
return { profiles: plan.map((p) => ({ name: p.profile.name, status: p.status, apps: p.profile.apps.length, settings: p.profile.settings !== null, lists: p.profile.lists !== null })), applied: apply }
}
/* ---- Preview ---- */
#server(id: string): HubServer {
const found = this.#config.servers.find((s) => s.id === id)
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
return found
}
#targets(ids: string[]): HubServer[] {
const unique = [...new Set(ids)]
if (unique.length === 0) throw new UserError('Choose at least one server.')
return unique.map((id) => this.#server(id))
}
async #look(server: HubServer): Promise<{ installed: Map<string, string>; store: Map<string, string> }> {
this.#upstream.invalidate(server.id)
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
const store = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; version?: string }[] } | null
return {
installed: new Map((list?.apps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
store: new Map((store?.storeApps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
}
}
/* What applying the profile would change on each target. Nothing is written. */
async preview(name: string, ids: string[], removeExtras: boolean): Promise<{ profile: string; targets: unknown[] }> {
const profile = this.#require(name)
const targets = this.#targets(ids)
const out = await Promise.all(
targets.map(async (s) => {
const writable = s.adminToken !== undefined
const base = { server: s.id, writable }
try {
const { installed, store } = await this.#look(s)
const apps = profile.apps.map((a) => {
const have = installed.get(a.name)
if (have === undefined) {
const offered = store.get(a.name)
return offered === undefined
? { name: a.name, state: 'not-in-store' as const, profileVersion: a.version }
: { name: a.name, state: 'install' as const, profileVersion: a.version, storeVersion: offered }
}
return { name: a.name, state: have === a.version ? ('present' as const) : ('version-differs' as const), profileVersion: a.version, installedVersion: have }
})
const configs = await Promise.all(
profile.apps
.filter((a) => a.config !== null)
.map(async (a) => {
if (!installed.has(a.name)) return { name: a.name, state: 'after-install' as const }
try {
const p = (await this.#appsConfig.preview(a.name, [s.id], a.config as string)).targets[0]
if (!p.ok) return { name: a.name, state: 'unreadable' as const, error: p.error }
return p.unchanged ? { name: a.name, state: 'same' as const } : { name: a.name, state: 'differs' as const, changes: p.changes as Diff }
} catch (e) {
return { name: a.name, state: 'unreadable' as const, error: reason(e) }
}
}),
)
let settings: unknown = null
if (profile.settings !== null) {
const p = (await this.#settings.preview([s.id], undefined, { settings: profile.settings, updatedAt: '', updatedBy: '' })).targets[0]
settings = p.ok ? { fields: p.changes.changes.map((c) => c.path), secretFields: p.secretFields, unchanged: p.unchanged, changes: p.changes, warnings: p.warnings } : { error: p.error }
}
let lists: unknown = null
if (profile.lists !== null) {
const p = (await this.#settings.listsPreview([s.id], removeExtras, { ...profile.lists, updatedAt: '', updatedBy: '' })).targets[0]
lists = p.ok ? { blocked: p.blocked, allowed: p.allowed, status: p.status, tooMany: p.tooMany } : { error: p.error }
}
return { ...base, ok: true, apps, configs, settings, lists, warnings: writable ? [] : ['The hub has no admin token for this server, so it cannot write to it.'] }
} catch (e) {
return { ...base, ok: false, error: reason(e), apps: [], configs: [], settings: null, lists: null, warnings: [] }
}
}),
)
return { profile: profile.name, targets: out }
}
/* ---- Apply ---- */
async #run(profile: Profile, s: HubServer, step: Step, removeExtras: boolean): Promise<{ ok: boolean; message: string }> {
const failed = (message: string) => ({ ok: false, message })
if (step === 'apps') {
this.#upstream.invalidate(s.id)
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string }[] } | null
const have = new Set((list?.apps ?? []).map((a) => a.name))
const installed: string[] = []
for (const a of profile.apps) {
if (have.has(a.name)) continue
const outcome = await this.#writer.runMany('install-store', a.name, [s.id])
if ('error' in outcome) return failed(`${a.name}: ${outcome.error}`)
const r = outcome.results[0]
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'could not be installed'}`)
installed.push(a.name)
}
return { ok: true, message: installed.length === 0 ? 'Every app is already installed.' : `Installed ${installed.join(', ')}.` }
}
if (step === 'configs') {
let written = 0
let same = 0
for (const a of profile.apps) {
if (a.config === null) continue
const done = await this.#appsConfig.apply(a.name, [s.id], {}, a.config)
const r = done.results[0]
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'failed'}`)
if (/nothing was written/.test(r.message ?? '')) same++
else written++
}
return { ok: true, message: `${written} config(s) applied, ${same} already in sync.` }
}
if (step === 'settings') {
const done = await this.#settings.apply([s.id], {}, undefined, { settings: profile.settings ?? {}, updatedAt: '', updatedBy: '' })
const r = done.results[0]
return { ok: r.ok, message: r.message ?? '' }
}
const done = await this.#settings.listsApply([s.id], removeExtras, { ...(profile.lists ?? { blocked: [], allowed: [] }), updatedAt: '', updatedBy: '' })
const r = done.results[0]
return { ok: r.ok, message: r.message ?? '' }
}
#stepsOf(profile: Profile): Step[] {
return STEPS.filter((step) => (step === 'apps' ? profile.apps.length > 0 : step === 'configs' ? profile.apps.some((a) => a.config !== null) : step === 'settings' ? profile.settings !== null : profile.lists !== null))
}
async #applyOne(profile: Profile, s: HubServer, from: Step | undefined, removeExtras: boolean): Promise<ApplyResult> {
const steps: StepResult[] = []
const plan = this.#stepsOf(profile)
if (plan.length === 0) return { server: s.id, ok: true, message: 'The profile has nothing to apply.', steps }
if (s.adminToken === undefined) {
return { server: s.id, ok: false, message: 'no admin token configured', steps: plan.map((step, i) => ({ step, status: i === 0 ? ('failed' as const) : ('not-run' as const), message: i === 0 ? 'no admin token configured' : 'An earlier step failed.' })) }
}
const start = from === undefined ? 0 : Math.max(0, plan.indexOf(from))
let stopped: Step | null = null
for (const [i, step] of plan.entries()) {
if (stopped !== null) {
steps.push({ step, status: 'not-run', message: 'An earlier step failed.' })
continue
}
if (i < start) {
steps.push({ step, status: 'skipped', message: 'Done by an earlier run.' })
continue
}
let outcome: { ok: boolean; message: string }
try {
outcome = await this.#run(profile, s, step, removeExtras)
} catch (e) {
outcome = { ok: false, message: reason(e) }
}
steps.push({ step, status: outcome.ok ? 'ok' : 'failed', message: outcome.message })
if (!outcome.ok) stopped = step
}
return {
server: s.id,
ok: stopped === null,
message: stopped === null ? 'Profile applied.' : `Stopped at ${stopped}: ${steps.find((x) => x.step === stopped)?.message ?? 'failed'}`.slice(0, 300),
steps,
}
}
/* Several servers go on in parallel; the steps of one server go one after the other. */
async apply(name: string, ids: string[], options: { removeExtras: boolean; from: Record<string, string> }): Promise<{ results: ApplyResult[] }> {
const profile = this.#require(name)
const targets = this.#targets(ids)
const results = await Promise.all(
targets.map((s) => {
const from = (STEPS as readonly string[]).includes(options.from[s.id]) ? (options.from[s.id] as Step) : undefined
return this.#applyOne(profile, s, from, options.removeExtras)
}),
)
return { results }
}
}
+12
View File
@@ -150,6 +150,17 @@ export const ROUTES: Record<string, Route> = {
'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<string, number> = {
'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,
}
+101 -2
View File
@@ -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<void> {
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.') }
try {
if (apiPath === 'hub/profiles/list') send(res, 200, ok({ profiles: profiles.list() }))
else if (apiPath === 'hub/profiles/get') send(res, 200, ok(profiles.get(params.name ?? '')))
else if (apiPath === 'hub/profiles/preview') {
const preview = await profiles.preview(params.name ?? '', csv(params.targets), params.removeExtras === 'true')
audit.log({ ...line, target: params.name, outcome: 'ok', detail: csv(params.targets).join(',') })
send(res, 200, ok(preview))
} else if (apiPath === 'hub/profiles/export') {
const file = profiles.exportFile(params.name)
audit.log({ ...line, target: params.name || undefined, outcome: 'ok', detail: `${file.profiles.length} profile(s)` })
const text = JSON.stringify(file, null, 2) + '\n'
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="hub-profiles-${file.exportedAt.slice(0, 10)}.json"`,
'cache-control': 'no-store',
'content-length': Buffer.byteLength(text),
})
res.end(text)
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
send(res, 400, fail(message))
}
}
/* Make, delete, import and apply a profile. Audit lines carry names, counts and the outcome of each step, never a value. */
async function profilesWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.'), target: (params.name ?? '').slice(0, 60) || undefined }
try {
if (apiPath === 'hub/profiles/snapshot') {
const done = await profiles.snapshot(params.name ?? '', { apps: params.apps === undefined ? undefined : csv(params.apps), settings: params.settings === 'true', lists: params.lists === 'true' }, who.user)
audit.log({ ...line, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ profile: profiles.get(params.name ?? ''), replaced: done.replaced }))
} else if (apiPath === 'hub/profiles/delete') {
profiles.delete(params.name ?? '')
audit.log({ ...line, outcome: 'ok' })
send(res, 200, ok({ deleted: true }))
} else if (apiPath === 'hub/profiles/import') {
const done = profiles.importFile(params.data ?? '', params.apply === 'true', who.user)
const count = (status: string) => done.profiles.filter((p) => p.status === status).length
audit.log({ ...line, target: undefined, outcome: 'ok', detail: `${done.applied ? 'applied' : 'dry run'}: ${count('new')} new, ${count('changed')} changed, ${count('same')} same` })
send(res, 200, ok(done))
} else if (apiPath === 'hub/profiles/apply') {
const from = jsonParam(params.from, {}) as Record<string, string>
const done = await profiles.apply(params.name ?? '', csv(params.targets), { removeExtras: params.removeExtras === 'true', from: from !== null && typeof from === 'object' ? from : {} })
for (const r of done.results) {
for (const s of r.steps.length > 0 ? r.steps : [{ step: 'apps', status: r.ok ? 'ok' : 'failed', message: r.message }]) {
if (s.status === 'not-run' || s.status === 'skipped') continue
audit.log({ ...line, server: r.server, outcome: s.status === 'ok' ? 'ok' : 'failed', detail: `${s.step}: ${s.message}`.slice(0, 300) })
}
}
send(res, 200, ok({ results: done.results }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
audit.log({ ...line, outcome: 'refused', detail: message.slice(0, 120) })
send(res, 400, fail(message))
}
}
/* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
if (route.kind === 'write-all') {
@@ -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
+51 -8
View File
@@ -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<string, string>, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
/* `override` is a baseline that is not the hub's own (a profile's settings). */
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[], override?: SettingsBaseline): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const 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<string, string> }> {
async listsApply(ids: string[], removeExtras: boolean, override?: ListsBaseline): Promise<{ results: TargetResult[]; details: Record<string, 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 done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
const details: Record<string, string> = {}
+230
View File
@@ -0,0 +1,230 @@
// @vitest-environment node
/*
Stage 8, notices to the outside: the signature, the timeout and the single retry, what is logged,
and the two endpoints (status and test) on a hub that has a webhook.
*/
import { createHmac } from 'node:crypto'
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseConfig } from './config.ts'
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
import { Webhook, sign } from './webhook.ts'
const urlConfig = (webhookUrl: string | null, webhookSecret: string | null = null) => ({ webhookUrl, webhookSecret, domain: 'hub.example' })
describe('Webhook', () => {
afterEach(() => vi.restoreAllMocks())
it('posts {event, at, hub, items} and signs the exact body when there is a secret', async () => {
const seen: { url: string; init: RequestInit }[] = []
const hook = new Webhook(urlConfig('https://hooks.example/in?token=abc', 's3cret'), {
now: () => Date.parse('2026-11-01T00:00:00Z'),
fetch: (async (url: string, init: RequestInit) => {
seen.push({ url, init })
return new Response('ok')
}) as typeof fetch,
})
hook.send('drift.found', [{ kind: 'settings', server: 'a', fields: ['blockingType'] }])
await hook.settled()
expect(seen).toHaveLength(1)
const body = String(seen[0].init.body)
expect(JSON.parse(body)).toEqual({ event: 'drift.found', at: '2026-11-01T00:00:00.000Z', hub: 'hub.example', items: [{ kind: 'settings', server: 'a', fields: ['blockingType'] }] })
const headers = seen[0].init.headers as Record<string, string>
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`)
expect(headers['x-hub-signature']).toBe(sign('s3cret', body))
})
it('sends no signature without a secret and nothing at all without a URL', async () => {
const seen: RequestInit[] = []
const fetcher = (async (_u: string, init: RequestInit) => {
seen.push(init)
return new Response('ok')
}) as typeof fetch
const hook = new Webhook(urlConfig('https://hooks.example/in'), { fetch: fetcher })
hook.send('drift.cleared', [{ kind: 'app' }])
await hook.settled()
expect((seen[0].headers as Record<string, string>)['x-hub-signature']).toBeUndefined()
const none = new Webhook(urlConfig(null), { fetch: fetcher })
none.send('drift.found', [{ kind: 'app' }])
await none.settled()
expect(seen).toHaveLength(1)
expect(none.status()).toEqual({ configured: false, host: null, signed: false, last: null })
})
it('retries once and logs the host only, never the query string or the error text', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const out = vi.spyOn(console, 'log').mockImplementation(() => undefined)
let tries = 0
const hook = new Webhook(urlConfig('https://hooks.example:8443/in?token=TOPSECRET'), {
retryDelayMs: 0,
fetch: (async () => {
tries++
throw new TypeError('fetch failed https://hooks.example:8443/in?token=TOPSECRET', { cause: { code: 'ECONNREFUSED' } })
}) as typeof fetch,
})
hook.send('drift.found', [{ kind: 'app' }])
await hook.settled()
expect(tries).toBe(2)
const text = [...log.mock.calls, ...out.mock.calls].flat().join(' ')
expect(text).toContain('hooks.example:8443')
expect(text).toContain('ECONNREFUSED')
expect(text).not.toContain('TOPSECRET')
expect(text).not.toContain('token=')
expect(hook.status()).toMatchObject({ configured: true, host: 'hooks.example:8443', last: { event: 'drift.found', ok: false, error: 'ECONNREFUSED' } })
expect(JSON.stringify(hook.status())).not.toContain('TOPSECRET')
})
it('treats a non-2xx answer as a failure and stops after the one retry; a second success is ok', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const answers = [500, 204]
let tries = 0
const hook = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: answers[tries++] ?? 500 })) as typeof fetch })
hook.send('drift.found', [{ kind: 'app' }])
await hook.settled()
expect(tries).toBe(2)
expect(hook.status().last).toMatchObject({ ok: true })
const bad = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: 503 })) as typeof fetch })
expect(await bad.deliver('webhook.test', [{}])).toMatchObject({ ok: false, error: 'HTTP 503' })
})
it('does not wait for a hanging receiver when sending, and gives up after the timeout', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
timeoutMs: 40,
retryDelayMs: 0,
fetch: ((_u: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject((init.signal as AbortSignal).reason))
})) as typeof fetch,
})
const started = Date.now()
hook.send('drift.found', [{ kind: 'app' }])
expect(Date.now() - started).toBeLessThan(30)
await hook.settled()
expect(hook.status().last).toMatchObject({ ok: false, error: 'TimeoutError' })
})
it('collects failed writes of one moment into a single notice', async () => {
const bodies: { event: string; items: unknown[] }[] = []
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
batchMs: 5,
fetch: (async (_u: string, init: RequestInit) => {
bodies.push(JSON.parse(String(init.body)))
return new Response('ok')
}) as typeof fetch,
})
hook.queueFailed({ action: 'a', server: 'x' })
hook.queueFailed({ action: 'a', server: 'y' })
await hook.settled()
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ event: 'write.failed', items: [{ server: 'x' }, { server: 'y' }] })
})
})
describe('HUB_WEBHOOK_URL', () => {
const base = { HUB_SERVERS: 'a=http://a.example', A_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' }
it('accepts http and https and refuses anything else', () => {
expect(parseConfig({ ...base, HUB_WEBHOOK_URL: 'https://h.example/x?y=1', HUB_WEBHOOK_SECRET: 's' })).toMatchObject({ webhookUrl: 'https://h.example/x?y=1', webhookSecret: 's' })
expect(parseConfig(base)).toMatchObject({ webhookUrl: null, webhookSecret: null })
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'ftp://h.example' })).toThrow(/HUB_WEBHOOK_URL/)
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'h.example' })).toThrow(/HUB_WEBHOOK_URL/)
})
})
describe('the webhook on a hub', () => {
let a: Fake
let b: Fake
let hub: TestHub
let receiver: http.Server
let received: { headers: http.IncomingHttpHeaders; body: string }[]
let status = 200
beforeAll(async () => {
a = await fake()
b = await fake()
receiver = http.createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
received.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') })
res.writeHead(status).end('ok')
})
})
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r))
hub = await startHub(a, b, {
HUB_WEBHOOK_URL: `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/in?token=QUERYSECRET`,
HUB_WEBHOOK_SECRET: 'shared-secret',
})
})
afterAll(() => {
hub.close()
a.server.close()
b.server.close()
receiver.close()
})
beforeEach(() => {
received = []
status = 200
})
it('shows its status to an admin only, with the host and never the query', async () => {
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/status', {}, 'GET')).code).toBe(403)
const got = await hub.call('admin', 'hub/webhook/status', {}, 'GET')
expect(got.code).toBe(200)
expect(got.body.response).toMatchObject({ configured: true, signed: true, last: null })
expect(got.body.response.host).toMatch(/^127\.0\.0\.1:\d+$/)
expect(JSON.stringify(got.body)).not.toContain('QUERYSECRET')
})
it('refuses the test to every role but admin, and refuses GET', async () => {
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/test')).code).toBe(403)
expect((await hub.call('admin', 'hub/webhook/test', {}, 'GET')).code).toBe(405)
expect(received).toEqual([])
})
it('sends a signed test notice, records the result and audits it', async () => {
const done = await hub.call('admin', 'hub/webhook/test')
expect(done.code).toBe(200)
expect(done.body.response.last).toMatchObject({ event: 'webhook.test', ok: true })
expect(received).toHaveLength(1)
const { headers, body } = received[0]
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 'shared-secret').update(body).digest('hex')}`)
expect(JSON.parse(body)).toMatchObject({ event: 'webhook.test', hub: 'hub' })
const audit = await hub.audit()
expect(audit.find((e) => e.action === 'webhook.test')).toMatchObject({ user: 'admin', outcome: 'ok' })
expect(JSON.stringify(audit)).not.toContain('QUERYSECRET')
expect(JSON.stringify(audit)).not.toContain('shared-secret')
})
it('reports a failing receiver in the answer without failing the request', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
status = 500
const done = await hub.call('admin', 'hub/webhook/test')
expect(done.code).toBe(200)
expect(done.body.status).toBe('ok')
expect(done.body.response.last).toMatchObject({ ok: false, error: 'HTTP 500' })
expect(received).toHaveLength(2)
expect((await hub.audit()).find((e) => e.action === 'webhook.test' && e.outcome === 'failed')).toBeTruthy()
vi.restoreAllMocks()
})
it('sends write.failed for a failed audited write, without the values', async () => {
a.on('zones/records/add', () => ({ error: 'The record already exists.' }))
const done = await hub.call('admin', 'zones/records/add', { node: 'a', zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'TOPVALUE' })
expect(done.body.status).toBe('error')
for (let i = 0; i < 40 && received.length === 0; i++) await new Promise((r) => setTimeout(r, 50))
expect(received).toHaveLength(1)
const sent = JSON.parse(received[0].body)
expect(sent.event).toBe('write.failed')
expect(sent.items[0]).toMatchObject({ action: 'zones.records.add', server: 'a', user: 'admin' })
expect(received[0].body).not.toContain('TOPVALUE')
// A failed login is not a write.
received = []
await fetch(`${hub.base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'user=admin&pass=wrong' })
await new Promise((r) => setTimeout(r, 500))
expect(received).toEqual([])
})
})
+141
View File
@@ -0,0 +1,141 @@
/*
Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body
`{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited
write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed:
`X-Hub-Signature: sha256=<hex hmac>`.
A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and
at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its
query string) and never the error text. Items carry names and field names, never values.
*/
import { createHmac } from 'node:crypto'
import type { HubConfig } from './config.ts'
export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test'
export interface Delivery {
event: WebhookEvent
at: string
ok: boolean
/** `HTTP 500`, or an error code or name. Never a URL. */
error?: string
}
export interface WebhookStatus {
configured: boolean
/** Host and port only. */
host: string | null
signed: boolean
last: Delivery | null
}
export interface WebhookOptions {
fetch?: typeof fetch
timeoutMs?: number
retryDelayMs?: number
/** How long write.failed items are collected into one notice. */
batchMs?: number
now?: () => number
}
export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
export class Webhook {
#url: string | null
#secret: string | null
#domain: string
#host: string | null
#fetch: typeof fetch
#timeoutMs: number
#retryDelayMs: number
#batchMs: number
#now: () => number
#last: Delivery | null = null
#pending = new Set<Promise<unknown>>()
#queued: Record<string, unknown>[] = []
#timer: NodeJS.Timeout | null = null
constructor(config: Pick<HubConfig, 'webhookUrl' | 'webhookSecret' | 'domain'>, options: WebhookOptions = {}) {
this.#url = config.webhookUrl
this.#secret = config.webhookSecret
this.#domain = config.domain
this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host
this.#fetch = options.fetch ?? fetch
this.#timeoutMs = options.timeoutMs ?? 5000
this.#retryDelayMs = options.retryDelayMs ?? 1000
this.#batchMs = options.batchMs ?? 250
this.#now = options.now ?? Date.now
}
get configured(): boolean {
return this.#url !== null
}
status(): WebhookStatus {
return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last }
}
/** Resolves when every delivery started so far has ended (for the tests). */
async settled(): Promise<void> {
if (this.#timer !== null) this.#flush()
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending])
}
#body(event: WebhookEvent, items: Record<string, unknown>[]): string {
return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items })
}
async #attempt(body: string): Promise<string | null> {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body)
try {
const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) })
await res.body?.cancel().catch(() => undefined)
return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}`
} catch (e) {
const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code
return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed'
}
}
/* Delivers one notice and records the result. Returns it. */
async deliver(event: WebhookEvent, items: Record<string, unknown>[]): Promise<Delivery> {
if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' }
const body = this.#body(event, items)
let error = await this.#attempt(body)
if (error !== null) {
if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs))
error = await this.#attempt(body)
}
const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) }
this.#last = result
if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`)
return result
}
/* Fire and forget: never throws, never waits. */
send(event: WebhookEvent, items: Record<string, unknown>[]): void {
if (this.#url === null || items.length === 0) return
const run = this.deliver(event, items).catch(() => undefined)
this.#pending.add(run)
void run.finally(() => this.#pending.delete(run))
}
/* Collects `write.failed` items for a moment, so ten failed servers make one notice. */
queueFailed(item: Record<string, unknown>): void {
if (this.#url === null) return
this.#queued.push(item)
if (this.#timer === null) {
this.#timer = setTimeout(() => this.#flush(), this.#batchMs)
this.#timer.unref()
}
}
#flush(): void {
if (this.#timer !== null) clearTimeout(this.#timer)
this.#timer = null
const items = this.#queued.splice(0, 50)
this.#queued = []
this.send('write.failed', items)
}
}
+18 -3
View File
@@ -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 {
+1 -1
View File
@@ -29,7 +29,7 @@ export async function refreshHubDrift(token: string | null): Promise<void> {
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 {
+126
View File
@@ -0,0 +1,126 @@
import { urlApi } from '../app/base'
import { apiRequest, envelopeOutcome, type ApiOutcome } from './client'
import type { ConfigDiff, RunOutcome } from './apps'
/*
Profiles and the webhook, behind a hub (hub/profiles.ts, hub/webhook.ts). Every endpoint needs the
hub's admin role. A profile holds settings secrets, so no answer shows one: they appear as
`<secret>`, and an app's config text is never sent, only whether there is one.
*/
export interface ProfileSummary {
name: string
apps: number
configs: number
settings: number | null
lists: { blocked: number; allowed: number } | null
updatedAt: string
updatedBy: string
}
export interface ProfileView {
name: string
createdAt: string
updatedAt: string
updatedBy: string
apps: { name: string; version: string; hasConfig: boolean; bytes: number }[]
settings: Record<string, unknown> | null
lists: { blocked: number; allowed: number } | null
}
export const listProfiles = (token: string | null): Promise<ApiOutcome<{ response: { profiles: ProfileSummary[] } }>> => apiRequest('hub/profiles/list', { token })
export const getProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: ProfileView }>> => apiRequest('hub/profiles/get', { token, body: { name } })
export interface SnapshotParts {
/* Undefined takes every app that has a baseline. */
apps?: string[]
settings: boolean
lists: boolean
}
export const snapshotProfile = (token: string | null, name: string, parts: SnapshotParts): Promise<ApiOutcome<{ response: { profile: ProfileView; replaced: boolean } }>> =>
apiRequest('hub/profiles/snapshot', {
token,
method: 'POST',
body: { name, settings: String(parts.settings), lists: String(parts.lists), ...(parts.apps === undefined ? {} : { apps: parts.apps.join(',') }) },
})
export const deleteProfile = (token: string | null, name: string): Promise<ApiOutcome<{ response: { deleted: boolean } }>> =>
apiRequest('hub/profiles/delete', { token, method: 'POST', body: { name } })
export interface ProfileImportPlan {
profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]
applied: boolean
}
export const importProfiles = (token: string | null, data: string, apply: boolean): Promise<ApiOutcome<{ response: ProfileImportPlan }>> =>
apiRequest('hub/profiles/import', { token, method: 'POST', body: { data, apply: apply ? 'true' : 'false' } })
/* The file of profiles (it holds settings secrets). A download is not the usual envelope. */
export async function exportProfiles(token: string | null, name?: string): Promise<ApiOutcome<{ filename: string; text: string }>> {
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
let res: Response
let text: string
try {
res = await fetch(urlApi(`api/hub/profiles/export${name === undefined ? '' : `?name=${encodeURIComponent(name)}`}`), { headers })
text = await res.text()
} catch {
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
}
try {
const env = JSON.parse(text) as { status?: string }
if (env.status !== undefined && env.status !== 'ok') return envelopeOutcome<{ filename: string; text: string }>(env, token)
} catch {
return { kind: 'error', message: 'The hub did not send a file.' }
}
const filename = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') ?? '')?.[1] ?? 'hub-profiles.json'
return { kind: 'ok', data: { filename, text } }
}
export interface ProfileAppState {
name: string
state: 'install' | 'present' | 'version-differs' | 'not-in-store'
profileVersion: string
storeVersion?: string
installedVersion?: string
}
export interface ProfileConfigState {
name: string
state: 'same' | 'differs' | 'after-install' | 'unreadable'
error?: string
changes?: ConfigDiff
}
export interface ProfileTargetPreview {
server: string
writable: boolean
ok: boolean
error?: string
apps: ProfileAppState[]
configs: ProfileConfigState[]
settings: { fields: string[]; secretFields: string[]; unchanged: boolean; changes: ConfigDiff; warnings: string[]; error?: string } | null
lists: { blocked: { missing: number; extra: number }; allowed: { missing: number; extra: number }; status: string; tooMany?: string; error?: string } | null
warnings: string[]
}
export const previewProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { profile: string; targets: ProfileTargetPreview[] } }>> =>
apiRequest('hub/profiles/preview', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras) } })
/* `from` says, per server, the step to start at: the retry of a failed run. */
export const applyProfile = (token: string | null, name: string, targets: string[], removeExtras: boolean, from: Record<string, string>): Promise<RunOutcome> =>
apiRequest('hub/profiles/apply', { token, method: 'POST', body: { name, targets: targets.join(','), removeExtras: String(removeExtras), from: JSON.stringify(from) } })
export interface WebhookStatus {
configured: boolean
/* Host and port only, never the address. */
host: string | null
signed: boolean
last: { event: string; at: string; ok: boolean; error?: string } | null
}
export const getWebhookStatus = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/status', { token })
export const testWebhook = (token: string | null): Promise<ApiOutcome<{ response: WebhookStatus }>> => apiRequest('hub/webhook/test', { token, method: 'POST', body: {} })
+167
View File
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DriftStatus } from './DriftStatus'
import { refreshHubDrift, resetHubDrift } from '../api/hub-drift'
import * as apps from '../api/apps'
import * as settings from '../api/hub-settings'
import * as profiles from '../api/hub-profiles'
import type { DriftEntry } from '../api/apps'
/*
The drift list with the kinds added in stage 8: settings and the Blocked and Allowed lists. Their
actions are for an admin and reuse the compare views and the apply flows of Settings and Blocking;
an entry names fields, never values. The webhook line is the admin's.
*/
beforeEach(() => resetHubDrift())
afterEach(() => vi.restoreAllMocks())
const ENTRIES: DriftEntry[] = [
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' },
{ kind: 'settings', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] },
{ kind: 'lists', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs', fields: ['blocked'] },
{ kind: 'settings', server: 'mdns', since: '2026-10-31T09:00:00Z', status: 'unreadable' },
]
async function load(entries: DriftEntry[] = ENTRIES) {
vi.spyOn(apps, 'getDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'x', apps: entries } } } as never)
await act(async () => refreshHubDrift('t'))
}
const NOT_SET = { configured: false, host: null, signed: false, last: null }
describe('DriftStatus kinds', () => {
it('counts every kind that differs and lists settings and lists with their field names', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
expect(await screen.findByRole('button', { name: 'Config drift' })).toHaveTextContent('3 config drifts')
await userEvent.click(screen.getByRole('button', { name: 'Config drift' }))
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
expect(within(settingsRow).getByText('blockingType, defaultRecordTtl')).toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByText('blocked')).toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Settings on mdns' })).getByText('Cannot read')).toBeInTheDocument()
})
it('gives a non-admin no action on settings or lists, but keeps the app actions of a writer', async () => {
render(<DriftStatus token="t" canWrite writable={['ndns']} />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
const settingsRow = await screen.findByRole('listitem', { name: 'Settings on ndns' })
expect(within(settingsRow).queryByRole('button')).not.toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).queryByRole('button')).not.toBeInTheDocument()
expect(within(screen.getByRole('listitem', { name: 'Blocker on ndns' })).getByRole('button', { name: 'Accept as baseline' })).toBeInTheDocument()
expect(screen.queryByText('Webhook')).not.toBeInTheDocument()
})
it('opens the settings compare, and the blocking compare for lists', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const compare = vi.spyOn(settings, 'getSettingsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
const lists = vi.spyOn(settings, 'getListsCompare').mockResolvedValue({ kind: 'error', message: 'stop here' } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: ENTRIES } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Open compare' }))
await waitFor(() => expect(compare).toHaveBeenCalledTimes(1))
expect(lists).not.toHaveBeenCalled()
expect(await screen.findByRole('dialog', { name: 'Compare settings across servers' })).toBeInTheDocument()
})
it('accepts the server settings as the baseline after a confirmation, then checks again', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const accept = vi.spyOn(settings, 'importSettingsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: { settings: {}, updatedAt: '', updatedBy: '' } } } } as never)
const check = vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
expect(accept).toHaveBeenCalledWith('t', 'ndns')
await waitFor(() => expect(check).toHaveBeenCalled())
})
it('accepts the lists of a server as the baseline', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const accept = vi.spyOn(settings, 'importListsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { imported: true } } } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Accept as baseline' }))
await userEvent.click(await screen.findByRole('button', { name: 'Accept' }))
expect(accept).toHaveBeenCalledWith('t', 'ndns')
})
it('reverts settings to the baseline through the preview', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const preview = vi.spyOn(settings, 'previewSettings').mockResolvedValue({
kind: 'ok',
data: { response: { targets: [{ server: 'ndns', ok: true, writable: true, currentHash: 'h1', changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] }, secretFields: [], unchanged: false, warnings: [] }] } },
} as never)
const apply = vi.spyOn(settings, 'applySettings').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Applied and verified (1 field).' }] } } } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Settings on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the settings baseline' })
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(preview).toHaveBeenCalledWith('t', ['ndns'], undefined)
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the settings baseline: review' })).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenCalledWith('t', ['ndns'], { ndns: 'h1' }, undefined)
expect(await screen.findByText('Applied and verified (1 field).')).toBeInTheDocument()
})
it('reverts the lists through the lists preview', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
vi.spyOn(settings, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [{ id: 'ndns', ok: true, writable: true, status: 'differs', blocked: { ...none, extra: 1 }, allowed: none }] } } } as never)
const apply = vi.spyOn(settings, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added 0, removed 0.' }] } } } as never)
vi.spyOn(apps, 'checkDrift').mockResolvedValue({ kind: 'ok', data: { response: { checkedAt: 'y', apps: [] } } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Blocked and Allowed lists on ndns' })).getByRole('button', { name: 'Revert to baseline' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists' })).getByRole('button', { name: 'Preview' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the Blocked and Allowed lists: review' })).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenCalledWith('t', ['ndns'], false)
})
})
describe('webhook line in the drift dialog', () => {
it('shows the host and the last delivery, and sends a test', async () => {
vi.spyOn(profiles, 'getWebhookStatus').mockResolvedValue({
kind: 'ok',
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'drift.found', at: '2026-11-01T00:00:00Z', ok: false, error: 'HTTP 500' } } },
} as never)
const test = vi.spyOn(profiles, 'testWebhook').mockResolvedValue({
kind: 'ok',
data: { response: { configured: true, host: 'hooks.example:8443', signed: true, last: { event: 'webhook.test', at: '2026-11-01T00:01:00Z', ok: true } } },
} as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
expect(await screen.findByText('hooks.example:8443')).toBeInTheDocument()
expect(screen.getByText('Last delivery failed (HTTP 500)')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
expect(test).toHaveBeenCalledWith('t')
expect(await screen.findByText('Last delivery ok')).toBeInTheDocument()
})
it('says it is not configured, with no test button, and shows an error with Retry', async () => {
const spy = vi
.spyOn(profiles, 'getWebhookStatus')
.mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never)
.mockResolvedValue({ kind: 'ok', data: { response: NOT_SET } } as never)
render(<DriftStatus token="t" canWrite writable={['ndns']} isAdmin />)
await load()
await userEvent.click(await screen.findByRole('button', { name: 'Config drift' }))
expect(await screen.findByText(/boom/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Not configured')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Send test' })).not.toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
})
})
+78 -19
View File
@@ -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<string | null>(null)
const [compare, setCompare] = useState<Scope | null>(null)
const [request, setRequest] = useState<RunRequest | null>(null)
const [accepting, setAccepting] = useState<DriftEntry | null>(null)
const [problem, setProblem] = useState<string | null>(null)
@@ -32,13 +41,25 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
if (count === 0 && !open) return null
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 (
<>
<button type="button" className={styles.status} data-attention="true" aria-label="Config drift" onClick={() => setOpen(true)}>
@@ -77,24 +106,36 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
}
>
<p className="m-0 text-sm text-muted-foreground">
These servers no longer have the config the hub expects: the baseline plus the server's own local fields.
These servers no longer have what the hub expects: an app&apos;s baseline plus the server&apos;s own local fields, or the shared settings and the Blocked and Allowed lists of their baselines.
</p>
{isAdmin && <WebhookStatus token={token} />}
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{entries.length === 0 ? (
<Empty title="No drift">Every server has the config the hub expects.</Empty>
<Empty title="No drift">Every server has what the hub expects.</Empty>
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Drifts">
{entries.map((e) => (
<li key={`${e.app}/${e.server}`} aria-label={`${e.app} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
<span className="font-medium">{e.app}</span>
<li key={keyOf(e)} aria-label={`${label(e)} on ${e.server}`} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
<span className="font-medium">{label(e)}</span>
<span>{e.server}</span>
<Tag tone={e.kind === 'differs' ? 'warn' : 'neutral'}>{KIND[e.kind]}</Tag>
<Tag tone={e.status === 'differs' ? 'warn' : 'neutral'}>{STATUS[e.status]}</Tag>
<span className="text-xs text-muted-foreground">since {new Date(e.since).toLocaleString()}</span>
{e.fields !== undefined && e.fields.length > 0 && (
<span className="basis-full font-mono text-xs text-muted-foreground">{e.fields.slice(0, 8).join(', ')}{e.fields.length > 8 ? ` and ${e.fields.length - 8} more` : ''}</span>
)}
<span className="ml-auto flex flex-wrap gap-2">
<Button size="sm" onClick={() => setConfigApp(e.app)}>
{e.kind === 'app' ? (
<Button size="sm" onClick={() => setConfigApp(e.name ?? e.app ?? '')}>
Open config
</Button>
{canWrite && e.kind === 'differs' && (
) : (
isAdmin && (
<Button size="sm" onClick={() => setCompare(e.kind === 'settings' ? 'settings' : 'blocking')}>
Open compare
</Button>
)
)}
{mayAct(e) && e.status === 'differs' && (
<>
<Button size="sm" disabled={busy} onClick={() => setAccepting(e)}>
Accept as baseline
@@ -114,7 +155,15 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
<Confirm
open={accepting !== null}
title="Accept as baseline"
text={`Make the config of ${accepting?.app ?? ''} on ${accepting?.server ?? ''} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`}
text={
accepting === null
? ''
: accepting.kind === 'app'
? `Make the config of ${label(accepting)} on ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the history.`
: accepting.kind === 'settings'
? `Make the settings of ${accepting.server} the baseline? The other servers will then differ from it. The old baseline is kept in the settings history.`
: `Make the Blocked and Allowed lists of ${accepting.server} the baseline? The other servers will then differ from it.`
}
label="Accept"
onClose={() => setAccepting(null)}
onConfirm={() => accepting && accept(accepting)}
@@ -123,12 +172,22 @@ export function DriftStatus({ token, canWrite, writable }: { token: string | nul
{configApp !== null && (
<AppConfigSync token={token} name={configApp} canWrite={canWrite} onClose={() => setConfigApp(null)} onChanged={() => void refreshHubDrift(token)} />
)}
{compare !== null && (
<SettingsCompare
token={token}
scope={compare}
onClose={() => {
setCompare(null)
void recheck()
}}
/>
)}
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void checkDrift(token).then(() => refreshHubDrift(token))
if (changed) void recheck()
}}
/>
)}
+1 -1
View File
@@ -107,7 +107,7 @@ describe('Shell on a hub', () => {
vi.restoreAllMocks()
vi.spyOn(client, 'apiRequest').mockImplementation((async (path: string) =>
path === 'hub/drift/get'
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', kind: 'differs' }] } } }
? { kind: 'ok', data: { status: 'ok', response: { checkedAt: 'x', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'ndns', since: '2026-10-31T09:00:00Z', status: 'differs' }] } } }
: { kind: 'ok', data: { status: 'ok', response: {} } }) as never)
const hubSession = session(true)
hubSession.info = { ...hubSession.info!, hubDrift: 1 }
+4 -3
View File
@@ -41,6 +41,7 @@ import { useBlockingMark } from '../screens/blocking/blocking-state'
import { useHubWarnings } from '../api/hub-warnings'
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
import { DriftStatus } from './DriftStatus'
import { WebhookStatus } from './WebhookStatus'
import { Confirm } from '../ui/Confirm'
import { Notifier } from '../ui/Notifier'
import type { Notice } from '../lib/notice'
@@ -613,7 +614,7 @@ export function Shell({
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
))}
</div>
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />}
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />}
<Popover>
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
@@ -679,9 +680,9 @@ export function Shell({
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
/>
) : current?.id === 'about' ? (
<About info={info} />
<About info={info} hubPanel={hub && permissions?.Settings?.canModify === true ? <WebhookStatus token={session.token} /> : undefined} />
) : current?.id === 'apps' ? (
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} isAdmin={permissions?.Settings?.canModify === true} />
) : current?.id === 'cache' ? (
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
) : current?.id === 'blocking' ? (
+78
View File
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useState } from 'react'
import { getWebhookStatus, testWebhook, type WebhookStatus as Status } from '../api/hub-profiles'
import { Button } from '../ui/Button'
import { Tag } from '../ui/Tag'
import { Skeleton } from '@/components/ui/skeleton'
/*
For the hub's admin: whether the hub sends notices to a webhook (HUB_WEBHOOK_URL), the host it
sends them to (never the address, which may carry a token), how the last delivery went, and a
button that sends a test notice. Read-only: the address and the secret are set on the hub.
*/
export function WebhookStatus({ token }: { token: string | null }) {
const [status, setStatus] = useState<Status | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
const outcome = await getWebhookStatus(token)
if (outcome.kind === 'ok') {
setStatus(outcome.data.response)
setFailure(null)
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}, [token])
useEffect(() => {
void load()
}, [load])
async function test() {
setBusy(true)
const outcome = await testWebhook(token)
setBusy(false)
if (outcome.kind === 'ok') {
setStatus(outcome.data.response)
setFailure(null)
} else setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}
if (failure !== null && status === null) {
return (
<div className="flex flex-wrap items-center gap-2 text-sm" role="alert">
<span>Could not read the webhook status. {failure}</span>
<Button size="sm" onClick={() => void load()}>
Retry
</Button>
</div>
)
}
if (status === null) return <Skeleton className="h-6 w-2/3" role="status" aria-label="Loading the webhook status" />
return (
<div className="flex flex-col gap-2" aria-label="Webhook">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">Webhook</span>
{status.configured ? <Tag tone="ok">Configured</Tag> : <Tag tone="neutral">Not configured</Tag>}
{status.configured && <span className="text-muted-foreground">{status.host}</span>}
{status.configured && status.last === null && <span className="text-xs text-muted-foreground">Nothing sent yet.</span>}
{status.last !== null && (
<>
<Tag tone={status.last.ok ? 'ok' : 'dan'}>{status.last.ok ? 'Last delivery ok' : `Last delivery failed${status.last.error === undefined ? '' : ` (${status.last.error})`}`}</Tag>
<span className="text-xs text-muted-foreground">{new Date(status.last.at).toLocaleString()}</span>
</>
)}
{status.configured && (
<Button size="sm" className="ml-auto" disabled={busy} onClick={() => void test()}>
Send test
</Button>
)}
</div>
{!status.configured && <p className="m-0 text-xs text-muted-foreground">Set HUB_WEBHOOK_URL on the hub to get a notice when a server drifts or a write fails.</p>}
{failure !== null && (
<p className="m-0 text-xs text-muted-foreground" role="alert">
{failure}
</p>
)}
</div>
)
}
+8 -1
View File
@@ -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 (
<>
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
@@ -166,6 +167,12 @@ export function About({ info }: { info?: Info }) {
</Body>
</Panel>
{hubPanel != null && (
<Panel title="Hub notifications" description="Where the hub sends drift and failure notices">
<Body>{hubPanel}</Body>
</Panel>
)}
{/*
This console's own panels (deviation 9 of CONVENTIONS.md, approved on
2026-10-16), under Server. Technitium's card on the left stays literal.
+7
View File
@@ -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<RunRequest | null>(null)
const [refresh, setRefresh] = useState(0)
useEffect(() => {
@@ -263,6 +268,7 @@ export function Apps({
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
<Button onClick={() => void exportFile()}>Export baselines</Button>
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
{isAdmin && <Button onClick={() => setProfilesDialog(true)}>Profiles</Button>}
</>
) : undefined
}
@@ -272,6 +278,7 @@ export function Apps({
) : (
<AppsMatrix token={token} canWrite={canWrite} refresh={refresh} />
)}
{isAdmin && <Profiles open={profilesDialog} onOpenChange={setProfilesDialog} token={token} servers={hubServers} />}
<ImportBaselines open={importDialog} onOpenChange={setImportDialog} token={token} onImported={() => setRefresh((n) => n + 1)} />
<HubInstallFile open={fileDialog} onOpenChange={setFileDialog} token={token} servers={hubServers} onRequest={setFileRequest} />
{fileRequest !== null && (
+3 -3
View File
@@ -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(<AppsMatrix token="t" />)
await screen.findByText('Blocker')
await waitFor(() => expect(screen.getAllByText('Drift')).toHaveLength(1))
+1 -1
View File
@@ -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(() => {})
+223
View File
@@ -0,0 +1,223 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Profiles } from './Profiles'
import * as api from '../../api/hub-profiles'
import type { ProfileSummary, ProfileTargetPreview } from '../../api/hub-profiles'
afterEach(() => vi.restoreAllMocks())
const SERVERS = [
{ id: 'mdns', writable: true },
{ id: 'ndns', writable: true },
]
const FRESH: ProfileSummary = { name: 'Fresh', apps: 2, configs: 1, settings: 12, lists: { blocked: 3, allowed: 1 }, updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin' }
const listOf = (profiles: ProfileSummary[]) => vi.spyOn(api, 'listProfiles').mockResolvedValue({ kind: 'ok', data: { response: { profiles } } } as never)
const PREVIEW: ProfileTargetPreview = {
server: 'ndns',
writable: true,
ok: true,
apps: [
{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' },
{ name: 'Logs', state: 'version-differs', profileVersion: '2.0', installedVersion: '1.0' },
],
configs: [{ name: 'Blocker', state: 'after-install' }],
settings: { fields: ['blockingType'], secretFields: ['tsigKeys'], unchanged: false, warnings: [], changes: { kind: 'json', changes: [{ path: 'blockingType', type: 'changed', a: 'AnyAddress', b: 'NxDomain' }] } },
lists: { blocked: { missing: 2, extra: 1 }, allowed: { missing: 0, extra: 0 }, status: 'differs' },
warnings: [],
}
const result = (steps: { step: 'apps' | 'configs' | 'settings' | 'lists'; status: 'ok' | 'failed' | 'skipped' | 'not-run'; message: string }[], ok: boolean) =>
({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok, message: ok ? 'Profile applied.' : 'Stopped at settings: refused', steps }] } } }) as never
describe('Profiles list', () => {
it('shows a skeleton while it loads, then each profile with its parts', async () => {
listOf([FRESH])
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
expect(screen.getByRole('status', { name: 'Loading the profiles' })).toBeInTheDocument()
const row = await screen.findByRole('listitem', { name: 'Fresh' })
expect(within(row).getByText('2 apps')).toBeInTheDocument()
expect(within(row).getByText('1 config')).toBeInTheDocument()
expect(within(row).getByText('12 settings')).toBeInTheDocument()
expect(within(row).getByText('4 list domains')).toBeInTheDocument()
for (const name of ['View', 'Apply to servers…', 'Export', 'Delete']) expect(within(row).getByRole('button', { name })).toBeInTheDocument()
})
it('has an empty state whose action creates a profile from the current baselines', async () => {
listOf([])
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Create from current baselines' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Fresh')
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(snapshot).toHaveBeenCalledWith('t', 'Fresh', { apps: undefined, settings: true, lists: true })
})
it('lets the admin leave a part out', async () => {
listOf([])
const snapshot = vi.spyOn(api, 'snapshotProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: {} as never, replaced: false } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(await screen.findByRole('button', { name: 'Create from current baselines' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a profile' })
await userEvent.type(within(dialog).getByLabelText('Profile name'), 'Light')
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Apps' }))
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'Blocked and Allowed lists' }))
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(snapshot).toHaveBeenCalledWith('t', 'Light', { apps: [], settings: true, lists: false })
})
it('shows an error with Retry', async () => {
const spy = vi.spyOn(api, 'listProfiles').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: { profiles: [FRESH] } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('listitem', { name: 'Fresh' })).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
})
it('views a profile with the secrets masked and no config text', async () => {
listOf([FRESH])
vi.spyOn(api, 'getProfile').mockResolvedValue({
kind: 'ok',
data: {
response: {
name: 'Fresh', createdAt: '', updatedAt: '2026-11-01T00:00:00Z', updatedBy: 'admin',
apps: [{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: 12 }],
settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] },
lists: { blocked: 3, allowed: 1 },
},
},
} as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'View' }))
const dialog = await screen.findByRole('dialog', { name: 'Profile: Fresh' })
expect(await within(dialog).findByText('With config')).toBeInTheDocument()
expect((within(dialog).getByLabelText('Shared settings (JSON)') as HTMLTextAreaElement).value).toContain('<secret>')
expect(within(dialog).getByText('3 blocked and 1 allowed domains.')).toBeInTheDocument()
})
it('deletes only after a confirmation', async () => {
const list = listOf([FRESH])
const removed = vi.spyOn(api, 'deleteProfile').mockResolvedValue({ kind: 'ok', data: { response: { deleted: true } } } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Delete' }))
expect(removed).not.toHaveBeenCalled()
list.mockResolvedValue({ kind: 'ok', data: { response: { profiles: [] } } } as never)
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }))
expect(removed).toHaveBeenCalledWith('t', 'Fresh')
expect(await screen.findByText('No profiles yet')).toBeInTheDocument()
})
})
describe('applying a profile', () => {
it('previews the steps per server, applies, and shows the steps of each result', async () => {
listOf([FRESH])
const preview = vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(
result(
[
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
],
true,
),
)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(preview).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false)
const review = await screen.findByRole('dialog', { name: 'Apply the profile Fresh: review' })
const steps = within(review).getByRole('list', { name: 'Steps on ndns' })
expect(within(steps).getByText('1. Apps')).toBeInTheDocument()
expect(within(steps).getByText('Will be installed')).toBeInTheDocument()
expect(within(steps).getByText('Other version installed')).toBeInTheDocument()
expect(within(steps).getByText('2. App configs')).toBeInTheDocument()
expect(within(steps).getByText(/tsigKeys: the secret values are sent but never shown/)).toBeInTheDocument()
expect(within(steps).getByText(/Blocked: 2 to add, 1 extra/)).toBeInTheDocument()
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], false, {})
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
expect(within(list).getByText('Installed Blocker.')).toBeInTheDocument()
expect(within(list).getAllByText('Done')).toHaveLength(4)
})
it('removes list extras only when ticked in the review', async () => {
listOf([FRESH])
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
const apply = vi.spyOn(api, 'applyProfile').mockResolvedValue(result([{ step: 'lists', status: 'ok', message: 'done' }], true))
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
await userEvent.click(await screen.findByRole('checkbox', { name: 'Also remove extras' }))
await userEvent.click(screen.getByRole('button', { name: 'Apply' }))
await waitFor(() => expect(apply).toHaveBeenCalledWith('t', 'Fresh', ['ndns'], true, {}))
})
it('says which step failed and retries from that step', async () => {
listOf([FRESH])
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'ok', data: { response: { profile: 'Fresh', targets: [PREVIEW] } } } as never)
const apply = vi
.spyOn(api, 'applyProfile')
.mockResolvedValueOnce(
result(
[
{ step: 'apps', status: 'ok', message: 'Installed Blocker.' },
{ step: 'configs', status: 'ok', message: '1 config(s) applied, 0 already in sync.' },
{ step: 'settings', status: 'failed', message: 'refused' },
{ step: 'lists', status: 'not-run', message: 'An earlier step failed.' },
],
false,
),
)
.mockResolvedValue(
result(
[
{ step: 'apps', status: 'skipped', message: 'Done by an earlier run.' },
{ step: 'configs', status: 'skipped', message: 'Done by an earlier run.' },
{ step: 'settings', status: 'ok', message: 'Applied and verified (1 field).' },
{ step: 'lists', status: 'ok', message: 'Added 2, removed 0.' },
],
true,
),
)
render(<Profiles open onOpenChange={() => {}} token="t" servers={SERVERS} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }))
const list = await screen.findByRole('list', { name: 'Steps on ndns' })
expect(within(list).getByText('Failed')).toBeInTheDocument()
expect(within(list).getByText('Not run')).toBeInTheDocument()
expect(screen.getByText(/Stopped at settings/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry failed' }))
await waitFor(() => expect(apply).toHaveBeenCalledTimes(2))
expect(apply).toHaveBeenLastCalledWith('t', 'Fresh', ['ndns'], false, { ndns: 'settings' })
await waitFor(() => expect(within(screen.getByRole('list', { name: 'Steps on ndns' })).getAllByText('Done earlier')).toHaveLength(2))
})
it('lists a server with no admin token disabled, and shows a preview error', async () => {
listOf([FRESH])
vi.spyOn(api, 'previewProfile').mockResolvedValue({ kind: 'error', message: 'unreachable' } as never)
render(<Profiles open onOpenChange={() => {}} token="t" servers={[{ id: 'mdns', writable: false }, { id: 'ndns', writable: true }]} />)
await userEvent.click(within(await screen.findByRole('listitem', { name: 'Fresh' })).getByRole('button', { name: 'Apply to servers…' }))
const picker = await screen.findByRole('dialog', { name: 'Apply the profile Fresh' })
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
await userEvent.click(within(picker).getByRole('checkbox', { name: 'ndns' }))
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
expect(await screen.findByText('unreachable')).toBeInTheDocument()
})
})
+546
View File
@@ -0,0 +1,546 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
applyProfile,
deleteProfile,
exportProfiles,
getProfile,
importProfiles,
listProfiles,
previewProfile,
snapshotProfile,
type ProfileImportPlan,
type ProfileSummary,
type ProfileTargetPreview,
type ProfileView,
} from '../../api/hub-profiles'
import type { RunOutcome } from '../../api/apps'
import { Alert } from '../../ui/Alert'
import { Button } from '../../ui/Button'
import { Check } from '../../ui/Check'
import { Confirm } from '../../ui/Confirm'
import { Dialog } from '../../ui/Dialog'
import { Empty, Failure } from '../../ui/Empty'
import { LabeledInput, LabeledTextarea } from '../../ui/Field'
import { Tag } from '../../ui/Tag'
import { Skeleton } from '@/components/ui/skeleton'
import { DiffView } from './ConfigDiffView'
import { RunFlow, pickerServers, type RunRequest } from './RunFlow'
/*
Behind a hub, for an admin: profiles, named bundles of store apps (with the hub's baseline config of
each), the shared settings and the Blocked and Allowed lists. One is made from the hub's current
baselines, can be exported to another hub, and is applied to one or several servers through the
same picker, review and results as every other change here. A server's steps run in order (apps,
app configs, settings, lists) and a failed one stops the rest for that server; "Retry failed" starts
again at the failed step. Nothing here shows a secret or an app's config text.
*/
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
const APP_STATE = {
install: ['warn', 'Will be installed'],
present: ['ok', 'Already installed'],
'version-differs': ['info', 'Other version installed'],
'not-in-store': ['dan', 'Not in this store'],
} as const
const CONFIG_STATE = {
same: ['ok', 'Same'],
differs: ['warn', 'Will change'],
'after-install': ['info', 'Set after install'],
unreadable: ['dan', 'Cannot read'],
} as const
/* ---- Apply: the review of what each server would get ---- */
function PreviewBody({ targets, opts }: { targets: ProfileTargetPreview[]; opts: { remove: boolean } }) {
const [remove, setRemove] = useState(opts.remove)
const extras = targets.reduce((n, t) => n + (t.lists?.blocked.extra ?? 0) + (t.lists?.allowed.extra ?? 0), 0)
return (
<div className="flex flex-col gap-6">
{targets.map((t) => (
<section key={t.server} aria-label={`Preview ${t.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{t.server}
{!t.ok && <Tag tone="dan">Cannot apply</Tag>}
</h3>
{t.error !== undefined && <Alert type="danger" title="Error">{t.error}</Alert>}
{t.warnings.map((w) => (
<Alert key={w} type="warning" title="Warning">
{w}
</Alert>
))}
{t.ok && (
<ol className="m-0 flex list-none flex-col gap-3 p-0" aria-label={`Steps on ${t.server}`}>
{t.apps.length > 0 && (
<li className="flex flex-col gap-1 text-sm">
<span className="font-medium">1. Apps</span>
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4">
{t.apps.map((a) => (
<li key={a.name} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-medium">{a.name}</span>
<Tag tone={APP_STATE[a.state][0]}>{APP_STATE[a.state][1]}</Tag>
<span className="text-muted-foreground">
{a.state === 'version-differs' ? `${a.installedVersion} here, ${a.profileVersion} in the profile; it is not updated` : a.state === 'install' ? `Store offers ${a.storeVersion}` : `Version ${a.profileVersion}`}
</span>
</li>
))}
</ul>
</li>
)}
{t.configs.length > 0 && (
<li className="flex flex-col gap-1 text-sm">
<span className="font-medium">2. App configs</span>
<ul className="m-0 flex list-none flex-col gap-2 p-0 pl-4">
{t.configs.map((c) => (
<li key={c.name} className="flex flex-col gap-1 text-xs">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{c.name}</span>
<Tag tone={CONFIG_STATE[c.state][0]}>{CONFIG_STATE[c.state][1]}</Tag>
{c.error !== undefined && <span className="text-muted-foreground">{c.error}</span>}
</span>
{c.changes !== undefined && <DiffView diff={c.changes} from="Now" to="After apply" />}
</li>
))}
</ul>
</li>
)}
{t.settings !== null && (
<li className="flex flex-col gap-1 text-sm">
<span className="flex items-center gap-2 font-medium">
3. Settings
{t.settings.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.settings.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">{`${t.settings.fields.length} field(s) will change`}</Tag>}
</span>
{t.settings.error !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.settings.error}</span>}
{t.settings.secretFields.length > 0 && <span className="pl-4 text-xs text-muted-foreground">{t.settings.secretFields.join(', ')}: the secret values are sent but never shown.</span>}
{t.settings.error === undefined && !t.settings.unchanged && (
<div className="pl-4">
<DiffView diff={t.settings.changes} from="Now" to="After apply" />
</div>
)}
</li>
)}
{t.lists !== null && (
<li className="flex flex-col gap-1 text-sm">
<span className="flex items-center gap-2 font-medium">
4. Blocked and Allowed lists
{t.lists.error !== undefined ? <Tag tone="dan">Cannot read</Tag> : t.lists.status === 'same' ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</span>
{t.lists.error !== undefined ? (
<span className="pl-4 text-xs text-muted-foreground">{t.lists.error}</span>
) : (
<span className="pl-4 text-xs text-muted-foreground">
Blocked: {t.lists.blocked.missing} to add, {t.lists.blocked.extra} extra. Allowed: {t.lists.allowed.missing} to add, {t.lists.allowed.extra} extra.
</span>
)}
{t.lists.tooMany !== undefined && <span className="pl-4 text-xs text-muted-foreground">{t.lists.tooMany}</span>}
</li>
)}
</ol>
)}
</section>
))}
{/* Only when a server has list domains the profile does not: otherwise there is nothing to remove. */}
{extras > 0 && (
<Check
label="Also remove extras"
help={`Deletes the ${extras} list domain(s) a server has that the profile does not. The server's lists are saved to the hub's history first.`}
checked={remove}
onChange={(on) => {
opts.remove = on
setRemove(on)
}}
/>
)}
</div>
)
}
/* The picker, the review and the results of applying one profile. */
function applyRequestFor(token: string | null, name: string, servers: { id: string; writable: boolean }[]): RunRequest {
const opts = { remove: false }
// Where each server should start next time: the step that failed on it.
const from: Record<string, string> = {}
return {
title: `Apply the profile ${name}`,
intro: 'Each server goes through the steps in order: install missing apps from its own store, apply the app configs, the settings, then the lists. A step that fails stops the later ones for that server.',
confirmLabel: 'Preview',
servers: pickerServers(servers),
preselected: [],
review: async (targets) => {
opts.remove = false
const outcome = await previewProfile(token, name, targets, false)
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
return { kind: 'ok', confirmLabel: 'Apply', context: undefined, body: <PreviewBody targets={outcome.data.response.targets} opts={opts} /> }
},
run: async (targets): Promise<RunOutcome> => {
const outcome = await applyProfile(token, name, targets, opts.remove, { ...from })
if (outcome.kind === 'ok') {
for (const r of outcome.data.response.results) {
const failed = r.steps?.find((s) => s.status === 'failed')
if (r.ok || failed === undefined) delete from[r.server]
else from[r.server] = failed.step
}
}
return outcome
},
}
}
/* ---- Make one ---- */
function CreateProfile({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
const [name, setName] = useState('')
const [apps, setApps] = useState(true)
const [settings, setSettings] = useState(true)
const [lists, setLists] = useState(true)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
async function create() {
setBusy(true)
setProblem(null)
const outcome = await snapshotProfile(token, name.trim(), { apps: apps ? undefined : [], settings, lists })
setBusy(false)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
onDone()
}
return (
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
title="Create a profile"
close="Cancel"
actions={
<Button variant="primary" disabled={busy || name.trim() === '' || (!apps && !settings && !lists)} onClick={() => void create()}>
Create
</Button>
}
>
<p className="m-0 text-sm text-muted-foreground">
A profile is a copy of the hub&apos;s baselines as they are now. An existing profile with the same name is replaced.
</p>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
<LabeledInput label="Profile name" value={name} maxLength={60} onChange={(e) => setName(e.target.value)} />
<div className="flex flex-col gap-3" role="group" aria-label="Parts">
<Check label="Apps" help="Every app that has a baseline, with its store version and its baseline config. Store apps only." checked={apps} onChange={setApps} />
<Check label="Settings" help="The shared fields of the settings baseline. Host fields stay out." checked={settings} onChange={setSettings} />
<Check label="Blocked and Allowed lists" help="The lists baseline." checked={lists} onChange={setLists} />
</div>
</Dialog>
)
}
/* ---- View one ---- */
function ViewProfile({ token, name, onClose }: { token: string | null; name: string; onClose: () => void }) {
const [view, setView] = useState<ProfileView | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const load = useCallback(async () => {
const outcome = await getProfile(token, name)
if (outcome.kind === 'ok') {
setView(outcome.data.response)
setFailure(null)
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
}, [token, name])
useEffect(() => {
void load()
}, [load])
return (
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
size="wide"
title={`Profile: ${name}`}
>
{failure !== null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the profile. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
) : view === null ? (
<Skeleton className="h-24 w-full" role="status" aria-label="Loading the profile" />
) : (
<div className="flex flex-col gap-4">
<p className="m-0 text-xs text-muted-foreground">
Made by {view.updatedBy} at {when(view.updatedAt)}. Secrets show as {'<secret>'}; an app&apos;s config text is not shown.
</p>
<section aria-label="Apps" className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Apps</h3>
{view.apps.length === 0 ? (
<Empty compact>No apps.</Empty>
) : (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{view.apps.map((a) => (
<li key={a.name} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{a.name}</span>
<span className="text-muted-foreground">{a.version}</span>
<Tag tone={a.hasConfig ? 'info' : 'neutral'}>{a.hasConfig ? 'With config' : 'No config'}</Tag>
</li>
))}
</ul>
)}
</section>
<section aria-label="Settings" className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Settings</h3>
{view.settings === null ? <Empty compact>Not part of this profile.</Empty> : <LabeledTextarea label="Shared settings (JSON)" mono rows={10} readOnly spellCheck={false} value={JSON.stringify(view.settings, null, 2)} onChange={() => {}} />}
</section>
<section aria-label="Lists" className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Blocked and Allowed lists</h3>
{view.lists === null ? <Empty compact>Not part of this profile.</Empty> : <p className="m-0 text-sm">{view.lists.blocked} blocked and {view.lists.allowed} allowed domains.</p>}
</section>
</div>
)}
</Dialog>
)
}
/* ---- Import ---- */
function ImportProfiles({ token, onClose, onDone }: { token: string | null; onClose: () => void; onDone: () => void }) {
const [text, setText] = useState<string | null>(null)
const [plan, setPlan] = useState<ProfileImportPlan | null>(null)
const [problem, setProblem] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
async function check() {
const file = fileRef.current?.files?.[0]
if (!file) return setProblem('Please select a profiles file.')
setBusy(true)
setProblem(null)
const content = await file.text()
const outcome = await importProfiles(token, content, false)
setBusy(false)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
setText(content)
setPlan(outcome.data.response)
}
async function commit() {
if (text === null) return
setBusy(true)
const outcome = await importProfiles(token, text, true)
setBusy(false)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
setPlan(outcome.data.response)
setDone(true)
onDone()
}
const changes = plan?.profiles.filter((p) => p.status !== 'same').length ?? 0
const TONE = { new: 'info', changed: 'warn', same: 'ok' } as const
const LABEL = { new: 'New', changed: 'Changed', same: 'Same' } as const
return (
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
title="Import profiles"
actions={
plan === null ? (
<Button variant="primary" disabled={busy} onClick={() => void check()}>
Check file
</Button>
) : !done ? (
<Button variant="primary" disabled={busy || changes === 0} onClick={() => void commit()}>
Import
</Button>
) : undefined
}
>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{plan === null ? (
<>
<p className="m-0 text-sm text-muted-foreground">Choose a file made by Export. It is checked first; nothing is changed until you press Import, and no server is touched.</p>
<LabeledInput label="Profiles file" type="file" accept="application/json,.json" ref={fileRef} />
</>
) : (
<>
{done && <Alert type="success" title="Imported.">The profiles were saved.</Alert>}
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Profiles in the file">
{plan.profiles.map((p) => (
<li key={p.name} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{p.name}</span>
<Tag tone={TONE[p.status]}>{LABEL[p.status]}</Tag>
<span className="text-xs text-muted-foreground">{p.apps} app(s){p.settings ? ', settings' : ''}{p.lists ? ', lists' : ''}</span>
</li>
))}
</ul>
</>
)}
</Dialog>
)
}
/* ---- The list ---- */
export function Profiles({
open,
onOpenChange,
token,
servers,
}: {
open: boolean
onOpenChange: (o: boolean) => void
token: string | null
/** The servers of the hub, and whether it holds an admin token for each. */
servers: { id: string; writable: boolean }[]
}) {
const [profiles, setProfiles] = useState<ProfileSummary[] | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const [viewing, setViewing] = useState<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const [request, setRequest] = useState<RunRequest | null>(null)
const [problem, setProblem] = useState<string | null>(null)
const load = useCallback(async () => {
const outcome = await listProfiles(token)
if (outcome.kind === 'ok') {
setProfiles(outcome.data.response.profiles)
setFailure(null)
} else setFailure(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
}, [token])
useEffect(() => {
if (open) void load()
}, [open, load])
async function exportFile(name: string) {
setProblem(null)
const outcome = await exportProfiles(token, name)
if (outcome.kind !== 'ok') return setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
const url = URL.createObjectURL(new Blob([outcome.data.text], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = outcome.data.filename
link.click()
URL.revokeObjectURL(url)
}
async function remove(name: string) {
const outcome = await deleteProfile(token, name)
if (outcome.kind !== 'ok') setProblem(sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined))
await load()
}
const create = (
<Button variant="primary" onClick={() => setCreating(true)}>
Create from current baselines
</Button>
)
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
size="wide"
title="Profiles"
actions={
<>
<Button onClick={() => setImporting(true)}>Import profiles</Button>
{profiles !== null && profiles.length > 0 && create}
</>
}
>
<p className="m-0 text-sm text-muted-foreground">
A profile brings a new or reset server in line: it installs the apps from that server&apos;s own store and applies their configs, the shared settings and the Blocked and Allowed lists.
</p>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{failure !== null && profiles === null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the profiles. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
) : profiles === null ? (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the profiles">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : profiles.length === 0 ? (
<Empty title="No profiles yet" actions={create}>
Make one from the baselines the hub holds now, or import a file from another hub.
</Empty>
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Profiles">
{profiles.map((p) => (
<li key={p.name} aria-label={p.name} className="flex flex-wrap items-center gap-3 border-b border-border pb-3 text-sm last:border-b-0">
<span className="font-medium">{p.name}</span>
<Tag tone="neutral">{`${p.apps} app${p.apps === 1 ? '' : 's'}`}</Tag>
{p.configs > 0 && <Tag tone="neutral">{`${p.configs} config${p.configs === 1 ? '' : 's'}`}</Tag>}
{p.settings !== null && <Tag tone="neutral">{`${p.settings} settings`}</Tag>}
{p.lists !== null && <Tag tone="neutral">{`${p.lists.blocked + p.lists.allowed} list domains`}</Tag>}
<span className="text-xs text-muted-foreground">{p.updatedBy} · {when(p.updatedAt)}</span>
<span className="ml-auto flex flex-wrap gap-2">
<Button size="sm" onClick={() => setViewing(p.name)}>
View
</Button>
<Button size="sm" variant="primary" onClick={() => setRequest(applyRequestFor(token, p.name, servers))}>
Apply to servers…
</Button>
<Button size="sm" onClick={() => void exportFile(p.name)}>
Export
</Button>
<Button size="sm" variant="danger" onClick={() => setDeleting(p.name)}>
Delete
</Button>
</span>
</li>
))}
</ul>
)}
</Dialog>
{creating && (
<CreateProfile
token={token}
onClose={() => setCreating(false)}
onDone={() => {
setCreating(false)
void load()
}}
/>
)}
{importing && (
<ImportProfiles
token={token}
onClose={() => setImporting(false)}
onDone={() => void load()}
/>
)}
{viewing !== null && <ViewProfile token={token} name={viewing} onClose={() => setViewing(null)} />}
<Confirm
open={deleting !== null}
title="Delete profile"
text={`Delete the profile ${deleting ?? ''}? Servers that already had it applied keep what they got.`}
label="Delete"
onClose={() => setDeleting(null)}
onConfirm={() => deleting !== null && remove(deleting)}
/>
{request !== null && <RunFlow request={request} onClose={() => setRequest(null)} />}
</>
)
}
+19 -2
View File
@@ -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<Stage>('pick')
@@ -185,10 +189,23 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
) : (
<ul className="m-0 flex list-none flex-col gap-3 p-0" aria-label="Results">
{results.map((r) => (
<li key={r.server} className="flex flex-wrap items-center gap-2 text-sm">
<li key={r.server} className="flex flex-col gap-2 text-sm">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.server}</span>
<Tag tone={r.ok ? 'ok' : 'dan'}>{r.ok ? 'Done' : 'Failed'}</Tag>
{r.message !== undefined && <span className="text-muted-foreground">{r.message}</span>}
</span>
{r.steps !== undefined && r.steps.length > 0 && (
<ul className="m-0 flex list-none flex-col gap-1 p-0 pl-4" aria-label={`Steps on ${r.server}`}>
{r.steps.map((s) => (
<li key={s.step} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-medium">{STEP_LABEL[s.step]}</span>
<Tag tone={s.status === 'ok' ? 'ok' : s.status === 'failed' ? 'dan' : 'neutral'}>{STEP_STATUS[s.status]}</Tag>
<span className="text-muted-foreground">{s.message}</span>
</li>
))}
</ul>
)}
</li>
))}
</ul>
+2 -2
View File
@@ -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',