Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
392b644faa
commit
9742f0fe69
32 files changed
+3123
-106
No files matched your search
+89
-5
@@ -23,6 +23,8 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
||||
| `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. |
|
||||
| `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. |
|
||||
| `HUB_DRIFT_MINUTES` | `15` | How often the hub checks that every server still has the config it should (see Drift). `0` turns the check off. |
|
||||
| `HUB_WEBHOOK_URL` | none | An http or https address that gets a signed JSON notice on a change of drift and on a failed write (see Webhook notices). Treat it like a secret: it may carry a token. |
|
||||
| `HUB_WEBHOOK_SECRET` | none | Signs each notice: `X-Hub-Signature: sha256=<HMAC-SHA256 of the body>`. Never logged or returned. |
|
||||
| `HUB_TRUST_PROXY` | off | `1` believes the first `X-Forwarded-For` entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
|
||||
| `<ID>_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Only app writes use it, and only it: a server without one is read-only for the hub (the matrix and the session list the writable servers). Never logged or returned. |
|
||||
| `HUB_PORT` | `8080` | Listening port. |
|
||||
@@ -214,7 +216,7 @@ baseline and the history (mode 0600, protect the data folder like the users file
|
||||
any role; the endpoints are `admin` only anyway. A changed secret is shown as "secret changed". The audit log has field names, counts
|
||||
and outcomes, never values. Applying a baseline with TSIG keys does send the secrets to the servers (that is the point), over the
|
||||
connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overrides}.json`, `settings/history/` (50 files),
|
||||
`${HUB_DATA}/blocking/baseline.json`. Settings are not part of the drift check yet (a note in `drift.ts`).
|
||||
`${HUB_DATA}/blocking/baseline.json`. Settings and the lists are part of the drift check (see Drift).
|
||||
|
||||
## History, rollback, drift, export
|
||||
|
||||
@@ -232,12 +234,13 @@ connection you configured for them. Files: `${HUB_DATA}/settings/{baseline,overr
|
||||
|
||||
**Drift.** Every `HUB_DRIFT_MINUTES` (and once shortly after start) the hub reads each server's config of every app that has a
|
||||
baseline, with the read token, and compares it by meaning with the server's effective config (baseline plus its local values). The
|
||||
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ app, server, since, kind }] }`, where `kind` is
|
||||
`differs`, `unreadable` (the server or the read failed; listed, not an alarm) or `not-installed`, and `since` is the first check that
|
||||
saw it. Two checks never run at once.
|
||||
result is kept in memory and in `${HUB_DATA}/drift.json`: `{ checkedAt, apps: [{ kind, name?, server, since, status, fields? }] }`, where
|
||||
`kind` is `app`, `settings` or `lists` (stage 8), `status` is `differs`, `unreadable` (the server or the read failed; listed, not an
|
||||
alarm) or `not-installed`, and `since` is the first check that saw it. Two checks never run at once. See "Webhook notices and
|
||||
profiles" for the settings and lists kinds.
|
||||
|
||||
- `GET hub/drift/get` (read): the state. `POST hub/drift/check` (`apps.write`): check now.
|
||||
- `user/session/get` carries `info.hubDrift`, the number of `differs` entries; the console shows it in the header.
|
||||
- `user/session/get` carries `info.hubDrift`, the number of `differs` entries of every kind; the console shows it in the header.
|
||||
- `POST hub/apps/drift/accept {name, server}` (`apps.write`): "Accept as baseline". The server's config becomes the baseline; if the
|
||||
app has local fields, they keep the baseline's old values and the server's own values go to the overrides instead.
|
||||
- "Revert to baseline" is the normal apply on that server.
|
||||
@@ -256,6 +259,87 @@ Before an uninstall the app's config is saved to `${HUB_DATA}/apps/<app>/history
|
||||
audit log as `apps.<action>`; the history files hold configs, so protect the data folder like the users file.
|
||||
After any write the hub forgets its cached answers of that server.
|
||||
|
||||
## Webhook notices and profiles (stage 8)
|
||||
|
||||
**Webhook.** With `HUB_WEBHOOK_URL` (http or https) the hub POSTs a small JSON body when something changes. `HUB_WEBHOOK_SECRET`
|
||||
is optional and signs the body. Events:
|
||||
|
||||
| Event | When |
|
||||
|---|---|
|
||||
| `drift.found` | A server/kind/name newly differs from its baseline. Sent once, not at every check. |
|
||||
| `drift.cleared` | An entry that differed is back in line. An entry that became unreadable is not cleared: it is not known. |
|
||||
| `write.failed` | An audited write ended `failed` (not `refused`, not a failed login). Failures of the same moment are one notice. |
|
||||
| `webhook.test` | The admin pressed "Send test" (`POST hub/webhook/test`). |
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "drift.found",
|
||||
"at": "2026-11-01T09:15:00.000Z",
|
||||
"hub": "hub.example.net",
|
||||
"items": [
|
||||
{ "kind": "settings", "server": "ndns", "since": "2026-11-01T09:15:00.000Z", "fields": ["blockingType", "defaultRecordTtl"] },
|
||||
{ "kind": "app", "name": "Advanced Blocking", "server": "ndns", "since": "2026-11-01T09:15:00.000Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Items carry names and field names, never a value. A `write.failed` item has `action`, `server`, `target`, `user`, `at` and the
|
||||
audited `detail`. The first state is the one in `drift.json`, so a restart does not repeat notices.
|
||||
|
||||
With a secret, the header `X-Hub-Signature: sha256=<hex>` is the HMAC-SHA256 of the exact request body. Verify it before parsing:
|
||||
|
||||
```js
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
const expected = 'sha256=' + createHmac('sha256', SECRET).update(rawBody).digest('hex')
|
||||
const ok = expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))
|
||||
```
|
||||
|
||||
Delivery is fire-and-forget: a 5 second timeout, at most one retry (after a second), redirects are not followed, and it never
|
||||
blocks or fails the request that caused it. A failure goes to the server log with the HOST only (never the query string, which may
|
||||
carry a token) and the reason (`HTTP 500`, `ECONNREFUSED`, `TimeoutError`).
|
||||
|
||||
| Endpoint | Method | Needs | Does |
|
||||
|---|---|---|---|
|
||||
| `hub/webhook/status` | GET | `admin` | `{ configured, host, signed, last: { event, at, ok, error? } }`. The host and port only, never the address. |
|
||||
| `hub/webhook/test` | POST | `admin` | Sends a `webhook.test` notice now and waits for it; answers the same status. Audited as `webhook.test`. |
|
||||
|
||||
**Drift kinds.** `GET hub/drift/get` answers `{ checkedAt, apps: [entry] }` (the key is kept) with
|
||||
`entry = { kind: 'app' | 'settings' | 'lists', name?, app?, server, since, status, fields? }`. `status` is `differs`, `unreadable`
|
||||
or `not-installed`; `fields` are setting names, or `blocked` / `allowed` for the lists; `name` (and `app`) are the app. A
|
||||
`drift.json` from before stage 8, where `kind` held the status and `app` the name, still loads. Settings are compared like the
|
||||
Settings compare view (shared fields against the settings baseline), lists as sets against the lists baseline, and only when that
|
||||
baseline exists. The header count is the number of `differs` entries of every kind.
|
||||
|
||||
**Profiles.** A profile is a named bundle in `${HUB_DATA}/profiles/<name>.json` (mode 0600; it holds settings secrets as the
|
||||
settings baseline does, so protect the folder): store apps with the version they had and the hub's baseline config of each at that
|
||||
moment, the shared fields of the settings baseline (host fields never travel), and the Blocked/Allowed lists baseline. Every
|
||||
profile endpoint needs `admin`; viewer, operator and apps-admin are refused.
|
||||
|
||||
| Endpoint | Method | Does |
|
||||
|---|---|---|
|
||||
| `hub/profiles/list` | GET | Names with counts. |
|
||||
| `hub/profiles/get?name=` | GET | One profile with secrets as `<secret>` and no app config text (only whether there is one). |
|
||||
| `hub/profiles/snapshot {name, apps?, settings, lists}` | POST | Makes or replaces a profile from the current baselines. `apps` is a comma list (absent: every app with a baseline; empty: none). A zip app, or one installed nowhere, is refused. |
|
||||
| `hub/profiles/delete {name}` | POST | Deletes it. |
|
||||
| `hub/profiles/export?name=` | GET | `{ hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt, profiles }`, with the real secrets: keep the file as a secret. |
|
||||
| `hub/profiles/import {data, apply}` | POST | Same size limit as the baselines import (5 MB, signed-in callers only). Dry run first (`new`, `changed`, `same`); `apply=true` writes. A `<secret>` placeholder or a host field in the file is refused or dropped. |
|
||||
| `hub/profiles/preview {name, targets, removeExtras}` | GET/POST | Per target: apps to install, already there, or another version; app configs that would change; settings fields that would change; lists missing and extra. Writes nothing. |
|
||||
| `hub/profiles/apply {name, targets, removeExtras, from}` | POST | Runs the steps on each target. |
|
||||
|
||||
**Apply.** The targets go in parallel; the steps of one server go in order, and a failed step stops the later ones for that server:
|
||||
|
||||
1. `apps`: the missing apps are installed from THAT server's own `apps/listStoreApps` (the URL never comes from the request; an
|
||||
installed app is not updated, a version difference is only shown).
|
||||
2. `configs`: the profile's config of each app plus that server's own local values, with the old config saved to the history first
|
||||
and a read-back check (the same as the config apply).
|
||||
3. `settings`: the changed shared fields only, through the checks of `guards.ts` (no `webService*`, user, SSO or LDAP field), the
|
||||
old settings in the settings history, and a read-back check.
|
||||
4. `lists`: the missing domains are added; extras are removed only with `removeExtras`.
|
||||
|
||||
The answer is `{ results: [{ server, ok, message, steps: [{ step, status, message }] }] }` with `status` one of `ok`, `failed`,
|
||||
`skipped` (done by an earlier run) or `not-run`. `from` is `{ "<server>": "<step>" }`: the step to start at, so "Retry failed" does
|
||||
not repeat what worked. The audit has one `profiles.apply` line per server and step (names, counts and the outcome, no values).
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
|
||||
+5
-4
@@ -286,9 +286,9 @@ export class AppsConfig {
|
||||
return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 8, profiles: making one from the hub's baselines, what a viewer of it may see, export and
|
||||
import, the preview, and the order and the stop of an apply, on two fake servers that keep their
|
||||
apps, configs, settings and lists.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
|
||||
import { cleanProfile } from './profiles.ts'
|
||||
import { writeBaseline, writeListsBaseline, writeSettingsBaseline } from './store.ts'
|
||||
|
||||
interface Box {
|
||||
apps: Record<string, string>
|
||||
configs: Record<string, string>
|
||||
store: { name: string; version: string }[]
|
||||
settings: Record<string, unknown>
|
||||
blocked: string[]
|
||||
allowed: string[]
|
||||
failSettings: boolean
|
||||
}
|
||||
|
||||
const boxes = new Map<Fake, Box>()
|
||||
const urlOf = (f: Fake, name: string): string => `${f.url}/store/${name}.zip`
|
||||
|
||||
function install(f: Fake, box: Box): void {
|
||||
boxes.set(f, box)
|
||||
f.on('apps/list', () => ({ response: { apps: Object.entries(box.apps).map(([name, version]) => ({ name, version })) } }))
|
||||
f.on('apps/listStoreApps', () => ({ response: { storeApps: box.store.map((a) => ({ ...a, url: urlOf(f, a.name) })) } }))
|
||||
f.on('apps/downloadAndInstall', (c: Call) => {
|
||||
const name = c.form.get('name') ?? c.query.get('name') ?? ''
|
||||
const url = c.form.get('url') ?? c.query.get('url') ?? ''
|
||||
const entry = box.store.find((a) => a.name === name)
|
||||
if (entry === undefined || url !== urlOf(f, name)) return { error: 'The app was not found in the store.' }
|
||||
box.apps[name] = entry.version
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('apps/config/get', (c: Call) => ({ response: { config: box.configs[c.query.get('name') ?? ''] ?? null } }))
|
||||
f.on('apps/config/set', (c: Call) => {
|
||||
box.configs[c.form.get('name') ?? c.query.get('name') ?? ''] = c.form.get('config') ?? ''
|
||||
return { response: {} }
|
||||
})
|
||||
f.on('settings/get', () => ({ response: box.settings }))
|
||||
f.on('settings/set', (c: Call) => {
|
||||
if (box.failSettings) return { error: 'The settings were refused.' }
|
||||
for (const [k, v] of c.form) {
|
||||
if (k === 'tsigKeys') {
|
||||
const cells = v.split('|')
|
||||
box.settings.tsigKeys = v === 'false' ? [] : [{ keyName: cells[0], sharedSecret: cells[1], algorithmName: cells[2] }]
|
||||
} else if (k in box.settings) box.settings[k] = v
|
||||
}
|
||||
return { response: {} }
|
||||
})
|
||||
for (const list of ['blocked', 'allowed'] as const) {
|
||||
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
|
||||
f.on(`${list}/add`, (c: Call) => {
|
||||
box[list].push(c.form.get('domain') ?? '')
|
||||
return { response: {} }
|
||||
})
|
||||
f.on(`${list}/delete`, (c: Call) => {
|
||||
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
|
||||
return { response: {} }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
const boxA = (): Box => boxes.get(a)!
|
||||
const boxB = (): Box => boxes.get(b)!
|
||||
const order = (f: Fake): string[] => f.calls.filter((c) => c.method === 'POST').map((c) => c.path)
|
||||
|
||||
const CONFIG = JSON.stringify({ ttl: 30 })
|
||||
const settingsOf = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
dnsServerDomain: 'a.example',
|
||||
blockingType: 'NxDomain',
|
||||
tsigKeys: [{ keyName: 'k1', sharedSecret: 'REALSECRET', algorithmName: 'hmac-sha256' }],
|
||||
...over,
|
||||
})
|
||||
|
||||
function baselines(): void {
|
||||
writeBaseline(hub.dataDir, { app: 'Blocker', text: CONFIG, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||
writeSettingsBaseline(hub.dataDir, { settings: settingsOf(), updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||
writeListsBaseline(hub.dataDir, { blocked: ['ads.example'], allowed: ['ok.example'], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
hub = await startHub(a, b)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
a.calls = []
|
||||
b.calls = []
|
||||
install(a, {
|
||||
apps: { Blocker: '2.0', Zipped: '1.0' },
|
||||
configs: { Blocker: CONFIG },
|
||||
store: [{ name: 'Blocker', version: '2.0' }],
|
||||
settings: settingsOf(),
|
||||
blocked: ['ads.example'],
|
||||
allowed: ['ok.example'],
|
||||
failSettings: false,
|
||||
})
|
||||
// b is a fresh server: no apps, different settings, a missing domain, an extra one.
|
||||
install(b, {
|
||||
apps: {},
|
||||
configs: {},
|
||||
store: [{ name: 'Blocker', version: '2.0' }],
|
||||
settings: settingsOf({ dnsServerDomain: 'b.example', blockingType: 'AnyAddress', tsigKeys: [] }),
|
||||
blocked: ['mine.example'],
|
||||
allowed: ['ok.example'],
|
||||
failSettings: false,
|
||||
})
|
||||
baselines()
|
||||
for (const f of readdirSync(path.join(hub.dataDir)).filter((n) => n === 'profiles')) void f
|
||||
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
||||
})
|
||||
|
||||
const snapshot = (over: Record<string, string> = {}) =>
|
||||
hub.call('admin', 'hub/profiles/snapshot', { name: 'Fresh', apps: 'Blocker', settings: 'true', lists: 'true', ...over })
|
||||
|
||||
describe('making a profile', () => {
|
||||
it('copies the baselines at that moment: store apps with version and config, shared settings fields, lists', async () => {
|
||||
const done = await snapshot()
|
||||
expect(done.code).toBe(200)
|
||||
expect(done.body.status).toBe('ok')
|
||||
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||
expect(file.apps).toEqual([{ name: 'Blocker', version: '2.0', config: CONFIG }])
|
||||
// The host field stays out; the real secret is kept, as the baseline file has it.
|
||||
expect(file.settings.dnsServerDomain).toBeUndefined()
|
||||
expect(file.settings.tsigKeys[0].sharedSecret).toBe('REALSECRET')
|
||||
expect(file.lists).toEqual({ blocked: ['ads.example'], allowed: ['ok.example'] })
|
||||
expect(file.updatedBy).toBe('admin')
|
||||
// Later changes of the baselines do not change the profile.
|
||||
writeBaseline(hub.dataDir, { app: 'Blocker', text: '{"ttl":1}', updatedAt: 'x', updatedBy: 't' })
|
||||
const again = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||
expect(again.apps[0].config).toBe(CONFIG)
|
||||
})
|
||||
|
||||
it('lets the admin choose the parts, and refuses an empty choice', async () => {
|
||||
expect((await snapshot({ settings: 'false', lists: 'false' })).body.status).toBe('ok')
|
||||
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
||||
expect(file.settings).toBeNull()
|
||||
expect(file.lists).toBeNull()
|
||||
const none = await snapshot({ apps: '', settings: 'false', lists: 'false' })
|
||||
expect(none.code).toBe(400)
|
||||
expect(none.body.errorMessage).toMatch(/at least one part/)
|
||||
})
|
||||
|
||||
it('refuses an app that is a zip, one that is installed nowhere, and a bad name', async () => {
|
||||
const zip = await snapshot({ apps: 'Zipped' })
|
||||
expect(zip.code).toBe(400)
|
||||
expect(zip.body.errorMessage).toMatch(/store listing/)
|
||||
expect((await snapshot({ apps: 'Ghost' })).body.errorMessage).toMatch(/not installed/)
|
||||
expect((await snapshot({ name: '../evil' })).code).toBe(400)
|
||||
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a profile of the same name and lists it with counts only', async () => {
|
||||
await snapshot()
|
||||
await snapshot({ lists: 'false' })
|
||||
const list = await hub.call('admin', 'hub/profiles/list', {}, 'GET')
|
||||
expect(list.body.response.profiles).toMatchObject([{ name: 'Fresh', apps: 1, configs: 1, lists: null }])
|
||||
expect(list.body.response.profiles[0].settings).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewing a profile', () => {
|
||||
it('masks secrets and never returns an app config', async () => {
|
||||
await snapshot()
|
||||
const got = await hub.call('admin', 'hub/profiles/get', { name: 'Fresh' }, 'GET')
|
||||
const text = JSON.stringify(got.body)
|
||||
expect(text).not.toContain('REALSECRET')
|
||||
expect(text).toContain('<secret>')
|
||||
expect(text).not.toContain('"ttl"')
|
||||
expect(got.body.response.apps).toEqual([{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: CONFIG.length }])
|
||||
expect(got.body.response.lists).toEqual({ blocked: 1, allowed: 1 })
|
||||
})
|
||||
|
||||
it('answers an unknown profile with an error', async () => {
|
||||
expect((await hub.call('admin', 'hub/profiles/get', { name: 'Nope' }, 'GET')).code).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles', () => {
|
||||
it('refuses every profile endpoint to every role but admin, and answers none of them from a server', async () => {
|
||||
const reads = ['list', 'get', 'export', 'preview']
|
||||
const writes = ['snapshot', 'delete', 'import', 'apply']
|
||||
for (const role of ROLES.filter((r) => r !== 'admin')) {
|
||||
for (const p of reads) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b' }, 'GET')).code).toBe(403)
|
||||
for (const p of writes) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b', data: '{}' })).code).toBe(403)
|
||||
}
|
||||
// An unlisted profile path is refused, not forwarded.
|
||||
expect((await hub.call('admin', 'hub/profiles/nothing', {}, 'GET')).code).toBe(403)
|
||||
expect(a.calls).toEqual([])
|
||||
expect(b.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('export and import', () => {
|
||||
it('exports with the real secret, and imports it dry first, then for real', async () => {
|
||||
await snapshot()
|
||||
const res = await fetch(`${hub.base}/api/hub/profiles/export`, { headers: { authorization: `Bearer ${hub.tokens.admin}` } })
|
||||
expect(res.headers.get('content-disposition')).toMatch(/hub-profiles-/)
|
||||
const text = await res.text()
|
||||
expect(text).toContain('REALSECRET')
|
||||
const file = JSON.parse(text)
|
||||
expect(file).toMatchObject({ hub: 'technitium-hub', type: 'profiles', version: 1 })
|
||||
|
||||
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
||||
const dry = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'false' })
|
||||
expect(dry.body.response).toMatchObject({ applied: false, profiles: [{ name: 'Fresh', status: 'new', apps: 1, settings: true, lists: true }] })
|
||||
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
||||
const done = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
||||
expect(done.body.response.applied).toBe(true)
|
||||
const again = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
||||
expect(again.body.response.profiles[0].status).toBe('same')
|
||||
expect(JSON.stringify(await hub.audit())).not.toContain('REALSECRET')
|
||||
})
|
||||
|
||||
it('refuses a file of another kind, a placeholder where a secret should be, and local fields are dropped', async () => {
|
||||
const bad = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [] }) })
|
||||
expect(bad.code).toBe(400)
|
||||
const masked = { hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'M', apps: [], settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] }, lists: null }] }
|
||||
const refused = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify(masked) })
|
||||
expect(refused.code).toBe(400)
|
||||
expect(refused.body.errorMessage).toMatch(/<secret>/)
|
||||
expect(() => cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).not.toThrow()
|
||||
expect(cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).toEqual({ blockingType: 'NxDomain' })
|
||||
})
|
||||
|
||||
it('accepts a body above the usual limit for an admin', async () => {
|
||||
const big = JSON.stringify({ hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'Big', apps: [{ name: 'X', version: '1', config: JSON.stringify({ pad: 'x'.repeat(200_000) }) }], settings: null, lists: null }] })
|
||||
expect(big.length).toBeGreaterThan(64 * 1024)
|
||||
const done = await hub.call('admin', 'hub/profiles/import', { data: big, apply: 'false' })
|
||||
expect(done.body.response.profiles[0]).toMatchObject({ name: 'Big', status: 'new' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview', () => {
|
||||
it('says what each part would change on a target, and writes nothing', async () => {
|
||||
await snapshot()
|
||||
const got = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b,a' })
|
||||
expect(got.code).toBe(200)
|
||||
const [tb, ta] = got.body.response.targets
|
||||
expect(tb.server).toBe('b')
|
||||
expect(tb.apps).toEqual([{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' }])
|
||||
expect(tb.configs).toEqual([{ name: 'Blocker', state: 'after-install' }])
|
||||
expect(tb.settings.fields).toContain('blockingType')
|
||||
expect(tb.settings.secretFields).toContain('tsigKeys')
|
||||
expect(JSON.stringify(got.body)).not.toContain('REALSECRET')
|
||||
expect(tb.lists.blocked.missing).toBe(1)
|
||||
expect(ta.apps).toEqual([{ name: 'Blocker', state: 'present', profileVersion: '2.0', installedVersion: '2.0' }])
|
||||
expect(ta.configs).toEqual([{ name: 'Blocker', state: 'same' }])
|
||||
expect(ta.settings.unchanged).toBe(true)
|
||||
expect(order(a).concat(order(b))).toEqual([])
|
||||
})
|
||||
|
||||
it('marks an installed app whose version differs, and an app the store does not offer', async () => {
|
||||
await snapshot()
|
||||
boxB().apps.Blocker = '1.0'
|
||||
const one = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
||||
expect(one.body.response.targets[0].apps[0]).toMatchObject({ state: 'version-differs', installedVersion: '1.0', profileVersion: '2.0' })
|
||||
boxB().apps = {}
|
||||
boxB().store = []
|
||||
const two = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
||||
expect(two.body.response.targets[0].apps[0].state).toBe('not-in-store')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply', () => {
|
||||
it('runs the steps in order on a fresh server: install, config, settings, lists', async () => {
|
||||
await snapshot()
|
||||
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
||||
expect(done.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
||||
expect(done.body.response.results[0].steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||
['apps', 'ok'],
|
||||
['configs', 'ok'],
|
||||
['settings', 'ok'],
|
||||
['lists', 'ok'],
|
||||
])
|
||||
expect(order(b)).toEqual(['apps/downloadAndInstall', 'apps/config/set', 'settings/set', 'blocked/add'])
|
||||
expect(boxB().apps.Blocker).toBe('2.0')
|
||||
expect(JSON.parse(boxB().configs.Blocker)).toEqual({ ttl: 30 })
|
||||
expect(boxB().settings.blockingType).toBe('NxDomain')
|
||||
// The host field was left alone, and the extra domain stays unless asked.
|
||||
expect(boxB().settings.dnsServerDomain).toBe('b.example')
|
||||
expect(boxB().blocked).toEqual(['mine.example', 'ads.example'])
|
||||
// The old config of a server that had one goes to the history; b had none, so nothing is claimed.
|
||||
expect(order(a)).toEqual([])
|
||||
})
|
||||
|
||||
it('takes the install URL from the target own store, never from the request', async () => {
|
||||
await snapshot()
|
||||
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', url: 'https://evil.example/x.zip' })
|
||||
const install = b.calls.find((c) => c.path === 'apps/downloadAndInstall')!
|
||||
expect(install.query.get('url') ?? install.form.get('url')).toBe(urlOf(b, 'Blocker'))
|
||||
expect(a.calls.some((c) => c.path === 'apps/downloadAndInstall')).toBe(false)
|
||||
// Each server is asked for its own listing, with its own read token.
|
||||
expect(b.calls.find((c) => c.path === 'apps/listStoreApps')!.token).toBe('read-b')
|
||||
})
|
||||
|
||||
it('removes extra domains only when asked', async () => {
|
||||
await snapshot()
|
||||
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', removeExtras: 'true' })
|
||||
expect(boxB().blocked).toEqual(['ads.example'])
|
||||
})
|
||||
|
||||
it('stops the later steps of a server when one fails, says so, and leaves the other server alone', async () => {
|
||||
await snapshot()
|
||||
boxB().failSettings = true
|
||||
boxA().settings.blockingType = 'AnyAddress'
|
||||
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'a,b' })
|
||||
const [ra, rb] = done.body.response.results
|
||||
expect(ra).toMatchObject({ server: 'a', ok: true })
|
||||
expect(rb).toMatchObject({ server: 'b', ok: false })
|
||||
expect(rb.message).toMatch(/Stopped at settings/)
|
||||
expect(rb.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||
['apps', 'ok'],
|
||||
['configs', 'ok'],
|
||||
['settings', 'failed'],
|
||||
['lists', 'not-run'],
|
||||
])
|
||||
expect(order(b)).not.toContain('blocked/add')
|
||||
const audit = await hub.audit()
|
||||
const lines = audit.filter((e) => e.action === 'profiles.apply' && e.server === 'b').slice(0, 3)
|
||||
expect(lines.map((e) => [e.outcome, e.detail?.split(':')[0]])).toEqual([['failed', 'settings'], ['ok', 'configs'], ['ok', 'apps']])
|
||||
expect(JSON.stringify(audit)).not.toContain('REALSECRET')
|
||||
})
|
||||
|
||||
it('retries from the failed step without redoing the earlier ones', async () => {
|
||||
await snapshot()
|
||||
boxB().failSettings = true
|
||||
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
||||
b.calls = []
|
||||
boxB().failSettings = false
|
||||
const again = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', from: JSON.stringify({ b: 'settings' }) })
|
||||
const r = again.body.response.results[0]
|
||||
expect(r.ok).toBe(true)
|
||||
expect(r.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
||||
['apps', 'skipped'],
|
||||
['configs', 'skipped'],
|
||||
['settings', 'ok'],
|
||||
['lists', 'ok'],
|
||||
])
|
||||
expect(order(b)).toEqual(['settings/set', 'blocked/add'])
|
||||
})
|
||||
|
||||
it('fails a server with no admin token on its first step, and refuses an unknown server or profile', async () => {
|
||||
await snapshot()
|
||||
const unknown = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'zzz' })
|
||||
expect(unknown.code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Nope', targets: 'b' })).code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: '' })).code).toBe(400)
|
||||
})
|
||||
|
||||
it('deletes a profile', async () => {
|
||||
await snapshot()
|
||||
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).body.response).toEqual({ deleted: true })
|
||||
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).code).toBe(400)
|
||||
expect((await hub.call('admin', 'hub/profiles/list', {}, 'GET')).body.response.profiles).toEqual([])
|
||||
})
|
||||
})
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
Profiles (stage 8): a named bundle of what a server should have, kept in `${HUB_DATA}/profiles/`,
|
||||
to bring a new or reset server in line in one go.
|
||||
|
||||
A profile holds, as the admin chose when it was made from the hub's current baselines:
|
||||
|
||||
apps store apps only (never a zip): the name and the version they had, and for each the hub's
|
||||
baseline config at that moment
|
||||
settings the SHARED fields of the settings baseline (host fields never travel), secrets as the
|
||||
baseline file keeps them
|
||||
lists the custom Blocked and Allowed lists baseline
|
||||
|
||||
Applying a profile to a server runs four steps in order, and a failed step stops the later ones for
|
||||
that server (the other servers go on):
|
||||
|
||||
apps install the apps that are missing, from THAT server's own store listing (apps-write.ts)
|
||||
configs the profile's config of each app plus that server's own local values; the old config
|
||||
goes to the history first (apps-config.ts)
|
||||
settings the changed shared fields, through the same checks as a per-server settings/set
|
||||
lists the missing domains; extras only when asked
|
||||
|
||||
`from` restarts a server at a given step (the retry of a failed run), so what already worked is not
|
||||
done again. Everything here is for admins. A view never carries a secret and never an app config.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { HubConfig, HubServer } from './config.ts'
|
||||
import { MAX_CONFIG, looksLikeJson, parseJson, type Diff } from './apps-sync.ts'
|
||||
import { UserError, compareVersions, type AppsConfig } from './apps-config.ts'
|
||||
import type { AppsWriter } from './apps-write.ts'
|
||||
import { isFieldName, isBuiltInLocal, MAX_LIST, SECRET_PLACEHOLDER, maskSettings } from './settings-sync.ts'
|
||||
import type { SettingsConfig } from './settings-config.ts'
|
||||
import { listBaselineApps, readBaseline, readListsBaseline, readSettingsBaseline, safeName, writeAtomic } from './store.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
|
||||
export const STEPS = ['apps', 'configs', 'settings', 'lists'] as const
|
||||
export type Step = (typeof STEPS)[number]
|
||||
|
||||
export interface ProfileApp {
|
||||
name: string
|
||||
/** The version the app had when the profile was made. */
|
||||
version: string
|
||||
/** The hub's baseline config of the app then; null when it had none. */
|
||||
config: string | null
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
apps: ProfileApp[]
|
||||
settings: Record<string, unknown> | null
|
||||
lists: { blocked: string[]; allowed: string[] } | null
|
||||
}
|
||||
|
||||
export interface StepResult {
|
||||
step: Step
|
||||
/* `skipped`: done by an earlier run. `not-run`: an earlier step failed. */
|
||||
status: 'ok' | 'failed' | 'skipped' | 'not-run'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
server: string
|
||||
ok: boolean
|
||||
message: string
|
||||
steps: StepResult[]
|
||||
}
|
||||
|
||||
const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/
|
||||
const MAX_PROFILES = 50
|
||||
const MAX_APPS = 100
|
||||
const MAX_SETTINGS_BYTES = 1024 * 1024
|
||||
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
const hasPlaceholder = (value: unknown): boolean => JSON.stringify(value).includes(JSON.stringify(SECRET_PLACEHOLDER))
|
||||
|
||||
/* The stored shape, checked field by field. Used for files on disk and for imports. */
|
||||
export function cleanProfile(raw: unknown, fallbackName?: string): Profile {
|
||||
if (!isObject(raw)) throw new UserError('A profile must be an object.')
|
||||
const name = typeof raw.name === 'string' ? raw.name : (fallbackName ?? '')
|
||||
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
||||
if (!Array.isArray(raw.apps) || raw.apps.length > MAX_APPS) throw new UserError(`"${name}": the apps must be a list of at most ${MAX_APPS}.`)
|
||||
const seen = new Set<string>()
|
||||
const apps = raw.apps.map((a): ProfileApp => {
|
||||
if (!isObject(a) || typeof a.name !== 'string' || a.name === '' || a.name.length > 200 || seen.has(a.name)) throw new UserError(`"${name}": an app has no valid, unique name.`)
|
||||
seen.add(a.name)
|
||||
const version = typeof a.version === 'string' ? a.version.slice(0, 40) : ''
|
||||
const config = typeof a.config === 'string' ? a.config : null
|
||||
if (config !== null) {
|
||||
if (config.length > MAX_CONFIG) throw new UserError(`"${name}": the config of "${a.name}" is larger than 1 MB.`)
|
||||
if (looksLikeJson(config) && !parseJson(config).ok) throw new UserError(`"${name}": the config of "${a.name}" is not valid JSON.`)
|
||||
}
|
||||
return { name: a.name, version, config }
|
||||
})
|
||||
let settings: Profile['settings'] = null
|
||||
if (raw.settings !== null && raw.settings !== undefined) {
|
||||
if (!isObject(raw.settings)) throw new UserError(`"${name}": the settings must be an object.`)
|
||||
if (JSON.stringify(raw.settings).length > MAX_SETTINGS_BYTES) throw new UserError(`"${name}": the settings are larger than 1 MB.`)
|
||||
if (hasPlaceholder(raw.settings)) throw new UserError(`"${name}": the settings hold ${SECRET_PLACEHOLDER} where a secret should be. Export the profile from the hub that has it.`)
|
||||
settings = {}
|
||||
for (const [k, v] of Object.entries(raw.settings)) {
|
||||
if (!isFieldName(k)) throw new UserError(`"${name}": "${k.slice(0, 40)}" is not a setting name.`)
|
||||
// Host fields never travel.
|
||||
if (!isBuiltInLocal(k)) settings[k] = v
|
||||
}
|
||||
}
|
||||
let lists: Profile['lists'] = null
|
||||
if (raw.lists !== null && raw.lists !== undefined) {
|
||||
const l = raw.lists as { blocked?: unknown; allowed?: unknown }
|
||||
if (!isObject(raw.lists) || !Array.isArray(l.blocked) || !Array.isArray(l.allowed)) throw new UserError(`"${name}": the lists need a blocked and an allowed array.`)
|
||||
const clean = (v: unknown[]): string[] => {
|
||||
if (v.length > MAX_LIST || v.some((d) => typeof d !== 'string' || d.length > 253)) throw new UserError(`"${name}": a list is longer than ${MAX_LIST} domains or holds a bad entry.`)
|
||||
return [...new Set(v as string[])].sort()
|
||||
}
|
||||
lists = { blocked: clean(l.blocked), allowed: clean(l.allowed) }
|
||||
}
|
||||
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
return { name, createdAt: text(raw.createdAt), updatedAt: text(raw.updatedAt), updatedBy: text(raw.updatedBy), apps, settings, lists }
|
||||
}
|
||||
|
||||
const shape = (p: Profile): string => JSON.stringify({ apps: p.apps, settings: p.settings, lists: p.lists })
|
||||
|
||||
export class Profiles {
|
||||
#config: HubConfig
|
||||
#upstream: Upstream
|
||||
#appsConfig: AppsConfig
|
||||
#settings: SettingsConfig
|
||||
#writer: AppsWriter
|
||||
#now: () => number
|
||||
|
||||
constructor(config: HubConfig, upstream: Upstream, appsConfig: AppsConfig, settings: SettingsConfig, writer: AppsWriter, now: () => number = Date.now) {
|
||||
this.#config = config
|
||||
this.#upstream = upstream
|
||||
this.#appsConfig = appsConfig
|
||||
this.#settings = settings
|
||||
this.#writer = writer
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
#dir(): string {
|
||||
return path.join(this.#config.dataDir, 'profiles')
|
||||
}
|
||||
|
||||
#file(name: string): string {
|
||||
return path.join(this.#dir(), `${safeName(name)}.json`)
|
||||
}
|
||||
|
||||
#read(name: string): Profile | null {
|
||||
const file = this.#file(name)
|
||||
if (!existsSync(file)) return null
|
||||
try {
|
||||
const p = cleanProfile(JSON.parse(readFileSync(file, 'utf8')))
|
||||
return p.name === name ? p : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
#all(): Profile[] {
|
||||
if (!existsSync(this.#dir())) return []
|
||||
const out: Profile[] = []
|
||||
for (const f of readdirSync(this.#dir())) {
|
||||
if (!f.endsWith('.json')) continue
|
||||
try {
|
||||
out.push(cleanProfile(JSON.parse(readFileSync(path.join(this.#dir(), f), 'utf8'))))
|
||||
} catch {
|
||||
// A file that is not a profile is left alone.
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
#require(name: string): Profile {
|
||||
const found = this.#read(name)
|
||||
if (found === null) throw new UserError(`There is no profile named "${name.slice(0, 60)}".`)
|
||||
return found
|
||||
}
|
||||
|
||||
#write(p: Profile): void {
|
||||
writeAtomic(this.#file(p.name), JSON.stringify(p, null, 2) + '\n')
|
||||
}
|
||||
|
||||
/* What the console lists: counts, no config and no value. */
|
||||
list(): { name: string; apps: number; configs: number; settings: number | null; lists: { blocked: number; allowed: number } | null; updatedAt: string; updatedBy: string }[] {
|
||||
return this.#all().map((p) => ({
|
||||
name: p.name,
|
||||
apps: p.apps.length,
|
||||
configs: p.apps.filter((a) => a.config !== null).length,
|
||||
settings: p.settings === null ? null : Object.keys(p.settings).length,
|
||||
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
||||
updatedAt: p.updatedAt,
|
||||
updatedBy: p.updatedBy,
|
||||
}))
|
||||
}
|
||||
|
||||
/* One profile for the console: secrets masked, no app config text. */
|
||||
get(name: string): unknown {
|
||||
const p = this.#require(name)
|
||||
return {
|
||||
name: p.name,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
updatedBy: p.updatedBy,
|
||||
apps: p.apps.map((a) => ({ name: a.name, version: a.version, hasConfig: a.config !== null, bytes: a.config === null ? 0 : a.config.length })),
|
||||
settings: p.settings === null ? null : maskSettings(p.settings),
|
||||
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
|
||||
}
|
||||
}
|
||||
|
||||
delete(name: string): void {
|
||||
this.#require(name)
|
||||
rmSync(this.#file(name), { force: true })
|
||||
}
|
||||
|
||||
/* ---- Making one from the hub's current baselines ---- */
|
||||
|
||||
async #installedVersions(): Promise<Map<string, string>> {
|
||||
const versions = new Map<string, string>()
|
||||
let answered = 0
|
||||
await Promise.all(
|
||||
this.#config.servers.map(async (s) => {
|
||||
try {
|
||||
this.#upstream.invalidate(s.id)
|
||||
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||
answered++
|
||||
for (const a of list?.apps ?? []) {
|
||||
const have = versions.get(a.name)
|
||||
const v = typeof a.version === 'string' ? a.version : ''
|
||||
if (have === undefined || compareVersions(v, have) > 0) versions.set(a.name, v)
|
||||
}
|
||||
} catch {
|
||||
// A server that does not answer adds nothing.
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (answered === 0) throw new UserError('No server answered, so the installed apps cannot be read.')
|
||||
return versions
|
||||
}
|
||||
|
||||
async #storeNames(): Promise<Set<string>> {
|
||||
const names = new Set<string>()
|
||||
let answered = 0
|
||||
await Promise.all(
|
||||
this.#config.servers.map(async (s) => {
|
||||
try {
|
||||
const r = (await this.#upstream.call(s, 'apps/listStoreApps')) as { storeApps?: { name: string }[] } | null
|
||||
answered++
|
||||
for (const a of r?.storeApps ?? []) names.add(a.name)
|
||||
} catch {
|
||||
// Same.
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (answered === 0) throw new UserError('No server answered, so the store listing cannot be read.')
|
||||
return names
|
||||
}
|
||||
|
||||
/*
|
||||
Makes or replaces a profile from the hub's current baselines. `apps`: the names to take (undefined
|
||||
takes every app that has a baseline). Settings and lists are taken whole when asked.
|
||||
*/
|
||||
async snapshot(name: string, parts: { apps?: string[]; settings: boolean; lists: boolean }, user: string): Promise<{ replaced: boolean; detail: string }> {
|
||||
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
|
||||
const appNames = parts.apps ?? listBaselineApps(this.#config.dataDir)
|
||||
if (appNames.length === 0 && !parts.settings && !parts.lists) throw new UserError('Choose at least one part: apps, settings or lists.')
|
||||
if (appNames.length > MAX_APPS) throw new UserError(`At most ${MAX_APPS} apps in a profile.`)
|
||||
const existing = this.#read(name)
|
||||
if (existing === null && this.#all().length >= MAX_PROFILES) throw new UserError(`At most ${MAX_PROFILES} profiles.`)
|
||||
|
||||
let apps: ProfileApp[] = []
|
||||
if (appNames.length > 0) {
|
||||
const [versions, store] = await Promise.all([this.#installedVersions(), this.#storeNames()])
|
||||
apps = [...new Set(appNames)].map((app) => {
|
||||
const version = versions.get(app)
|
||||
if (version === undefined) throw new UserError(`"${app}" is not installed on any server, so its version is not known.`)
|
||||
if (!store.has(app)) throw new UserError(`"${app}" is not in the store listing. A profile holds store apps only, not apps installed from a file.`)
|
||||
return { name: app, version, config: readBaseline(this.#config.dataDir, app)?.text ?? null }
|
||||
})
|
||||
}
|
||||
let settings: Profile['settings'] = null
|
||||
if (parts.settings) {
|
||||
const baseline = readSettingsBaseline(this.#config.dataDir)
|
||||
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
|
||||
const extra = this.#settings.extraLocal()
|
||||
settings = Object.fromEntries(Object.entries(baseline.settings).filter(([k]) => !isBuiltInLocal(k) && !extra.includes(k)))
|
||||
}
|
||||
let lists: Profile['lists'] = null
|
||||
if (parts.lists) {
|
||||
const baseline = readListsBaseline(this.#config.dataDir)
|
||||
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
|
||||
lists = { blocked: [...baseline.blocked], allowed: [...baseline.allowed] }
|
||||
}
|
||||
const at = new Date(this.#now()).toISOString()
|
||||
this.#write({ name, createdAt: existing?.createdAt || at, updatedAt: at, updatedBy: user, apps, settings, lists })
|
||||
return {
|
||||
replaced: existing !== null,
|
||||
detail: `${existing === null ? 'created' : 'replaced'}: ${apps.length} app(s), settings ${settings === null ? 'no' : Object.keys(settings).length + ' fields'}, lists ${lists === null ? 'no' : 'yes'}`,
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Export and import ---- */
|
||||
|
||||
exportFile(name?: string): { hub: string; type: string; version: number; exportedAt: string; profiles: Profile[] } {
|
||||
const profiles = name === undefined || name === '' ? this.#all() : [this.#require(name)]
|
||||
return { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt: new Date(this.#now()).toISOString(), profiles }
|
||||
}
|
||||
|
||||
importFile(raw: string, apply: boolean, user: string): { profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]; applied: boolean } {
|
||||
const parsed = parseJson(raw)
|
||||
if (!parsed.ok) throw new UserError('The file is not valid JSON.')
|
||||
const file = parsed.value as { hub?: unknown; type?: unknown; version?: unknown; profiles?: unknown }
|
||||
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub' || file.type !== 'profiles') throw new UserError('This is not a profiles file of the hub.')
|
||||
if (file.version !== 1) throw new UserError('This file has a version the hub does not know.')
|
||||
if (!Array.isArray(file.profiles) || file.profiles.length > MAX_PROFILES) throw new UserError(`The file has no list of profiles, or has more than ${MAX_PROFILES}.`)
|
||||
const plan: { profile: Profile; status: 'new' | 'changed' | 'same' }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of file.profiles) {
|
||||
const profile = cleanProfile(item)
|
||||
if (seen.has(profile.name)) throw new UserError(`"${profile.name}" is in the file twice.`)
|
||||
seen.add(profile.name)
|
||||
const old = this.#read(profile.name)
|
||||
plan.push({ profile, status: old === null ? 'new' : shape(old) === shape(profile) ? 'same' : 'changed' })
|
||||
}
|
||||
const total = this.#all().filter((p) => !seen.has(p.name)).length + plan.length
|
||||
if (total > MAX_PROFILES) throw new UserError(`That would make more than ${MAX_PROFILES} profiles.`)
|
||||
if (apply) {
|
||||
const at = new Date(this.#now()).toISOString()
|
||||
for (const p of plan) {
|
||||
if (p.status === 'same') continue
|
||||
const old = this.#read(p.profile.name)
|
||||
this.#write({ ...p.profile, createdAt: old?.createdAt || at, updatedAt: at, updatedBy: user })
|
||||
}
|
||||
}
|
||||
return { profiles: plan.map((p) => ({ name: p.profile.name, status: p.status, apps: p.profile.apps.length, settings: p.profile.settings !== null, lists: p.profile.lists !== null })), applied: apply }
|
||||
}
|
||||
|
||||
/* ---- Preview ---- */
|
||||
|
||||
#server(id: string): HubServer {
|
||||
const found = this.#config.servers.find((s) => s.id === id)
|
||||
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
|
||||
return found
|
||||
}
|
||||
|
||||
#targets(ids: string[]): HubServer[] {
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length === 0) throw new UserError('Choose at least one server.')
|
||||
return unique.map((id) => this.#server(id))
|
||||
}
|
||||
|
||||
async #look(server: HubServer): Promise<{ installed: Map<string, string>; store: Map<string, string> }> {
|
||||
this.#upstream.invalidate(server.id)
|
||||
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
|
||||
const store = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; version?: string }[] } | null
|
||||
return {
|
||||
installed: new Map((list?.apps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
||||
store: new Map((store?.storeApps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
|
||||
}
|
||||
}
|
||||
|
||||
/* What applying the profile would change on each target. Nothing is written. */
|
||||
async preview(name: string, ids: string[], removeExtras: boolean): Promise<{ profile: string; targets: unknown[] }> {
|
||||
const profile = this.#require(name)
|
||||
const targets = this.#targets(ids)
|
||||
const out = await Promise.all(
|
||||
targets.map(async (s) => {
|
||||
const writable = s.adminToken !== undefined
|
||||
const base = { server: s.id, writable }
|
||||
try {
|
||||
const { installed, store } = await this.#look(s)
|
||||
const apps = profile.apps.map((a) => {
|
||||
const have = installed.get(a.name)
|
||||
if (have === undefined) {
|
||||
const offered = store.get(a.name)
|
||||
return offered === undefined
|
||||
? { name: a.name, state: 'not-in-store' as const, profileVersion: a.version }
|
||||
: { name: a.name, state: 'install' as const, profileVersion: a.version, storeVersion: offered }
|
||||
}
|
||||
return { name: a.name, state: have === a.version ? ('present' as const) : ('version-differs' as const), profileVersion: a.version, installedVersion: have }
|
||||
})
|
||||
const configs = await Promise.all(
|
||||
profile.apps
|
||||
.filter((a) => a.config !== null)
|
||||
.map(async (a) => {
|
||||
if (!installed.has(a.name)) return { name: a.name, state: 'after-install' as const }
|
||||
try {
|
||||
const p = (await this.#appsConfig.preview(a.name, [s.id], a.config as string)).targets[0]
|
||||
if (!p.ok) return { name: a.name, state: 'unreadable' as const, error: p.error }
|
||||
return p.unchanged ? { name: a.name, state: 'same' as const } : { name: a.name, state: 'differs' as const, changes: p.changes as Diff }
|
||||
} catch (e) {
|
||||
return { name: a.name, state: 'unreadable' as const, error: reason(e) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
let settings: unknown = null
|
||||
if (profile.settings !== null) {
|
||||
const p = (await this.#settings.preview([s.id], undefined, { settings: profile.settings, updatedAt: '', updatedBy: '' })).targets[0]
|
||||
settings = p.ok ? { fields: p.changes.changes.map((c) => c.path), secretFields: p.secretFields, unchanged: p.unchanged, changes: p.changes, warnings: p.warnings } : { error: p.error }
|
||||
}
|
||||
let lists: unknown = null
|
||||
if (profile.lists !== null) {
|
||||
const p = (await this.#settings.listsPreview([s.id], removeExtras, { ...profile.lists, updatedAt: '', updatedBy: '' })).targets[0]
|
||||
lists = p.ok ? { blocked: p.blocked, allowed: p.allowed, status: p.status, tooMany: p.tooMany } : { error: p.error }
|
||||
}
|
||||
return { ...base, ok: true, apps, configs, settings, lists, warnings: writable ? [] : ['The hub has no admin token for this server, so it cannot write to it.'] }
|
||||
} catch (e) {
|
||||
return { ...base, ok: false, error: reason(e), apps: [], configs: [], settings: null, lists: null, warnings: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { profile: profile.name, targets: out }
|
||||
}
|
||||
|
||||
/* ---- Apply ---- */
|
||||
|
||||
async #run(profile: Profile, s: HubServer, step: Step, removeExtras: boolean): Promise<{ ok: boolean; message: string }> {
|
||||
const failed = (message: string) => ({ ok: false, message })
|
||||
if (step === 'apps') {
|
||||
this.#upstream.invalidate(s.id)
|
||||
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string }[] } | null
|
||||
const have = new Set((list?.apps ?? []).map((a) => a.name))
|
||||
const installed: string[] = []
|
||||
for (const a of profile.apps) {
|
||||
if (have.has(a.name)) continue
|
||||
const outcome = await this.#writer.runMany('install-store', a.name, [s.id])
|
||||
if ('error' in outcome) return failed(`${a.name}: ${outcome.error}`)
|
||||
const r = outcome.results[0]
|
||||
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'could not be installed'}`)
|
||||
installed.push(a.name)
|
||||
}
|
||||
return { ok: true, message: installed.length === 0 ? 'Every app is already installed.' : `Installed ${installed.join(', ')}.` }
|
||||
}
|
||||
if (step === 'configs') {
|
||||
let written = 0
|
||||
let same = 0
|
||||
for (const a of profile.apps) {
|
||||
if (a.config === null) continue
|
||||
const done = await this.#appsConfig.apply(a.name, [s.id], {}, a.config)
|
||||
const r = done.results[0]
|
||||
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'failed'}`)
|
||||
if (/nothing was written/.test(r.message ?? '')) same++
|
||||
else written++
|
||||
}
|
||||
return { ok: true, message: `${written} config(s) applied, ${same} already in sync.` }
|
||||
}
|
||||
if (step === 'settings') {
|
||||
const done = await this.#settings.apply([s.id], {}, undefined, { settings: profile.settings ?? {}, updatedAt: '', updatedBy: '' })
|
||||
const r = done.results[0]
|
||||
return { ok: r.ok, message: r.message ?? '' }
|
||||
}
|
||||
const done = await this.#settings.listsApply([s.id], removeExtras, { ...(profile.lists ?? { blocked: [], allowed: [] }), updatedAt: '', updatedBy: '' })
|
||||
const r = done.results[0]
|
||||
return { ok: r.ok, message: r.message ?? '' }
|
||||
}
|
||||
|
||||
#stepsOf(profile: Profile): Step[] {
|
||||
return STEPS.filter((step) => (step === 'apps' ? profile.apps.length > 0 : step === 'configs' ? profile.apps.some((a) => a.config !== null) : step === 'settings' ? profile.settings !== null : profile.lists !== null))
|
||||
}
|
||||
|
||||
async #applyOne(profile: Profile, s: HubServer, from: Step | undefined, removeExtras: boolean): Promise<ApplyResult> {
|
||||
const steps: StepResult[] = []
|
||||
const plan = this.#stepsOf(profile)
|
||||
if (plan.length === 0) return { server: s.id, ok: true, message: 'The profile has nothing to apply.', steps }
|
||||
if (s.adminToken === undefined) {
|
||||
return { server: s.id, ok: false, message: 'no admin token configured', steps: plan.map((step, i) => ({ step, status: i === 0 ? ('failed' as const) : ('not-run' as const), message: i === 0 ? 'no admin token configured' : 'An earlier step failed.' })) }
|
||||
}
|
||||
const start = from === undefined ? 0 : Math.max(0, plan.indexOf(from))
|
||||
let stopped: Step | null = null
|
||||
for (const [i, step] of plan.entries()) {
|
||||
if (stopped !== null) {
|
||||
steps.push({ step, status: 'not-run', message: 'An earlier step failed.' })
|
||||
continue
|
||||
}
|
||||
if (i < start) {
|
||||
steps.push({ step, status: 'skipped', message: 'Done by an earlier run.' })
|
||||
continue
|
||||
}
|
||||
let outcome: { ok: boolean; message: string }
|
||||
try {
|
||||
outcome = await this.#run(profile, s, step, removeExtras)
|
||||
} catch (e) {
|
||||
outcome = { ok: false, message: reason(e) }
|
||||
}
|
||||
steps.push({ step, status: outcome.ok ? 'ok' : 'failed', message: outcome.message })
|
||||
if (!outcome.ok) stopped = step
|
||||
}
|
||||
return {
|
||||
server: s.id,
|
||||
ok: stopped === null,
|
||||
message: stopped === null ? 'Profile applied.' : `Stopped at ${stopped}: ${steps.find((x) => x.step === stopped)?.message ?? 'failed'}`.slice(0, 300),
|
||||
steps,
|
||||
}
|
||||
}
|
||||
|
||||
/* Several servers go on in parallel; the steps of one server go one after the other. */
|
||||
async apply(name: string, ids: string[], options: { removeExtras: boolean; from: Record<string, string> }): Promise<{ results: ApplyResult[] }> {
|
||||
const profile = this.#require(name)
|
||||
const targets = this.#targets(ids)
|
||||
const results = await Promise.all(
|
||||
targets.map((s) => {
|
||||
const from = (STEPS as readonly string[]).includes(options.from[s.id]) ? (options.from[s.id] as Step) : undefined
|
||||
return this.#applyOne(profile, s, from, options.removeExtras)
|
||||
}),
|
||||
)
|
||||
return { results }
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,17 @@ export const ROUTES: Record<string, Route> = {
|
||||
'hub/apps/history/rollback': writeAll('apps.write'),
|
||||
'hub/apps/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
@@ -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
@@ -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> = {}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 8, notices to the outside: the signature, the timeout and the single retry, what is logged,
|
||||
and the two endpoints (status and test) on a hub that has a webhook.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseConfig } from './config.ts'
|
||||
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
|
||||
import { Webhook, sign } from './webhook.ts'
|
||||
|
||||
const urlConfig = (webhookUrl: string | null, webhookSecret: string | null = null) => ({ webhookUrl, webhookSecret, domain: 'hub.example' })
|
||||
|
||||
describe('Webhook', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('posts {event, at, hub, items} and signs the exact body when there is a secret', async () => {
|
||||
const seen: { url: string; init: RequestInit }[] = []
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in?token=abc', 's3cret'), {
|
||||
now: () => Date.parse('2026-11-01T00:00:00Z'),
|
||||
fetch: (async (url: string, init: RequestInit) => {
|
||||
seen.push({ url, init })
|
||||
return new Response('ok')
|
||||
}) as typeof fetch,
|
||||
})
|
||||
hook.send('drift.found', [{ kind: 'settings', server: 'a', fields: ['blockingType'] }])
|
||||
await hook.settled()
|
||||
expect(seen).toHaveLength(1)
|
||||
const body = String(seen[0].init.body)
|
||||
expect(JSON.parse(body)).toEqual({ event: 'drift.found', at: '2026-11-01T00:00:00.000Z', hub: 'hub.example', items: [{ kind: 'settings', server: 'a', fields: ['blockingType'] }] })
|
||||
const headers = seen[0].init.headers as Record<string, string>
|
||||
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`)
|
||||
expect(headers['x-hub-signature']).toBe(sign('s3cret', body))
|
||||
})
|
||||
|
||||
it('sends no signature without a secret and nothing at all without a URL', async () => {
|
||||
const seen: RequestInit[] = []
|
||||
const fetcher = (async (_u: string, init: RequestInit) => {
|
||||
seen.push(init)
|
||||
return new Response('ok')
|
||||
}) as typeof fetch
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), { fetch: fetcher })
|
||||
hook.send('drift.cleared', [{ kind: 'app' }])
|
||||
await hook.settled()
|
||||
expect((seen[0].headers as Record<string, string>)['x-hub-signature']).toBeUndefined()
|
||||
const none = new Webhook(urlConfig(null), { fetch: fetcher })
|
||||
none.send('drift.found', [{ kind: 'app' }])
|
||||
await none.settled()
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(none.status()).toEqual({ configured: false, host: null, signed: false, last: null })
|
||||
})
|
||||
|
||||
it('retries once and logs the host only, never the query string or the error text', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const out = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
let tries = 0
|
||||
const hook = new Webhook(urlConfig('https://hooks.example:8443/in?token=TOPSECRET'), {
|
||||
retryDelayMs: 0,
|
||||
fetch: (async () => {
|
||||
tries++
|
||||
throw new TypeError('fetch failed https://hooks.example:8443/in?token=TOPSECRET', { cause: { code: 'ECONNREFUSED' } })
|
||||
}) as typeof fetch,
|
||||
})
|
||||
hook.send('drift.found', [{ kind: 'app' }])
|
||||
await hook.settled()
|
||||
expect(tries).toBe(2)
|
||||
const text = [...log.mock.calls, ...out.mock.calls].flat().join(' ')
|
||||
expect(text).toContain('hooks.example:8443')
|
||||
expect(text).toContain('ECONNREFUSED')
|
||||
expect(text).not.toContain('TOPSECRET')
|
||||
expect(text).not.toContain('token=')
|
||||
expect(hook.status()).toMatchObject({ configured: true, host: 'hooks.example:8443', last: { event: 'drift.found', ok: false, error: 'ECONNREFUSED' } })
|
||||
expect(JSON.stringify(hook.status())).not.toContain('TOPSECRET')
|
||||
})
|
||||
|
||||
it('treats a non-2xx answer as a failure and stops after the one retry; a second success is ok', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const answers = [500, 204]
|
||||
let tries = 0
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: answers[tries++] ?? 500 })) as typeof fetch })
|
||||
hook.send('drift.found', [{ kind: 'app' }])
|
||||
await hook.settled()
|
||||
expect(tries).toBe(2)
|
||||
expect(hook.status().last).toMatchObject({ ok: true })
|
||||
const bad = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: 503 })) as typeof fetch })
|
||||
expect(await bad.deliver('webhook.test', [{}])).toMatchObject({ ok: false, error: 'HTTP 503' })
|
||||
})
|
||||
|
||||
it('does not wait for a hanging receiver when sending, and gives up after the timeout', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
|
||||
timeoutMs: 40,
|
||||
retryDelayMs: 0,
|
||||
fetch: ((_u: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject((init.signal as AbortSignal).reason))
|
||||
})) as typeof fetch,
|
||||
})
|
||||
const started = Date.now()
|
||||
hook.send('drift.found', [{ kind: 'app' }])
|
||||
expect(Date.now() - started).toBeLessThan(30)
|
||||
await hook.settled()
|
||||
expect(hook.status().last).toMatchObject({ ok: false, error: 'TimeoutError' })
|
||||
})
|
||||
|
||||
it('collects failed writes of one moment into a single notice', async () => {
|
||||
const bodies: { event: string; items: unknown[] }[] = []
|
||||
const hook = new Webhook(urlConfig('https://hooks.example/in'), {
|
||||
batchMs: 5,
|
||||
fetch: (async (_u: string, init: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init.body)))
|
||||
return new Response('ok')
|
||||
}) as typeof fetch,
|
||||
})
|
||||
hook.queueFailed({ action: 'a', server: 'x' })
|
||||
hook.queueFailed({ action: 'a', server: 'y' })
|
||||
await hook.settled()
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ event: 'write.failed', items: [{ server: 'x' }, { server: 'y' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HUB_WEBHOOK_URL', () => {
|
||||
const base = { HUB_SERVERS: 'a=http://a.example', A_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' }
|
||||
it('accepts http and https and refuses anything else', () => {
|
||||
expect(parseConfig({ ...base, HUB_WEBHOOK_URL: 'https://h.example/x?y=1', HUB_WEBHOOK_SECRET: 's' })).toMatchObject({ webhookUrl: 'https://h.example/x?y=1', webhookSecret: 's' })
|
||||
expect(parseConfig(base)).toMatchObject({ webhookUrl: null, webhookSecret: null })
|
||||
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'ftp://h.example' })).toThrow(/HUB_WEBHOOK_URL/)
|
||||
expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'h.example' })).toThrow(/HUB_WEBHOOK_URL/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the webhook on a hub', () => {
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
let receiver: http.Server
|
||||
let received: { headers: http.IncomingHttpHeaders; body: string }[]
|
||||
let status = 200
|
||||
|
||||
beforeAll(async () => {
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
receiver = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
received.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') })
|
||||
res.writeHead(status).end('ok')
|
||||
})
|
||||
})
|
||||
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r))
|
||||
hub = await startHub(a, b, {
|
||||
HUB_WEBHOOK_URL: `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/in?token=QUERYSECRET`,
|
||||
HUB_WEBHOOK_SECRET: 'shared-secret',
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
receiver.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
received = []
|
||||
status = 200
|
||||
})
|
||||
|
||||
it('shows its status to an admin only, with the host and never the query', async () => {
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/status', {}, 'GET')).code).toBe(403)
|
||||
const got = await hub.call('admin', 'hub/webhook/status', {}, 'GET')
|
||||
expect(got.code).toBe(200)
|
||||
expect(got.body.response).toMatchObject({ configured: true, signed: true, last: null })
|
||||
expect(got.body.response.host).toMatch(/^127\.0\.0\.1:\d+$/)
|
||||
expect(JSON.stringify(got.body)).not.toContain('QUERYSECRET')
|
||||
})
|
||||
|
||||
it('refuses the test to every role but admin, and refuses GET', async () => {
|
||||
for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/test')).code).toBe(403)
|
||||
expect((await hub.call('admin', 'hub/webhook/test', {}, 'GET')).code).toBe(405)
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
|
||||
it('sends a signed test notice, records the result and audits it', async () => {
|
||||
const done = await hub.call('admin', 'hub/webhook/test')
|
||||
expect(done.code).toBe(200)
|
||||
expect(done.body.response.last).toMatchObject({ event: 'webhook.test', ok: true })
|
||||
expect(received).toHaveLength(1)
|
||||
const { headers, body } = received[0]
|
||||
expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 'shared-secret').update(body).digest('hex')}`)
|
||||
expect(JSON.parse(body)).toMatchObject({ event: 'webhook.test', hub: 'hub' })
|
||||
const audit = await hub.audit()
|
||||
expect(audit.find((e) => e.action === 'webhook.test')).toMatchObject({ user: 'admin', outcome: 'ok' })
|
||||
expect(JSON.stringify(audit)).not.toContain('QUERYSECRET')
|
||||
expect(JSON.stringify(audit)).not.toContain('shared-secret')
|
||||
})
|
||||
|
||||
it('reports a failing receiver in the answer without failing the request', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
status = 500
|
||||
const done = await hub.call('admin', 'hub/webhook/test')
|
||||
expect(done.code).toBe(200)
|
||||
expect(done.body.status).toBe('ok')
|
||||
expect(done.body.response.last).toMatchObject({ ok: false, error: 'HTTP 500' })
|
||||
expect(received).toHaveLength(2)
|
||||
expect((await hub.audit()).find((e) => e.action === 'webhook.test' && e.outcome === 'failed')).toBeTruthy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends write.failed for a failed audited write, without the values', async () => {
|
||||
a.on('zones/records/add', () => ({ error: 'The record already exists.' }))
|
||||
const done = await hub.call('admin', 'zones/records/add', { node: 'a', zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'TOPVALUE' })
|
||||
expect(done.body.status).toBe('error')
|
||||
for (let i = 0; i < 40 && received.length === 0; i++) await new Promise((r) => setTimeout(r, 50))
|
||||
expect(received).toHaveLength(1)
|
||||
const sent = JSON.parse(received[0].body)
|
||||
expect(sent.event).toBe('write.failed')
|
||||
expect(sent.items[0]).toMatchObject({ action: 'zones.records.add', server: 'a', user: 'admin' })
|
||||
expect(received[0].body).not.toContain('TOPVALUE')
|
||||
// A failed login is not a write.
|
||||
received = []
|
||||
await fetch(`${hub.base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'user=admin&pass=wrong' })
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
})
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body
|
||||
`{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited
|
||||
write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed:
|
||||
`X-Hub-Signature: sha256=<hex hmac>`.
|
||||
|
||||
A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and
|
||||
at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its
|
||||
query string) and never the error text. Items carry names and field names, never values.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import type { HubConfig } from './config.ts'
|
||||
|
||||
export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test'
|
||||
|
||||
export interface Delivery {
|
||||
event: WebhookEvent
|
||||
at: string
|
||||
ok: boolean
|
||||
/** `HTTP 500`, or an error code or name. Never a URL. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WebhookStatus {
|
||||
configured: boolean
|
||||
/** Host and port only. */
|
||||
host: string | null
|
||||
signed: boolean
|
||||
last: Delivery | null
|
||||
}
|
||||
|
||||
export interface WebhookOptions {
|
||||
fetch?: typeof fetch
|
||||
timeoutMs?: number
|
||||
retryDelayMs?: number
|
||||
/** How long write.failed items are collected into one notice. */
|
||||
batchMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
||||
|
||||
export class Webhook {
|
||||
#url: string | null
|
||||
#secret: string | null
|
||||
#domain: string
|
||||
#host: string | null
|
||||
#fetch: typeof fetch
|
||||
#timeoutMs: number
|
||||
#retryDelayMs: number
|
||||
#batchMs: number
|
||||
#now: () => number
|
||||
#last: Delivery | null = null
|
||||
#pending = new Set<Promise<unknown>>()
|
||||
#queued: Record<string, unknown>[] = []
|
||||
#timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(config: Pick<HubConfig, 'webhookUrl' | 'webhookSecret' | 'domain'>, options: WebhookOptions = {}) {
|
||||
this.#url = config.webhookUrl
|
||||
this.#secret = config.webhookSecret
|
||||
this.#domain = config.domain
|
||||
this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host
|
||||
this.#fetch = options.fetch ?? fetch
|
||||
this.#timeoutMs = options.timeoutMs ?? 5000
|
||||
this.#retryDelayMs = options.retryDelayMs ?? 1000
|
||||
this.#batchMs = options.batchMs ?? 250
|
||||
this.#now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
get configured(): boolean {
|
||||
return this.#url !== null
|
||||
}
|
||||
|
||||
status(): WebhookStatus {
|
||||
return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last }
|
||||
}
|
||||
|
||||
/** Resolves when every delivery started so far has ended (for the tests). */
|
||||
async settled(): Promise<void> {
|
||||
if (this.#timer !== null) this.#flush()
|
||||
while (this.#pending.size > 0) await Promise.allSettled([...this.#pending])
|
||||
}
|
||||
|
||||
#body(event: WebhookEvent, items: Record<string, unknown>[]): string {
|
||||
return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items })
|
||||
}
|
||||
|
||||
async #attempt(body: string): Promise<string | null> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body)
|
||||
try {
|
||||
const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) })
|
||||
await res.body?.cancel().catch(() => undefined)
|
||||
return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}`
|
||||
} catch (e) {
|
||||
const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code
|
||||
return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/* Delivers one notice and records the result. Returns it. */
|
||||
async deliver(event: WebhookEvent, items: Record<string, unknown>[]): Promise<Delivery> {
|
||||
if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' }
|
||||
const body = this.#body(event, items)
|
||||
let error = await this.#attempt(body)
|
||||
if (error !== null) {
|
||||
if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs))
|
||||
error = await this.#attempt(body)
|
||||
}
|
||||
const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) }
|
||||
this.#last = result
|
||||
if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`)
|
||||
return result
|
||||
}
|
||||
|
||||
/* Fire and forget: never throws, never waits. */
|
||||
send(event: WebhookEvent, items: Record<string, unknown>[]): void {
|
||||
if (this.#url === null || items.length === 0) return
|
||||
const run = this.deliver(event, items).catch(() => undefined)
|
||||
this.#pending.add(run)
|
||||
void run.finally(() => this.#pending.delete(run))
|
||||
}
|
||||
|
||||
/* Collects `write.failed` items for a moment, so ten failed servers make one notice. */
|
||||
queueFailed(item: Record<string, unknown>): void {
|
||||
if (this.#url === null) return
|
||||
this.#queued.push(item)
|
||||
if (this.#timer === null) {
|
||||
this.#timer = setTimeout(() => this.#flush(), this.#batchMs)
|
||||
this.#timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
#flush(): void {
|
||||
if (this.#timer !== null) clearTimeout(this.#timer)
|
||||
this.#timer = null
|
||||
const items = this.#queued.splice(0, 50)
|
||||
this.#queued = []
|
||||
this.send('write.failed', items)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user