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