From 392b644faabf2a3c5bf2e24b4326867edd0be593 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 1 Nov 2026 01:20:05 +0700 Subject: [PATCH] Hub stage 7: compare and align zones, settings and blocking Zones gets "All servers" in hub mode: one table of every zone on every server with its type and serial, marking zones that are on one server only or whose serials or types differ. A zone can be copied to another server (export, create, import; Overwrite must be ticked for a zone that is already there) or made a Secondary there (the source's zone transfer and notify lists gain the target, then the target zone is created). Settings and Blocking can be compared across servers against a baseline kept in the hub. Host fields stay local, secrets are compared inside the hub only and never shown or logged, and an apply sends only the changed shared fields after saving each server's settings to history. Blocked and Allowed lists are compared as sets; extras are removed only when asked. Co-Authored-By: Claude Opus 5.5 --- dev/mock-api.mjs | 67 +- docs/hub-deploy.md | 2 + docs/hub-management-plan.md | 2 +- hub/README.md | 35 + hub/drift.ts | 6 + hub/fake-servers.ts | 152 ++++ hub/routes.ts | 21 + hub/server.ts | 135 ++++ hub/settings-config.ts | 543 +++++++++++++++ hub/settings-sync.test.ts | 630 +++++++++++++++++ hub/settings-sync.ts | 385 ++++++++++ hub/store.ts | 87 ++- hub/zones-sync.test.ts | 530 ++++++++++++++ hub/zones-sync.ts | 656 ++++++++++++++++++ src/api/hub-settings.ts | 124 ++++ src/api/hub-zones.ts | 80 +++ src/screens/apps/RunFlow.tsx | 11 +- src/screens/blocking/Blocking.tsx | 6 + src/screens/settings/Settings.tsx | 2 + src/screens/settings/SettingsCompare.test.tsx | 162 +++++ src/screens/settings/SettingsCompare.tsx | 580 ++++++++++++++++ src/screens/zones/Zones.tsx | 21 +- src/screens/zones/ZonesFlows.tsx | 205 ++++++ src/screens/zones/ZonesMatrix.test.tsx | 172 +++++ src/screens/zones/ZonesMatrix.tsx | 273 ++++++++ 25 files changed, 4865 insertions(+), 22 deletions(-) create mode 100644 hub/fake-servers.ts create mode 100644 hub/settings-config.ts create mode 100644 hub/settings-sync.test.ts create mode 100644 hub/settings-sync.ts create mode 100644 hub/zones-sync.test.ts create mode 100644 hub/zones-sync.ts create mode 100644 src/api/hub-settings.ts create mode 100644 src/api/hub-zones.ts create mode 100644 src/screens/settings/SettingsCompare.test.tsx create mode 100644 src/screens/settings/SettingsCompare.tsx create mode 100644 src/screens/zones/ZonesFlows.tsx create mode 100644 src/screens/zones/ZonesMatrix.test.tsx create mode 100644 src/screens/zones/ZonesMatrix.tsx diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index 30f3548..e2b66f9 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -93,6 +93,18 @@ let zones = [ { name: 'vpn.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 12, disabled: false, notifyFailed: true, notifyFailedFor: ['ns2.example.net'], lastModified: ago(60 * 24 * 9) }, { name: 'staging.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 4, disabled: true, lastModified: ago(60 * 24 * 20) }, ].map((z) => ({ notifyFailed: false, notifyFailedFor: [], ...z })) +if (VARIANT === 'b') { + // The second server: a zone the first lacks, a serial that moved on, and a Secondary of the first server's Primary. + zones = zones.filter((z) => z.name !== 'dev.example.net' && z.name !== 'vpn.example.net') + zones = zones.map((z) => (z.name === 'lan' ? { ...z, soaSerial: 44 } : z.name === 'home.example.net' ? { ...z, type: 'Secondary', soaSerial: 16, catalog: null } : z)) + zones.push({ name: 'ndns-only.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 3, disabled: false, lastModified: ago(60 * 3), notifyFailed: false, notifyFailedFor: [] }) +} +// Zone files (zones/export, zones/import) and options (zones/options/get, set) are kept per zone. +const zoneFiles = {} +const zoneOptions = {} +const zoneFile = (name) => zoneFiles[name] ?? `$ORIGIN ${name}.\n@ IN SOA ns1.${name}. hostmaster.${name}. ${(zones.find((z) => z.name === name) ?? {}).soaSerial ?? 1} 900 300 604800 900\n@ IN NS ns1.${name}.\nwww IN A 203.0.113.${VARIANT === 'b' ? 20 : 10}\n` +const optionsOf = (name) => ({ zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], ...(zoneOptions[name] ?? {}) }) + function recordsFor(zone) { const z = zone.name @@ -127,6 +139,10 @@ const cacheRecords = (domain) => ] const allowed = new Set(['updates.example.net', 'api.github.com', 'time.cloudflare.com']) const blocked = new Set(['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com']) +if (VARIANT === 'b') { + blocked.delete('beacon.analytics-example.com'); blocked.add('only-ndns.tracker-example.com') + allowed.add('extra-allowed.example.net') +} const listNode = (set, domain) => { const all = [...set].sort() return { domain, zones: domain === '' ? all : [], records: domain !== '' && set.has(domain) ? [{ name: domain, type: 'SOA', ttl: 60, rData: {}, dnssecStatus: 'Disabled' }] : [] } @@ -166,6 +182,15 @@ const settings = { loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365, } +if (VARIANT === 'b') { + // The second server: other shared values (these show up in the compare), and its own identity and log folder (local fields). + Object.assign(settings, { + dnsServerDomain: 'ndns.example.net', dnsServerLocalEndPoints: ['10.0.0.9:53'], logFolder: 'logs-ndns', webServiceHttpPort: 5381, + defaultRecordTtl: 7200, forwarders: ['8.8.8.8'], blockingType: 'AnyAddress', serveStale: false, + tsigKeys: [{ keyName: 'transfer.example.net', sharedSecret: 'bmRucy1vdGhlci1zZWNyZXQ=', algorithmName: 'hmac-sha256' }], + }) +} + // ── DHCP ─────────────────────────────────────────────────────────────────── let scopes = [ { name: 'Main', enabled: true, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', networkAddress: '192.168.10.0', broadcastAddress: '192.168.10.255', interfaceAddress: '192.168.10.2' }, @@ -246,10 +271,10 @@ const userDetails = (u) => ({ ...u, sessionTimeoutSeconds: 1800, remotelyManaged // ── Router ───────────────────────────────────────────────────────────────── const addedRecords = {} // Answers that are files, not JSON (the downloads and the Rules table's reads). -const fileFor = (path) => ({ +const fileFor = (path, q = new URLSearchParams()) => ({ 'allowed/export': [...allowed].join('\n') + '\n', 'blocked/export': [...blocked].join('\n') + '\n', - 'zones/export': '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 1 900 300 604800 900\n', + 'zones/export': zones.some((z) => z.name === q.get('zone')) ? zoneFile(q.get('zone')) : undefined, 'logs/download': '[2026-10-26 10:00:00 UTC] [127.0.0.1:5380] [] DNS Server was started.\n', })[path] const ok = (response = {}) => ({ status: 'ok', response }) @@ -282,8 +307,31 @@ function route(path, q) { } case 'zones/enable': case 'zones/disable': zones = zones.map((z) => (q.get('zone') === z.name ? { ...z, disabled: path === 'zones/disable' } : z)); return ok() case 'zones/delete': { const gone = (q.get('zones') ?? q.get('zone') ?? '').split(',').filter(Boolean); zones = zones.filter((z) => !gone.includes(z.name)); return ok({ deleted: gone, failed: {} }) } - case 'zones/create': zones = [{ name: domain, type: q.get('type') ?? 'Primary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]; return ok({ domain }) - case 'zones/options/get': return ok({ name: domain, type: 'Primary', dnssecStatus: 'Unsigned', disabled: false, catalog: null, queryAccess: 'Allow', queryAccessNetworkACL: [], zoneTransfer: 'AllowOnlyZoneNameServers', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'] }) + case 'zones/create': { + if (zones.some((z) => z.name === domain)) return { status: 'error', errorMessage: `Zone already exists: ${domain}` } + const type = q.get('type') ?? 'Primary' + zones = [{ name: domain, type, dnssecStatus: type === 'Primary' ? 'Unsigned' : undefined, soaSerial: type === 'Forwarder' ? undefined : 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones] + if (type === 'Secondary') zoneOptions[domain] = { primaryNameServerAddresses: (q.get('primaryNameServerAddresses') ?? '').split(','), primaryZoneTransferProtocol: q.get('zoneTransferProtocol') ?? 'Tcp', primaryZoneTransferTsigKeyName: q.get('tsigKeyName') ?? '' } + return ok({ domain }) + } + case 'zones/import': { + const z = zones.find((x) => x.name === domain) + if (!z) return { status: 'error', errorMessage: `No such zone was found: ${domain}` } + zoneFiles[domain] = q.get('__text') ?? zoneFile(domain) + const serial = /SOA\s+\S+\s+\S+\s+(\d+)/.exec(zoneFiles[domain]) + if (serial) zones = zones.map((x) => (x.name === domain ? { ...x, soaSerial: Number(serial[1]), lastModified: iso(Date.now()) } : x)) + return ok() + } + case 'zones/options/get': { const z = zones.find((x) => x.name === domain); return ok({ name: domain, type: z?.type ?? 'Primary', dnssecStatus: z?.dnssecStatus ?? 'Unsigned', disabled: false, catalog: z?.catalog ?? null, queryAccess: 'Allow', queryAccessNetworkACL: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'], ...optionsOf(domain) }) } + case 'zones/options/set': { + const next = { ...(zoneOptions[domain] ?? {}) } + for (const [k, v] of q) { + if (['zone', 'node', 'token'].includes(k)) continue + next[k] = /ACL$|Names$|Servers$|Addresses$/.test(k) ? (v === 'false' || v === '' ? [] : v.split(',')) : v + } + zoneOptions[domain] = next + return ok() + } case 'zones/permissions/get': return ok({ zone: domain, userPermissions: [{ username: 'admin', canView: true, canModify: true, canDelete: true }], groupPermissions: [{ name: 'DNS Administrators', canView: true, canModify: true, canDelete: false }], users: users.map((u) => u.username), groups: groups.map((g) => g.name) }) case 'zones/catalogs/list': return ok({ catalogZoneNames: ['catalog.example.net'] }) case 'cache/list': return ok({ domain, zones: cacheTree[domain] ?? [], records: cacheRecords(domain) }) @@ -299,6 +347,12 @@ function route(path, q) { // Changes only what is sent and already there, by its kind (a flag, a number, a list or text); the rest is kept. for (const [k, v] of q) { if (!(k in settings) || k === 'node' || k === 'token') continue + if (k === 'tsigKeys') { + // Rows of keyName|sharedSecret|algorithmName joined with a pipe, or "false" for none. + const cells = v === 'false' ? [] : v.split('|') + settings.tsigKeys = Array.from({ length: Math.floor(cells.length / 3) }, (_, i) => ({ keyName: cells[i * 3], sharedSecret: cells[i * 3 + 1], algorithmName: cells[i * 3 + 2] })) + continue + } const old = settings[k] if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v @@ -314,7 +368,7 @@ function route(path, q) { return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill }) } case 'user/createSingleUseToken': return ok({ token: 'mock-once' }) - case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] }) + case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: settings.tsigKeys.map((k) => k.keyName) }) case 'dhcp/leases/list': return ok({ leases }) case 'dhcp/scopes/list': return ok({ scopes }) case 'dhcp/scopes/get': return ok(scopeDetail(q.get('name') ?? 'Main')) @@ -383,7 +437,8 @@ http.createServer((req, res) => { req.on('end', () => { const q = new URLSearchParams(url.search) if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v) - const file = fileFor(path) + if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body) + const file = fileFor(path, q) if (file !== undefined) { res.setHeader('content-type', 'text/plain') res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`) diff --git a/docs/hub-deploy.md b/docs/hub-deploy.md index c5b91b3..9a650e8 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -109,6 +109,8 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0 Действия на несколько серверов сразу (этап 6): в «All servers» кнопки Block, Allow, Disable в Blocking › Overview, «Update lists on all servers», «Edit list URLs on all servers», «Remove from Blocked» и «Remove from Allowed», а также «Flush on all servers» и «Delete a domain on all servers» в Cache и Block/Allow в строках Query Logs открывают выбор серверов. По умолчанию отмечены все серверы с токеном записи; сервер без токена виден, но отключён, с причиной. Итог показывается по каждому серверу, «Retry failed» повторяет только неудавшиеся. Для этого нужны те же права на серверах: Blocked и Allowed (Modify, Delete), Cache (Delete), Settings (Modify: URL списков и переключатель блокировки). Роли `operator` и `admin`; в аудит попадает по строке на сервер, список URL в аудит не пишется. Заменённый список URL полностью перезаписывает прежний на каждом выбранном сервере. +Зоны, настройки и блокировка на нескольких серверах (этап 7), только роль `admin`. Zones при «All servers» показывает общую таблицу: где зона есть, её тип и SOA serial, статус (одинаково, serial отличается, тип отличается, только на одном сервере; Secondary с отставшим serial отмечен, но расхождением не считается). В меню строки: «Open on …», «Copy to …» (экспорт зоны с одного сервера и импорт на другой; копируются только Primary и Forwarder, подписанные DNSSEC и Secondary не копируются; если зона уже есть, нужна галочка Overwrite) и «Make secondary on …» (хаб добавляет адрес второго сервера в передачу зоны и уведомления первого и создаёт Secondary на втором; адрес первого, как его видит второй, подставляется из адреса сервера в хабе, адрес второго для списка передачи вводится вручную; по желанию ключ TSIG, он должен быть на обоих серверах). Для этого на **обоих** серверах у `hub-admin` нужны Zones (View, Modify, Delete) и Settings (View, Modify). Кнопка «Compare servers» в Settings и в Blocking открывает сравнение настроек (вкладки Compare, Baseline, Local fields, History): эталон берётся с выбранного сервера кнопкой «Accept … as baseline», применение (Apply) отправляет только изменившиеся общие поля и перед записью сохраняет прежние настройки сервера в историю. По умолчанию **локальны** и не отправляются: `dnsServerDomain`, адреса привязки (`dnsServerLocalEndPoints`, `dnsServerIPv4SourceAddresses`, `dnsServerIPv6SourceAddresses`), все `webService*`, сертификат DNS (`dnsTlsCertificatePath`, `dnsTlsCertificatePassword`), сокеты и адреса обратного прокси этого хоста, `logFolder`; версия, время работы, данные кластера и временное отключение блокировки не сравниваются. Секреты (ключи TSIG, пароль прокси) сравниваются внутри хаба, нигде не показываются (только «secret changed») и в аудит не пишутся; файлы эталона и истории хранят их в `/data` с правами 0600. В Blocking сравниваются ещё свои списки Blocked и Allowed как множества («не хватает N, лишних M»); лишние удаляются только при галочке «Also remove extras». + Какие роли хаба что могут: `operator` меняет кеш, списки Blocked и Allowed, переключатель блокировки (включить, временно отключить) и обновляет списки, `admin` меняет и зоны, настройки и DHCP; `viewer` и `apps-admin` в этих разделах только читают. Хаб отклоняет всё, чего нет в таблице путей: `admin/*` и `user/*` (кроме собственного входа хаба), `logs/delete`, `logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup`, `settings/restore`, права на зоны (`zones/permissions/*`). Сохранение настроек с изменённым полем веб-службы сервера (`webService*`) или с полем пользователей, SSO и LDAP хаб тоже отклоняет с сообщением «Change the web service settings in that server's own console.» diff --git a/docs/hub-management-plan.md b/docs/hub-management-plan.md index 46d44ed..528ffdf 100644 --- a/docs/hub-management-plan.md +++ b/docs/hub-management-plan.md @@ -4,7 +4,7 @@ - **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание; - **упор на Apps**: приложения на всех серверах и синхронизация их конфигов. -Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером) и 6 (действия на все серверы вне Apps) сделаны. Этапы 7 и далее пока планы (`docs/hub-deploy.md`). +Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером), 6 (действия на все серверы вне Apps) и 7 (зоны, настройки и блокировка: сравнение и выравнивание) сделаны. Этапы 8 и далее пока планы (`docs/hub-deploy.md`). --- diff --git a/hub/README.md b/hub/README.md index 786f0e6..bb66e1e 100644 --- a/hub/README.md +++ b/hub/README.md @@ -181,6 +181,41 @@ matrix hash ignores them too. | `hub/apps/config/preview` | POST `{name, targets}` | `apps.write` | Per target: current and effective text, the diff, warnings (a missing local value, a version older than the newest installed, no admin token) and `currentHash`. | | `hub/apps/config/apply` | POST `{name, targets, expect?}` | `apps.write` | Per target: re-reads the config; refuses if its hash differs from `expect[server]`; saves the old config to the history; `apps/config/set` with the admin token; reads it back and checks it. A server that rejects the config keeps its old one. | +## Zones, settings and blocking across the servers (stage 7) + +| Endpoint | Kind | Needs | Does | +|---|---|---|---| +| `hub/zones/matrix` | read | `read` | Every zone of every server, one row per name: per server `present`, `absent` or `unknown` with type, SOA serial, disabled, DNSSEC and last modified. Row `status`: `same`, `serial-differs` (Primary zones, different serials), `type-differs`, `only-on` (`onlyOn`), `missing-on`, `unknown`. A Primary with a Secondary of it is the intended setup: it stays `same` with `note: primary-secondary`, or `secondary-behind` when the Secondary lags. A server that did not answer gives `unknown` cells and a `hubWarnings` line; a zone is never called `only-on` while a server that might have it is silent. Pages of 500, internal zones left out. | +| `hub/zones/copy/preview` | read | `admin` | `{source, zone, targets}`: the source zone (type, serial, about N record lines) and per target whether it exists (type, serial, `needsOverwrite`). | +| `hub/zones/copy` | write-all | `admin` | `{source, zone, targets, overwrite}`. Reads `zones/export` from the source with the read token (cap 5 MB, 30 s), then per target `zones/create` (the source's type; a Forwarder with `initializeForwarder=false`) when the zone is missing and `zones/import` (`overwrite`, `overwriteZone`, `overwriteSoaSerial` all true, the text as `text/plain`: what the console's Import zone sends). A target that has the zone is touched only if its id is in `overwrite` (checked in the hub). Only Primary and Forwarder zones; Secondary, Stub, Catalog and DNSSEC-signed zones are refused with a reason; a target with the zone as another type is refused; a copy to the source is refused. If the import fails after the create, the result says an empty zone was left. | +| `hub/zones/secondary/preview` | read | `admin` | `{source, target, zone, primaryAddress, targetAddress, tsigKeyName?}`: the two steps in words. | +| `hub/zones/secondary` | write-all | `admin` | Step 1 on the source: `zones/options/set` with only `zoneTransfer`, `zoneTransferNetworkACL`, `zoneTransferTsigKeyNames`, `notify`, `notifyNameServers`; what is listed stays, the target address is appended once (so it can run again), a mode that cannot hold a list is widened (Deny to UseSpecifiedNetworkACL, AllowOnlyZoneNameServers to AllowZoneNameServersAndUseSpecifiedNetworkACL, notify None to SpecifiedNameServers, ZoneNameServers to BothZoneAndSpecifiedNameServers). Step 2 on the target: `zones/create` type Secondary with `primaryNameServerAddresses`, `zoneTransferProtocol=Tcp`, `tsigKeyName`. The source goes first; if the target step fails the source change is kept and the result says so. The TSIG key must exist on both servers (`settings/getTsigKeyNames`). Results are two rows, one per server. | +| `hub/settings/compare` | read | `admin` | Per server `same`, `differs` (N fields) or `local-only`, and the differing fields with each server's value (secrets as ``, a row only says `secretChanged`). Without a baseline the servers are compared with the first. | +| `hub/settings/baseline/import` | write-all | `admin` | `{server, group?}`: the server's settings (read with its admin token) become the baseline; `group=blocking` replaces only the blocking fields. The old baseline goes to the history. | +| `hub/settings/baseline/save` | write-all | `admin` | `{text}`: the baseline from the editor (JSON, up to 2 MB). A secret shown as `` keeps the stored one; a placeholder with nothing to keep is refused. | +| `hub/settings/overrides/save` | write-all | `admin` | `{paths}`: extra local field names (up to 50). | +| `hub/settings/preview` | read | `admin` | `{targets, group?}`: per target the fields an apply would change, as `{kind:'json', changes}`, `secretFields`, `currentHash`. | +| `hub/settings/apply` | write-all | `admin` | `{targets, expect?, group?}`. Per server: read now, refuse if it changed since the preview, send ONLY the shared fields that differ in one `settings/set` (through the checks of `guards.ts`: never `webService*`, never user, SSO or LDAP fields), after saving the server's settings to the history; read back and compare the fields sent. `enableBlocking` is left alone while blocking is paused. | +| `hub/settings/history/list`, `hub/settings/history/get` | read | `admin` | The settings kept before an apply, the lists kept before extras were removed, and old baselines. View only, secrets masked: there is no rollback for settings (put values back by accepting a server as baseline, or by hand). | +| `hub/blocking/lists/compare` | read | `admin` | The custom Blocked and Allowed lists (`blocked/export`, `allowed/export`, read as text with a size cap) as sets: per server `missing N, extra M` with samples. | +| `hub/blocking/lists/baseline/import` | write-all | `admin` | `{server}`: both lists of that server become the lists baseline (up to 5000 domains each). | +| `hub/blocking/lists/preview`, `hub/blocking/lists/apply` | read, write-all | `admin` | `{targets, removeExtras}`. Adds the missing domains, one `add` call each (at most 1000); deletes the extras only when `removeExtras=true` (at most 500, after saving the server's lists to the history); reads back. | + +**What is local.** Never sent by an apply, listed for information: `dnsServerDomain`, `dnsServerLocalEndPoints`, +`dnsServerIPv4SourceAddresses`, `dnsServerIPv6SourceAddresses` (the host's identity and bind addresses), every `webService*` +(how the hub reaches the server), `dnsTlsCertificatePath`, `dnsTlsCertificatePassword` (certificate), `dnsOverHttpUnixSocket`, +`dnsOverHttpsUnixSocket`, `enableDnsOverHttpUnixSocket`, `enableDnsOverHttpsUnixSocket`, `dnsReverseProxyNetworkACL` (this host's sockets +and proxy) and `logFolder` (a path). User, SSO and LDAP fields are always local. More can be added in the Local fields tab +(`${HUB_DATA}/settings/overrides.json`); the built-in ones cannot be removed. **Never compared:** `version`, `uptimestamp`, +`clusterInitialized`, `clusterNodes`, `temporaryDisableBlockingTill`, `blockListNextUpdatedOn`. Fields only one side reports are skipped. + +**Secrets.** TSIG shared secrets and the proxy password are compared inside the hub by their real value and are stored in the +baseline and the history (mode 0600, protect the data folder like the users file). No answer carries a secret or a hash of one, to +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`). + ## History, rollback, drift, export **History.** Everything the hub replaces is kept in `${HUB_DATA}/apps//history/` (50 files per app): diff --git a/hub/drift.ts b/hub/drift.ts index 3a0268c..603f569 100644 --- a/hub/drift.ts +++ b/hub/drift.ts @@ -16,6 +16,12 @@ import { readConfigText } from './apps-config.ts' import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts' import type { Upstream } from './upstream.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 interface DriftEntry { diff --git a/hub/fake-servers.ts b/hub/fake-servers.ts new file mode 100644 index 0000000..cbffffb --- /dev/null +++ b/hub/fake-servers.ts @@ -0,0 +1,152 @@ +/* +Test helpers for the stage 7 suites: fake Technitium servers that answer by path and record every +call, and a hub started against two of them with one user per role. Not used by the hub itself. +*/ +import http from 'node:http' +import type { AddressInfo } from 'node:net' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { parseConfig } from './config.ts' +import { createHub } from './server.ts' +import { addUser, writeUsersFile } from './users.ts' + +export interface Call { + path: string + method: string + token: string + query: URLSearchParams + form: URLSearchParams + /* A text/plain body (a zone file). */ + text: string +} + +/* What a handler answers: the envelope's `response`, an error message, or a file. */ +export type Reply = { response?: unknown } | { error: string } | { text: string } + +export interface Fake { + server: http.Server + url: string + calls: Call[] + down: boolean + handlers: Map Reply> + /** Answers one path; later calls replace earlier ones. */ + on: (apiPath: string, handler: (call: Call) => Reply) => void +} + +export function fake(): Promise { + return new Promise((resolve) => { + const f: Fake = { + server: http.createServer(), + url: '', + calls: [], + down: false, + handlers: new Map(), + on(apiPath, handler) { + f.handlers.set(apiPath, handler) + }, + } + f.server.on('request', (req, res) => { + const chunks: Buffer[] = [] + req.on('data', (c: Buffer) => chunks.push(c)) + req.on('end', () => { + if (f.down) return req.socket.destroy() + const url = new URL(req.url ?? '/', 'http://fake') + const type = String(req.headers['content-type'] ?? '') + const raw = Buffer.concat(chunks).toString('utf8') + const call: Call = { + path: url.pathname.replace(/^\/api\//, ''), + method: req.method ?? '', + token: url.searchParams.get('token') ?? '', + query: url.searchParams, + form: new URLSearchParams(type.includes('x-www-form-urlencoded') ? raw : ''), + text: type.includes('text/plain') ? raw : '', + } + f.calls.push(call) + const reply: Reply = f.handlers.get(call.path)?.(call) ?? { response: {} } + if ('text' in reply) { + res.setHeader('content-type', 'text/plain') + return res.end(reply.text) + } + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify('error' in reply ? { status: 'error', errorMessage: reply.error } : { status: 'ok', response: reply.response ?? {} })) + }) + }) + f.server.listen(0, '127.0.0.1', () => { + f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}` + resolve(f) + }) + }) +} + +export const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] as const + +export interface TestHub { + base: string + tokens: Record + dataDir: string + /** Calls the hub with a role's session. */ + call: (role: string, apiPath: string, params?: Record, method?: 'GET' | 'POST') => Promise<{ code: number; body: Record }> + audit: () => Promise[]> + close: () => void +} + +export async function startHub(a: Fake, b: Fake, extra: Record = {}): Promise { + const dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-data-')) + const staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-static-')) + let users = addUser([], 'viewer', 'viewer', 'password-viewer') + users = addUser(users, 'operator', 'operator', 'password-operator') + users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin') + users = addUser(users, 'admin', 'admin', 'password-admin') + writeUsersFile(dataDir, users) + const hub = createHub( + parseConfig({ + HUB_SERVERS: `a=${a.url},b=${b.url}`, + A_TOKEN: 'read-a', + B_TOKEN: 'read-b', + A_ADMIN_TOKEN: 'admin-a', + B_ADMIN_TOKEN: 'admin-b', + HUB_DATA: dataDir, + HUB_STATIC: staticDir, + HUB_CACHE_SECONDS: '0', + HUB_TIMEOUT_MS: '2000', + HUB_DRIFT_MINUTES: '0', + ...extra, + }), + ) + await new Promise((resolve) => hub.listen(0, '127.0.0.1', resolve)) + const base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}` + const tokens: Record = {} + for (const role of ROLES) { + const res = await fetch(`${base}/api/user/login`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: `user=${role}&pass=password-${role}`, + }) + tokens[role] = ((await res.json()) as { token: string }).token + } + const call: TestHub['call'] = async (role, apiPath, params = {}, method = 'POST') => { + const query = new URLSearchParams(method === 'GET' ? params : {}) + const res = await fetch(`${base}/api/${apiPath}${query.size > 0 ? `?${query}` : ''}`, { + method, + headers: { authorization: `Bearer ${tokens[role]}`, ...(method === 'POST' ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) }, + ...(method === 'POST' ? { body: new URLSearchParams(params).toString() } : {}), + }) + return { code: res.status, body: (await res.json()) as Record } + } + return { + base, + tokens, + dataDir, + call, + audit: async () => { + const res = await fetch(`${base}/api/hub/audit/list?limit=500`, { headers: { authorization: `Bearer ${tokens.admin}` } }) + return ((await res.json()) as { response: { entries: Record[] } }).response.entries + }, + close: () => { + hub.close() + rmSync(dataDir, { recursive: true, force: true }) + rmSync(staticDir, { recursive: true, force: true }) + }, + } +} diff --git a/hub/routes.ts b/hub/routes.ts index c7460f4..8634dcd 100644 --- a/hub/routes.ts +++ b/hub/routes.ts @@ -159,6 +159,25 @@ export const ROUTES: Record = { 'hub/apps/upload': writeAll('apps.write'), // The same change on several servers, outside Apps (servers-run.ts). One endpoint with a fixed list of actions. 'hub/servers/run': writeAll('operate'), + // Zones across the servers (zones-sync.ts). The merged list is zone names and serials only. + 'hub/zones/matrix': read(), + 'hub/zones/copy/preview': read('admin'), + 'hub/zones/copy': writeAll('admin'), + 'hub/zones/secondary/preview': read('admin'), + 'hub/zones/secondary': writeAll('admin'), + // Settings and blocking lists across the servers (settings-config.ts). Settings carry secrets, so even reads need admin. + 'hub/settings/compare': read('admin'), + 'hub/settings/preview': read('admin'), + 'hub/settings/history/list': read('admin'), + 'hub/settings/history/get': read('admin'), + 'hub/settings/baseline/import': writeAll('admin'), + 'hub/settings/baseline/save': writeAll('admin'), + 'hub/settings/overrides/save': writeAll('admin'), + 'hub/settings/apply': writeAll('admin'), + 'hub/blocking/lists/compare': read('admin'), + 'hub/blocking/lists/preview': read('admin'), + 'hub/blocking/lists/baseline/import': writeAll('admin'), + 'hub/blocking/lists/apply': writeAll('admin'), } export function routeFor(apiPath: string): Route | undefined { @@ -199,6 +218,8 @@ const LARGE_BODIES: Record = { 'zones/records/update': 2 * MB, 'zones/options/set': 2 * MB, 'dhcp/scopes/set': 2 * MB, + 'hub/settings/baseline/save': 2 * MB, + 'hub/settings/overrides/save': 2 * MB, 'allowed/import': 5 * MB, 'blocked/import': 5 * MB, } diff --git a/hub/server.ts b/hub/server.ts index 09605d2..4809498 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -32,6 +32,9 @@ 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 { SettingsConfig } from './settings-config.ts' +import { BLOCKING_FIELDS } from './settings-sync.ts' +import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts' import { mergeQueryLogs, mergeSettled, @@ -147,6 +150,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da 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 byId = new Map(config.servers.map((s) => [s.id, s])) /* Ask every server; the ones that fail become warnings (see mergeSettled). */ @@ -369,6 +374,120 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da } } + /* The merged zone list and the previews of copying a zone and of making it a Secondary: no change is made. */ + async function zonesRead(res: http.ServerResponse, apiPath: string, params: Params): Promise { + try { + if (apiPath === 'hub/zones/matrix') { + const { matrix, warnings } = await buildZoneMatrix(config, upstream) + send(res, 200, ok(matrix, warnings)) + } else if (apiPath === 'hub/zones/copy/preview') { + send(res, 200, ok(await zonesSync.copyPreview(params.source ?? '', params.zone ?? '', csv(params.targets)))) + } else if (apiPath === 'hub/zones/secondary/preview') { + send(res, 200, ok(await zonesSync.secondaryPreview(secondaryInput(params)))) + } else send(res, 403, fail('The hub is read-only.')) + } catch (e) { + if (e instanceof ZoneUserError) send(res, 400, fail(e.message)) + else if (e instanceof Error && !(e instanceof UpstreamError)) send(res, 200, fail(e.message)) + else throw e + } + } + + const secondaryInput = (params: Params) => ({ + source: params.source ?? '', + target: params.target ?? '', + zone: params.zone ?? '', + primaryAddress: params.primaryAddress ?? '', + targetAddress: params.targetAddress ?? '', + tsigKeyName: params.tsigKeyName, + }) + + /* Copy a zone and make a Secondary. One audit line per server: zone, source, target, outcome. */ + async function zonesWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise { + const copy = apiPath === 'hub/zones/copy' + const line = { user: who.user, role: who.role, ip, action: copy ? 'zones.copy' : 'zones.secondary', target: (params.zone ?? '').slice(0, 120) || undefined } + try { + const done = copy + ? await zonesSync.copy(params.source ?? '', params.zone ?? '', csv(params.targets), csv(params.overwrite)) + : await zonesSync.makeSecondary(secondaryInput(params)) + for (const r of done.results) { + audit.log({ ...line, server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: `from ${params.source ?? ''}${copy ? '' : ` to ${params.target ?? ''}`}: ${r.message ?? ''}`.slice(0, 300) }) + } + send(res, 200, ok({ results: done.results })) + } catch (e) { + if (!(e instanceof ZoneUserError)) throw e + audit.log({ ...line, outcome: 'refused', detail: e.message.slice(0, 200) }) + send(res, 400, fail(e.message)) + } + } + + /* `group=blocking` limits a settings preview or apply to the blocking fields. */ + const onlyOf = (params: Params): readonly string[] | undefined => (params.group === 'blocking' ? BLOCKING_FIELDS : undefined) + + const userMessage = (e: unknown): string | null => (e instanceof UserError || e instanceof ZoneUserError ? e.message : null) + + /* Settings and blocking lists: compare, preview, history. No change is made. */ + async function settingsRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise { + try { + if (apiPath === 'hub/settings/compare') send(res, 200, ok(await settingsConfig.compare())) + else if (apiPath === 'hub/settings/preview') { + const preview = await settingsConfig.preview(csv(params.targets), onlyOf(params)) + audit.log({ user: who.user, role: who.role, ip, action: 'settings.preview', outcome: 'ok', detail: csv(params.targets).join(',') }) + send(res, 200, ok(preview)) + } else if (apiPath === 'hub/settings/history/list') send(res, 200, ok({ entries: settingsConfig.historyList() })) + else if (apiPath === 'hub/settings/history/get') send(res, 200, ok(settingsConfig.historyGet(params.id ?? ''))) + else if (apiPath === 'hub/blocking/lists/compare') send(res, 200, ok(await settingsConfig.listsCompare())) + else if (apiPath === 'hub/blocking/lists/preview') send(res, 200, ok(await settingsConfig.listsPreview(csv(params.targets), params.removeExtras === 'true'))) + 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)) + } + } + + /* Baseline, local fields and apply for settings; baseline and apply for the lists. Audit lines carry field names, counts and outcomes, never a value. */ + async function settingsWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise { + const action = apiPath.replace('hub/', '').replace(/\//g, '.') + const base = { user: who.user, role: who.role, ip, action } + try { + if (apiPath === 'hub/settings/baseline/import') { + const done = await settingsConfig.importBaseline(params.server ?? '', params.group, who.user) + audit.log({ ...base, server: params.server, outcome: 'ok', detail: done.detail }) + send(res, 200, ok({ baseline: settingsConfig.baselineView() })) + } else if (apiPath === 'hub/settings/baseline/save') { + const done = settingsConfig.saveBaseline(params.text ?? '', who.user) + audit.log({ ...base, outcome: 'ok', detail: done.detail }) + send(res, 200, ok({ baseline: settingsConfig.baselineView() })) + } else if (apiPath === 'hub/settings/overrides/save') { + const done = settingsConfig.saveOverrides(csv(params.paths)) + audit.log({ ...base, outcome: 'ok', detail: done.detail }) + send(res, 200, ok({ overrides: { paths: csv(params.paths) } })) + } else if (apiPath === 'hub/settings/apply') { + const expect = jsonParam(params.expect, {}) as Record + const done = await settingsConfig.apply(csv(params.targets), expect, onlyOf(params)) + for (const r of done.results) { + audit.log({ ...base, server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server] === undefined ? '' : `fields: ${done.details[r.server]}`].filter(Boolean).join(' | ').slice(0, 400) }) + } + send(res, 200, ok({ results: done.results })) + } else if (apiPath === 'hub/blocking/lists/baseline/import') { + const done = await settingsConfig.listsImport(params.server ?? '', who.user) + audit.log({ ...base, server: params.server, outcome: 'ok', detail: done.detail }) + send(res, 200, ok({ imported: true })) + } else if (apiPath === 'hub/blocking/lists/apply') { + const done = await settingsConfig.listsApply(csv(params.targets), params.removeExtras === 'true') + for (const r of done.results) { + audit.log({ ...base, server: r.server, outcome: r.ok ? 'ok' : 'failed', detail: [r.message, done.details[r.server]].filter(Boolean).join(' | ').slice(0, 400) }) + } + 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({ ...base, outcome: 'refused', detail: apiPath.endsWith('baseline/save') ? 'invalid baseline' : message.slice(0, 120) }) + send(res, 400, fail(message)) + } + } + /* The write endpoints. The role has already been checked against the route's capability. */ async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise { if (route.kind === 'write-all') { @@ -386,6 +505,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da await configWrite(res, apiPath, params, who, ip) return } + if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) { + await settingsWrite(res, apiPath, params, who, ip) + return + } + if (apiPath === 'hub/zones/copy' || apiPath === 'hub/zones/secondary') { + await zonesWrite(res, apiPath, params, who, ip) + return + } if (apiPath === 'hub/servers/run') { const planned = planAction(params.action, params) const line = { user: who.user, role: who.role, ip, action: 'error' in planned ? 'servers.run' : `servers.${planned.plan.action}` } @@ -695,6 +822,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da await configRead(res, apiPath, params, who, ip) return } + if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) { + await settingsRead(res, apiPath, params, who, ip) + return + } + if (apiPath.startsWith('hub/zones/')) { + await zonesRead(res, apiPath, params) + return + } if (apiPath === 'hub/drift/get') { send(res, 200, ok(drift.get())) return diff --git a/hub/settings-config.ts b/hub/settings-config.ts new file mode 100644 index 0000000..a1f89a5 --- /dev/null +++ b/hub/settings-config.ts @@ -0,0 +1,543 @@ +/* +Settings and blocking compare across the servers (stage 7): the baseline kept in the hub, the +fields that stay local, a preview, an apply with history and read-back, and the custom Blocked and +Allowed lists compared as sets. + +The settings are read with each server's ADMIN token when the hub has one (a read-only token may +see blanked secrets, and a baseline imported from those would blank them elsewhere). Apply sends +ONLY the shared fields that differ, through the same checks as a per-server `settings/set` +(guards.ts), after saving the server's settings to the history, and reads them back. + +Nothing here puts a secret value, or a hash of one, in an answer; the audit lines carry field +names only (see settings-sync.ts). +*/ +import { createHash } from 'node:crypto' +import type { HubConfig, HubServer } from './config.ts' +import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, webServiceChanges } from './guards.ts' +import { + BLOCKING_FIELDS, + LOCAL_DEFAULTS, + MAX_DELETES, + MAX_LIST, + changedFields, + compareSettings, + diffSets, + hasSecret, + isBuiltInLocal, + isFieldName, + maskSettings, + maskValue, + normalize, + parseDomains, + prepare, + restoreSecrets, + sameValue, + toParams, + type Comparison, +} from './settings-sync.ts' +import { cleanDomain } from './servers-run.ts' +import { UserError } from './apps-config.ts' +import type { FieldChange } from './apps-sync.ts' +import { MAX_CONFIG } from './apps-sync.ts' +import { + listHistoryIn, + readHistoryIn, + readListsBaseline, + readSettingsBaseline, + readSettingsOverrides, + settingsHistoryDir, + writeHistoryIn, + writeListsBaseline, + writeSettingsBaseline, + writeSettingsOverrides, + type HistoryEntry, + type ListsBaseline, + type SettingsBaseline, +} from './store.ts' +import { Upstream } from './upstream.ts' +import { readCapped } from './zones-sync.ts' + +const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') +const hashOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16) + +export const MAX_ADDS = 1000 +const LIST_TIMEOUT_MS = 30_000 +const LIST_BYTES = 8 * 1024 * 1024 + +export interface TargetResult { + server: string + ok: boolean + message?: string +} + +export interface SettingsTargetPreview { + server: string + ok: boolean + error?: string + writable: boolean + /* Tells the apply whether the server changed since this preview. Built from masked values. */ + currentHash: string + /* `{kind:'json'}` so the console's field table draws it as it does a config. */ + changes: { kind: 'json'; changes: FieldChange[] } + /* Fields whose change is a secret: shown as "secret changed", never as values. */ + secretFields: string[] + unchanged: boolean + warnings: string[] +} + +export interface ListsServerView { + id: string + ok: boolean + error?: string + writable: boolean + blocked: { count: number; missing: number; extra: number; missingSample: string[]; extraSample: string[] } + allowed: { count: number; missing: number; extra: number; missingSample: string[]; extraSample: string[] } + status: 'same' | 'differs' | 'unknown' +} + +const SAMPLE = 100 + +export class SettingsConfig { + #config: HubConfig + #upstream: Upstream + #now: () => number + + constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) { + this.#config = config + this.#upstream = upstream + this.#now = now + } + + #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)) + } + + /* Fresh `settings/get`, with the admin token when there is one. */ + async #read(server: HubServer): Promise { + this.#upstream.invalidate(server.id) + return this.#upstream.call(server, 'settings/get', {}, server.adminToken ?? server.token) + } + + async #readAll(): Promise> { + const entries = await Promise.all( + this.#config.servers.map(async (s): Promise<[string, { settings: unknown } | { error: string }]> => { + try { + return [s.id, { settings: await this.#read(s) }] + } catch (e) { + return [s.id, { error: reason(e) }] + } + }), + ) + return Object.fromEntries(entries) + } + + getBaseline(): SettingsBaseline | null { + return readSettingsBaseline(this.#config.dataDir) + } + + #extraLocal(): string[] { + return readSettingsOverrides(this.#config.dataDir).paths + } + + /* The baseline as the console may see it: secrets masked. */ + baselineView(): { settings: Record; importedFrom?: string; updatedAt: string; updatedBy: string } | null { + const b = this.getBaseline() + return b === null ? null : { ...b, settings: maskSettings(b.settings) } + } + + async compare(): Promise { + const baseline = this.getBaseline() + const extra = this.#extraLocal() + const live = await this.#readAll() + const comparison: Comparison = compareSettings({ baseline: baseline?.settings ?? null, live, extraLocal: extra }) + return { + baseline: this.baselineView(), + localFields: { builtIn: [...LOCAL_DEFAULTS], builtInPatterns: ['webService*', 'user*, sso*, ldap*, auth*'], extra }, + blockingFields: [...BLOCKING_FIELDS], + servers: comparison.servers.map((s) => ({ ...s, writable: this.#config.servers.find((x) => x.id === s.id)?.adminToken !== undefined })), + fields: comparison.fields, + withBaseline: comparison.withBaseline, + } + } + + /* Writes the baseline; the one it replaces goes to the history first. */ + #setBaseline(settings: Record, importedFrom: string | undefined, user: string): void { + const old = this.getBaseline() + if (old !== null && JSON.stringify(old.settings) !== JSON.stringify(settings)) { + try { + writeHistoryIn(settingsHistoryDir(this.#config.dataDir), 'baseline', JSON.stringify(old.settings), 'before-save', this.#now()) + } catch (e) { + throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`) + } + } + writeSettingsBaseline(this.#config.dataDir, { + settings, + ...(importedFrom === undefined ? {} : { importedFrom }), + updatedAt: new Date(this.#now()).toISOString(), + updatedBy: user, + }) + } + + /* Takes a server's settings as the baseline; with `group=blocking` only its blocking fields replace the baseline's. */ + async importBaseline(serverId: string, group: string | undefined, user: string): Promise<{ detail: string }> { + const server = this.#server(serverId) + let raw: unknown + try { + raw = await this.#read(server) + } catch (e) { + throw new UserError(`${serverId}: ${reason(e)}`) + } + const read = normalize(raw) + if (Object.keys(read).length === 0) throw new UserError(`${serverId} sent no settings.`) + let next = read + if (group === 'blocking') { + const old = this.getBaseline()?.settings ?? {} + const kept: Record = { ...old } + for (const name of BLOCKING_FIELDS) if (Object.hasOwn(read, name)) kept[name] = read[name] + next = kept + } else if (group !== undefined && group !== '') throw new UserError('group can only be "blocking".') + this.#setBaseline(next, serverId, user) + return { detail: `from ${serverId}${group === 'blocking' ? ' (blocking fields)' : ''}, ${Object.keys(next).length} fields` } + } + + /* The baseline from the editor's JSON. A secret shown as the placeholder keeps the stored one. */ + saveBaseline(textBody: string, user: string): { detail: string } { + if (textBody.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.') + let parsed: unknown + try { + parsed = JSON.parse(textBody) + } catch (e) { + throw new UserError(`The baseline is not valid JSON: ${reason(e)}`) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new UserError('The baseline must be a JSON object of settings.') + const named = Object.keys(parsed as object) + const bad = named.find((k) => !isFieldName(k)) + if (bad !== undefined) throw new UserError(`"${bad.slice(0, 40)}" is not a setting name.`) + let settings: Record + try { + settings = restoreSecrets(normalize(parsed), this.getBaseline()?.settings ?? null) + } catch (e) { + throw new UserError(reason(e)) + } + this.#setBaseline(settings, this.getBaseline()?.importedFrom, user) + return { detail: `${Object.keys(settings).length} fields` } + } + + saveOverrides(pathsRaw: string[]): { detail: string } { + const seen = new Set() + if (pathsRaw.length > 50) throw new UserError('At most 50 extra local fields.') + for (const p of pathsRaw) { + if (!isFieldName(p)) throw new UserError(`"${p.slice(0, 40)}" is not a setting name.`) + if (seen.has(p)) throw new UserError(`"${p}" is listed twice.`) + seen.add(p) + } + writeSettingsOverrides(this.#config.dataDir, { paths: pathsRaw }) + return { detail: `${pathsRaw.length} extra local field(s)` } + } + + /* ---- Preview and apply ---- */ + + #fingerprint(current: unknown, baseline: Record, extra: string[]): string { + const { settings } = prepare(current) + const shared = Object.keys(settings) + .filter((k) => !isBuiltInLocal(k) && !extra.includes(k) && Object.hasOwn(baseline, k)) + .sort() + .map((k) => [k, maskValue(k, settings[k])]) + return hashOf(shared) + } + + async preview(ids: string[], only?: readonly string[]): Promise<{ targets: SettingsTargetPreview[] }> { + const targets = this.#targets(ids) + const baseline = 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) + const out = await Promise.all( + targets.map(async (s): Promise => { + const empty = { kind: 'json' as const, changes: [] } + const writable = s.adminToken !== undefined + let current: unknown + try { + current = await this.#read(s) + } catch (e) { + return { server: s.id, ok: false, error: reason(e), writable, currentHash: '', changes: empty, secretFields: [], unchanged: false, warnings: [] } + } + const names = changedFields(baseline.settings, current, extra, only) + const live = prepare(current) + const warnings: string[] = [] + if (!writable) warnings.push('The hub has no admin token for this server, so it cannot write to it.') + if (live.paused) warnings.push('Blocking is paused for a while on this server; its blocking switch is left alone.') + const changes: FieldChange[] = names.map((name) => { + const [a, b] = [maskValue(name, live.settings[name]), maskValue(name, base[name])] + return { path: name, type: a === undefined ? 'added' : b === undefined ? 'removed' : 'changed', a, b } + }) + return { + server: s.id, + ok: true, + writable, + currentHash: this.#fingerprint(current, base, extra), + changes: { kind: 'json', changes }, + secretFields: names.filter(hasSecret), + unchanged: names.length === 0, + warnings, + } + }), + ) + return { targets: out } + } + + async #applyOne(s: HubServer, baseline: SettingsBaseline, extra: string[], expect: string | undefined, only?: readonly string[]): Promise<{ result: TargetResult; detail?: string }> { + const fail = (message: string): { result: TargetResult } => ({ result: { server: s.id, ok: false, message } }) + if (s.adminToken === undefined) return fail('no admin token configured') + try { + let current: unknown + try { + current = await this.#read(s) + } catch (e) { + return fail(reason(e)) + } + const base = normalize(baseline.settings) + if (expect !== undefined && expect !== this.#fingerprint(current, base, extra)) return fail('the settings changed on the server since the preview; run the preview again') + const names = changedFields(baseline.settings, current, extra, only) + if (names.length === 0) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } } + + const params: Record = {} + for (const name of names) { + const encoded = toParams(name, base[name]) + if ('error' in encoded) return fail(`${name}: ${encoded.error}; nothing was changed`) + Object.assign(params, encoded.params) + } + // The same checks as a per-server settings/set (guards.ts). + if (Object.keys(params).some((k) => isForbiddenSettingsField(k) || k.startsWith('webService'))) return fail('a setting the hub does not change was in the list; nothing was changed') + const web = webServiceChanges(params, current) + if (web.length > 0) return fail(`${WEB_SERVICE_MESSAGE} nothing was changed`) + + try { + writeHistoryIn(settingsHistoryDir(this.#config.dataDir), s.id, JSON.stringify(normalize(current)), 'before-apply', this.#now()) + } catch (e) { + return fail(`the old settings could not be saved first (${reason(e)}); nothing was changed`) + } + await this.#upstream.write(s, 'settings/set', params) + const detail = names.join(', ') + let after: unknown + try { + after = prepare(await this.#read(s)).settings + } catch (e) { + return { result: { server: s.id, ok: false, message: `the server accepted the change but could not be read back (${reason(e)})` }, detail } + } + const wrong = names.filter((n) => !sameValue(n, base[n], (after as Record)[n])) + if (wrong.length > 0) return { result: { server: s.id, ok: false, message: `the server accepted the change but reads back different values for: ${wrong.join(', ')}` }, detail } + return { result: { server: s.id, ok: true, message: `Applied and verified (${names.length} field${names.length === 1 ? '' : 's'}).` }, detail } + } catch (e) { + return fail(reason(e)) + } + } + + async apply(ids: string[], expect: Record, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record }> { + const targets = this.#targets(ids) + const baseline = 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))) + const details: Record = {} + for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail + return { results: done.map((d) => d.result), details } + } + + /* ---- History: list and view (no rollback; apply the baseline again, or accept an old one by hand) ---- */ + + historyList(): HistoryEntry[] { + return listHistoryIn(settingsHistoryDir(this.#config.dataDir)) + } + + historyGet(id: string): { entry: HistoryEntry; settings?: Record; lists?: { blocked: number; allowed: number } } { + const entry = this.historyList().find((e) => e.id === id) + const text = entry === undefined ? null : readHistoryIn(settingsHistoryDir(this.#config.dataDir), id) + if (entry === undefined || text === null) throw new UserError('There is no such history entry.') + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new UserError('That history entry cannot be read.') + } + if (entry.kind === 'before-lists') { + const p = parsed as { blocked?: unknown[]; allowed?: unknown[] } + return { entry, lists: { blocked: p.blocked?.length ?? 0, allowed: p.allowed?.length ?? 0 } } + } + return { entry, settings: maskSettings(normalize(parsed)) } + } + + /* ---- Blocked and Allowed lists ---- */ + + async #readList(server: HubServer, list: 'blocked' | 'allowed'): Promise { + const file = await this.#upstream.raw(server, `${list}/export`, {}) + return parseDomains(await readCapped(file, LIST_BYTES, LIST_TIMEOUT_MS, `The ${list} list`)) + } + + async #readLists(server: HubServer): Promise<{ blocked: string[]; allowed: string[] }> { + const [blocked, allowed] = await Promise.all([this.#readList(server, 'blocked'), this.#readList(server, 'allowed')]) + return { blocked, allowed } + } + + getListsBaseline(): ListsBaseline | null { + return readListsBaseline(this.#config.dataDir) + } + + async listsCompare(): Promise { + const baseline = this.getListsBaseline() + const reads = await Promise.all( + this.#config.servers.map(async (s) => { + try { + return { s, lists: await this.#readLists(s), error: undefined } + } catch (e) { + return { s, lists: null, error: reason(e) } + } + }), + ) + const reference = baseline ?? reads.find((r) => r.lists !== null)?.lists ?? null + const refId = baseline === null ? reads.find((r) => r.lists !== null)?.s.id : undefined + const view = (live: string[], ref: string[], own: boolean) => { + const d = own ? { missing: [], extra: [] } : diffSets(ref, live) + return { count: live.length, missing: d.missing.length, extra: d.extra.length, missingSample: d.missing.slice(0, SAMPLE), extraSample: d.extra.slice(0, SAMPLE) } + } + const servers: ListsServerView[] = reads.map((r) => { + const writable = r.s.adminToken !== undefined + if (r.lists === null || reference === null) { + const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] } + return { id: r.s.id, ok: false, error: r.error, writable, blocked: none, allowed: none, status: 'unknown' } + } + const own = r.s.id === refId + const blocked = view(r.lists.blocked, reference.blocked, own) + const allowed = view(r.lists.allowed, reference.allowed, own) + const differs = blocked.missing + blocked.extra + allowed.missing + allowed.extra > 0 + return { id: r.s.id, ok: true, writable, blocked, allowed, status: differs ? 'differs' : 'same' } + }) + // Without a baseline the first server is the reference; it differs when any other does. + if (baseline === null) { + const first = servers.find((s) => s.id === refId) + if (first !== undefined && servers.some((s) => s.id !== first.id && s.status === 'differs')) first.status = 'differs' + } + return { + baseline: baseline === null ? null : { blocked: baseline.blocked.length, allowed: baseline.allowed.length, importedFrom: baseline.importedFrom, updatedAt: baseline.updatedAt, updatedBy: baseline.updatedBy }, + withBaseline: baseline !== null, + servers, + } + } + + async listsImport(serverId: string, user: string): Promise<{ detail: string }> { + const server = this.#server(serverId) + let lists: { blocked: string[]; allowed: string[] } + try { + lists = await this.#readLists(server) + } catch (e) { + throw new UserError(`${serverId}: ${reason(e)}`) + } + if (lists.blocked.length > MAX_LIST || lists.allowed.length > MAX_LIST) { + throw new UserError(`A list is longer than ${MAX_LIST} domains. The hub compares custom lists, not whole block lists.`) + } + writeListsBaseline(this.#config.dataDir, { ...lists, importedFrom: serverId, updatedAt: new Date(this.#now()).toISOString(), updatedBy: user }) + return { detail: `from ${serverId}: ${lists.blocked.length} blocked, ${lists.allowed.length} allowed` } + } + + async listsPreview(ids: string[], removeExtras: boolean): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> { + const targets = this.#targets(ids) + const baseline = 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) => { + const writable = s.adminToken !== undefined + const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] } + try { + const live = await this.#readLists(s) + const mk = (l: string[], b: string[]) => { + const d = diffSets(b, l) + return { count: l.length, missing: d.missing.length, extra: d.extra.length, missingSample: d.missing.slice(0, SAMPLE), extraSample: d.extra.slice(0, SAMPLE) } + } + const blocked = mk(live.blocked, baseline.blocked) + const allowed = mk(live.allowed, baseline.allowed) + const adds = blocked.missing + allowed.missing + const deletes = removeExtras ? blocked.extra + allowed.extra : 0 + const tooMany = adds > MAX_ADDS ? `${adds} domains to add; at most ${MAX_ADDS} at a time.` : deletes > MAX_DELETES ? `${deletes} domains to remove; at most ${MAX_DELETES} at a time.` : undefined + return { id: s.id, ok: true, writable, blocked, allowed, status: adds + (removeExtras ? blocked.extra + allowed.extra : 0) > 0 ? ('differs' as const) : ('same' as const), ...(tooMany === undefined ? {} : { tooMany }) } + } catch (e) { + return { id: s.id, ok: false, error: reason(e), writable, blocked: none, allowed: none, status: 'unknown' as const } + } + }), + ) + return { targets: out } + } + + /* Adds the missing domains and, only when asked, deletes the extras; one call per domain, a few at a time. */ + async #applyLists(s: HubServer, baseline: ListsBaseline, removeExtras: boolean): Promise<{ result: TargetResult; detail?: string }> { + const fail = (message: string): { result: TargetResult } => ({ result: { server: s.id, ok: false, message } }) + if (s.adminToken === undefined) return fail('no admin token configured') + try { + const live = await this.#readLists(s) + const plan = (['blocked', 'allowed'] as const).map((list) => { + const d = diffSets(baseline[list], live[list]) + return { list, add: d.missing, remove: removeExtras ? d.extra : [] } + }) + const adds = plan.reduce((n, p) => n + p.add.length, 0) + const removals = plan.reduce((n, p) => n + p.remove.length, 0) + if (adds + removals === 0) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } } + if (adds > MAX_ADDS) return fail(`${adds} domains to add; at most ${MAX_ADDS} at a time, so nothing was changed`) + if (removals > MAX_DELETES) return fail(`${removals} domains to remove; at most ${MAX_DELETES} at a time, so nothing was changed`) + const calls: { path: string; domain: string }[] = [] + let invalid = 0 + for (const p of plan) { + for (const d of p.add) (cleanDomain(d) === null ? invalid++ : calls.push({ path: `${p.list}/add`, domain: d })) + for (const d of p.remove) (cleanDomain(d) === null ? invalid++ : calls.push({ path: `${p.list}/delete`, domain: d })) + } + if (removals > 0) { + try { + writeHistoryIn(settingsHistoryDir(this.#config.dataDir), s.id, JSON.stringify(live), 'before-lists', this.#now()) + } catch (e) { + return fail(`the old lists could not be saved first (${reason(e)}); nothing was changed`) + } + } + let failed = 0 + let firstError = '' + for (let i = 0; i < calls.length; i += 10) { + await Promise.all( + calls.slice(i, i + 10).map(async (c) => { + try { + await this.#upstream.write(s, c.path, { domain: c.domain }) + } catch (e) { + failed++ + if (firstError === '') firstError = reason(e) + } + }), + ) + } + const detail = `${adds} to add, ${removals} to remove` + if (failed > 0 || invalid > 0) { + return { result: { server: s.id, ok: false, message: `${failed} change(s) failed${firstError === '' ? '' : ` (${firstError})`}${invalid > 0 ? `, ${invalid} domain(s) skipped as invalid` : ''}` }, detail } + } + const after = await this.#readLists(s) + const left = (['blocked', 'allowed'] as const).reduce((n, l) => n + diffSets(baseline[l], after[l]).missing.length + (removeExtras ? diffSets(baseline[l], after[l]).extra.length : 0), 0) + if (left > 0) return { result: { server: s.id, ok: false, message: `the changes were accepted but ${left} difference(s) remain when read back` }, detail } + return { result: { server: s.id, ok: true, message: `Added ${adds}, removed ${removals}.` }, detail } + } catch (e) { + return fail(reason(e)) + } + } + + async listsApply(ids: string[], removeExtras: boolean): Promise<{ results: TargetResult[]; details: Record }> { + const targets = this.#targets(ids) + const baseline = 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 = {} + for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail + return { results: done.map((d) => d.result), details } + } +} + diff --git a/hub/settings-sync.test.ts b/hub/settings-sync.test.ts new file mode 100644 index 0000000..e4c8670 --- /dev/null +++ b/hub/settings-sync.test.ts @@ -0,0 +1,630 @@ +// @vitest-environment node +/* +Stage 7, settings and blocking: the pure rules (local, ignored and secret fields, the encoding for +settings/set) and the endpoints against fake servers that keep their settings and lists. +*/ +import { existsSync, readdirSync, readFileSync, rmSync } 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 { + SECRET_PLACEHOLDER, + changedFields, + compareSettings, + diffSets, + isBuiltInLocal, + maskSettings, + normalize, + parseDomains, + prepare, + restoreSecrets, + sameValue, + toParams, +} from './settings-sync.ts' +import { routeFor } from './routes.ts' + +const TSIG = 'TSIG-SECRET-9f3a' +const PROXY = 'PROXY-PASS-77c1' +const CERT = 'CERT-PASS-4b2d' + +function settingsOf(over: Record = {}): Record { + return { + version: '15.5', + uptimestamp: '2026-01-01T00:00:00Z', + clusterInitialized: false, + dnsServerDomain: 'a.example.net', + dnsServerLocalEndPoints: ['0.0.0.0:53'], + dnsServerIPv4SourceAddresses: null, + webServiceHttpPort: 5380, + webServiceTlsCertificatePassword: CERT, + dnsTlsCertificatePath: '/etc/a.pfx', + dnsTlsCertificatePassword: CERT, + logFolder: 'logs-a', + defaultRecordTtl: 3600, + dnssecValidation: true, + recursion: 'AllowOnlyForPrivateNetworks', + forwarders: ['1.1.1.1', '9.9.9.9'], + qpmPrefixLimitsIPv4: [{ prefix: 32, udpLimit: 600, tcpLimit: 600 }], + tsigKeys: [{ keyName: 'transfer.key', sharedSecret: TSIG, algorithmName: 'hmac-sha256' }], + proxy: null, + enableBlocking: true, + temporaryDisableBlockingTill: null, + blockingType: 'NxDomain', + blockingAnswerTtl: 30, + blockListUrls: ['https://a.example/hosts', '# Privacy'], + blockListNextUpdatedOn: '2026-02-01T00:00:00Z', + ...over, + } +} + +interface Box { + settings: Record + blocked: string[] + allowed: string[] + /* The settings a read answers with after a write, to pretend the server ignored the change. */ + ignoreWrites: boolean +} + +const boxes = new Map() + +function decode(old: unknown, value: string): unknown { + if (typeof old === 'boolean') return value === 'true' + if (typeof old === 'number') return Number(value) + if (Array.isArray(old)) return value === 'false' ? [] : value.split(',') + return value +} + +function install(f: Fake, box: Box): void { + boxes.set(f, box) + f.on('settings/get', () => ({ response: box.settings })) + f.on('settings/set', (c: Call) => { + if (box.ignoreWrites) return { response: box.settings } + for (const [k, v] of c.form) { + if (k === 'tsigKeys') { + box.settings.tsigKeys = v === 'false' ? [] : (() => { + const cells = v.split('|') + const keys = [] + for (let i = 0; i < cells.length; i += 3) keys.push({ keyName: cells[i], sharedSecret: cells[i + 1], algorithmName: cells[i + 2] }) + return keys + })() + } else if (k === 'qpmPrefixLimitsIPv4') { + const cells = v.split('|') + box.settings[k] = v === 'false' ? [] : [{ prefix: Number(cells[0]), udpLimit: Number(cells[1]), tcpLimit: Number(cells[2]) }] + } else if (k === 'proxyType') { + box.settings.proxy = v === 'none' ? null : { type: v, address: c.form.get('proxyAddress'), port: Number(c.form.get('proxyPort')), username: c.form.get('proxyUsername'), password: c.form.get('proxyPassword'), bypass: [] } + } else if (!k.startsWith('proxy') && k in box.settings) box.settings[k] = decode(box.settings[k], v) + } + return { response: box.settings } + }) + for (const list of ['blocked', 'allowed'] as const) { + f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' })) + f.on(`${list}/add`, (c) => { + box[list].push(c.form.get('domain') ?? '') + return { response: {} } + }) + f.on(`${list}/delete`, (c) => { + box[list] = box[list].filter((d) => d !== c.form.get('domain')) + return { response: {} } + }) + } +} + +let a: Fake +let b: Fake +let hub: TestHub + +const boxB = (): Box => boxes.get(b)! +const writes = (f: Fake): Call[] => f.calls.filter((c) => c.method === 'POST') + +beforeAll(async () => { + a = await fake() + b = await fake() + hub = await startHub(a, b) +}) + +afterAll(() => { + hub.close() + a.server.close() + b.server.close() +}) + +beforeEach(() => { + a.calls = [] + b.calls = [] + a.down = false + b.down = false + install(a, { settings: settingsOf(), blocked: ['ads.example', 'both.example'], allowed: ['ok.example'], ignoreWrites: false }) + install( + b, + { + settings: settingsOf({ + dnsServerDomain: 'b.example.net', + logFolder: 'logs-b', + webServiceHttpPort: 5381, + uptimestamp: '2026-03-03T00:00:00Z', + defaultRecordTtl: 7200, + forwarders: ['8.8.8.8'], + blockingType: 'CustomAddress', + }), + blocked: ['both.example', 'only-b.example'], + allowed: ['ok.example', 'extra-allowed.example'], + ignoreWrites: false, + }, + ) + // A baseline and its history from an earlier test must not leak into the next one. + for (const dir of ['settings', 'blocking']) rmSync(path.join(hub.dataDir, dir), { recursive: true, force: true }) +}) + +describe('which fields count', () => { + it('treats the host identity, certificates, paths and every webService field as local', () => { + for (const n of ['dnsServerDomain', 'dnsServerLocalEndPoints', 'dnsServerIPv4SourceAddresses', 'dnsServerIPv6SourceAddresses', 'webServiceHttpPort', 'webServiceTlsCertificatePath', 'dnsTlsCertificatePath', 'dnsTlsCertificatePassword', 'logFolder', 'ssoEnabled', 'ldapServer']) { + expect(isBuiltInLocal(n), n).toBe(true) + } + for (const n of ['forwarders', 'defaultRecordTtl', 'blockListUrls', 'tsigKeys']) expect(isBuiltInLocal(n), n).toBe(false) + }) + + it('drops the read-only and runtime fields', () => { + const n = normalize(settingsOf()) + for (const gone of ['version', 'uptimestamp', 'clusterInitialized', 'temporaryDisableBlockingTill', 'blockListNextUpdatedOn']) expect(n).not.toHaveProperty(gone) + expect(n).toHaveProperty('forwarders') + }) + + it('leaves the blocking switch alone while blocking is paused', () => { + const p = prepare(settingsOf({ enableBlocking: false, temporaryDisableBlockingTill: '2026-12-01T00:00:00Z' })) + expect(p.paused).toBe(true) + expect(p.settings).not.toHaveProperty('enableBlocking') + }) + + it('compares by meaning: absent, null, empty text and an empty list are the same, key order does not count', () => { + expect(sameValue('x', null, '')).toBe(true) + expect(sameValue('x', [], null)).toBe(true) + expect(sameValue('x', { a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true) + expect(sameValue('x', ['a', 'b'], ['b', 'a'])).toBe(false) + expect(sameValue('proxy', null, { type: 'None', address: 'x' })).toBe(true) + expect(sameValue('x', 5, '5')).toBe(false) + }) +}) + +describe('comparing', () => { + const live = (x: Record, y: Record) => ({ a: { settings: settingsOf(x) }, b: { settings: settingsOf(y) } }) + + it('says same, differs and local-only against a baseline', () => { + const baseline = settingsOf() + const out = compareSettings({ + baseline, + live: live({}, { dnsServerDomain: 'b.example.net', defaultRecordTtl: 1 }), + extraLocal: [], + }) + const by = Object.fromEntries(out.servers.map((s) => [s.id, s])) + expect(by.a.status).toBe('same') + expect(by.b).toMatchObject({ status: 'differs', differing: 1, localDiffering: 1 }) + expect(out.fields.map((f) => f.name)).toEqual(['defaultRecordTtl', 'dnsServerDomain']) + + const onlyLocal = compareSettings({ baseline, live: live({}, { dnsServerDomain: 'b.example.net' }), extraLocal: [] }) + expect(onlyLocal.servers.find((s) => s.id === 'b')!.status).toBe('local-only') + }) + + it('compares with the first server when there is no baseline', () => { + const out = compareSettings({ baseline: null, live: live({}, { forwarders: ['8.8.8.8'] }), extraLocal: [] }) + expect(out.withBaseline).toBe(false) + expect(out.fields.map((f) => f.name)).toEqual(['forwarders']) + expect(out.servers.every((s) => s.status === 'differs')).toBe(true) + }) + + it('marks an unreachable server unknown', () => { + const out = compareSettings({ baseline: settingsOf(), live: { a: { settings: settingsOf() }, b: { error: 'timed out' } }, extraLocal: [] }) + expect(out.servers.find((s) => s.id === 'b')).toMatchObject({ ok: false, status: 'unknown', error: 'timed out' }) + }) + + it('counts an extra local field as local, not as a difference', () => { + const out = compareSettings({ baseline: settingsOf(), live: live({}, { defaultRecordTtl: 1 }), extraLocal: ['defaultRecordTtl'] }) + expect(out.servers.find((s) => s.id === 'b')!.status).toBe('local-only') + }) + + it('never puts a secret value in a row, and says only that a secret differs', () => { + const other = [{ keyName: 'transfer.key', sharedSecret: 'DIFFERENT-SECRET', algorithmName: 'hmac-sha256' }] + const out = compareSettings({ baseline: settingsOf(), live: live({}, { tsigKeys: other, proxy: { type: 'Http', address: 'p', port: 1, username: 'u', password: PROXY, bypass: [] } }), extraLocal: [] }) + const text = JSON.stringify(out) + for (const secret of [TSIG, PROXY, CERT, 'DIFFERENT-SECRET']) expect(text).not.toContain(secret) + expect(out.fields.find((f) => f.name === 'tsigKeys')).toMatchObject({ secret: true, cells: { b: { differs: true, secretChanged: true } } }) + }) + + it('masks every secret of a settings object', () => { + const text = JSON.stringify(maskSettings(settingsOf({ proxy: { type: 'Http', address: 'p', port: 1, username: 'u', password: PROXY, bypass: [] } }))) + for (const secret of [TSIG, PROXY, CERT]) expect(text).not.toContain(secret) + expect(text).toContain(SECRET_PLACEHOLDER) + }) +}) + +describe('what an apply would send', () => { + it('lists only shared fields that differ and that both sides have', () => { + const baseline = settingsOf({ defaultRecordTtl: 1, webServiceHttpPort: 1, dnsServerDomain: 'x', onlyInBaseline: true }) + expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2 }), [])).toEqual(['defaultRecordTtl']) + expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2 }), ['defaultRecordTtl'])).toEqual([]) + expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2, blockingType: 'x' }), [], ['blockingType'])).toEqual(['blockingType']) + }) +}) + +describe('encoding for settings/set', () => { + const one = (name: string, value: unknown) => { + const r = toParams(name, value) + if ('error' in r) throw new Error(r.error) + return r.params + } + + it('encodes flags, numbers and text', () => { + expect(one('dnssecValidation', true)).toEqual({ dnssecValidation: 'true' }) + expect(one('blockingAnswerTtl', 30)).toEqual({ blockingAnswerTtl: '30' }) + expect(one('recursion', 'Allow')).toEqual({ recursion: 'Allow' }) + expect(one('eDnsClientSubnetIpv4Override', null)).toEqual({ eDnsClientSubnetIpv4Override: '' }) + }) + + it('joins lists with commas and sends an empty one as false', () => { + expect(one('forwarders', ['1.1.1.1', '9.9.9.9'])).toEqual({ forwarders: '1.1.1.1,9.9.9.9' }) + expect(one('forwarders', [])).toEqual({ forwarders: 'false' }) + expect(one('blockListUrls', ['https://a.example/x', '# Privacy'])).toEqual({ blockListUrls: 'https://a.example/x,# Privacy' }) + }) + + it('refuses an entry the server would split', () => { + expect(toParams('blockListUrls', ['https://a.example/x,y'])).toHaveProperty('error') + }) + + it('sends tsigKeys and the QPM limits as pipe-joined rows, and false when empty', () => { + expect(one('tsigKeys', [{ keyName: 'k', sharedSecret: 's', algorithmName: 'hmac-sha256' }])).toEqual({ tsigKeys: 'k|s|hmac-sha256' }) + expect(one('tsigKeys', [])).toEqual({ tsigKeys: 'false' }) + expect(one('qpmPrefixLimitsIPv4', [{ prefix: 32, udpLimit: 600, tcpLimit: 500 }, { prefix: 24, udpLimit: 1, tcpLimit: 2 }])).toEqual({ qpmPrefixLimitsIPv4: '32|600|500|24|1|2' }) + expect(one('qpmPrefixLimitsIPv6', [])).toEqual({ qpmPrefixLimitsIPv6: 'false' }) + expect(toParams('tsigKeys', [{ keyName: 'k|x', sharedSecret: 's', algorithmName: 'a' }])).toHaveProperty('error') + }) + + it('sends the proxy flat, and only its type when there is none', () => { + expect(one('proxy', null)).toEqual({ proxyType: 'none' }) + expect(one('proxy', { type: 'None', address: 'x' })).toEqual({ proxyType: 'none' }) + expect(one('proxy', { type: 'Socks5', address: 'p.example', port: 1080, username: 'u', password: 'pw', bypass: ['127.0.0.1', 'lan'] })).toEqual({ + proxyType: 'socks5', + proxyAddress: 'p.example', + proxyPort: '1080', + proxyUsername: 'u', + proxyPassword: 'pw', + proxyBypass: '127.0.0.1,lan', + }) + }) + + it('refuses a name that is not a setting', () => { + expect(toParams('a=b&c', 1)).toHaveProperty('error') + }) +}) + +describe('secrets coming back from the baseline editor', () => { + const old = normalize(settingsOf()) + + it('keeps the stored secret behind a placeholder, and takes a typed one', () => { + const kept = restoreSecrets({ tsigKeys: [{ keyName: 'transfer.key', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }, old) + expect((kept.tsigKeys as { sharedSecret: string }[])[0].sharedSecret).toBe(TSIG) + const typed = restoreSecrets({ tsigKeys: [{ keyName: 'transfer.key', sharedSecret: 'NEW', algorithmName: 'x' }] }, old) + expect((typed.tsigKeys as { sharedSecret: string }[])[0].sharedSecret).toBe('NEW') + }) + + it('refuses a placeholder with nothing to stand for', () => { + expect(() => restoreSecrets({ tsigKeys: [{ keyName: 'new.key', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }, old)).toThrow(/transfer|new\.key/) + expect(() => restoreSecrets({ proxy: { type: 'Http', password: SECRET_PLACEHOLDER } }, old)).toThrow(/proxy/) + }) +}) + +describe('lists', () => { + it('parses an export and compares as sets', () => { + expect(parseDomains('B.example.\n# note\n\na.example\r\na.example\n')).toEqual(['a.example', 'b.example']) + expect(diffSets(['a', 'b'], ['b', 'c'])).toEqual({ missing: ['a'], extra: ['c'] }) + }) +}) + +describe('routes and roles', () => { + const PATHS = [ + 'hub/settings/compare', + 'hub/settings/preview', + 'hub/settings/history/list', + 'hub/settings/history/get', + 'hub/settings/baseline/import', + 'hub/settings/baseline/save', + 'hub/settings/overrides/save', + 'hub/settings/apply', + 'hub/blocking/lists/compare', + 'hub/blocking/lists/preview', + 'hub/blocking/lists/baseline/import', + 'hub/blocking/lists/apply', + ] + + it('needs admin on every endpoint, reads included', () => { + for (const p of PATHS) expect(routeFor(p)?.capability, p).toBe('admin') + for (const p of PATHS.filter((x) => /import|save|apply/.test(x))) expect(routeFor(p)?.kind, p).toBe('write-all') + }) + + it('refuses the other roles without touching a server', async () => { + for (const role of ROLES.filter((r) => r !== 'admin')) { + for (const p of PATHS) expect((await hub.call(role, p, { server: 'a', targets: 'a', text: '{}' })).code, `${role} ${p}`).toBe(403) + } + expect(a.calls).toHaveLength(0) + expect(b.calls).toHaveLength(0) + }) +}) + +describe('hub/settings', () => { + const importFrom = (server: string, extra: Record = {}) => hub.call('admin', 'hub/settings/baseline/import', { server, ...extra }) + const historyFiles = (): string[] => { + const dir = path.join(hub.dataDir, 'settings', 'history') + return existsSync(dir) ? readdirSync(dir) : [] + } + + it('compares the servers with each other before there is a baseline', async () => { + const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET') + expect(r.body.response.withBaseline).toBe(false) + expect(r.body.response.baseline).toBeNull() + const names = r.body.response.fields.map((f: { name: string }) => f.name) + expect(names).toContain('defaultRecordTtl') + expect(names).toContain('dnsServerDomain') + expect(names).not.toContain('uptimestamp') + expect(r.body.response.localFields.builtIn).toContain('dnsServerDomain') + }) + + it('accepts a server as the baseline, then reports b as differing and a as same', async () => { + expect((await importFrom('a')).body.status).toBe('ok') + const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET') + const by = Object.fromEntries(r.body.response.servers.map((s: { id: string }) => [s.id, s])) + expect(by.a.status).toBe('same') + expect(by.b.status).toBe('differs') + expect(by.b.differing).toBe(3) + expect(r.body.response.baseline.importedFrom).toBe('a') + // The import read with the admin token. + expect(a.calls.find((c) => c.path === 'settings/get')!.token).toBe('admin-a') + }) + + it('imports only the blocking fields with group=blocking', async () => { + await importFrom('a') + boxB().settings.blockingType = 'AnyAddress' + boxB().settings.defaultRecordTtl = 5 + await importFrom('b', { group: 'blocking' }) + const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET') + const rows = r.body.response.fields.map((f: { name: string }) => f.name) + // The baseline now has b's blocking type and a's TTL: a differs in blocking, b in TTL. + expect(rows).toContain('blockingType') + expect(r.body.response.baseline.settings.blockingType).toBe('AnyAddress') + expect(r.body.response.baseline.settings.defaultRecordTtl).toBe(3600) + }) + + it('previews the fields that would change per target, and writes nothing', async () => { + await importFrom('a') + a.calls = [] + b.calls = [] + const r = await hub.call('admin', 'hub/settings/preview', { targets: 'a,b' }) + const [pa, pb] = r.body.response.targets + expect(pa).toMatchObject({ server: 'a', ok: true, unchanged: true }) + expect(pb.unchanged).toBe(false) + expect(pb.changes.kind).toBe('json') + expect(pb.changes.changes.map((c: { path: string }) => c.path)).toEqual(['blockingType', 'defaultRecordTtl', 'forwarders']) + expect(writes(a)).toHaveLength(0) + expect(writes(b)).toHaveLength(0) + }) + + it('applies only the changed shared fields, never a local or webService field, and saves the old settings first', async () => { + await importFrom('a') + b.calls = [] + const preview = await hub.call('admin', 'hub/settings/preview', { targets: 'b' }) + const expectHash = { b: preview.body.response.targets[0].currentHash } + const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', expect: JSON.stringify(expectHash) }) + expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true }) + expect(r.body.response.results[0].message).toMatch(/verified \(3 fields\)/) + const set = writes(b).filter((c) => c.path === 'settings/set') + expect(set).toHaveLength(1) + expect(set[0].token).toBe('admin-b') + expect(Object.fromEntries(set[0].form)).toEqual({ blockingType: 'NxDomain', defaultRecordTtl: '3600', forwarders: '1.1.1.1,9.9.9.9' }) + expect(Object.keys(Object.fromEntries(set[0].form)).some((k) => k.startsWith('webService') || k === 'dnsServerDomain' || k === 'logFolder')).toBe(false) + expect(boxB().settings.dnsServerDomain).toBe('b.example.net') + // The history entry holds b's settings from before the change. + const files = historyFiles() + expect(files).toHaveLength(1) + expect(files[0]).toMatch(/__b__before-apply\.txt$/) + expect(JSON.parse(readFileSync(path.join(hub.dataDir, 'settings', 'history', files[0]), 'utf8')).defaultRecordTtl).toBe(7200) + }) + + it('limits an apply to the blocking fields with group=blocking', async () => { + await importFrom('a') + b.calls = [] + const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', group: 'blocking' }) + expect(r.body.response.results[0].ok).toBe(true) + expect(Object.keys(Object.fromEntries(writes(b)[0].form))).toEqual(['blockingType']) + expect(boxB().settings.defaultRecordTtl).toBe(7200) + }) + + it('refuses to apply when the server changed since the preview', async () => { + await importFrom('a') + const preview = await hub.call('admin', 'hub/settings/preview', { targets: 'b' }) + boxB().settings.defaultRecordTtl = 99 + b.calls = [] + const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', expect: JSON.stringify({ b: preview.body.response.targets[0].currentHash }) }) + expect(r.body.response.results[0]).toMatchObject({ ok: false }) + expect(r.body.response.results[0].message).toMatch(/changed on the server since the preview/) + expect(writes(b)).toHaveLength(0) + }) + + it('reports a server that accepts the write but reads back something else', async () => { + await importFrom('a') + boxB().ignoreWrites = true + const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + expect(r.body.response.results[0].ok).toBe(false) + expect(r.body.response.results[0].message).toMatch(/reads back different values for: blockingType, defaultRecordTtl, forwarders/) + }) + + it('reports an unreachable target and a rejected write per server', async () => { + await importFrom('a') + b.on('settings/set', () => ({ error: 'Access was denied.' })) + const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false, message: 'Access was denied.' }) + b.down = true + const down = await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + expect(down.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) + }) + + it('leaves the blocking switch alone while blocking is paused on the server', async () => { + await importFrom('a') + boxB().settings.enableBlocking = false + boxB().settings.temporaryDisableBlockingTill = '2026-12-01T00:00:00Z' + b.calls = [] + await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('enableBlocking') + }) + + it('does not send a field the server does not report', async () => { + await importFrom('a') + delete boxB().settings.forwarders + b.calls = [] + await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('forwarders') + }) + + it('treats a field added to the local list as not to be sent', async () => { + await importFrom('a') + expect((await hub.call('admin', 'hub/settings/overrides/save', { paths: 'defaultRecordTtl' })).body.status).toBe('ok') + b.calls = [] + await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('defaultRecordTtl') + expect((await hub.call('admin', 'hub/settings/overrides/save', { paths: 'bad name' })).code).toBe(400) + await hub.call('admin', 'hub/settings/overrides/save', { paths: '' }) + }) + + it('keeps a secret behind the placeholder when the baseline is saved from the editor', async () => { + await importFrom('a') + const shown = (await hub.call('admin', 'hub/settings/compare', {}, 'GET')).body.response.baseline.settings + expect(shown.tsigKeys[0].sharedSecret).toBe(SECRET_PLACEHOLDER) + const saved = await hub.call('admin', 'hub/settings/baseline/save', { text: JSON.stringify({ ...shown, defaultRecordTtl: 1800 }) }) + expect(saved.body.status).toBe('ok') + const stored = JSON.parse(readFileSync(path.join(hub.dataDir, 'settings', 'baseline.json'), 'utf8')) + expect(stored.settings.tsigKeys[0].sharedSecret).toBe(TSIG) + expect(stored.settings.defaultRecordTtl).toBe(1800) + // The old baseline went to the history. + expect(historyFiles().some((f) => f.endsWith('__baseline__before-save.txt'))).toBe(true) + const bad = await hub.call('admin', 'hub/settings/baseline/save', { text: JSON.stringify({ tsigKeys: [{ keyName: 'other', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }) }) + expect(bad.code).toBe(400) + expect((await hub.call('admin', 'hub/settings/baseline/save', { text: '{nope' })).code).toBe(400) + }) + + it('lists and shows history without a secret', async () => { + await importFrom('a') + await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + const list = await hub.call('admin', 'hub/settings/history/list', {}, 'GET') + expect(list.body.response.entries.length).toBeGreaterThan(0) + const entry = list.body.response.entries.find((e: { server: string }) => e.server === 'b') + const got = await hub.call('admin', 'hub/settings/history/get', { id: entry.id }, 'GET') + expect(got.body.response.settings.defaultRecordTtl).toBe(7200) + expect(JSON.stringify(got.body)).not.toContain(TSIG) + expect((await hub.call('admin', 'hub/settings/history/get', { id: '../../users.json' }, 'GET')).code).toBe(400) + }) + + it('never lets a secret into any response or into the audit log', async () => { + boxB().settings.tsigKeys = [{ keyName: 'transfer.key', sharedSecret: 'B-DIFFERENT-SECRET', algorithmName: 'hmac-sha256' }] + boxB().settings.proxy = { type: 'Http', address: 'p.example', port: 8080, username: 'u', password: PROXY, bypass: [] } + const bodies: string[] = [] + const keep = async (p: Promise<{ body: unknown }>) => bodies.push(JSON.stringify((await p).body)) + await keep(hub.call('admin', 'hub/settings/compare', {}, 'GET')) + await keep(hub.call('admin', 'hub/settings/baseline/import', { server: 'a' })) + await keep(hub.call('admin', 'hub/settings/compare', {}, 'GET')) + await keep(hub.call('admin', 'hub/settings/preview', { targets: 'a,b' })) + await keep(hub.call('admin', 'hub/settings/apply', { targets: 'b' })) + await keep(hub.call('admin', 'hub/settings/history/list', {}, 'GET')) + const list = await hub.call('admin', 'hub/settings/history/list', {}, 'GET') + for (const e of list.body.response.entries) await keep(hub.call('admin', 'hub/settings/history/get', { id: e.id }, 'GET')) + // And what the existing settings/get gives a viewer. + const viewer = await hub.call('viewer', 'settings/get', { node: 'b' }, 'GET') + bodies.push(JSON.stringify(viewer.body)) + const audit = JSON.stringify(await hub.audit()) + for (const secret of [TSIG, PROXY, CERT, 'B-DIFFERENT-SECRET']) { + for (const body of bodies) expect(body).not.toContain(secret) + expect(audit).not.toContain(secret) + } + // The apply did send the baseline's TSIG key to b; that travels to the server, not to the console. + expect(JSON.stringify(writes(b).map((c) => [...c.form]))).toContain(TSIG) + }) + + it('writes audit lines with field names and no values', async () => { + await importFrom('a') + await hub.call('admin', 'hub/settings/apply', { targets: 'b' }) + const line = (await hub.audit()).find((e) => e.action === 'settings.apply' && e.server === 'b')! + expect(line.outcome).toBe('ok') + expect(line.detail).toMatch(/fields: blockingType, defaultRecordTtl, forwarders/) + expect(line.detail).not.toContain('8.8.8.8') + }) +}) + +describe('hub/blocking/lists', () => { + const importFrom = (server: string) => hub.call('admin', 'hub/blocking/lists/baseline/import', { server }) + + it('compares as sets, per server: missing and extra', async () => { + await importFrom('a') + const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET') + const by = Object.fromEntries(r.body.response.servers.map((s: { id: string }) => [s.id, s])) + expect(by.a.status).toBe('same') + expect(by.b.status).toBe('differs') + expect(by.b.blocked).toMatchObject({ count: 2, missing: 1, extra: 1, missingSample: ['ads.example'], extraSample: ['only-b.example'] }) + expect(by.b.allowed).toMatchObject({ missing: 0, extra: 1, extraSample: ['extra-allowed.example'] }) + }) + + it('compares with the first server before there is a baseline', async () => { + const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET') + expect(r.body.response.withBaseline).toBe(false) + expect(r.body.response.servers.map((s: { status: string }) => s.status)).toEqual(['differs', 'differs']) + }) + + it('marks an unreachable server unknown', async () => { + await importFrom('a') + b.down = true + const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET') + expect(r.body.response.servers.find((s: { id: string }) => s.id === 'b')).toMatchObject({ ok: false, status: 'unknown' }) + }) + + it('adds the missing domains and leaves the extras unless asked', async () => { + await importFrom('a') + const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' }) + expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true }) + // Extras remain: they are not what the user asked to change. + expect(boxB().blocked.sort()).toEqual(['ads.example', 'both.example', 'only-b.example']) + expect(writes(b).some((c) => c.path.endsWith('/delete'))).toBe(false) + }) + + it('removes the extras only when the user asked', async () => { + await importFrom('a') + b.calls = [] + const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b', removeExtras: 'true' }) + expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true }) + expect(boxB().blocked.sort()).toEqual(['ads.example', 'both.example']) + expect(boxB().allowed).toEqual(['ok.example']) + expect(writes(b).every((c) => c.token === 'admin-b')).toBe(true) + // Before deleting, the server's lists went to the history. + const dir = path.join(hub.dataDir, 'settings', 'history') + expect(readdirSync(dir).some((f) => f.endsWith('__b__before-lists.txt'))).toBe(true) + }) + + it('previews the counts and warns when a change is too large', async () => { + await importFrom('a') + const r = await hub.call('admin', 'hub/blocking/lists/preview', { targets: 'b', removeExtras: 'true' }) + expect(r.body.response.targets[0]).toMatchObject({ id: 'b', ok: true, status: 'differs' }) + boxB().blocked = Array.from({ length: 1200 }, (_, i) => `d${i}.example`) + const big = await hub.call('admin', 'hub/blocking/lists/preview', { targets: 'b', removeExtras: 'true' }) + expect(big.body.response.targets[0].tooMany).toMatch(/at most/) + const applied = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b', removeExtras: 'true' }) + expect(applied.body.response.results[0].ok).toBe(false) + }) + + it('is a no-op when the lists already match, and reports a rejected write', async () => { + await importFrom('a') + const same = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'a' }) + expect(same.body.response.results[0].message).toMatch(/Already in sync/) + b.on('blocked/add', () => ({ error: 'Access was denied.' })) + const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' }) + expect(r.body.response.results[0].ok).toBe(false) + expect(r.body.response.results[0].message).toMatch(/failed/) + }) + + it('refuses to apply without a baseline', async () => { + rmSync(path.join(hub.dataDir, 'blocking', 'baseline.json'), { force: true }) + expect((await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })).code).toBe(400) + }) +}) diff --git a/hub/settings-sync.ts b/hub/settings-sync.ts new file mode 100644 index 0000000..0bdfebc --- /dev/null +++ b/hub/settings-sync.ts @@ -0,0 +1,385 @@ +/* +Pure functions behind the settings and blocking compare (stage 7): which fields are never +compared, which stay local to a server, how two settings are compared, how a field is encoded for +`settings/set`, and how secrets are kept out of every view. No I/O here. + +Three kinds of field: + + * IGNORED: runtime or read-only (`version`, `uptimestamp`, cluster info, the temporary blocking + switch, the next list update). Never compared, never sent. + * LOCAL: the identity and the bind addresses of the host, certificates and paths, and every + `webService*` field. Compared across servers for information, never sent by an apply. The + built-in list cannot be shortened; more fields can be added (`overrides.json`). + * everything else is SHARED: compared with the baseline and sent by an apply when it differs. + +Secrets (TSIG shared secrets and the forward proxy password, plus the two certificate passwords, +which are local anyway) are compared inside the hub by their real value, but no view ever carries +a value or a hash of one: a view shows `SECRET_PLACEHOLDER`, and the row says only that the secret +differs. +*/ +import { isForbiddenSettingsField } from './guards.ts' + +export const SECRET_PLACEHOLDER = '' + +/* Read-only or runtime: never compared. */ +const IGNORED = new Set([ + 'version', + 'uptimestamp', + 'clusterInitialized', + 'clusterNodes', + 'temporaryDisableBlockingTill', + 'blockListNextUpdatedOn', +]) + +export const isIgnoredField = (name: string): boolean => IGNORED.has(name) || /^cluster/i.test(name) + +/* +Local by default, and why: + dnsServerDomain, dnsServerLocalEndPoints, dnsServerIPv4SourceAddresses, + dnsServerIPv6SourceAddresses the identity of the host and the addresses it binds + webService* how the hub itself reaches the server (guards.ts refuses them anyway) + dnsTlsCertificatePath, dnsTlsCertificatePassword the certificate file of this host + dnsOverHttpUnixSocket, dnsOverHttpsUnixSocket, enableDnsOverHttpUnixSocket, + enableDnsOverHttpsUnixSocket sockets of this host + dnsReverseProxyNetworkACL the address of the reverse proxy in front of this host + logFolder a path on this host +*/ +export const LOCAL_DEFAULTS = [ + 'dnsServerDomain', + 'dnsServerLocalEndPoints', + 'dnsServerIPv4SourceAddresses', + 'dnsServerIPv6SourceAddresses', + 'dnsTlsCertificatePath', + 'dnsTlsCertificatePassword', + 'dnsOverHttpUnixSocket', + 'dnsOverHttpsUnixSocket', + 'enableDnsOverHttpUnixSocket', + 'enableDnsOverHttpsUnixSocket', + 'dnsReverseProxyNetworkACL', + 'logFolder', +] as const + +const LOCAL_SET = new Set(LOCAL_DEFAULTS) + +/* True for a field that is local whatever the overrides say: the built-in list, `webService*`, and anything user, SSO or LDAP. */ +export const isBuiltInLocal = (name: string): boolean => LOCAL_SET.has(name) || name.startsWith('webService') || isForbiddenSettingsField(name) + +/* The fields of the Blocking group (the rest are General). */ +export const BLOCKING_FIELDS = [ + 'enableBlocking', + 'allowTxtBlockingReport', + 'blockingBypassList', + 'blockingType', + 'customBlockingAddresses', + 'blockingAnswerTtl', + 'blockListUrls', + 'blockListUpdateIntervalHours', +] as const + +const BLOCKING_SET = new Set(BLOCKING_FIELDS) +export const groupOf = (name: string): 'blocking' | 'general' => (BLOCKING_SET.has(name) ? 'blocking' : 'general') + +const PASSWORD_FIELDS = new Set(['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword']) +const FIELD_NAME = /^[A-Za-z][A-Za-z0-9]{0,59}$/ + +export const isFieldName = (name: string): boolean => FIELD_NAME.test(name) + +type Settings = Record + +const isObject = (v: unknown): v is Record => v !== null && typeof v === 'object' && !Array.isArray(v) + +/* `settings/get` without the ignored fields. */ +export function normalize(raw: unknown): Settings { + const out: Settings = {} + if (!isObject(raw)) return out + for (const [k, v] of Object.entries(raw)) if (isFieldName(k) && !isIgnoredField(k)) out[k] = v + return out +} + +/* Whether a field carries a secret anywhere inside it. */ +export const hasSecret = (name: string): boolean => name === 'tsigKeys' || name === 'proxy' || PASSWORD_FIELDS.has(name) + +/* A copy of a field's value that is safe to send out: every secret replaced by the placeholder. */ +export function maskValue(name: string, value: unknown): unknown { + if (PASSWORD_FIELDS.has(name)) return typeof value === 'string' && value !== '' ? SECRET_PLACEHOLDER : value + if (name === 'tsigKeys' && Array.isArray(value)) { + return value.map((k) => (isObject(k) ? { ...k, sharedSecret: typeof k.sharedSecret === 'string' && k.sharedSecret !== '' ? SECRET_PLACEHOLDER : k.sharedSecret } : k)) + } + if (name === 'proxy' && isObject(value)) return { ...value, password: typeof value.password === 'string' && value.password !== '' ? SECRET_PLACEHOLDER : value.password } + return value +} + +export function maskSettings(settings: Settings): Settings { + return Object.fromEntries(Object.entries(settings).map(([k, v]) => [k, maskValue(k, v)])) +} + +/* ---- Comparing ---- */ + +/* A value reduced to what it means: no key order, no difference between absent, null, empty text and an empty list. */ +function canon(name: string, value: unknown): unknown { + if (value === undefined || value === null || value === '') return null + if (typeof value === 'string') return value.trim() + if (Array.isArray(value)) return value.length === 0 ? null : value.map((v) => canon('', v)) + if (isObject(value)) { + // A proxy of type None carries no other setting. + if (name === 'proxy' && String(value.type ?? '').toLowerCase() === 'none') return null + return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canon('', value[k])])) + } + return value +} + +export const sameValue = (name: string, a: unknown, b: unknown): boolean => JSON.stringify(canon(name, a)) === JSON.stringify(canon(name, b)) + +/* +Settings ready for comparing: normalised, and without `enableBlocking` while blocking is paused +for a while (it is off for now, not by configuration). +*/ +export function prepare(raw: unknown): { settings: Settings; paused: boolean } { + const settings = normalize(raw) + const paused = isObject(raw) && typeof raw.temporaryDisableBlockingTill === 'string' && raw.temporaryDisableBlockingTill !== '' + if (paused) delete settings.enableBlocking + return { settings, paused } +} + +export interface Cell { + value: unknown + differs: boolean + /* The values look alike once masked: only a secret differs. */ + secretChanged?: boolean +} + +export interface FieldRow { + name: string + group: 'blocking' | 'general' + local: boolean + secret: boolean + /* The baseline's value (masked), when comparing with a baseline. */ + expected?: unknown + cells: Record +} + +export interface ServerSummary { + id: string + ok: boolean + error?: string + /* same, differs, local-only (only local fields differ) or unknown (the server did not answer). */ + status: 'same' | 'differs' | 'local-only' | 'unknown' + differing: number + localDiffering: number + paused: boolean +} + +export interface Comparison { + /* True when compared with a baseline, false when the servers are compared with the first that answered. */ + withBaseline: boolean + servers: ServerSummary[] + fields: FieldRow[] +} + +export interface CompareInput { + baseline: Settings | null + /** Raw `settings/get` per server; null when it did not answer. */ + live: Record + extraLocal: string[] +} + +export function compareSettings({ baseline, live, extraLocal }: CompareInput): Comparison { + const isLocal = (name: string): boolean => isBuiltInLocal(name) || extraLocal.includes(name) + const base = baseline === null ? null : normalize(baseline) + const prepared: Record = {} + const summaries: ServerSummary[] = [] + for (const [id, answer] of Object.entries(live)) { + if ('error' in answer) summaries.push({ id, ok: false, error: answer.error, status: 'unknown', differing: 0, localDiffering: 0, paused: false }) + else { + prepared[id] = prepare(answer.settings) + summaries.push({ id, ok: true, status: 'same', differing: 0, localDiffering: 0, paused: prepared[id].paused }) + } + } + const ids = Object.keys(prepared) + const reference = base ?? (ids.length > 0 ? prepared[ids[0]].settings : null) + const names = new Set() + if (base !== null) for (const k of Object.keys(base)) names.add(k) + for (const id of ids) for (const k of Object.keys(prepared[id].settings)) names.add(k) + + const rows: FieldRow[] = [] + for (const name of [...names].sort()) { + const local = isLocal(name) + const holders = ids.filter((id) => Object.hasOwn(prepared[id].settings, name)) + const cells: Record = {} + let any = false + if (local) { + // Compared with the baseline's value when it has one, else with the first server that has the field. + const useBase = base !== null && Object.hasOwn(base, name) + const ref = useBase ? base[name] : holders.length > 0 ? prepared[holders[0]].settings[name] : undefined + for (const id of holders) cells[id] = { value: maskValue(name, prepared[id].settings[name]), differs: !sameValue(name, ref, prepared[id].settings[name]) } + any = holders.some((id) => cells[id].differs) + if (any && !useBase) for (const id of holders) cells[id].differs = true + } else { + if (reference === null || !Object.hasOwn(reference, name)) continue + for (const id of holders) { + const differs = !sameValue(name, reference[name], prepared[id].settings[name]) + const secretChanged = differs && hasSecret(name) && JSON.stringify(maskValue(name, canon(name, reference[name]))) === JSON.stringify(maskValue(name, canon(name, prepared[id].settings[name]))) + cells[id] = { value: maskValue(name, prepared[id].settings[name]), differs, ...(secretChanged ? { secretChanged: true } : {}) } + if (differs) any = true + } + // Without a baseline the reference itself is one side of the difference. + if (any && base === null) cells[ids[0]].differs = true + } + if (!any) continue + rows.push({ + name, + group: groupOf(name), + local, + secret: hasSecret(name), + ...(base !== null && !local ? { expected: maskValue(name, base[name]) } : {}), + cells, + }) + } + for (const s of summaries) { + if (!s.ok) continue + s.differing = rows.filter((r) => !r.local && r.cells[s.id]?.differs === true && (base !== null || s.id !== ids[0])).length + s.localDiffering = rows.filter((r) => r.local && r.cells[s.id]?.differs === true).length + s.status = s.differing > 0 ? 'differs' : s.localDiffering > 0 ? 'local-only' : 'same' + } + // Without a baseline the first server is the reference: it differs only if another does. + if (base === null && ids.length > 0) { + const first = summaries.find((s) => s.id === ids[0])! + const others = summaries.some((s) => s.ok && s.id !== first.id && s.differing > 0) + first.differing = rows.filter((r) => !r.local).length + first.status = others ? 'differs' : first.localDiffering > 0 ? 'local-only' : 'same' + } + return { withBaseline: base !== null, servers: summaries, fields: rows } +} + +/* +The shared fields an apply would send to a server: in the baseline and reported by the server, +different, not local, not forbidden. Sorted. A field only one side has is left alone. +*/ +export function changedFields(baseline: unknown, current: unknown, extraLocal: string[], only?: readonly string[]): string[] { + const base = normalize(baseline) + const { settings } = prepare(current) + const out: string[] = [] + for (const name of Object.keys(base)) { + if (isBuiltInLocal(name) || extraLocal.includes(name)) continue + if (only !== undefined && !only.includes(name)) continue + if (!Object.hasOwn(settings, name)) continue + if (!sameValue(name, base[name], settings[name])) out.push(name) + } + return out.sort() +} + +/* ---- Encoding for `settings/set` (the same as the console's own save, model.ts `buildBody`) ---- */ + +const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)) + +function table(rows: unknown, keys: string[]): string | null { + if (!Array.isArray(rows)) return rows === null || rows === undefined ? 'false' : null + const cells: string[] = [] + for (const row of rows) { + if (!isObject(row)) return null + for (const k of keys) { + const cell = text(row[k]) + if (cell.includes('|')) return null + cells.push(cell) + } + } + return cells.length === 0 ? 'false' : cells.join('|') +} + +/* The form parameters for one field, or an error that says why it cannot be sent. */ +export function toParams(name: string, value: unknown): { params: Record } | { error: string } { + if (!isFieldName(name)) return { error: `"${name}" is not a setting name.` } + if (name === 'proxy') { + if (!isObject(value) || String(value.type ?? '').toLowerCase() === 'none' || String(value.type ?? '') === '') return { params: { proxyType: 'none' } } + const bypass = Array.isArray(value.bypass) ? value.bypass.map(String) : [] + if (bypass.some((b) => b.includes(','))) return { error: 'A proxy bypass entry contains a comma.' } + return { + params: { + proxyType: String(value.type).toLowerCase(), + proxyAddress: text(value.address), + proxyPort: text(value.port), + proxyUsername: text(value.username), + proxyPassword: text(value.password), + proxyBypass: bypass.join(','), + }, + } + } + if (name === 'tsigKeys') { + const t = table(value, ['keyName', 'sharedSecret', 'algorithmName']) + return t === null ? { error: 'A TSIG key cannot be sent (a "|" in it, or a malformed list).' } : { params: { tsigKeys: t } } + } + if (name === 'qpmPrefixLimitsIPv4' || name === 'qpmPrefixLimitsIPv6') { + const t = table(value, ['prefix', 'udpLimit', 'tcpLimit']) + return t === null ? { error: `${name} cannot be sent (a malformed list).` } : { params: { [name]: t } } + } + if (Array.isArray(value)) { + const items = value.map((v) => text(v).trim()).filter((v) => v !== '') + if (items.some((i) => i.includes(',') || i.includes('\n'))) return { error: `An entry of ${name} contains a comma or a line break, which the server takes as a separator.` } + return { params: { [name]: items.length === 0 ? 'false' : items.join(',') } } + } + if (isObject(value)) return { error: `${name} has a shape the hub does not send.` } + if (typeof value === 'boolean' || typeof value === 'number') return { params: { [name]: String(value) } } + return { params: { [name]: text(value) } } +} + +/* ---- Baseline editing ---- */ + +/* +A baseline coming back from the editor shows secrets as the placeholder. Each placeholder stands for +the secret the stored baseline already has (a TSIG key is found by its name); anything else is a new +secret and replaces it. A placeholder with nothing to stand for is refused, so a save can never +write the placeholder over a real key. +*/ +export function restoreSecrets(next: Settings, old: Settings | null): Settings { + const out: Settings = structuredClone(next) + const fail = (what: string): never => { + throw new Error(`The secret of ${what} is shown as ${SECRET_PLACEHOLDER} but the stored baseline has none to keep. Type the value or remove the entry.`) + } + for (const name of PASSWORD_FIELDS) { + if (out[name] === SECRET_PLACEHOLDER) { + const was = old?.[name] + out[name] = typeof was === 'string' ? was : fail(name) + } + } + if (Array.isArray(out.tsigKeys)) { + const oldKeys = Array.isArray(old?.tsigKeys) ? (old.tsigKeys as unknown[]).filter(isObject) : [] + out.tsigKeys = out.tsigKeys.map((k: unknown) => { + if (!isObject(k) || k.sharedSecret !== SECRET_PLACEHOLDER) return k + const was = oldKeys.find((o) => o.keyName === k.keyName) + return { ...k, sharedSecret: typeof was?.sharedSecret === 'string' ? was.sharedSecret : fail(`the TSIG key ${text(k.keyName)}`) } + }) + } + if (isObject(out.proxy) && out.proxy.password === SECRET_PLACEHOLDER) { + const was = isObject(old?.proxy) ? old.proxy.password : undefined + out.proxy = { ...out.proxy, password: typeof was === 'string' ? was : fail('the proxy') } + } + return out +} + +/* ---- Blocked and Allowed lists ---- */ + +export const MAX_LIST = 5000 +export const MAX_DELETES = 500 +export const IMPORT_CHUNK = 500 + +/* The domains of an export: lower case, no trailing dot, no blank or `#` line, once each, sorted. */ +export function parseDomains(textBody: string): string[] { + const set = new Set() + for (const line of textBody.split(/\r?\n/)) { + const t = line.trim().toLowerCase().replace(/\.$/, '') + if (t !== '' && !t.startsWith('#')) set.add(t) + } + return [...set].sort() +} + +export interface SetDiff { + /* In the baseline, not on the server. */ + missing: string[] + /* On the server, not in the baseline. */ + extra: string[] +} + +export function diffSets(baseline: readonly string[], live: readonly string[]): SetDiff { + const [b, l] = [new Set(baseline), new Set(live)] + return { missing: baseline.filter((d) => !l.has(d)), extra: live.filter((d) => !b.has(d)) } +} diff --git a/hub/store.ts b/hub/store.ts index c5b4b02..4997203 100644 --- a/hub/store.ts +++ b/hub/store.ts @@ -87,7 +87,7 @@ export function writeOverrides(dataDir: string, app: string, overrides: Override writeAtomic(path.join(appDir(dataDir, app), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n') } -export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' +export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' | 'before-lists' export interface HistoryEntry { /** The file name; the only handle `readHistory` accepts. */ @@ -104,9 +104,10 @@ const HISTORY_FILE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{3}Z)__(.+) const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history') -/* Newest first. Never the text. */ -export function listHistory(dataDir: string, app: string): HistoryEntry[] { - const dir = historyDir(dataDir, app) +export const listHistory = (dataDir: string, app: string): HistoryEntry[] => listHistoryIn(historyDir(dataDir, app)) + +/* Newest first. Never the text. `dir` is a history folder. */ +export function listHistoryIn(dir: string): HistoryEntry[] { if (!existsSync(dir)) return [] const entries: HistoryEntry[] = [] for (const name of readdirSync(dir)) { @@ -130,9 +131,11 @@ export function listHistory(dataDir: string, app: string): HistoryEntry[] { } /* The text of one entry. `id` has to be a name the listing returns, so nothing outside the folder can be read. */ -export function readHistory(dataDir: string, app: string, id: string): string | null { - const known = listHistory(dataDir, app).find((e) => e.id === id) - return known === undefined ? null : readFileSync(path.join(historyDir(dataDir, app), known.id), 'utf8') +export const readHistory = (dataDir: string, app: string, id: string): string | null => readHistoryIn(historyDir(dataDir, app), id) + +export function readHistoryIn(dir: string, id: string): string | null { + const known = listHistoryIn(dir).find((e) => e.id === id) + return known === undefined ? null : readFileSync(path.join(dir, known.id), 'utf8') } /* The apps that have a baseline, by the name stored in the file. */ @@ -164,7 +167,10 @@ export function writeHistory( kind: HistoryKind, now: number, ): string { - const dir = path.join(appDir(dataDir, app), 'history') + return writeHistoryIn(historyDir(dataDir, app), server, text, kind, now) +} + +export function writeHistoryIn(dir: string, server: string, text: string, kind: HistoryKind, now: number): string { const stamp = new Date(now).toISOString().replace(/:/g, '-') const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`) writeAtomic(file, text) @@ -172,3 +178,68 @@ export function writeHistory( for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true }) return file } + +/* ---- Settings and blocking lists (stage 7): `${HUB_DATA}/settings/` and `${HUB_DATA}/blocking/` ---- */ + +export const settingsDir = (dataDir: string): string => path.join(dataDir, 'settings') +export const settingsHistoryDir = (dataDir: string): string => path.join(settingsDir(dataDir), 'history') +export const blockingDir = (dataDir: string): string => path.join(dataDir, 'blocking') + +export interface SettingsBaseline { + /** The settings as `settings/get` gave them, secrets included (the file is 0600). */ + settings: Record + importedFrom?: string + updatedAt: string + updatedBy: string +} + +/* Extra local fields, on top of the built-in ones. Values are read live from the servers, not stored. */ +export interface SettingsOverrides { + paths: string[] +} + +export function readSettingsBaseline(dataDir: string): SettingsBaseline | null { + const data = readJson(path.join(settingsDir(dataDir), 'baseline.json')) as Partial | null + if (data === null || data.settings === null || typeof data.settings !== 'object' || Array.isArray(data.settings)) return null + return { + settings: data.settings, + ...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}), + updatedAt: String(data.updatedAt ?? ''), + updatedBy: String(data.updatedBy ?? ''), + } +} + +export const writeSettingsBaseline = (dataDir: string, baseline: SettingsBaseline): void => + writeAtomic(path.join(settingsDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n') + +export function readSettingsOverrides(dataDir: string): SettingsOverrides { + const data = readJson(path.join(settingsDir(dataDir), 'overrides.json')) as Partial | null + return { paths: Array.isArray(data?.paths) ? data.paths.filter((p): p is string => typeof p === 'string') : [] } +} + +export const writeSettingsOverrides = (dataDir: string, overrides: SettingsOverrides): void => + writeAtomic(path.join(settingsDir(dataDir), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n') + +export interface ListsBaseline { + blocked: string[] + allowed: string[] + importedFrom?: string + updatedAt: string + updatedBy: string +} + +export function readListsBaseline(dataDir: string): ListsBaseline | null { + const data = readJson(path.join(blockingDir(dataDir), 'baseline.json')) as Partial | null + if (data === null || !Array.isArray(data.blocked) || !Array.isArray(data.allowed)) return null + const strings = (v: unknown[]): string[] => v.filter((x): x is string => typeof x === 'string') + return { + blocked: strings(data.blocked), + allowed: strings(data.allowed), + ...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}), + updatedAt: String(data.updatedAt ?? ''), + updatedBy: String(data.updatedBy ?? ''), + } +} + +export const writeListsBaseline = (dataDir: string, baseline: ListsBaseline): void => + writeAtomic(path.join(blockingDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n') diff --git a/hub/zones-sync.test.ts b/hub/zones-sync.test.ts new file mode 100644 index 0000000..8016969 --- /dev/null +++ b/hub/zones-sync.test.ts @@ -0,0 +1,530 @@ +// @vitest-environment node +/* +Stage 7, zones: the merged list, copying a zone and making a Secondary. The servers are fakes that +keep a zone table and record every call. +*/ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts' +import { classifyRow, countRecordLines, secondaryOptions, validNameServer, cleanZone, type ZoneCell } from './zones-sync.ts' +import { routeFor } from './routes.ts' + +interface Z { + name: string + type: string + soaSerial?: number | null + disabled?: boolean + dnssecStatus?: string + internal?: boolean + catalog?: string | null +} + +interface State { + zones: Z[] + files: Record + options: Record> + tsig: string[] +} + +const state = new Map() + +function install(f: Fake, init: Partial): void { + const s: State = { zones: [], files: {}, options: {}, tsig: [], ...init } + state.set(f, s) + f.on('zones/list', (c) => { + const per = Number(c.query.get('zonesPerPage') ?? 10) + const page = Number(c.query.get('pageNumber') ?? 1) + return { response: { zones: s.zones.slice((page - 1) * per, page * per), pageNumber: page, totalPages: Math.max(1, Math.ceil(s.zones.length / per)), totalZones: s.zones.length } } + }) + f.on('zones/export', (c) => { + const text = s.files[c.query.get('zone') ?? ''] + return text === undefined ? { error: 'No such zone was found.' } : { text } + }) + f.on('zones/create', (c) => { + s.zones.push({ name: c.query.get('zone') ?? c.form.get('zone') ?? '', type: c.query.get('type') ?? c.form.get('type') ?? 'Primary', soaSerial: 1 }) + return { response: {} } + }) + f.on('zones/import', (c) => { + s.files[c.query.get('zone') ?? ''] = c.text + return { response: {} } + }) + f.on('zones/options/get', (c) => ({ + response: { zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'None', notifyNameServers: [], ...(s.options[c.query.get('zone') ?? ''] ?? {}) }, + })) + f.on('zones/options/set', (c) => { + const zone = c.form.get('zone') ?? '' + const next: Record = { ...(s.options[zone] ?? {}) } + for (const [k, v] of c.form) { + if (k === 'zone') continue + next[k] = k.endsWith('ACL') || k.endsWith('Names') || k.endsWith('Servers') ? (v === 'false' ? [] : v.split(',')) : v + } + s.options[zone] = next + return { response: {} } + }) + f.on('settings/getTsigKeyNames', () => ({ response: { tsigKeyNames: s.tsig } })) +} + +const SOA = '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 5 900 300 604800 900\n@ IN NS ns1\nwww IN A 203.0.113.5\n; a comment\n' + +let a: Fake +let b: Fake +let hub: TestHub + +beforeAll(async () => { + a = await fake() + b = await fake() + hub = await startHub(a, b) +}) + +afterAll(() => { + hub.close() + a.server.close() + b.server.close() +}) + +beforeEach(() => { + a.calls = [] + b.calls = [] + a.down = false + b.down = false + install(a, { + zones: [ + { name: 'example.net', type: 'Primary', soaSerial: 5, dnssecStatus: 'Unsigned' }, + { name: 'lan', type: 'Primary', soaSerial: 3 }, + { name: 'only-a.test', type: 'Primary', soaSerial: 1 }, + { name: 'fwd.test', type: 'Forwarder', disabled: false }, + { name: 'signed.test', type: 'Primary', soaSerial: 2, dnssecStatus: 'SignedWithNSEC' }, + { name: 'sec.test', type: 'Secondary', soaSerial: 9 }, + { name: 'primary.test', type: 'Primary', soaSerial: 10 }, + { name: 'localhost', type: 'Primary', soaSerial: 1, internal: true }, + ], + files: { 'example.net': SOA, 'fwd.test': 'fwd.test. 3600 IN FWD this-server\n', 'signed.test': SOA, 'lan': SOA, 'primary.test': SOA }, + tsig: ['transfer.key'], + }) + install(b, { + zones: [ + { name: 'example.net', type: 'Primary', soaSerial: 5, dnssecStatus: 'Unsigned' }, + { name: 'lan', type: 'Primary', soaSerial: 4 }, + { name: 'only-b.test', type: 'Primary', soaSerial: 1 }, + { name: 'primary.test', type: 'Secondary', soaSerial: 8 }, + { name: 'localhost', type: 'Primary', soaSerial: 1, internal: true }, + ], + files: {}, + tsig: ['transfer.key'], + }) +}) + +afterEach(() => { + a.down = false + b.down = false +}) + +const writes = (f: Fake): Call[] => f.calls.filter((c) => c.method === 'POST') + +const present = (type: string, serial: number | null = null): ZoneCell => ({ state: 'present', type, serial }) +const absent: ZoneCell = { state: 'absent' } +const unknown: ZoneCell = { state: 'unknown' } + +describe('the status of a row', () => { + it('is the same when the zone is alike everywhere', () => { + expect(classifyRow('z', { a: present('Primary', 5), b: present('Primary', 5) }).status).toBe('same') + }) + + it('says serial-differs for two Primary zones with different serials', () => { + expect(classifyRow('z', { a: present('Primary', 5), b: present('Primary', 6) }).status).toBe('serial-differs') + }) + + it('says type-differs for types that do not follow one another', () => { + expect(classifyRow('z', { a: present('Primary', 5), b: present('Forwarder') }).status).toBe('type-differs') + expect(classifyRow('z', { a: present('Stub', 5), b: present('Secondary', 5) }).status).toBe('type-differs') + }) + + it('treats a Primary and its Secondary as the intended setup, and a lagging Secondary as behind, not as a divergence', () => { + const same = classifyRow('z', { a: present('Primary', 10), b: present('Secondary', 10) }) + expect(same).toMatchObject({ status: 'same', note: 'primary-secondary' }) + const behind = classifyRow('z', { a: present('Primary', 10), b: present('Secondary', 8) }) + expect(behind).toMatchObject({ status: 'same', note: 'secondary-behind' }) + expect(classifyRow('z', { a: present('Secondary', 8), b: present('Primary', 10) })).toMatchObject({ status: 'same', note: 'secondary-behind' }) + // A Secondary that is AHEAD of its primary is not normal. + expect(classifyRow('z', { a: present('Primary', 8), b: present('Secondary', 10) }).status).toBe('serial-differs') + }) + + it('says only-on for a zone one server lacks', () => { + expect(classifyRow('z', { a: present('Primary', 1), b: absent })).toMatchObject({ status: 'only-on', onlyOn: 'a' }) + }) + + it('does not call a zone only-on while the other server did not answer', () => { + expect(classifyRow('z', { a: present('Primary', 1), b: unknown }).status).toBe('unknown') + expect(classifyRow('z', { a: present('Primary', 1), b: present('Primary', 2), c: unknown }).status).toBe('serial-differs') + }) + + it('names the reachable servers that lack a zone the others have', () => { + expect(classifyRow('z', { a: present('Primary', 1), b: present('Primary', 1), c: absent })).toMatchObject({ status: 'missing-on', missingOn: ['c'] }) + }) + + it('ignores serials of types that have none', () => { + expect(classifyRow('z', { a: present('Forwarder'), b: present('Forwarder') }).status).toBe('same') + }) +}) + +describe('small helpers', () => { + it('counts the record lines of a zone file', () => { + expect(countRecordLines(SOA)).toBe(3) + expect(countRecordLines('')).toBe(0) + }) + + it('checks the zone name', () => { + expect(cleanZone('Example.NET.')).toBe('Example.NET') + for (const bad of [undefined, '', '*.example.net', 'a b', 'a..b']) expect(cleanZone(bad)).toBeNull() + }) + + it('checks a name server address', () => { + for (const ok of ['10.0.0.1', '10.0.0.1:53', 'ns.example.net', 'ns.example.net:5353', '2001:db8::1', '[2001:db8::1]:53']) expect(validNameServer(ok), ok).toBe(true) + for (const bad of ['', 'a b', '10.0.0.1,10.0.0.2', 'ns.example.net:abc', '*.example.net', 'x:1:2']) expect(validNameServer(bad), bad).toBe(false) + }) +}) + +describe('secondaryOptions', () => { + it('widens the modes that cannot hold a list and keeps what is listed', () => { + const out = secondaryOptions('z', { zoneTransfer: 'Deny', zoneTransferNetworkACL: ['10.0.0.9'], notify: 'ZoneNameServers', notifyNameServers: ['10.0.0.8'] }, '10.0.0.2', '') + expect(out).toEqual({ + zone: 'z', + zoneTransfer: 'UseSpecifiedNetworkACL', + zoneTransferNetworkACL: '10.0.0.9,10.0.0.2', + zoneTransferTsigKeyNames: 'false', + notify: 'BothZoneAndSpecifiedNameServers', + notifyNameServers: '10.0.0.8,10.0.0.2', + }) + }) + + it('maps the other modes and keeps those that already take a list', () => { + expect(secondaryOptions('z', { zoneTransfer: 'AllowOnlyZoneNameServers', notify: 'None' }, '10.0.0.2', 'k')).toMatchObject({ + zoneTransfer: 'AllowZoneNameServersAndUseSpecifiedNetworkACL', + notify: 'SpecifiedNameServers', + zoneTransferTsigKeyNames: 'k', + }) + expect(secondaryOptions('z', { zoneTransfer: 'UseSpecifiedNetworkACL', notify: 'SpecifiedNameServers' }, '10.0.0.2', '')).toMatchObject({ + zoneTransfer: 'UseSpecifiedNetworkACL', + notify: 'SpecifiedNameServers', + }) + }) + + it('is null when the address and key are already there', () => { + expect( + secondaryOptions('z', { zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: ['10.0.0.2'], zoneTransferTsigKeyNames: ['k'], notify: 'SpecifiedNameServers', notifyNameServers: ['10.0.0.2'] }, '10.0.0.2', 'k'), + ).toBeNull() + }) +}) + +describe('routes', () => { + it('lists the zone endpoints with their kinds and rights', () => { + expect(routeFor('hub/zones/matrix')).toEqual({ kind: 'read', capability: 'read' }) + expect(routeFor('hub/zones/copy/preview')).toEqual({ kind: 'read', capability: 'admin' }) + expect(routeFor('hub/zones/copy')).toEqual({ kind: 'write-all', capability: 'admin' }) + expect(routeFor('hub/zones/secondary/preview')).toEqual({ kind: 'read', capability: 'admin' }) + expect(routeFor('hub/zones/secondary')).toEqual({ kind: 'write-all', capability: 'admin' }) + }) + + it('refuses the admin endpoints to every other role, without touching a server', async () => { + const params = { source: 'a', zone: 'example.net', targets: 'b', target: 'b', primaryAddress: '10.0.0.1', targetAddress: '10.0.0.2' } + for (const role of ROLES.filter((r) => r !== 'admin')) { + for (const p of ['hub/zones/copy/preview', 'hub/zones/copy', 'hub/zones/secondary/preview', 'hub/zones/secondary']) { + const r = await hub.call(role, p, params) + expect(r.code, `${role} ${p}`).toBe(403) + } + } + expect(a.calls).toHaveLength(0) + expect(b.calls).toHaveLength(0) + }) + + it('lets a viewer read the merged list', async () => { + expect((await hub.call('viewer', 'hub/zones/matrix', {}, 'GET')).body.status).toBe('ok') + }) +}) + +describe('hub/zones/matrix', () => { + it('merges the zones of both servers, one row per name, skipping the internal ones', async () => { + const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') + const rows = r.body.response.zones as { name: string; status: string; onlyOn?: string; note?: string; cells: Record }[] + const by = Object.fromEntries(rows.map((z) => [z.name, z])) + expect(Object.keys(by).sort()).toEqual(['example.net', 'fwd.test', 'lan', 'only-a.test', 'only-b.test', 'primary.test', 'sec.test', 'signed.test']) + expect(by['example.net'].status).toBe('same') + expect(by.lan.status).toBe('serial-differs') + expect(by['only-a.test']).toMatchObject({ status: 'only-on', onlyOn: 'a' }) + expect(by['only-b.test']).toMatchObject({ status: 'only-on', onlyOn: 'b' }) + expect(by['primary.test']).toMatchObject({ status: 'same', note: 'secondary-behind' }) + expect(by['example.net'].cells.a).toMatchObject({ state: 'present', type: 'Primary', serial: 5, dnssec: 'Unsigned' }) + expect(by.lan.cells.b.serial).toBe(4) + expect(r.body.response.servers).toEqual([ + { id: 'a', ok: true, writable: true, host: '127.0.0.1' }, + { id: 'b', ok: true, writable: true, host: '127.0.0.1' }, + ]) + expect(r.body.hubWarnings).toEqual([]) + }) + + it('asks for every page', async () => { + const s = state.get(a)! + s.zones = Array.from({ length: 1200 }, (_, i) => ({ name: `z${String(i).padStart(4, '0')}.test`, type: 'Primary', soaSerial: 1 })) + const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') + expect(r.body.response.zones.filter((z: { name: string }) => z.name.startsWith('z'))).toHaveLength(1200) + expect(a.calls.filter((c) => c.path === 'zones/list').length).toBeGreaterThanOrEqual(3) + }) + + it('marks the cells of a server that did not answer as unknown, warns, and does not call the zone only-on', async () => { + b.down = true + const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') + expect(r.body.status).toBe('ok') + expect(r.body.hubWarnings).toHaveLength(1) + expect(r.body.hubWarnings[0]).toMatch(/^b:/) + const rows = r.body.response.zones as { name: string; status: string; cells: Record }[] + const lan = rows.find((z) => z.name === 'lan')! + expect(lan.cells.b).toEqual({ state: 'unknown' }) + expect(lan.status).toBe('unknown') + expect(rows.some((z) => z.status === 'only-on')).toBe(false) + expect(r.body.response.servers.find((s: { id: string }) => s.id === 'b').ok).toBe(false) + }) + + it('fails when no server answers', async () => { + a.down = true + b.down = true + const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') + expect(r.body.status).toBe('error') + }) +}) + +describe('hub/zones/copy/preview', () => { + const preview = (params: Record) => hub.call('admin', 'hub/zones/copy/preview', params) + + it('says what the source has and what each target holds', async () => { + const r = await preview({ source: 'a', zone: 'example.net', targets: 'b' }) + expect(r.body.response).toMatchObject({ + zone: 'example.net', + source: { server: 'a', type: 'Primary', serial: 5, records: 3 }, + targets: [{ server: 'b', ok: true, exists: true, type: 'Primary', serial: 5, needsOverwrite: true }], + }) + // only-a.test has no file on the fake, so the export fails and the preview says so. + expect((await preview({ source: 'a', zone: 'only-a.test', targets: 'b' })).body.status).toBe('error') + }) + + it('shows a target that lacks the zone as free to create', async () => { + state.get(a)!.files['only-a.test'] = SOA + const r = await preview({ source: 'a', zone: 'only-a.test', targets: 'b' }) + expect(r.body.response.targets[0]).toMatchObject({ server: 'b', ok: true, exists: false, needsOverwrite: false }) + }) + + it('refuses a Secondary, a signed zone, a missing zone and the source itself, each with a reason', async () => { + expect((await preview({ source: 'a', zone: 'sec.test', targets: 'b' })).body.errorMessage).toMatch(/Only Primary and Forwarder/) + expect((await preview({ source: 'a', zone: 'signed.test', targets: 'b' })).body.errorMessage).toMatch(/DNSSEC-signed/) + expect((await preview({ source: 'a', zone: 'nope.test', targets: 'b' })).body.errorMessage).toMatch(/no zone nope\.test/) + expect((await preview({ source: 'a', zone: 'example.net', targets: 'a' })).body.errorMessage).toMatch(/server it comes from/) + expect((await preview({ source: 'x', zone: 'example.net', targets: 'b' })).body.errorMessage).toMatch(/Unknown source server/) + expect((await preview({ source: 'a', zone: 'example.net', targets: '' })).body.errorMessage).toMatch(/at least one server/) + expect((await preview({ source: 'a', zone: '*.net', targets: 'b' })).body.errorMessage).toMatch(/valid zone name/) + }) + + it('refuses a target that holds the zone as another type', async () => { + state.get(b)!.zones.push({ name: 'fwd.test', type: 'Primary', soaSerial: 1 }) + const r = await preview({ source: 'a', zone: 'fwd.test', targets: 'b' }) + expect(r.body.response.targets[0]).toMatchObject({ ok: false, exists: true, type: 'Primary' }) + expect(r.body.response.targets[0].error).toMatch(/cannot replace it/) + }) + + it('does not write anything and reads the export with the read token', async () => { + await preview({ source: 'a', zone: 'example.net', targets: 'b' }) + expect(writes(a)).toHaveLength(0) + expect(writes(b)).toHaveLength(0) + expect(a.calls.find((c) => c.path === 'zones/export')!.token).toBe('read-a') + }) +}) + +describe('hub/zones/copy', () => { + const copy = (params: Record) => hub.call('admin', 'hub/zones/copy', params) + + it('creates the zone on a target that lacks it and imports the text, with the admin token', async () => { + state.get(a)!.files['only-a.test'] = SOA + const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) + expect(r.body.response.results).toEqual([{ server: 'b', ok: true, message: 'Zone created and filled.' }]) + const [create, imp] = writes(b) + expect(create.path).toBe('zones/create') + expect(create.token).toBe('admin-b') + expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'only-a.test', type: 'Primary' }) + expect(imp.path).toBe('zones/import') + expect(imp.token).toBe('admin-b') + expect(Object.fromEntries(imp.query)).toMatchObject({ zone: 'only-a.test', overwrite: 'true', overwriteZone: 'true', overwriteSoaSerial: 'true' }) + expect(imp.text).toBe(SOA) + expect(writes(a)).toHaveLength(0) + }) + + it('creates a Forwarder empty and fills it by the import', async () => { + const r = await copy({ source: 'a', zone: 'fwd.test', targets: 'b' }) + expect(r.body.response.results[0].ok).toBe(true) + const create = writes(b)[0] + expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'fwd.test', type: 'Forwarder', initializeForwarder: 'false' }) + }) + + it('refuses to replace a zone that exists unless Overwrite was ticked for that server', async () => { + const refused = await copy({ source: 'a', zone: 'example.net', targets: 'b' }) + expect(refused.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) + expect(refused.body.response.results[0].message).toMatch(/Tick Overwrite/) + expect(writes(b)).toHaveLength(0) + + const other = await copy({ source: 'a', zone: 'example.net', targets: 'b', overwrite: 'a' }) + expect(other.body.response.results[0].ok).toBe(false) + expect(writes(b)).toHaveLength(0) + + const done = await copy({ source: 'a', zone: 'example.net', targets: 'b', overwrite: 'b' }) + expect(done.body.response.results[0]).toEqual({ server: 'b', ok: true, message: 'Records replaced.' }) + expect(writes(b).map((c) => c.path)).toEqual(['zones/import']) + }) + + it('refuses a Secondary source and a copy to the source, and writes nothing', async () => { + expect((await copy({ source: 'a', zone: 'sec.test', targets: 'b' })).code).toBe(400) + expect((await copy({ source: 'a', zone: 'example.net', targets: 'a' })).code).toBe(400) + expect(writes(a)).toHaveLength(0) + expect(writes(b)).toHaveLength(0) + }) + + it('reports a target that fails and still finishes the others', async () => { + state.get(a)!.files['only-a.test'] = SOA + b.on('zones/create', () => ({ error: 'Access was denied.' })) + const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) + expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) + expect(r.body.response.results[0].message).toMatch(/could not be created: Access was denied/) + }) + + it('says an empty zone was left when the import fails after the create', async () => { + state.get(a)!.files['only-a.test'] = SOA + b.on('zones/import', () => ({ error: 'Bad record.' })) + const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) + expect(r.body.response.results[0].ok).toBe(false) + expect(r.body.response.results[0].message).toMatch(/created but the import failed \(Bad record\.\); an empty zone is left on b/) + }) + + it('reports an unreachable target without failing the request', async () => { + state.get(a)!.files['only-a.test'] = SOA + b.down = true + const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) + expect(r.body.status).toBe('ok') + expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) + }) + + it('writes one audit line per target with the zone, the source, the target and the outcome, and no zone content', async () => { + state.get(a)!.files['only-a.test'] = SOA + await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) + const line = (await hub.audit()).find((e) => e.action === 'zones.copy')! + expect(line).toMatchObject({ target: 'only-a.test', server: 'b', outcome: 'ok' }) + expect(line.detail).toMatch(/from a/) + expect(JSON.stringify(line)).not.toContain('203.0.113.5') + }) +}) + +describe('hub/zones/secondary', () => { + const input = { source: 'a', target: 'b', zone: 'example.net', primaryAddress: '10.0.0.1', targetAddress: '10.0.0.2' } + const secondary = (params: Record) => hub.call('admin', 'hub/zones/secondary', params) + const preview = (params: Record) => hub.call('admin', 'hub/zones/secondary/preview', params) + + beforeEach(() => { + // The target does not have the zone yet. + state.get(b)!.zones = state.get(b)!.zones.filter((z) => z.name !== 'example.net') + }) + + it('previews the two steps and writes nothing', async () => { + const r = await preview(input) + expect(r.body.response.steps).toHaveLength(2) + expect(r.body.response.steps[0]).toMatchObject({ server: 'a', role: 'source', changes: true }) + expect(r.body.response.steps[1]).toMatchObject({ server: 'b', role: 'target', changes: true }) + expect(writes(a)).toHaveLength(0) + expect(writes(b)).toHaveLength(0) + }) + + it('adds the target to transfer and notify on the source, keeping what is there, then creates the Secondary', async () => { + state.get(a)!.options['example.net'] = { zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: ['10.9.9.9'], notify: 'SpecifiedNameServers', notifyNameServers: ['10.9.9.8'] } + const r = await secondary({ ...input, tsigKeyName: 'transfer.key' }) + expect(r.body.response.results.map((x: { server: string; ok: boolean }) => [x.server, x.ok])).toEqual([['a', true], ['b', true]]) + const set = writes(a)[0] + expect(set.path).toBe('zones/options/set') + expect(set.token).toBe('admin-a') + expect(Object.fromEntries(set.form)).toEqual({ + zone: 'example.net', + zoneTransfer: 'UseSpecifiedNetworkACL', + zoneTransferNetworkACL: '10.9.9.9,10.0.0.2', + zoneTransferTsigKeyNames: 'transfer.key', + notify: 'SpecifiedNameServers', + notifyNameServers: '10.9.9.8,10.0.0.2', + }) + const create = writes(b)[0] + expect(create.path).toBe('zones/create') + expect(create.token).toBe('admin-b') + expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ + zone: 'example.net', + type: 'Secondary', + primaryNameServerAddresses: '10.0.0.1', + zoneTransferProtocol: 'Tcp', + tsigKeyName: 'transfer.key', + }) + // The source went first. + expect(a.calls.findIndex((c) => c.path === 'zones/options/set')).toBeGreaterThan(-1) + }) + + it('can run again: an address already allowed is not written twice and the source is left alone', async () => { + await secondary(input) + state.get(b)!.zones = state.get(b)!.zones.filter((z) => z.name !== 'example.net') + a.calls = [] + const again = await secondary(input) + expect(again.body.response.results[0]).toMatchObject({ server: 'a', ok: true }) + expect(again.body.response.results[0].message).toMatch(/already allow/) + expect(writes(a)).toHaveLength(0) + expect(writes(b).filter((c) => c.path === 'zones/create')).toHaveLength(2) + }) + + it('reports a failing target and says the source change was left in place', async () => { + b.on('zones/create', () => ({ error: 'The primary is not reachable.' })) + const r = await secondary(input) + const [source, target] = r.body.response.results + expect(source).toMatchObject({ server: 'a', ok: true }) + expect(target).toMatchObject({ server: 'b', ok: false }) + expect(target.message).toMatch(/could not be created: The primary is not reachable\./) + expect(target.message).toMatch(/left in place/) + }) + + it('does not touch the target when the source step fails', async () => { + a.on('zones/options/set', () => ({ error: 'Access was denied.' })) + const r = await secondary(input) + expect(r.body.response.results[0]).toMatchObject({ server: 'a', ok: false }) + expect(r.body.response.results[1]).toMatchObject({ server: 'b', ok: false }) + expect(writes(b)).toHaveLength(0) + }) + + it('refuses what cannot work, with a reason and no write', async () => { + const refused = async (patch: Record, text: RegExp) => { + const r = await secondary({ ...input, ...patch }) + expect(r.code).toBe(400) + expect(r.body.errorMessage).toMatch(text) + } + await refused({ zone: 'sec.test' }, /Only a Primary zone/) + await refused({ zone: 'nope.test' }, /no zone nope\.test/) + await refused({ target: 'a' }, /two different servers/) + await refused({ primaryAddress: '' }, /primary's address/) + await refused({ targetAddress: 'ns.example.net' }, /IP address/) + await refused({ tsigKeyName: 'missing.key' }, /does not exist on a/) + state.get(b)!.zones.push({ name: 'example.net', type: 'Primary' }) + await refused({}, /already has example\.net/) + expect(writes(a)).toHaveLength(0) + expect(writes(b)).toHaveLength(0) + }) + + it('needs the TSIG key on both servers', async () => { + state.get(b)!.tsig = [] + const r = await secondary({ ...input, tsigKeyName: 'transfer.key' }) + expect(r.body.errorMessage).toMatch(/does not exist on b/) + expect(writes(a)).toHaveLength(0) + }) + + it('audits both steps', async () => { + await secondary(input) + // Newest first: the two lines of this call are the first two of their action. + const lines = (await hub.audit()).filter((e) => e.action === 'zones.secondary').slice(0, 2) + expect(lines.map((l) => [l.server, l.outcome]).sort()).toEqual([['a', 'ok'], ['b', 'ok']]) + expect(lines[0].target).toBe('example.net') + expect(lines[0].detail).toMatch(/from a to b/) + }) +}) diff --git a/hub/zones-sync.ts b/hub/zones-sync.ts new file mode 100644 index 0000000..74916dd --- /dev/null +++ b/hub/zones-sync.ts @@ -0,0 +1,656 @@ +/* +Zones across the servers (stage 7): the merged list, copying a zone to another server, and +making a zone a Secondary of another server's Primary (zone transfer, AXFR, done by Technitium +itself rather than by copying records over and over). + + * `buildZoneMatrix` reads `zones/list` of every server (all pages, read token) and puts one row + per zone name. `classifyRow` is the pure part that decides the status of a row. + * `ZonesSync.copyPreview` / `copy` move a zone as a zone file: `zones/export` from the source + with the READ token, then `zones/create` (when the zone is missing) and `zones/import` on each + target with that server's ADMIN token. The parameter names are the ones the console's own + Import zone dialog sends (`importZone` and `saveZoneFile` in src/api/zones.ts) and its Add + zone dialog sends (add-zone.ts). + * `ZonesSync.secondaryPreview` / `makeSecondary` do the two steps of a transfer: on the source + `zones/options/set` adds the target to the transfer ACL and to the notify list (what is there + stays), then on the target `zones/create` type Secondary. Each step can run again with no harm. + +Only Primary and Forwarder zones are copied; a Secondary, Stub or Catalog zone is refused with a +reason. A DNSSEC-signed Primary is refused too: its keys do not travel in a zone file, so the copy +would carry signatures nobody can renew. No zone content is written to the audit log. +*/ +import { isIP } from 'node:net' +import type { HubConfig, HubServer } from './config.ts' +import { cleanDomain } from './servers-run.ts' +import { Upstream, UpstreamError, type RawAnswer } from './upstream.ts' + +export class ZoneUserError extends Error { + constructor(message: string) { + super(message) + this.name = 'ZoneUserError' + } +} + +const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') + +export const PAGE_SIZE = 500 +export const MAX_PAGES = 40 +/* The largest zone file the hub carries between servers. */ +export const MAX_ZONE_BYTES = 5 * 1024 * 1024 +const EXPORT_TIMEOUT_MS = 30_000 + +/* ---- The merged list ---- */ + +export interface ListedZone { + name: string + type: string + lastModified?: string + disabled?: boolean + soaSerial?: number | null + dnssecStatus?: string + catalog?: string | null + internal?: boolean +} + +export interface ZoneCell { + state: 'present' | 'absent' | 'unknown' + type?: string + serial?: number | null + disabled?: boolean + dnssec?: string + lastModified?: string +} + +export type RowStatus = 'same' | 'serial-differs' | 'type-differs' | 'only-on' | 'missing-on' | 'unknown' + +export interface MatrixRow { + name: string + cells: Record + status: RowStatus + /* The server that has the zone, for `only-on`. */ + onlyOn?: string + /* The reachable servers that lack it, for `missing-on`. */ + missingOn?: string[] + /* A Primary with a Secondary of it is the intended setup: said here, not counted as a divergence. */ + note?: 'primary-secondary' | 'secondary-behind' +} + +export interface ZoneMatrix { + /* `host` is where the server's address points, to prefill the primary's address of a Secondary. */ + servers: { id: string; ok: boolean; writable: boolean; host: string; error?: string }[] + zones: MatrixRow[] +} + +/* The pairs where one side follows the other: not a type difference. */ +const FOLLOWS: Record = { Secondary: 'Primary', SecondaryForwarder: 'Forwarder', SecondaryCatalog: 'Catalog' } + +function pairedTypes(types: Set): boolean { + if (types.size !== 2) return false + const [a, b] = [...types] + return FOLLOWS[a] === b || FOLLOWS[b] === a +} + +/* The status of one row, from its cells. Pure. */ +export function classifyRow(name: string, cells: Record): MatrixRow { + const entries = Object.entries(cells) + const present = entries.filter(([, c]) => c.state === 'present') + const absent = entries.filter(([, c]) => c.state === 'absent').map(([id]) => id) + const unknown = entries.some(([, c]) => c.state === 'unknown') + const row: MatrixRow = { name, cells, status: 'same' } + + if (present.length < 2) { + // Whether it is "only on" one server cannot be said while a server that might have it did not answer. + if (unknown || present.length === 0) return { ...row, status: 'unknown' } + return { ...row, status: 'only-on', onlyOn: present[0][0] } + } + + const types = new Set(present.map(([, c]) => c.type ?? '')) + if (types.size > 1 && !pairedTypes(types)) return { ...row, status: 'type-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) } + + if (types.size > 1) { + // Primary and its Secondary: lagging is expected, ahead is not. + const master = present.find(([, c]) => FOLLOWS[c.type ?? ''] === undefined)![1] + const followers = present.filter(([, c]) => FOLLOWS[c.type ?? ''] !== undefined) + const ahead = followers.some(([, c]) => typeof c.serial === 'number' && typeof master.serial === 'number' && c.serial > master.serial) + const behind = followers.some(([, c]) => typeof c.serial === 'number' && typeof master.serial === 'number' && c.serial < master.serial) + if (ahead) return { ...row, status: 'serial-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) } + return finish({ ...row, note: behind ? 'secondary-behind' : 'primary-secondary' }, absent, unknown) + } + + const [type] = [...types] + const serials = new Set(present.map(([, c]) => c.serial)) + if (type === 'Primary' && serials.size > 1) return { ...row, status: 'serial-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) } + return finish(row, absent, unknown) +} + +function finish(row: MatrixRow, absent: string[], unknown: boolean): MatrixRow { + if (absent.length > 0) return { ...row, status: 'missing-on', missingOn: absent } + return unknown ? { ...row, status: 'unknown' } : row +} + +/* Every page of one server's zones, without the server's own internal zones. */ +export async function listAllZones(upstream: Upstream, server: HubServer, fresh = false): Promise<{ zones: ListedZone[]; truncated: boolean }> { + if (fresh) upstream.invalidate(server.id) + const zones: ListedZone[] = [] + let truncated = false + for (let page = 1; ; page++) { + const r = (await upstream.call(server, 'zones/list', { pageNumber: String(page), zonesPerPage: String(PAGE_SIZE) })) as { + zones?: ListedZone[] + totalPages?: number + } | null + for (const z of r?.zones ?? []) if (z.internal !== true) zones.push(z) + const total = typeof r?.totalPages === 'number' ? r.totalPages : 1 + if (page >= total) break + if (page >= MAX_PAGES) { + truncated = true + break + } + } + return { zones, truncated } +} + +export async function buildZoneMatrix(config: HubConfig, upstream: Upstream): Promise<{ matrix: ZoneMatrix; warnings: string[] }> { + const perServer = await Promise.all( + config.servers.map(async (server) => { + try { + return { server, error: undefined, ...(await listAllZones(upstream, server)) } + } catch (e) { + return { server, error: reason(e), zones: [] as ListedZone[], truncated: false } + } + }), + ) + const warnings = perServer.filter((p) => p.error !== undefined).map((p) => `${p.server.id}: ${p.error}`) + if (warnings.length === perServer.length) throw new Error(`No server answered. ${warnings.join('; ')}`.trim()) + for (const p of perServer) if (p.truncated) warnings.push(`${p.server.id}: more than ${MAX_PAGES * PAGE_SIZE} zones, the list is cut short.`) + + const names = [...new Set(perServer.flatMap((p) => p.zones.map((z) => z.name)))].sort((a, b) => a.localeCompare(b)) + const zones = names.map((name) => { + const cells: Record = {} + for (const p of perServer) { + if (p.error !== undefined) { + cells[p.server.id] = { state: 'unknown' } + continue + } + const z = p.zones.find((x) => x.name === name) + cells[p.server.id] = + z === undefined + ? { state: 'absent' } + : { + state: 'present', + type: z.type, + serial: typeof z.soaSerial === 'number' ? z.soaSerial : null, + disabled: z.disabled === true, + ...(z.dnssecStatus === undefined ? {} : { dnssec: z.dnssecStatus }), + ...(z.lastModified === undefined ? {} : { lastModified: z.lastModified }), + } + } + return classifyRow(name, cells) + }) + const servers = perServer.map((p) => ({ + id: p.server.id, + ok: p.error === undefined, + writable: p.server.adminToken !== undefined, + host: new URL(p.server.url).hostname, + ...(p.error === undefined ? {} : { error: p.error }), + })) + return { matrix: { servers, zones }, warnings } +} + +/* ---- Copy ---- */ + +const COPYABLE = new Set(['Primary', 'Forwarder']) + +/* A zone name as the hub accepts it: a plain domain, not a wildcard and not the root. */ +export function cleanZone(value: string | undefined): string | null { + const zone = cleanDomain(value) + return zone === null || zone.startsWith('*') ? null : zone +} + +/* Records in a zone file, roughly: the lines that are not blank, a comment or a `$` directive. */ +export function countRecordLines(text: string): number { + let n = 0 + for (const line of text.split(/\r?\n/)) { + const t = line.trim() + if (t !== '' && !t.startsWith(';') && !t.startsWith('$')) n++ + } + return n +} + +/* The body of an answer as text, stopping at `cap` bytes and `ms` milliseconds. */ +export async function readCapped(file: RawAnswer, cap: number, ms: number, what = 'The zone file'): Promise { + if (file.body === null) return '' + const timer = setTimeout(() => file.abort(), ms) + const reader = file.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + size += value.length + if (size > cap) { + file.abort() + throw new ZoneUserError(`${what} is larger than ${Math.round(cap / 1024 / 1024)} MB.`) + } + chunks.push(value) + } + } catch (e) { + if (e instanceof ZoneUserError) throw e + throw new UpstreamError('unreachable', 'the zone file could not be read in time') + } finally { + clearTimeout(timer) + } + return Buffer.concat(chunks).toString('utf8') +} + +export interface TargetResult { + server: string + ok: boolean + message?: string +} + +interface SourceZone { + zone: ListedZone + warnings: string[] +} + +export interface CopyTargetPreview { + server: string + ok: boolean + error?: string + writable: boolean + exists: boolean + type?: string + serial?: number | null + /* The user has to tick Overwrite for this one. */ + needsOverwrite: boolean +} + +export interface CopyPreview { + zone: string + source: { server: string; type: string; serial: number | null; records: number; bytes: number } + warnings: string[] + targets: CopyTargetPreview[] +} + +export class ZonesSync { + #config: HubConfig + #upstream: Upstream + + constructor(config: HubConfig, upstream: Upstream) { + this.#config = config + this.#upstream = upstream + } + + #server(id: string, what = 'server'): HubServer { + const found = this.#config.servers.find((s) => s.id === id) + if (found === undefined) throw new ZoneUserError(`Unknown ${what}: ${id}.`) + return found + } + + #targets(ids: string[], source: string): HubServer[] { + const unique = [...new Set(ids)] + if (unique.length === 0) throw new ZoneUserError('Choose at least one server.') + if (unique.includes(source)) throw new ZoneUserError('A zone cannot be copied to the server it comes from.') + return unique.map((id) => this.#server(id)) + } + + /* The zone as the source lists it, and why it may not be copied. */ + async #source(server: HubServer, zone: string): Promise { + let listed: ListedZone[] + try { + listed = (await listAllZones(this.#upstream, server, true)).zones + } catch (e) { + throw new ZoneUserError(`${server.id}: ${reason(e)}`) + } + const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase()) + if (found === undefined) throw new ZoneUserError(`${server.id} has no zone ${zone}.`) + if (!COPYABLE.has(found.type)) { + throw new ZoneUserError(`${zone} is a ${found.type} zone on ${server.id}. Only Primary and Forwarder zones are copied; a ${found.type} zone gets its content from elsewhere.`) + } + if (found.dnssecStatus !== undefined && found.dnssecStatus !== 'Unsigned') { + throw new ZoneUserError(`${zone} is DNSSEC-signed. The signing keys do not travel in a zone file, so a copy would carry signatures that cannot be renewed. Sign the copy on the target instead.`) + } + const warnings: string[] = [] + if (found.disabled === true) warnings.push(`${zone} is disabled on ${server.id}; the copy is created enabled.`) + if (typeof found.catalog === 'string' && found.catalog !== '') warnings.push(`${zone} is a member of the catalog ${found.catalog} on ${server.id}; the copy is not added to a catalog.`) + return { zone: found, warnings } + } + + async #readZone(server: HubServer, zone: string): Promise { + let file: RawAnswer + try { + file = await this.#upstream.raw(server, 'zones/export', { zone }) + } catch (e) { + throw new ZoneUserError(`${server.id}: ${reason(e)}`) + } + try { + return await readCapped(file, MAX_ZONE_BYTES, EXPORT_TIMEOUT_MS) + } catch (e) { + throw new ZoneUserError(`${server.id}: ${reason(e)}`) + } + } + + /* What is on a target for this zone, or why the target cannot take it. */ + async #look(target: HubServer, zone: string, type: string): Promise { + const base = { server: target.id, writable: target.adminToken !== undefined } + if (target.adminToken === undefined) return { ...base, ok: false, error: 'The hub has no admin token for this server, so it cannot write to it.', exists: false, needsOverwrite: false } + let listed: ListedZone[] + try { + listed = (await listAllZones(this.#upstream, target, true)).zones + } catch (e) { + return { ...base, ok: false, error: reason(e), exists: false, needsOverwrite: false } + } + const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase()) + if (found === undefined) return { ...base, ok: true, exists: false, needsOverwrite: false } + const here = { ...base, exists: true, type: found.type, serial: typeof found.soaSerial === 'number' ? found.soaSerial : null } + if (found.type !== type) { + return { ...here, ok: false, needsOverwrite: false, error: `${target.id} has ${zone} as a ${found.type} zone; a ${type} zone cannot replace it. Delete or convert it there first.` } + } + return { ...here, ok: true, needsOverwrite: true } + } + + async copyPreview(sourceId: string, zoneRaw: string, ids: string[]): Promise { + const zone = cleanZone(zoneRaw) + if (zone === null) throw new ZoneUserError('A valid zone name is required.') + const source = this.#server(sourceId, 'source server') + const targets = this.#targets(ids, source.id) + const { zone: listed, warnings } = await this.#source(source, zone) + const text = await this.#readZone(source, zone) + return { + zone, + source: { + server: source.id, + type: listed.type, + serial: typeof listed.soaSerial === 'number' ? listed.soaSerial : null, + records: countRecordLines(text), + bytes: Buffer.byteLength(text), + }, + warnings, + targets: await Promise.all(targets.map((t) => this.#look(t, zone, listed.type))), + } + } + + /* + Copies the zone to each target. A target that has the zone is touched only when its id is in + `overwrite` (checked here, not only by the console). Create and import are two calls: when the + import fails after the create, the answer says an empty zone was left behind. + */ + async copy(sourceId: string, zoneRaw: string, ids: string[], overwrite: string[]): Promise<{ results: TargetResult[] }> { + const zone = cleanZone(zoneRaw) + if (zone === null) throw new ZoneUserError('A valid zone name is required.') + const source = this.#server(sourceId, 'source server') + const targets = this.#targets(ids, source.id) + const { zone: listed } = await this.#source(source, zone) + const text = await this.#readZone(source, zone) + if (text.trim() === '') throw new ZoneUserError(`${source.id} exported an empty zone file for ${zone}.`) + + const results = await Promise.all( + targets.map(async (target): Promise => { + const look = await this.#look(target, zone, listed.type) + if (!look.ok) return { server: target.id, ok: false, message: look.error } + if (look.exists && !overwrite.includes(target.id)) { + return { server: target.id, ok: false, message: `${zone} already exists here (${look.type}, serial ${look.serial ?? 'none'}). Tick Overwrite to replace its records.` } + } + try { + if (!look.exists) { + const params: Record = { zone, type: listed.type } + // A Forwarder is created empty and filled by the import (add-zone.ts: `initializeForwarder=false`). + if (listed.type === 'Forwarder') params.initializeForwarder = 'false' + await this.#upstream.write(target, 'zones/create', params) + } + } catch (e) { + return { server: target.id, ok: false, message: `the zone could not be created: ${reason(e)}` } + } + try { + // The three switches and a text/plain body, as `importZone` sends them. + await this.#upstream.write(target, 'zones/import', { zone, overwrite: 'true', overwriteZone: 'true', overwriteSoaSerial: 'true' }, text) + } catch (e) { + return { + server: target.id, + ok: false, + message: look.exists + ? `the import failed: ${reason(e)}` + : `the zone was created but the import failed (${reason(e)}); an empty zone is left on ${target.id}`, + } + } + return { server: target.id, ok: true, message: look.exists ? 'Records replaced.' : 'Zone created and filled.' } + }), + ) + return { results } + } + + /* ---- Make secondary ---- */ + + async #tsigNames(server: HubServer): Promise { + this.#upstream.invalidate(server.id) + const r = (await this.#upstream.call(server, 'settings/getTsigKeyNames', {})) as { tsigKeyNames?: string[] } | null + return r?.tsigKeyNames ?? [] + } + + async secondaryPreview(input: SecondaryInput): Promise { + const plan = await this.#plan(input) + return { zone: plan.zone, source: plan.source.id, target: plan.target.id, warnings: plan.warnings, steps: plan.steps } + } + + async makeSecondary(input: SecondaryInput): Promise<{ results: TargetResult[] }> { + const plan = await this.#plan(input) + const results: TargetResult[] = [] + const { source, target } = plan + + if (plan.sourceParams === null) { + results.push({ server: source.id, ok: true, message: 'Transfer and notify already allow the target; nothing was changed.' }) + } else { + try { + await this.#upstream.write(source, 'zones/options/set', plan.sourceParams) + results.push({ server: source.id, ok: true, message: 'Zone transfer and notify now include the target.' }) + } catch (e) { + results.push({ server: source.id, ok: false, message: `${reason(e)}; nothing was changed on either server` }) + results.push({ server: target.id, ok: false, message: 'Not attempted: the source step failed.' }) + return { results } + } + } + try { + await this.#upstream.write(target, 'zones/create', plan.targetParams) + results.push({ server: target.id, ok: true, message: 'Secondary zone created; it will fetch the zone from the primary.' }) + } catch (e) { + results.push({ + server: target.id, + ok: false, + message: `the Secondary zone could not be created: ${reason(e)}. The change on ${source.id} (zone transfer and notify) was left in place; run this again after fixing the cause.`, + }) + } + return { results } + } + + async #plan(input: SecondaryInput): Promise { + const zone = cleanZone(input.zone) + if (zone === null) throw new ZoneUserError('A valid zone name is required.') + const source = this.#server(input.source, 'source server') + const target = this.#server(input.target, 'target server') + if (source.id === target.id) throw new ZoneUserError('The source and the target must be two different servers.') + if (source.adminToken === undefined || target.adminToken === undefined) { + throw new ZoneUserError(`The hub has no admin token for ${source.adminToken === undefined ? source.id : target.id}, so it cannot change it.`) + } + const primaryAddress = input.primaryAddress.trim() + const targetAddress = input.targetAddress.trim() + if (!validNameServer(primaryAddress)) throw new ZoneUserError("Give the primary's address as the target reaches it: an IP address or a host name.") + if (isIP(targetAddress) === 0) throw new ZoneUserError("Give the target's IP address as the source sees it (the zone transfer list takes addresses).") + const key = (input.tsigKeyName ?? '').trim() + + const warnings: string[] = [] + const { zone: listed } = await this.#sourceForSecondary(source, zone) + let existsOnTarget: ListedZone | undefined + try { + existsOnTarget = (await listAllZones(this.#upstream, target, true)).zones.find((z) => z.name.toLowerCase() === zone.toLowerCase()) + } catch (e) { + throw new ZoneUserError(`${target.id}: ${reason(e)}`) + } + if (existsOnTarget !== undefined) { + throw new ZoneUserError(`${target.id} already has ${zone} as a ${existsOnTarget.type} zone. Delete it there first if it should become a Secondary.`) + } + if (key !== '') { + for (const s of [source, target]) { + let names: string[] + try { + names = await this.#tsigNames(s) + } catch (e) { + throw new ZoneUserError(`${s.id}: ${reason(e)}`) + } + if (!names.includes(key)) throw new ZoneUserError(`The TSIG key "${key}" does not exist on ${s.id}. Add it in that server's Settings first.`) + } + } + + let options: ZoneOptionsLike + try { + this.#upstream.invalidate(source.id) + options = (await this.#upstream.call(source, 'zones/options/get', { zone })) as ZoneOptionsLike + } catch (e) { + throw new ZoneUserError(`${source.id}: ${reason(e)}`) + } + if (typeof options.catalog === 'string' && options.catalog !== '' && options.overrideCatalogZoneTransfer !== true) { + warnings.push(`${zone} follows the zone transfer settings of the catalog ${options.catalog} on ${source.id}; the change may have no effect until the zone overrides them.`) + } + + const sourceParams = secondaryOptions(zone, options, targetAddress, key) + const targetParams: Record = { + zone, + type: 'Secondary', + catalog: '', + primaryNameServerAddresses: primaryAddress, + zoneTransferProtocol: 'Tcp', + tsigKeyName: key, + validateZone: 'false', + } + const steps: SecondaryStep[] = [ + { + server: source.id, + role: 'source', + summary: + sourceParams === null + ? `${zone} on ${source.id} already allows zone transfer and notify to ${targetAddress}.` + : `On ${source.id}: allow zone transfer of ${zone} to ${targetAddress}${key === '' ? '' : ` with TSIG key ${key}`} and notify ${targetAddress}. What is already listed stays.`, + changes: sourceParams !== null, + }, + { + server: target.id, + role: 'target', + summary: `On ${target.id}: create ${zone} as a Secondary zone that fetches it from ${primaryAddress} over TCP${key === '' ? '' : ` signed with ${key}`}.`, + changes: true, + }, + ] + return { zone, source, target, warnings, steps, sourceParams, targetParams, type: listed.type } + } + + async #sourceForSecondary(source: HubServer, zone: string): Promise { + let listed: ListedZone[] + try { + listed = (await listAllZones(this.#upstream, source, true)).zones + } catch (e) { + throw new ZoneUserError(`${source.id}: ${reason(e)}`) + } + const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase()) + if (found === undefined) throw new ZoneUserError(`${source.id} has no zone ${zone}.`) + if (found.type !== 'Primary') throw new ZoneUserError(`${zone} is a ${found.type} zone on ${source.id}. Only a Primary zone can be transferred to a Secondary.`) + return { zone: found, warnings: [] } + } +} + +export interface SecondaryInput { + source: string + target: string + zone: string + primaryAddress: string + targetAddress: string + tsigKeyName?: string +} + +export interface SecondaryStep { + server: string + role: 'source' | 'target' + summary: string + /* False when nothing would be written there. */ + changes: boolean +} + +export interface SecondaryPreview { + zone: string + source: string + target: string + warnings: string[] + steps: SecondaryStep[] +} + +interface SecondaryPlan { + zone: string + source: HubServer + target: HubServer + warnings: string[] + steps: SecondaryStep[] + /* Null when the source already allows it. */ + sourceParams: Record | null + targetParams: Record + type: string +} + +interface ZoneOptionsLike { + catalog?: string | null + overrideCatalogZoneTransfer?: boolean + zoneTransfer?: string + zoneTransferNetworkACL?: string[] + zoneTransferTsigKeyNames?: string[] + notify?: string + notifyNameServers?: string[] +} + +/* An address for a name server: an IP, a host name, either with a port. */ +export function validNameServer(value: string): boolean { + if (value === '' || value.length > 255 || /[\s,]/.test(value)) return false + if (isIP(value) !== 0) return true + const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value) + if (bracketed !== null) return isIP(bracketed[1]) === 6 + const [host, port, extra] = value.split(':') + if (extra !== undefined || (port !== undefined && !/^\d{1,5}$/.test(port))) return false + return isIP(host) === 4 || cleanZone(host) !== null +} + +/* A transfer mode that also lets the listed networks in. */ +const TRANSFER_WITH_ACL: Record = { Deny: 'UseSpecifiedNetworkACL', AllowOnlyZoneNameServers: 'AllowZoneNameServersAndUseSpecifiedNetworkACL' } +const NOTIFY_WITH_LIST: Record = { None: 'SpecifiedNameServers', ZoneNameServers: 'BothZoneAndSpecifiedNameServers' } + +const joined = (list: string[]): string => (list.length === 0 ? 'false' : list.join(',')) + +/* +The `zones/options/set` that lets `address` transfer and be notified, keeping what is there: modes +that cannot hold a list are widened to the matching one that can, an address already listed is not +added twice. Null when nothing needs to change. Only the transfer and notify fields are sent, so +the rest of the zone's options is not touched. +*/ +export function secondaryOptions(zone: string, current: ZoneOptionsLike, address: string, tsigKey: string): Record | null { + const mode = current.zoneTransfer ?? 'Deny' + const acl = [...(current.zoneTransferNetworkACL ?? [])] + const keys = [...(current.zoneTransferTsigKeyNames ?? [])] + const notify = current.notify ?? 'None' + const notifyList = [...(current.notifyNameServers ?? [])] + + const nextMode = TRANSFER_WITH_ACL[mode] ?? mode + if (nextMode !== 'Allow' && !acl.includes(address)) acl.push(address) + if (tsigKey !== '' && !keys.includes(tsigKey)) keys.push(tsigKey) + const nextNotify = NOTIFY_WITH_LIST[notify] ?? notify + if (!notifyList.includes(address)) notifyList.push(address) + + const unchanged = + nextMode === mode && + nextNotify === notify && + acl.length === (current.zoneTransferNetworkACL ?? []).length && + keys.length === (current.zoneTransferTsigKeyNames ?? []).length && + notifyList.length === (current.notifyNameServers ?? []).length + if (unchanged) return null + return { + zone, + zoneTransfer: nextMode, + zoneTransferNetworkACL: joined(acl), + zoneTransferTsigKeyNames: joined(keys), + notify: nextNotify, + notifyNameServers: joined(notifyList), + } +} diff --git a/src/api/hub-settings.ts b/src/api/hub-settings.ts new file mode 100644 index 0000000..0fba12a --- /dev/null +++ b/src/api/hub-settings.ts @@ -0,0 +1,124 @@ +import { apiRequest, type ApiOutcome } from './client' +import type { ConfigDiff, HistoryEntry, RunOutcome } from './apps' + +/* +Settings and blocking across the servers, behind a hub (hub/settings-config.ts). Every endpoint +needs the hub's admin role: settings carry secrets, so even the comparison is for admins. No answer +ever carries a secret; a secret shows as `` and a row says only that it differs. +*/ +export const SECRET_PLACEHOLDER = '' + +export interface SettingsCell { + value: unknown + differs: boolean + secretChanged?: boolean +} + +export interface SettingsRow { + name: string + group: 'blocking' | 'general' + local: boolean + secret: boolean + expected?: unknown + cells: Record +} + +export interface SettingsServer { + id: string + ok: boolean + error?: string + writable: boolean + status: 'same' | 'differs' | 'local-only' | 'unknown' + differing: number + localDiffering: number + paused: boolean +} + +export interface SettingsBaselineView { + settings: Record + importedFrom?: string + updatedAt: string + updatedBy: string +} + +export interface SettingsCompare { + baseline: SettingsBaselineView | null + withBaseline: boolean + localFields: { builtIn: string[]; builtInPatterns: string[]; extra: string[] } + blockingFields: string[] + servers: SettingsServer[] + fields: SettingsRow[] +} + +export const getSettingsCompare = (token: string | null): Promise> => apiRequest('hub/settings/compare', { token }) + +export const importSettingsBaseline = (token: string | null, server: string, group?: 'blocking'): Promise> => + apiRequest('hub/settings/baseline/import', { token, method: 'POST', body: { server, ...(group === undefined ? {} : { group }) } }) + +export const saveSettingsBaseline = (token: string | null, text: string): Promise> => + apiRequest('hub/settings/baseline/save', { token, method: 'POST', body: { text } }) + +export const saveSettingsOverrides = (token: string | null, paths: string[]): Promise> => + apiRequest('hub/settings/overrides/save', { token, method: 'POST', body: { paths: paths.join(',') } }) + +export interface SettingsPreview { + server: string + ok: boolean + error?: string + writable: boolean + currentHash: string + changes: ConfigDiff + secretFields: string[] + unchanged: boolean + warnings: string[] +} + +export const previewSettings = (token: string | null, targets: string[], group?: 'blocking'): Promise> => + apiRequest('hub/settings/preview', { token, method: 'POST', body: { targets: targets.join(','), ...(group === undefined ? {} : { group }) } }) + +export const applySettings = (token: string | null, targets: string[], expect: Record, group?: 'blocking'): Promise => + apiRequest('hub/settings/apply', { token, method: 'POST', body: { targets: targets.join(','), expect: JSON.stringify(expect), ...(group === undefined ? {} : { group }) } }) + +export const getSettingsHistory = (token: string | null): Promise> => apiRequest('hub/settings/history/list', { token }) + +export const getSettingsHistoryEntry = ( + token: string | null, + id: string, +): Promise; lists?: { blocked: number; allowed: number } } }>> => + apiRequest('hub/settings/history/get', { token, body: { id } }) + +export interface ListCounts { + count: number + missing: number + extra: number + missingSample: string[] + extraSample: string[] +} + +export interface ListsServer { + id: string + ok: boolean + error?: string + writable: boolean + blocked: ListCounts + allowed: ListCounts + status: 'same' | 'differs' | 'unknown' + tooMany?: string +} + +export interface ListsCompare { + baseline: { blocked: number; allowed: number; importedFrom?: string; updatedAt: string; updatedBy: string } | null + withBaseline: boolean + servers: ListsServer[] +} + +export const getListsCompare = (token: string | null): Promise> => apiRequest('hub/blocking/lists/compare', { token }) + +export const importListsBaseline = (token: string | null, server: string): Promise> => + apiRequest('hub/blocking/lists/baseline/import', { token, method: 'POST', body: { server } }) + +export const previewLists = (token: string | null, targets: string[], removeExtras: boolean): Promise> => + apiRequest('hub/blocking/lists/preview', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } }) + +export const applyLists = (token: string | null, targets: string[], removeExtras: boolean): Promise => + apiRequest('hub/blocking/lists/apply', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } }) diff --git a/src/api/hub-zones.ts b/src/api/hub-zones.ts new file mode 100644 index 0000000..1a6d41b --- /dev/null +++ b/src/api/hub-zones.ts @@ -0,0 +1,80 @@ +import { apiRequest, type ApiOutcome } from './client' +import type { RunOutcome } from './apps' + +/* +Zones across the servers, behind a hub (hub/zones-sync.ts): the merged list, copying a zone to +other servers, and making a zone a Secondary of another server's Primary. +*/ +export interface ZoneCell { + state: 'present' | 'absent' | 'unknown' + type?: string + serial?: number | null + disabled?: boolean + dnssec?: string + lastModified?: string +} + +export type RowStatus = 'same' | 'serial-differs' | 'type-differs' | 'only-on' | 'missing-on' | 'unknown' + +export interface MatrixZone { + name: string + cells: Record + status: RowStatus + onlyOn?: string + missingOn?: string[] + note?: 'primary-secondary' | 'secondary-behind' +} + +export interface ZonesMatrixData { + servers: { id: string; ok: boolean; writable: boolean; host: string; error?: string }[] + zones: MatrixZone[] +} + +export const getZonesMatrix = (token: string | null): Promise> => apiRequest('hub/zones/matrix', { token }) + +export interface CopyTargetPreview { + server: string + ok: boolean + error?: string + writable: boolean + exists: boolean + type?: string + serial?: number | null + needsOverwrite: boolean +} + +export interface CopyPreview { + zone: string + source: { server: string; type: string; serial: number | null; records: number; bytes: number } + warnings: string[] + targets: CopyTargetPreview[] +} + +export const previewZoneCopy = (token: string | null, source: string, zone: string, targets: string[]): Promise> => + apiRequest('hub/zones/copy/preview', { token, method: 'POST', body: { source, zone, targets: targets.join(',') } }) + +export const copyZone = (token: string | null, source: string, zone: string, targets: string[], overwrite: string[]): Promise => + apiRequest('hub/zones/copy', { token, method: 'POST', body: { source, zone, targets: targets.join(','), overwrite: overwrite.join(',') } }) + +export interface SecondaryInput { + source: string + target: string + zone: string + primaryAddress: string + targetAddress: string + tsigKeyName: string +} + +export interface SecondaryPreview { + zone: string + source: string + target: string + warnings: string[] + steps: { server: string; role: 'source' | 'target'; summary: string; changes: boolean }[] +} + +export const previewSecondary = (token: string | null, input: SecondaryInput): Promise> => + apiRequest('hub/zones/secondary/preview', { token, method: 'POST', body: { ...input } }) + +export const makeSecondary = (token: string | null, input: SecondaryInput): Promise => + apiRequest('hub/zones/secondary', { token, method: 'POST', body: { ...input } }) diff --git a/src/screens/apps/RunFlow.tsx b/src/screens/apps/RunFlow.tsx index f7ad873..7159a8c 100644 --- a/src/screens/apps/RunFlow.tsx +++ b/src/screens/apps/RunFlow.tsx @@ -30,7 +30,7 @@ export interface RunRequest { A review step between the picker and the change (the config sync uses it): it reads what would happen on the picked servers and draws it. `context` is handed back to `run`. */ - review?: (targets: string[]) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }> + review?: (targets: string[], setReady: (ready: boolean) => void) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }> run: (targets: string[], context?: unknown) => Promise } @@ -57,12 +57,15 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c const [lastTargets, setLastTargets] = useState([]) const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null) const [reviewFailure, setReviewFailure] = useState(null) + /* A review can hold the confirm button back until the user has answered it (an Overwrite tick). */ + const [ready, setReady] = useState(true) async function next(targets: string[]) { if (request.review === undefined) return go(targets) setStage('reviewing') setReviewFailure(null) - const outcome = await request.review(targets) + setReady(true) + const outcome = await request.review(targets, setReady) if (outcome.kind === 'ok') setReviewed(outcome) else { setReviewed(null) @@ -103,7 +106,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c } > - {request.intro != null &&

{request.intro}

} + {request.intro != null &&
{request.intro}
}
{request.servers.map((s) => ( {reviewed !== null && ( - )} diff --git a/src/screens/blocking/Blocking.tsx b/src/screens/blocking/Blocking.tsx index cd17b30..82a4edd 100644 --- a/src/screens/blocking/Blocking.tsx +++ b/src/screens/blocking/Blocking.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useState } from 'react' import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' +import { CompareServersButton } from '../settings/SettingsCompare' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' import { getSettings } from '../../api/settings' @@ -152,6 +153,11 @@ export function Blocking({ const tabs = ( <> + {hub && permissions?.Settings?.canModify === true && ( +
+ +
+ )} {hub && active !== 'Overview' && ( <> diff --git a/src/screens/settings/Settings.tsx b/src/screens/settings/Settings.tsx index 1dd8523..e649d59 100644 --- a/src/screens/settings/Settings.tsx +++ b/src/screens/settings/Settings.tsx @@ -4,6 +4,7 @@ import { nodeArg, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' import { PermissionButton } from '../../ui/PermissionButton' import { SectionHeader } from '../../ui/SectionHeader' +import { CompareServersButton } from './SettingsCompare' import { SubTabs } from '../../ui/SubTabs' import { IndexedForm } from '../../ui/IndexedForm' import { useSectionInView } from '../../ui/useSectionInView' @@ -499,6 +500,7 @@ export function Settings({ } actions={ <> + {hub && canModify && } vi.restoreAllMocks()) + +const base = { + withBaseline: true, + localFields: { builtIn: ['dnsServerDomain'], builtInPatterns: ['webService*'], extra: [] }, + blockingFields: ['blockingType'], +} + +const DATA: Compare = { + ...base, + baseline: { settings: { defaultRecordTtl: 3600 }, importedFrom: 'mdns', updatedAt: '2026-10-01T00:00:00Z', updatedBy: 'admin' }, + servers: [ + { id: 'mdns', ok: true, writable: true, status: 'same', differing: 0, localDiffering: 1, paused: false }, + { id: 'ndns', ok: true, writable: true, status: 'differs', differing: 3, localDiffering: 1, paused: false }, + ], + fields: [ + { name: 'defaultRecordTtl', group: 'general', local: false, secret: false, expected: 3600, cells: { mdns: { value: 3600, differs: false }, ndns: { value: 7200, differs: true } } }, + { name: 'blockingType', group: 'blocking', local: false, secret: false, expected: 'NxDomain', cells: { mdns: { value: 'NxDomain', differs: false }, ndns: { value: 'AnyAddress', differs: true } } }, + { name: 'tsigKeys', group: 'general', local: false, secret: true, expected: [], cells: { mdns: { value: [], differs: false }, ndns: { value: [], differs: true, secretChanged: true } } }, + { name: 'dnsServerDomain', group: 'general', local: true, secret: false, cells: { mdns: { value: 'a.example', differs: true }, ndns: { value: 'b.example', differs: true } } }, + ], +} + +const LISTS: ListsCompare = { + baseline: { blocked: 2, allowed: 1, importedFrom: 'mdns', updatedAt: '2026-10-01T00:00:00Z', updatedBy: 'admin' }, + withBaseline: true, + servers: [ + { id: 'mdns', ok: true, writable: true, status: 'same', blocked: { count: 2, missing: 0, extra: 0, missingSample: [], extraSample: [] }, allowed: { count: 1, missing: 0, extra: 0, missingSample: [], extraSample: [] } }, + { id: 'ndns', ok: true, writable: true, status: 'differs', blocked: { count: 2, missing: 1, extra: 1, missingSample: ['ads.example'], extraSample: ['only-b.example'] }, allowed: { count: 1, missing: 0, extra: 0, missingSample: [], extraSample: [] } }, + ], +} + +function mock(data: Compare = DATA) { + vi.spyOn(api, 'getSettingsCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never) + vi.spyOn(api, 'getListsCompare').mockResolvedValue({ kind: 'ok', data: { response: LISTS } } as never) +} + +describe('SettingsCompare', () => { + it('shows the status of each server and the fields that differ, with a secret only as "secret changed"', async () => { + mock() + render( {}} />) + expect(await screen.findByText('defaultRecordTtl')).toBeInTheDocument() + expect(screen.getByText('Same')).toBeInTheDocument() + expect(screen.getByText('Differs · 3')).toBeInTheDocument() + expect(screen.getByText('Secret changed')).toBeInTheDocument() + expect(screen.getByText('7200')).toBeInTheDocument() + expect(screen.getAllByText('Local').length).toBeGreaterThan(0) + }) + + it('shows only the blocking fields and the lists in the blocking scope', async () => { + mock() + render( {}} />) + expect(await screen.findByText('blockingType')).toBeInTheDocument() + expect(screen.queryByText('defaultRecordTtl')).not.toBeInTheDocument() + expect(screen.getByText('Missing: ads.example')).toBeInTheDocument() + expect(screen.getByText('Extra: only-b.example')).toBeInTheDocument() + }) + + it('offers to accept a server as the baseline when there is none', async () => { + mock({ ...DATA, baseline: null, withBaseline: false }) + const accept = vi.spyOn(api, 'importSettingsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: {} } } } as never) + render( {}} />) + await userEvent.click((await screen.findAllByRole('button', { name: 'Accept ndns as baseline' }))[0]) + expect(accept).toHaveBeenCalledWith('t', 'ndns', undefined) + expect(screen.getByRole('button', { name: 'Apply to servers…' })).toBeDisabled() + }) + + it('applies after a preview: the preview hashes reach the apply, a secret is not shown', async () => { + mock() + vi.spyOn(api, 'previewSettings').mockResolvedValue({ + kind: 'ok', + data: { + response: { + targets: [ + { + server: 'ndns', + ok: true, + writable: true, + currentHash: 'h1', + unchanged: false, + warnings: [], + secretFields: ['tsigKeys'], + changes: { kind: 'json', changes: [{ path: 'defaultRecordTtl', type: 'changed', a: 7200, b: 3600 }] }, + }, + ], + }, + }, + } as never) + const apply = vi.spyOn(api, 'applySettings').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Applied and verified (1 field).' }] } } } as never) + render( {}} />) + await userEvent.click(await screen.findByRole('button', { name: 'Apply to servers…' })) + await userEvent.click(await screen.findByRole('button', { name: 'Preview' })) + expect(await screen.findByText(/the secret values are sent but never shown/)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Apply' })) + expect(await screen.findByText('Applied and verified (1 field).')).toBeInTheDocument() + expect(apply).toHaveBeenCalledWith('t', ['ndns'], { ndns: 'h1' }, undefined) + }) + + it('applies the lists, removing extras only when ticked', async () => { + mock() + vi.spyOn(api, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [LISTS.servers[1]] } } } as never) + const apply = vi.spyOn(api, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added 1, removed 1.' }] } } } as never) + render( {}} />) + await userEvent.click(await screen.findByRole('button', { name: 'Apply lists…' })) + await userEvent.click(await screen.findByRole('button', { name: 'Preview' })) + await userEvent.click(await screen.findByRole('checkbox', { name: 'Also remove extras' })) + await userEvent.click(screen.getByRole('button', { name: 'Apply' })) + await screen.findByText('Added 1, removed 1.') + expect(apply).toHaveBeenCalledWith('t', ['ndns'], true) + }) + + it('does not remove extras when the box is left alone', async () => { + mock() + vi.spyOn(api, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [LISTS.servers[1]] } } } as never) + const apply = vi.spyOn(api, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'done' }] } } } as never) + render( {}} />) + await userEvent.click(await screen.findByRole('button', { name: 'Apply lists…' })) + await userEvent.click(await screen.findByRole('button', { name: 'Preview' })) + await userEvent.click(await screen.findByRole('button', { name: 'Apply' })) + await screen.findByText('done') + expect(apply).toHaveBeenCalledWith('t', ['ndns'], false) + }) + + it('lists the built-in local fields and saves extra ones', async () => { + mock() + const save = vi.spyOn(api, 'saveSettingsOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: { paths: [] } } } } as never) + render( {}} />) + await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' })) + expect(within(screen.getByRole('list', { name: 'Built-in local fields' })).getByText('dnsServerDomain')).toBeInTheDocument() + await userEvent.type(screen.getByLabelText('New local field'), 'forwarderTimeout') + await userEvent.click(screen.getByRole('button', { name: 'Add field' })) + await userEvent.click(screen.getByRole('button', { name: 'Save local fields' })) + expect(save).toHaveBeenCalledWith('t', ['forwarderTimeout']) + }) + + it('shows an error with Retry, and history with an empty state', async () => { + const spy = vi.spyOn(api, 'getSettingsCompare').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never) + vi.spyOn(api, 'getSettingsHistory').mockResolvedValue({ kind: 'ok', data: { response: { entries: [] } } } as never) + render( {}} />) + expect(await screen.findByText(/boom/)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Retry' })) + await screen.findByText('defaultRecordTtl') + expect(spy).toHaveBeenCalledTimes(2) + await userEvent.click(screen.getByRole('tab', { name: 'History' })) + expect(await screen.findByText('No history yet')).toBeInTheDocument() + }) + + it('opens from its button', async () => { + mock() + render() + await userEvent.click(screen.getByRole('button', { name: 'Compare servers' })) + expect(await screen.findByText('defaultRecordTtl')).toBeInTheDocument() + }) +}) + diff --git a/src/screens/settings/SettingsCompare.tsx b/src/screens/settings/SettingsCompare.tsx new file mode 100644 index 0000000..a7deabd --- /dev/null +++ b/src/screens/settings/SettingsCompare.tsx @@ -0,0 +1,580 @@ +import { useCallback, useEffect, useState } from 'react' +import { + applyLists, + applySettings, + getListsCompare, + getSettingsCompare, + getSettingsHistory, + getSettingsHistoryEntry, + importListsBaseline, + importSettingsBaseline, + previewLists, + previewSettings, + saveSettingsBaseline, + saveSettingsOverrides, + type ListsCompare, + type ListsServer, + type SettingsCompare as Compare, + type SettingsRow, +} from '../../api/hub-settings' +import type { HistoryEntry } from '../../api/apps' +import { Alert } from '../../ui/Alert' +import { Button } from '../../ui/Button' +import { Check } from '../../ui/Check' +import { DataTable, type Col } from '../../ui/DataTable' +import { Dialog } from '../../ui/Dialog' +import { Empty, Failure } from '../../ui/Empty' +import { Input, LabeledTextarea } from '../../ui/Field' +import { head } from '../../ui/Table' +import { Tag } from '../../ui/Tag' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { DiffView, show } from '../apps/ConfigDiffView' +import { RunFlow, pickerServers, type PickerServer, type RunRequest } from '../apps/RunFlow' + +/* +Behind a hub, for an admin: the settings of every server side by side, a baseline kept in the hub, +the fields that stay local to each server, and what an apply would change. The same dialog serves +Settings (every field) and Blocking (the blocking fields, plus the custom Blocked and Allowed lists +compared as sets). A secret is never shown: a row says "secret changed". +*/ + +export type Scope = 'settings' | 'blocking' +type Tab = 'compare' | 'baseline' | 'local' | 'history' + +const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString()) +const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.') + +function StatusTag({ status, differing }: { status: string; differing: number }) { + if (status === 'same') return Same + if (status === 'differs') return {differing > 0 ? `Differs · ${differing}` : 'Differs'} + if (status === 'local-only') return Only local fields differ + return Did not answer +} + +/* ---- Requests for the picker, the review and the results ---- */ + +function settingsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[], scope: Scope): RunRequest { + const group = scope === 'blocking' ? 'blocking' : undefined + return { + title: scope === 'blocking' ? 'Apply the blocking settings' : 'Apply the settings baseline', + intro: 'Only the shared fields that differ are sent. Local fields and the web service settings are never touched, and each server\'s settings are saved to the hub\'s history first.', + confirmLabel: 'Preview', + servers, + preselected, + review: async (targets) => { + const outcome = await previewSettings(token, targets, group) + if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) } + const previews = outcome.data.response.targets + return { + kind: 'ok', + confirmLabel: 'Apply', + context: Object.fromEntries(previews.filter((p) => p.ok).map((p) => [p.server, p.currentHash])), + body: ( +
+ {previews.map((p) => ( +
+

+ {p.server} + {!p.ok ? Cannot apply : p.unchanged ? Already in sync : Will change} +

+ {p.error !== undefined && {p.error}} + {p.warnings.map((w) => ( + + {w} + + ))} + {p.secretFields.length > 0 && ( + + {p.secretFields.join(', ')}: the secret values are sent but never shown. + + )} + {p.ok && !p.unchanged && } +
+ ))} +
+ ), + } + }, + run: (targets, context) => applySettings(token, targets, (context ?? {}) as Record, group), + } +} + +const sample = (items: string[], total: number): string => (items.length === 0 ? 'none' : `${items.slice(0, 5).join(', ')}${total > 5 ? ` and ${total - 5} more` : ''}`) + +function ListLine({ label, c }: { label: string; c: ListsServer['blocked'] }) { + return ( +
  • + {label} {c.count} domains, missing {c.missing}, extra {c.extra} + {c.missing > 0 &&
    Missing: {sample(c.missingSample, c.missing)}
    } + {c.extra > 0 &&
    Extra: {sample(c.extraSample, c.extra)}
    } +
  • + ) +} + +function ListsReview({ targets, opts }: { targets: ListsServer[]; opts: { remove: boolean } }) { + const [remove, setRemove] = useState(opts.remove) + const extras = targets.reduce((n, t) => n + t.blocked.extra + t.allowed.extra, 0) + return ( +
    + {targets.map((t) => ( +
    +

    + {t.id} + {!t.ok ? Cannot apply : t.status === 'same' ? Already in sync : Will change} +

    + {t.error !== undefined && {t.error}} + {t.ok && ( +
      + + +
    + )} +
    + ))} + { + opts.remove = on + setRemove(on) + }} + /> +
    + ) +} + +function listsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[]): RunRequest { + const opts = { remove: false } + return { + title: 'Apply the Blocked and Allowed lists', + intro: 'Missing domains are added. Extra domains stay unless you tick "Also remove extras" in the next step.', + confirmLabel: 'Preview', + servers, + preselected, + review: async (targets) => { + opts.remove = false + const outcome = await previewLists(token, targets, false) + if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) } + return { kind: 'ok', confirmLabel: 'Apply', context: undefined, body: } + }, + run: (targets) => applyLists(token, targets, opts.remove), + } +} + +/* ---- The dialog ---- */ + +export function SettingsCompare({ token, scope, onClose }: { token: string | null; scope: Scope; onClose: () => void }) { + const [data, setData] = useState(null) + const [lists, setLists] = useState(null) + const [failure, setFailure] = useState(null) + const [tab, setTab] = useState('compare') + const [request, setRequest] = useState(null) + const [busy, setBusy] = useState(false) + const [problem, setProblem] = useState(null) + const [saved, setSaved] = useState(null) + const [baselineText, setBaselineText] = useState('') + const [extra, setExtra] = useState([]) + const [newField, setNewField] = useState('') + const [loads, setLoads] = useState(0) + const blocking = scope === 'blocking' + + const load = useCallback(async () => { + const [s, l] = await Promise.all([getSettingsCompare(token), blocking ? getListsCompare(token) : Promise.resolve(null)]) + if (s.kind !== 'ok') { + setFailure(sessionEnded(s.kind, s.kind === 'error' ? s.message : undefined)) + return + } + if (l !== null && l.kind !== 'ok') { + setFailure(sessionEnded(l.kind, l.kind === 'error' ? l.message : undefined)) + return + } + setData(s.data.response) + setLists(l === null || l.kind !== 'ok' ? null : l.data.response) + setLoads((n) => n + 1) + setFailure(null) + setBaselineText(s.data.response.baseline === null ? '' : JSON.stringify(s.data.response.baseline.settings, null, 2)) + setExtra(s.data.response.localFields.extra) + }, [token, blocking]) + + useEffect(() => { + void load() + }, [load]) + + async function run(call: () => Promise<{ kind: string; message?: string }>, done: string) { + setBusy(true) + setProblem(null) + setSaved(null) + const outcome = await call() + setBusy(false) + if (outcome.kind !== 'ok') { + setProblem(sessionEnded(outcome.kind, outcome.message)) + return + } + setSaved(done) + await load() + } + + const servers = data?.servers ?? [] + const pickable = (extraReasons: Record = {}) => pickerServers(servers.map((s) => ({ id: s.id, writable: s.writable, ok: s.ok })), extraReasons) + const rows: SettingsRow[] = (data?.fields ?? []).filter((r) => !blocking || r.group === 'blocking') + const hasBaseline = data?.baseline != null + const liveServers = servers.filter((s) => s.ok) + const differsIds = servers.filter((s) => s.status === 'differs').map((s) => s.id) + + const columns: Col[] = [ + { + id: 'name', + ...head({ children: 'Field' }), + cell: (r) => ( + + {r.name} + {r.local && Local} + {r.secret && Secret} + + ), + }, + ...liveServers.map( + (s): Col => ({ + id: `s-${s.id}`, + ...head({ children: s.id }), + cell: (r) => { + const c = r.cells[s.id] + if (c === undefined) return Not reported + if (c.secretChanged === true) return Secret changed + return {show(c.value)} + }, + }), + ), + ...(hasBaseline + ? [ + { + id: 'expected', + ...head({ children: 'Baseline' }), + cell: (r: SettingsRow) => (r.local ? Local : {show(r.expected)}), + } satisfies Col, + ] + : []), + ] + + const retry = ( + + ) + + return ( + <> + { + if (!o) onClose() + }} + size="wide" + title={blocking ? 'Compare blocking across servers' : 'Compare settings across servers'} + actions={ + <> + {blocking && ( + + )} + + + } + > + {failure !== null && data === null ? ( + + + Could not read the settings. {failure} + {retry} + + + ) : data === null ? ( +
    + + + +
    + ) : ( + setTab(v as Tab)} className="flex-col"> + + Compare + Baseline + Local fields + History + + {problem !== null && {problem}} + {saved !== null && setSaved(null)}>{saved}} + + +
    + {servers.map((s) => ( + + {s.id} + + {s.paused && Blocking paused} + + ))} +
    + {servers.filter((s) => s.error !== undefined).map((s) => ( + + {s.error} + + ))} + {!hasBaseline && ( + + {liveServers.map((s) => ( + + ))} + + } + > + The baseline is the settings every server should share. Accept one from a server that is right; the fields that belong to a host stay local to it. Until then the servers are compared with the first one. + + )} + {rows.length === 0 ? ( + No differences in {blocking ? 'the blocking settings' : 'the settings'}. + ) : ( + r.name} /> + )} + {hasBaseline && ( +
    + {liveServers.map((s) => ( + + ))} +
    + )} + + {blocking && lists !== null && ( +
    +

    Blocked and Allowed lists

    +

    + {lists.baseline === null + ? 'No lists baseline yet; the servers are compared with the first one.' + : `Baseline: ${lists.baseline.blocked} blocked, ${lists.baseline.allowed} allowed${lists.baseline.importedFrom === undefined ? '' : `, from ${lists.baseline.importedFrom}`}.`} +

    + {lists.servers.map((s) => ( +
    + + {s.id} + {s.status === 'unknown' ? Did not answer : s.status === 'same' ? Same : Differs} + + {s.error !== undefined && {s.error}} + {s.ok && ( +
      + + +
    + )} +
    + ))} +
    + {lists.servers.filter((s) => s.ok).map((s) => ( + + ))} +
    +
    + )} +
    + + +

    + {data.baseline === null + ? 'No baseline saved.' + : `${data.baseline.importedFrom !== undefined ? `Taken from ${data.baseline.importedFrom}. ` : ''}Updated by ${data.baseline.updatedBy} at ${when(data.baseline.updatedAt)}. Secrets show as ${''}; leave them as they are to keep them.`} +

    + setBaselineText(e.target.value)} /> +
    + + {liveServers.map((s) => ( + + ))} +
    +
    + + +

    + Local fields are never sent by an apply: each server keeps its own. Built in: the host identity and addresses, certificates, paths and sockets of the host, and every webService* setting. Read-only and runtime values (version, uptime, cluster, the temporary blocking switch) are never compared. +

    +
      + {data.localFields.builtIn.map((f) => ( +
    • + {f} +
    • + ))} + {data.localFields.builtInPatterns.map((f) => ( +
    • + {f} +
    • + ))} +
    +
      + {extra.length === 0 &&
    • No extra local fields.
    • } + {extra.map((f) => ( +
    • + {f} + +
    • + ))} +
    +
    + + + +
    + {data.fields.some((f) => f.local) && ( +
    +

    Local values that differ

    +
      + {data.fields.filter((f) => f.local).map((f) => ( +
    • + {f.name}: {Object.entries(f.cells).map(([id, c]) => `${id} = ${show(c.value)}`).join(', ')} +
    • + ))} +
    +
    + )} +
    + + + + +
    + )} +
    + {request !== null && ( + { + setRequest(null) + if (changed) void load() + }} + /> + )} + + ) +} + +const KIND: Record = { + 'before-apply': 'Before an apply', + 'before-save': 'Old baseline', + 'before-lists': 'Before removing list entries', +} + +/* What the hub kept before it changed a server or replaced the baseline. View only: there is no rollback here. */ +function HistoryTab({ token, active, version }: { token: string | null; active: boolean; version: string }) { + const [entries, setEntries] = useState(null) + const [failure, setFailure] = useState(null) + const [picked, setPicked] = useState(null) + const [text, setText] = useState('') + + const load = useCallback(async () => { + const o = await getSettingsHistory(token) + if (o.kind === 'ok') { + setEntries(o.data.response.entries) + setFailure(null) + } else setFailure(sessionEnded(o.kind, o.kind === 'error' ? o.message : undefined)) + }, [token]) + + useEffect(() => { + if (active) void load() + }, [active, load, version]) + + useEffect(() => { + if (picked === null) return + void getSettingsHistoryEntry(token, picked).then((o) => { + if (o.kind !== 'ok') return setText('') + const r = o.data.response + setText(r.settings !== undefined ? JSON.stringify(r.settings, null, 2) : `${r.lists?.blocked ?? 0} blocked and ${r.lists?.allowed ?? 0} allowed domains.`) + }) + }, [token, picked]) + + if (failure !== null && entries === null) { + return ( + + + Could not read the history. {failure} + + + + ) + } + if (entries === null) return + if (entries.length === 0) { + return ( + + The hub keeps a server's settings before an apply, its lists before extras are removed, and the baseline before it is replaced. They appear here to read; they are put back by hand. + + ) + } + return ( +
    +
      + {entries.map((e) => ( +
    • + +
    • + ))} +
    + {picked !== null && {}} />} +
    + ) +} + +/* The button that opens the dialog, for the Settings and Blocking headers. Drawn by the caller only for an admin behind a hub. */ +export function CompareServersButton({ token, scope }: { token: string | null; scope: Scope }) { + const [open, setOpen] = useState(false) + return ( + <> + + {open && setOpen(false)} />} + + ) +} diff --git a/src/screens/zones/Zones.tsx b/src/screens/zones/Zones.tsx index b24ad01..b030e54 100644 --- a/src/screens/zones/Zones.tsx +++ b/src/screens/zones/Zones.tsx @@ -1,6 +1,7 @@ import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect' import { useContext, useState } from 'react' -import { HubMode } from '../../ui/ClusterNodeSelect' +import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect' +import { ZonesMatrix } from './ZonesMatrix' import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' import type { ResourceRecord, ZoneDetails } from '../../api/records' @@ -90,6 +91,8 @@ export function Zones({ const write = useHubWrite(node, mayModify) const canModify = write.allowed const canDelete = useHubWrite(node, mayDelete).allowed + /* Behind a hub, "All servers" shows the merged table instead of one server's list. */ + const matrixMode = hub && node === AGGREGATE const [open, setOpen] = useState(null) const [notice, setNotice] = useState(null) const [confirmation, setConfirmation] = useState(null) @@ -154,6 +157,7 @@ export function Zones({ { setNode(n) @@ -162,9 +166,20 @@ export function Zones({ disabled={open != null} label="Cluster Node" /> - + {!matrixMode && } - {open == null ? ( + {matrixMode ? ( + { + setNode(server) + setNotice(null) + setOpen(zone) + }} + /> + ) : open == null ? ( (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.') + +function TargetReview({ t, zone, ticked, onTick }: { t: CopyTargetPreview; zone: string; ticked: boolean; onTick: (on: boolean) => void }) { + return ( +
    +

    + {t.server} + {!t.ok ? Cannot copy : t.exists ? Zone exists : Will be created} +

    + {t.error !== undefined && ( + + {t.error} + + )} + {t.ok && t.exists && ( + <> +

    + {zone} is a {t.type} zone here with serial {t.serial ?? 'none'}. Copying replaces all of its records with the source's. +

    + + + )} +
    + ) +} + +function CopyReview({ preview, overwrite, setReady }: { preview: CopyPreview; overwrite: Set; setReady: (ready: boolean) => void }) { + const [, rerender] = useState(0) + /* Copy stays off until every server that already has the zone is ticked for Overwrite. */ + const answered = () => preview.targets.every((t) => !t.ok || !t.exists || overwrite.has(t.server)) + return ( +
    +

    + {preview.zone} on {preview.source.server}: {preview.source.type}, serial {preview.source.serial ?? 'none'}, about {preview.source.records} records. +

    + {preview.warnings.map((w) => ( + + {w} + + ))} + {preview.targets.map((t) => ( + { + if (on) overwrite.add(t.server) + else overwrite.delete(t.server) + setReady(answered()) + rerender((n) => n + 1) + }} + /> + ))} +
    + ) +} + +/* The zone of `source`, copied to the servers picked. The ones that already have it need Overwrite ticked. */ +export function copyRequest(token: string | null, source: string, zone: string, servers: PickerServer[], preselected: string[]): RunRequest { + const overwrite = new Set() + return { + title: `Copy ${zone} from ${source}`, + intro: 'The zone file is read from the source and imported on each server you choose. You see what is there first.', + confirmLabel: 'Preview', + servers, + preselected, + review: async (targets, setReady) => { + overwrite.clear() + const outcome = await previewZoneCopy(token, source, zone, targets) + if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) } + const preview = outcome.data.response + setReady(preview.targets.every((t) => !t.ok || !t.exists)) + return { kind: 'ok', confirmLabel: 'Copy', context: undefined, body: } + }, + run: (targets) => copyZone(token, source, zone, targets, [...overwrite].filter((id) => targets.includes(id))), + } +} + +interface Fields { + primaryAddress: string + targetAddress: string + tsigKeyName: string +} + +function SecondaryFields({ token, source, target, fields }: { token: string | null; source: string; target: string; fields: Fields }) { + const [primary, setPrimary] = useState(fields.primaryAddress) + const [address, setAddress] = useState(fields.targetAddress) + const [key, setKey] = useState(fields.tsigKeyName) + const [keys, setKeys] = useState(null) + + useEffect(() => { + // A key has to exist on both servers: the choices are the ones both have. + void Promise.all([getTsigKeyNames(token, source), getTsigKeyNames(token, target)]).then(([a, b]) => setKeys(a.filter((k) => b.includes(k)))) + }, [token, source, target]) + + return ( +
    +

    + {source} keeps the Primary zone. {target} gets a Secondary zone that fetches it by zone transfer (AXFR), so records are never copied by the hub. +

    + { + setPrimary(e.target.value) + fields.primaryAddress = e.target.value + }} + /> + { + setAddress(e.target.value) + fields.targetAddress = e.target.value + }} + /> +

    It goes into the zone transfer and notify lists of {source}. The hub cannot know it for you.

    + { + setKey(e.target.value) + fields.tsigKeyName = e.target.value + }} + > + + {(keys ?? []).map((k) => ( + + ))} + +
    + ) +} + +/* `source` keeps the Primary zone; `target` becomes its Secondary. `hostOfSource` fills the first field. */ +export function secondaryRequest(token: string | null, source: string, target: string, zone: string, hostOfSource: string, servers: PickerServer[]): RunRequest { + const fields: Fields = { primaryAddress: hostOfSource, targetAddress: '', tsigKeyName: '' } + const input = () => ({ source, target, zone, ...fields }) + return { + title: `Make ${zone} a Secondary on ${target}`, + intro: , + confirmLabel: 'Preview', + servers, + preselected: [target], + review: async () => { + const outcome = await previewSecondary(token, input()) + if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) } + const p = outcome.data.response + return { + kind: 'ok', + confirmLabel: 'Make secondary', + context: undefined, + body: ( +
    + {p.warnings.map((w) => ( + + {w} + + ))} +
      + {p.steps.map((s) => ( +
    1. + + {s.role === 'source' ? 'Source' : 'Target'} + {s.changes ? 'Will change' : 'Already set'} + + {s.summary} +
    2. + ))} +
    +

    + The source is changed first. If creating the zone on {target} then fails, the change on {source} stays, and running this again is safe. +

    +
    + ), + } + }, + // Both steps always run (they are safe to repeat), so a retry of one server runs the pair again. + run: () => makeSecondary(token, input()), + } +} diff --git a/src/screens/zones/ZonesMatrix.test.tsx b/src/screens/zones/ZonesMatrix.test.tsx new file mode 100644 index 0000000..ac7864e --- /dev/null +++ b/src/screens/zones/ZonesMatrix.test.tsx @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ZonesMatrix } from './ZonesMatrix' +import * as api from '../../api/hub-zones' +import * as settings from '../../api/settings' +import type { ZonesMatrixData } from '../../api/hub-zones' + +afterEach(() => vi.restoreAllMocks()) + +const present = (type: string, serial: number | null) => ({ state: 'present' as const, type, serial }) +const DATA: ZonesMatrixData = { + servers: [ + { id: 'mdns', ok: true, writable: true, host: 'mdns.example' }, + { id: 'ndns', ok: true, writable: true, host: 'ndns.example' }, + ], + zones: [ + { name: 'alike.test', status: 'same', cells: { mdns: present('Primary', 5), ndns: present('Primary', 5) } }, + { name: 'lan.test', status: 'serial-differs', cells: { mdns: present('Primary', 3), ndns: present('Primary', 4) } }, + { name: 'mine.test', status: 'only-on', onlyOn: 'mdns', cells: { mdns: present('Primary', 1), ndns: { state: 'absent' } } }, + { name: 'pair.test', status: 'same', note: 'secondary-behind', cells: { mdns: present('Primary', 10), ndns: present('Secondary', 8) } }, + ], +} + +function mockMatrix(data: ZonesMatrixData = DATA) { + return vi.spyOn(api, 'getZonesMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never) +} +const row = (name: string) => screen.getByText(name).closest('tr') as HTMLElement + +const PREVIEW = { + kind: 'ok', + data: { + response: { + zone: 'lan.test', + source: { server: 'mdns', type: 'Primary', serial: 3, records: 4, bytes: 90 }, + warnings: [], + targets: [{ server: 'ndns', ok: true, writable: true, exists: true, type: 'Primary', serial: 4, needsOverwrite: true }], + }, + }, +} as never + +async function startCopy() { + await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Copy to ndns' })) + await userEvent.click(await screen.findByRole('button', { name: 'Preview' })) +} + +describe('ZonesMatrix', () => { + it('draws a row per zone with the presence on each server and a status', async () => { + mockMatrix() + render( {}} />) + expect(await screen.findByText('alike.test')).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument() + expect(within(row('lan.test')).getByText('Serial differs')).toBeInTheDocument() + expect(within(row('mine.test')).getByText('Only on mdns')).toBeInTheDocument() + expect(within(row('mine.test')).getByText('Not present')).toBeInTheDocument() + expect(within(row('pair.test')).getByText('Secondary behind')).toBeInTheDocument() + expect(within(row('alike.test')).getByText('Same')).toBeInTheDocument() + }) + + it('filters with counts', async () => { + mockMatrix() + render( {}} />) + await screen.findByText('alike.test') + const filters = screen.getByRole('group', { name: 'Filter zones' }) + expect(within(filters).getByRole('button', { name: 'Differs · 1' })).toBeInTheDocument() + await userEvent.click(within(filters).getByRole('button', { name: 'Only on one server · 1' })) + expect(screen.getByText('mine.test')).toBeInTheDocument() + expect(screen.queryByText('alike.test')).not.toBeInTheDocument() + await userEvent.click(within(filters).getByRole('button', { name: 'Differs · 1' })) + expect(screen.getByText('lan.test')).toBeInTheDocument() + expect(screen.queryByText('mine.test')).not.toBeInTheDocument() + }) + + it('opens a zone on the server chosen in the row menu', async () => { + mockMatrix() + const onOpen = vi.fn() + render() + await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Open on ndns' })) + expect(onOpen).toHaveBeenCalledWith('ndns', 'lan.test') + }) + + it('gives a reader Open only', async () => { + mockMatrix() + render( {}} canWrite={false} />) + await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' })) + expect(screen.getByRole('menuitem', { name: 'Open on mdns' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: 'Copy to ndns' })).not.toBeInTheDocument() + }) + + it('copies a zone: preview, Overwrite ticked, then the copy names the servers that were ticked', async () => { + mockMatrix() + vi.spyOn(api, 'previewZoneCopy').mockResolvedValue(PREVIEW) + const copy = vi.spyOn(api, 'copyZone').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Records replaced.' }] } } } as never) + render( {}} />) + await startCopy() + await userEvent.click(await screen.findByRole('checkbox', { name: 'Overwrite lan.test on ndns' })) + await userEvent.click(screen.getByRole('button', { name: 'Copy' })) + expect(await screen.findByText('Records replaced.')).toBeInTheDocument() + expect(copy).toHaveBeenCalledWith('t', 'mdns', 'lan.test', ['ndns'], ['ndns']) + }) + + it('keeps Copy off until the server that has the zone is ticked for Overwrite', async () => { + mockMatrix() + vi.spyOn(api, 'previewZoneCopy').mockResolvedValue(PREVIEW) + const copy = vi.spyOn(api, 'copyZone') + render( {}} />) + await startCopy() + const button = await screen.findByRole('button', { name: 'Copy' }) + expect(button).toBeDisabled() + await userEvent.click(screen.getByRole('checkbox', { name: 'Overwrite lan.test on ndns' })) + expect(button).toBeEnabled() + await userEvent.click(screen.getByRole('checkbox', { name: 'Overwrite lan.test on ndns' })) + expect(button).toBeDisabled() + expect(copy).not.toHaveBeenCalled() + }) + + it('makes a secondary with the primary address prefilled from the server host', async () => { + mockMatrix() + vi.spyOn(settings, 'getTsigKeyNames').mockResolvedValue([]) + const preview = vi.spyOn(api, 'previewSecondary').mockResolvedValue({ + kind: 'ok', + data: { + response: { + zone: 'mine.test', + source: 'mdns', + target: 'ndns', + warnings: [], + steps: [ + { server: 'mdns', role: 'source', summary: 'step one', changes: true }, + { server: 'ndns', role: 'target', summary: 'step two', changes: true }, + ], + }, + }, + } as never) + const make = vi.spyOn(api, 'makeSecondary').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'mdns', ok: true }, { server: 'ndns', ok: true }] } } } as never) + render( {}} />) + await userEvent.click(await screen.findByRole('button', { name: 'Actions: mine.test' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Make secondary on ndns' })) + expect(await screen.findByLabelText('Address of mdns as ndns reaches it')).toHaveValue('mdns.example') + await userEvent.type(screen.getByLabelText('IP address of ndns as mdns sees it'), '203.0.113.9') + await userEvent.click(screen.getByRole('button', { name: 'Preview' })) + expect(await screen.findByText('step one')).toBeInTheDocument() + expect(preview).toHaveBeenCalledWith('t', { source: 'mdns', target: 'ndns', zone: 'mine.test', primaryAddress: 'mdns.example', targetAddress: '203.0.113.9', tsigKeyName: '' }) + await userEvent.click(screen.getByRole('button', { name: 'Make secondary' })) + await screen.findByRole('list', { name: 'Results' }) + expect(make).toHaveBeenCalledTimes(1) + }) + + it('offers Make secondary only where the zone is absent and the other side is a Primary', async () => { + mockMatrix() + render( {}} />) + await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' })) + expect(screen.getByRole('button', { name: 'Make secondary on ndns' })).toBeDisabled() + }) + + it('shows an empty state with an action, and an error with Retry', async () => { + mockMatrix({ servers: DATA.servers, zones: [] }) + const { unmount } = render( {}} />) + expect(await screen.findByText('No zones')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() + unmount() + vi.restoreAllMocks() + const spy = vi.spyOn(api, 'getZonesMatrix').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never) + render( {}} />) + expect(await screen.findByText(/boom/)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('alike.test')).toBeInTheDocument() + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/screens/zones/ZonesMatrix.tsx b/src/screens/zones/ZonesMatrix.tsx new file mode 100644 index 0000000..1e6c56e --- /dev/null +++ b/src/screens/zones/ZonesMatrix.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { getZonesMatrix, type MatrixZone, type ZoneCell, type ZonesMatrixData } from '../../api/hub-zones' +import { typeLabel } from '../../api/zones' +import { Badge } from '@/components/reui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Button } from '../../ui/Button' +import { DataTable, type Col } from '../../ui/DataTable' +import { Empty, Failure } from '../../ui/Empty' +import { Menu } from '../../ui/Menu' +import { Panel } from '../../ui/Panel' +import { Segmented } from '../../ui/Segmented' +import { Tooltip } from '../../ui/Tooltip' +import { head } from '../../ui/Table' +import { RunFlow, pickerServers, type RunRequest } from '../apps/RunFlow' +import { copyRequest, secondaryRequest } from './ZonesFlows' + +/* +Behind a hub, with "All servers" chosen: one row per zone name, one column per server, and whether +the servers agree. Opening a zone, copying it and making it a Secondary start from the row menu. +The numbers come from `hub/zones/matrix`, which sends names, types and serials, never records. +*/ + +type Filter = 'all' | 'differs' | 'only' + +const DIFFERS = new Set(['serial-differs', 'type-differs']) +const FILTERS: Record boolean> = { + all: () => true, + differs: (z) => DIFFERS.has(z.status), + only: (z) => z.status === 'only-on' || z.status === 'missing-on', +} + +/* The zone types a file copy handles; the matrix cell says if the zone is signed. */ +const COPYABLE = new Set(['Primary', 'Forwarder']) + +function Status({ zone }: { zone: MatrixZone }) { + switch (zone.status) { + case 'same': + return ( + + Same + {zone.note === 'primary-secondary' && Primary and Secondary} + {zone.note === 'secondary-behind' && Secondary behind} + + ) + case 'serial-differs': + return Serial differs + case 'type-differs': + return Type differs + case 'only-on': + return Only on {zone.onlyOn} + case 'missing-on': + return Missing on {(zone.missingOn ?? []).join(', ')} + default: + return ( + + + Unknown + + + ) + } +} + +function Presence({ cell }: { cell: ZoneCell | undefined }) { + if (cell === undefined || cell.state === 'unknown') return Unknown + if (cell.state === 'absent') return Not present + return ( + + {typeLabel(cell.type ?? '')} + {typeof cell.serial === 'number' && serial {cell.serial}} + {cell.disabled === true && Disabled} + {cell.dnssec !== undefined && cell.dnssec !== 'Unsigned' && DNSSEC} + + ) +} + +export function ZonesMatrix({ + token, + canWrite, + refresh = 0, + onOpen, +}: { + token: string | null + canWrite: boolean + refresh?: number + /** Switches the selector to a server and opens the zone there. */ + onOpen: (server: string, zone: string) => void +}) { + const [data, setData] = useState(null) + const [failure, setFailure] = useState(null) + const [filter, setFilter] = useState('all') + const [request, setRequest] = useState(null) + + const load = useCallback(async () => { + const outcome = await getZonesMatrix(token) + if (outcome.kind === 'ok') { + setData(outcome.data.response) + setFailure(null) + return + } + setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.') + }, [token]) + + useEffect(() => { + void load() + }, [load, refresh]) + + const counts = useMemo( + () => (Object.keys(FILTERS) as Filter[]).reduce((acc, f) => ({ ...acc, [f]: (data?.zones ?? []).filter(FILTERS[f]).length }), {} as Record), + [data], + ) + + if (failure !== null && data === null) { + return ( + + + Could not load the zones of the servers. {failure} + + + + ) + } + if (data === null) { + return ( +
    + + +
    + ) + } + if (data.zones.length === 0) { + return ( + void load()}> + Refresh + + } + > + None of the servers that answered has a zone. Choose a server to add one. + + ) + } + + const servers = data.servers + const rows = data.zones.filter(FILTERS[filter]) + + /* The menu of one row: open on a server, copy to another, make a Secondary there. */ + function actions(zone: MatrixZone, d: ZonesMatrixData) { + const holders = d.servers.filter((s) => zone.cells[s.id]?.state === 'present') + const sources = d.servers.filter((s) => { + const c = zone.cells[s.id] + return c?.state === 'present' && COPYABLE.has(c.type ?? '') && (c.dnssec === undefined || c.dnssec === 'Unsigned') + }) + const primaries = d.servers.filter((s) => zone.cells[s.id]?.state === 'present' && zone.cells[s.id].type === 'Primary') + const pickable = (source: string) => pickerServers(d.servers.filter((s) => s.id !== source), Object.fromEntries(d.servers.filter((s) => s.id !== source && !s.ok).map((s) => [s.id, 'Did not answer']))) + + return ( + + {(close) => ( + <> + {holders.map((s) => ( + + ))} + {/* A server that is the only place to copy from is not a target: it would be copied onto itself. */} + {canWrite && + d.servers.filter((dst) => sources.some((s) => s.id !== dst.id)).map((dst) => { + const source = sources.find((s) => s.id !== dst.id) + const ok = dst.ok && zone.cells[dst.id]?.state !== 'unknown' + return ( + + ) + })} + {canWrite && + d.servers.filter((dst) => primaries.some((s) => s.id !== dst.id)).map((dst) => { + const source = primaries.find((s) => s.id !== dst.id) + const free = dst.ok && zone.cells[dst.id]?.state === 'absent' + return ( + + ) + })} + + )} + + ) + } + + const columns: Col[] = [ + { id: 'name', ...head({ children: 'Zone' }), cell: (z) => {z.name === '' ? '' : z.name} }, + ...servers.map((s): Col => ({ id: `server-${s.id}`, ...head({ children: s.id }), cell: (z) => })), + { id: 'status', ...head({ children: 'Status' }), cell: (z) => }, + { id: 'actions', ...head({ children: Actions }), cell: (z) => actions(z, data) }, + ] + + return ( +
    + + + {rows.length === 0 ? ( + setFilter('all')}> + Show all + + } + > + No zone fits this filter. + + ) : ( + z.name} /> + )} + + {request !== null && ( + { + setRequest(null) + if (changed) void load() + }} + /> + )} +
    + ) +}