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 <noreply@anthropic.com>
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
+3123 -106

No files matched your search

+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)
}
}