Hub stage 7: compare and align zones, settings and blocking

Zones gets "All servers" in hub mode: one table of every zone on every
server with its type and serial, marking zones that are on one server
only or whose serials or types differ. A zone can be copied to another
server (export, create, import; Overwrite must be ticked for a zone
that is already there) or made a Secondary there (the source's zone
transfer and notify lists gain the target, then the target zone is
created).

Settings and Blocking can be compared across servers against a baseline
kept in the hub. Host fields stay local, secrets are compared inside
the hub only and never shown or logged, and an apply sends only the
changed shared fields after saving each server's settings to history.
Blocked and Allowed lists are compared as sets; extras are removed only
when asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 01:20:05 +07:00
1 parent 34de910e04
commit 392b644faa
25 files changed
+4865 -22

No files matched your search

+61 -6
View File
@@ -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: '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) }, { name: 'staging.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 4, disabled: true, lastModified: ago(60 * 24 * 20) },
].map((z) => ({ notifyFailed: false, notifyFailedFor: [], ...z })) ].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) { function recordsFor(zone) {
const z = zone.name 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 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']) 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 listNode = (set, domain) => {
const all = [...set].sort() const all = [...set].sort()
return { domain, zones: domain === '' ? all : [], records: domain !== '' && set.has(domain) ? [{ name: domain, type: 'SOA', ttl: 60, rData: {}, dnssecStatus: 'Disabled' }] : [] } 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, 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 ─────────────────────────────────────────────────────────────────── // ── DHCP ───────────────────────────────────────────────────────────────────
let scopes = [ 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' }, { 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 ───────────────────────────────────────────────────────────────── // ── Router ─────────────────────────────────────────────────────────────────
const addedRecords = {} const addedRecords = {}
// Answers that are files, not JSON (the downloads and the Rules table's reads). // 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', 'allowed/export': [...allowed].join('\n') + '\n',
'blocked/export': [...blocked].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', 'logs/download': '[2026-10-26 10:00:00 UTC] [127.0.0.1:5380] [] DNS Server was started.\n',
})[path] })[path]
const ok = (response = {}) => ({ status: 'ok', response }) 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/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/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/create': {
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'] }) 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/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 'zones/catalogs/list': return ok({ catalogZoneNames: ['catalog.example.net'] })
case 'cache/list': return ok({ domain, zones: cacheTree[domain] ?? [], records: cacheRecords(domain) }) 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. // 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) { for (const [k, v] of q) {
if (!(k in settings) || k === 'node' || k === 'token') continue 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] const old = settings[k]
if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null 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 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 }) return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill })
} }
case 'user/createSingleUseToken': return ok({ token: 'mock-once' }) 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/leases/list': return ok({ leases })
case 'dhcp/scopes/list': return ok({ scopes }) case 'dhcp/scopes/list': return ok({ scopes })
case 'dhcp/scopes/get': return ok(scopeDetail(q.get('name') ?? 'Main')) case 'dhcp/scopes/get': return ok(scopeDetail(q.get('name') ?? 'Main'))
@@ -383,7 +437,8 @@ http.createServer((req, res) => {
req.on('end', () => { req.on('end', () => {
const q = new URLSearchParams(url.search) 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) 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) { if (file !== undefined) {
res.setHeader('content-type', 'text/plain') res.setHeader('content-type', 'text/plain')
res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`) res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`)
+2
View File
@@ -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 полностью перезаписывает прежний на каждом выбранном сервере. Действия на несколько серверов сразу (этап 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` в этих разделах только читают. Какие роли хаба что могут: `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.» Хаб отклоняет всё, чего нет в таблице путей: `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.»
+1 -1
View File
@@ -4,7 +4,7 @@
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание; - **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов. - **упор на 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`).
--- ---
+35
View File
@@ -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/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. | | `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 `<secret>`, 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 `<secret>` 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, rollback, drift, export
**History.** Everything the hub replaces is kept in `${HUB_DATA}/apps/<app>/history/` (50 files per app): **History.** Everything the hub replaces is kept in `${HUB_DATA}/apps/<app>/history/` (50 files per app):
+6
View File
@@ -16,6 +16,12 @@ import { readConfigText } from './apps-config.ts'
import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts' import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts'
import type { Upstream } from './upstream.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 type DriftKind = 'differs' | 'unreadable' | 'not-installed'
export interface DriftEntry { export interface DriftEntry {
+152
View File
@@ -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<string, (call: Call) => Reply>
/** Answers one path; later calls replace earlier ones. */
on: (apiPath: string, handler: (call: Call) => Reply) => void
}
export function fake(): Promise<Fake> {
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<string, string>
dataDir: string
/** Calls the hub with a role's session. */
call: (role: string, apiPath: string, params?: Record<string, string>, method?: 'GET' | 'POST') => Promise<{ code: number; body: Record<string, any> }>
audit: () => Promise<Record<string, string>[]>
close: () => void
}
export async function startHub(a: Fake, b: Fake, extra: Record<string, string> = {}): Promise<TestHub> {
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<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
const tokens: Record<string, string> = {}
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<string, any> }
}
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<string, string>[] } }).response.entries
},
close: () => {
hub.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
},
}
}
+21
View File
@@ -159,6 +159,25 @@ export const ROUTES: Record<string, Route> = {
'hub/apps/upload': writeAll('apps.write'), '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. // The same change on several servers, outside Apps (servers-run.ts). One endpoint with a fixed list of actions.
'hub/servers/run': writeAll('operate'), '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 { export function routeFor(apiPath: string): Route | undefined {
@@ -199,6 +218,8 @@ const LARGE_BODIES: Record<string, number> = {
'zones/records/update': 2 * MB, 'zones/records/update': 2 * MB,
'zones/options/set': 2 * MB, 'zones/options/set': 2 * MB,
'dhcp/scopes/set': 2 * MB, 'dhcp/scopes/set': 2 * MB,
'hub/settings/baseline/save': 2 * MB,
'hub/settings/overrides/save': 2 * MB,
'allowed/import': 5 * MB, 'allowed/import': 5 * MB,
'blocked/import': 5 * MB, 'blocked/import': 5 * MB,
} }
+135
View File
@@ -32,6 +32,9 @@ import { AppsWriter, actionForPath, type FileAction, type TargetResult } from '.
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts' import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
import { AppsConfig, UserError } from './apps-config.ts' import { AppsConfig, UserError } from './apps-config.ts'
import { Drift } from './drift.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 { import {
mergeQueryLogs, mergeQueryLogs,
mergeSettled, mergeSettled,
@@ -147,6 +150,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
const writer = new AppsWriter(config, upstream) const writer = new AppsWriter(config, upstream)
const runner = new ServersRunner(config, upstream) const runner = new ServersRunner(config, upstream)
const appsConfig = new AppsConfig(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])) const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */ /* 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<void> {
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<void> {
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<void> {
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<void> {
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<string, string>
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. */ /* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> { async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
if (route.kind === 'write-all') { 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) await configWrite(res, apiPath, params, who, ip)
return 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') { if (apiPath === 'hub/servers/run') {
const planned = planAction(params.action, params) 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}` } 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) await configRead(res, apiPath, params, who, ip)
return 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') { if (apiPath === 'hub/drift/get') {
send(res, 200, ok(drift.get())) send(res, 200, ok(drift.get()))
return return
+543
View File
@@ -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<unknown> {
this.#upstream.invalidate(server.id)
return this.#upstream.call(server, 'settings/get', {}, server.adminToken ?? server.token)
}
async #readAll(): Promise<Record<string, { settings: unknown } | { error: string }>> {
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<string, unknown>; importedFrom?: string; updatedAt: string; updatedBy: string } | null {
const b = this.getBaseline()
return b === null ? null : { ...b, settings: maskSettings(b.settings) }
}
async compare(): Promise<unknown> {
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<string, unknown>, 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<string, unknown> = { ...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<string, unknown>
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<string>()
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<string, unknown>, 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<SettingsTargetPreview> => {
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<string, string> = {}
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<string, unknown>)[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<string, string>, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
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<string, string> = {}
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<string, unknown>; 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<string[]> {
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<unknown> {
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<string, 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 done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
const details: Record<string, string> = {}
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
return { results: done.map((d) => d.result), details }
}
}
+630
View File
@@ -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<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>
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<Fake, Box>()
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<string, unknown>, y: Record<string, unknown>) => ({ 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<string, string> = {}) => 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)
})
})
+385
View File
@@ -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 = '<secret>'
/* 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<string>(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<string>(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<string, unknown>
const isObject = (v: unknown): v is Record<string, unknown> => 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<string, Cell>
}
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<string, { settings: unknown } | { error: string }>
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<string, { settings: Settings; paused: boolean }> = {}
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<string>()
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<string, Cell> = {}
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<string, string> } | { 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<string>()
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)) }
}
+79 -8
View File
@@ -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') 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 { export interface HistoryEntry {
/** The file name; the only handle `readHistory` accepts. */ /** 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') const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history')
/* Newest first. Never the text. */ export const listHistory = (dataDir: string, app: string): HistoryEntry[] => listHistoryIn(historyDir(dataDir, app))
export function listHistory(dataDir: string, app: string): HistoryEntry[] {
const dir = historyDir(dataDir, app) /* Newest first. Never the text. `dir` is a history folder. */
export function listHistoryIn(dir: string): HistoryEntry[] {
if (!existsSync(dir)) return [] if (!existsSync(dir)) return []
const entries: HistoryEntry[] = [] const entries: HistoryEntry[] = []
for (const name of readdirSync(dir)) { 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. */ /* 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 { export const readHistory = (dataDir: string, app: string, id: string): string | null => readHistoryIn(historyDir(dataDir, app), id)
const known = listHistory(dataDir, app).find((e) => e.id === id)
return known === undefined ? null : readFileSync(path.join(historyDir(dataDir, app), known.id), 'utf8') 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. */ /* The apps that have a baseline, by the name stored in the file. */
@@ -164,7 +167,10 @@ export function writeHistory(
kind: HistoryKind, kind: HistoryKind,
now: number, now: number,
): string { ): 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 stamp = new Date(now).toISOString().replace(/:/g, '-')
const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`) const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`)
writeAtomic(file, text) 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 }) for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true })
return file 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<string, unknown>
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<SettingsBaseline> | 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<SettingsOverrides> | 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<ListsBaseline> | 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')
+530
View File
@@ -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<string, string>
options: Record<string, Record<string, unknown>>
tsig: string[]
}
const state = new Map<Fake, State>()
function install(f: Fake, init: Partial<State>): 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<string, unknown> = { ...(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<string, any> }[]
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<string, any> }[]
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<string, string>) => 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<string, string>) => 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<string, string>) => hub.call('admin', 'hub/zones/secondary', params)
const preview = (params: Record<string, string>) => 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<string, string>, 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/)
})
})
+656
View File
@@ -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<string, ZoneCell>
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<string, string> = { Secondary: 'Primary', SecondaryForwarder: 'Forwarder', SecondaryCatalog: 'Catalog' }
function pairedTypes(types: Set<string>): 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<string, ZoneCell>): 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<string, ZoneCell> = {}
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<string> {
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<SourceZone> {
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<string> {
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<CopyTargetPreview> {
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<CopyPreview> {
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<TargetResult> => {
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<string, string> = { 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<string[]> {
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<SecondaryPreview> {
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<SecondaryPlan> {
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<string, string> = {
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<SourceZone> {
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<string, string> | null
targetParams: Record<string, string>
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<string, string> = { Deny: 'UseSpecifiedNetworkACL', AllowOnlyZoneNameServers: 'AllowZoneNameServersAndUseSpecifiedNetworkACL' }
const NOTIFY_WITH_LIST: Record<string, string> = { 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<string, string> | 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),
}
}
+124
View File
@@ -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 `<secret>` and a row says only that it differs.
*/
export const SECRET_PLACEHOLDER = '<secret>'
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<string, SettingsCell>
}
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<string, unknown>
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<ApiOutcome<{ response: SettingsCompare }>> => apiRequest('hub/settings/compare', { token })
export const importSettingsBaseline = (token: string | null, server: string, group?: 'blocking'): Promise<ApiOutcome<{ response: { baseline: SettingsBaselineView } }>> =>
apiRequest('hub/settings/baseline/import', { token, method: 'POST', body: { server, ...(group === undefined ? {} : { group }) } })
export const saveSettingsBaseline = (token: string | null, text: string): Promise<ApiOutcome<{ response: { baseline: SettingsBaselineView } }>> =>
apiRequest('hub/settings/baseline/save', { token, method: 'POST', body: { text } })
export const saveSettingsOverrides = (token: string | null, paths: string[]): Promise<ApiOutcome<{ response: { overrides: { paths: string[] } } }>> =>
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<ApiOutcome<{ response: { targets: SettingsPreview[] } }>> =>
apiRequest('hub/settings/preview', { token, method: 'POST', body: { targets: targets.join(','), ...(group === undefined ? {} : { group }) } })
export const applySettings = (token: string | null, targets: string[], expect: Record<string, string>, group?: 'blocking'): Promise<RunOutcome> =>
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<ApiOutcome<{ response: { entries: HistoryEntry[] } }>> => apiRequest('hub/settings/history/list', { token })
export const getSettingsHistoryEntry = (
token: string | null,
id: string,
): Promise<ApiOutcome<{ response: { entry: HistoryEntry; settings?: Record<string, unknown>; 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<ApiOutcome<{ response: ListsCompare }>> => apiRequest('hub/blocking/lists/compare', { token })
export const importListsBaseline = (token: string | null, server: string): Promise<ApiOutcome<{ response: { imported: boolean } }>> =>
apiRequest('hub/blocking/lists/baseline/import', { token, method: 'POST', body: { server } })
export const previewLists = (token: string | null, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { targets: ListsServer[] } }>> =>
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<RunOutcome> =>
apiRequest('hub/blocking/lists/apply', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } })
+80
View File
@@ -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<string, ZoneCell>
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<ApiOutcome<{ response: ZonesMatrixData }>> => 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<ApiOutcome<{ response: CopyPreview }>> =>
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<RunOutcome> =>
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<ApiOutcome<{ response: SecondaryPreview }>> =>
apiRequest('hub/zones/secondary/preview', { token, method: 'POST', body: { ...input } })
export const makeSecondary = (token: string | null, input: SecondaryInput): Promise<RunOutcome> =>
apiRequest('hub/zones/secondary', { token, method: 'POST', body: { ...input } })
+7 -4
View File
@@ -30,7 +30,7 @@ export interface RunRequest {
A review step between the picker and the change (the config sync uses it): it reads what 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`. 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<RunOutcome> run: (targets: string[], context?: unknown) => Promise<RunOutcome>
} }
@@ -57,12 +57,15 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
const [lastTargets, setLastTargets] = useState<string[]>([]) const [lastTargets, setLastTargets] = useState<string[]>([])
const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null) const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null)
const [reviewFailure, setReviewFailure] = useState<string | null>(null) const [reviewFailure, setReviewFailure] = useState<string | null>(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[]) { async function next(targets: string[]) {
if (request.review === undefined) return go(targets) if (request.review === undefined) return go(targets)
setStage('reviewing') setStage('reviewing')
setReviewFailure(null) setReviewFailure(null)
const outcome = await request.review(targets) setReady(true)
const outcome = await request.review(targets, setReady)
if (outcome.kind === 'ok') setReviewed(outcome) if (outcome.kind === 'ok') setReviewed(outcome)
else { else {
setReviewed(null) setReviewed(null)
@@ -103,7 +106,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
</Button> </Button>
} }
> >
{request.intro != null && <p className="m-0 text-sm text-muted-foreground">{request.intro}</p>} {request.intro != null && <div className="m-0 text-sm text-muted-foreground">{request.intro}</div>}
<div className="flex flex-col gap-3" role="group" aria-label="Servers"> <div className="flex flex-col gap-3" role="group" aria-label="Servers">
{request.servers.map((s) => ( {request.servers.map((s) => (
<Check <Check
@@ -140,7 +143,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
<> <>
<Button onClick={() => setStage('pick')}>Back</Button> <Button onClick={() => setStage('pick')}>Back</Button>
{reviewed !== null && ( {reviewed !== null && (
<Button variant="primary" onClick={() => void go(lastTargets)}> <Button variant="primary" disabled={!ready} onClick={() => void go(lastTargets)}>
{reviewed.confirmLabel ?? request.confirmLabel} {reviewed.confirmLabel ?? request.confirmLabel}
</Button> </Button>
)} )}
+6
View File
@@ -1,5 +1,6 @@
import { useContext, useEffect, useState } from 'react' import { useContext, useEffect, useState } from 'react'
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { CompareServersButton } from '../settings/SettingsCompare'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint' import { HubWriteHint } from '../../ui/HubWriteHint'
import { getSettings } from '../../api/settings' import { getSettings } from '../../api/settings'
@@ -152,6 +153,11 @@ export function Blocking({
const tabs = ( const tabs = (
<> <>
{hub && permissions?.Settings?.canModify === true && (
<div className="flex flex-wrap items-center gap-2">
<CompareServersButton token={token} scope="blocking" />
</div>
)}
{hub && active !== 'Overview' && ( {hub && active !== 'Overview' && (
<> <>
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} /> <ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
+2
View File
@@ -4,6 +4,7 @@ import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint' import { HubWriteHint } from '../../ui/HubWriteHint'
import { PermissionButton } from '../../ui/PermissionButton' import { PermissionButton } from '../../ui/PermissionButton'
import { SectionHeader } from '../../ui/SectionHeader' import { SectionHeader } from '../../ui/SectionHeader'
import { CompareServersButton } from './SettingsCompare'
import { SubTabs } from '../../ui/SubTabs' import { SubTabs } from '../../ui/SubTabs'
import { IndexedForm } from '../../ui/IndexedForm' import { IndexedForm } from '../../ui/IndexedForm'
import { useSectionInView } from '../../ui/useSectionInView' import { useSectionInView } from '../../ui/useSectionInView'
@@ -499,6 +500,7 @@ export function Settings({
} }
actions={ actions={
<> <>
{hub && canModify && <CompareServersButton token={token} scope="settings" />}
<ClusterNodeSelect <ClusterNodeSelect
nodes={nodes} nodes={nodes}
initialised={clusterInitialised} initialised={clusterInitialised}
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SettingsCompare, CompareServersButton } from './SettingsCompare'
import * as api from '../../api/hub-settings'
import type { ListsCompare, SettingsCompare as Compare } from '../../api/hub-settings'
afterEach(() => 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(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="blocking" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="blocking" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="blocking" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
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(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
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(<CompareServersButton token="t" scope="settings" />)
await userEvent.click(screen.getByRole('button', { name: 'Compare servers' }))
expect(await screen.findByText('defaultRecordTtl')).toBeInTheDocument()
})
})
+580
View File
@@ -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 <Tag tone="ok">Same</Tag>
if (status === 'differs') return <Tag tone="warn">{differing > 0 ? `Differs · ${differing}` : 'Differs'}</Tag>
if (status === 'local-only') return <Tag tone="info">Only local fields differ</Tag>
return <Tag tone="dan">Did not answer</Tag>
}
/* ---- 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: (
<div className="flex flex-col gap-6">
{previews.map((p) => (
<section key={p.server} aria-label={`Preview ${p.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{p.server}
{!p.ok ? <Tag tone="dan">Cannot apply</Tag> : p.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</h3>
{p.error !== undefined && <Alert type="danger" title="Error">{p.error}</Alert>}
{p.warnings.map((w) => (
<Alert key={w} type="warning" title="Warning">
{w}
</Alert>
))}
{p.secretFields.length > 0 && (
<Alert type="info" title="Secret changed">
{p.secretFields.join(', ')}: the secret values are sent but never shown.
</Alert>
)}
{p.ok && !p.unchanged && <DiffView diff={p.changes} from="Now" to="After apply" />}
</section>
))}
</div>
),
}
},
run: (targets, context) => applySettings(token, targets, (context ?? {}) as Record<string, string>, 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 (
<li className="text-sm">
<span className="font-medium">{label}</span> <span className="text-muted-foreground">{c.count} domains, missing {c.missing}, extra {c.extra}</span>
{c.missing > 0 && <div className="text-xs text-muted-foreground">Missing: {sample(c.missingSample, c.missing)}</div>}
{c.extra > 0 && <div className="text-xs text-muted-foreground">Extra: {sample(c.extraSample, c.extra)}</div>}
</li>
)
}
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 (
<div className="flex flex-col gap-4">
{targets.map((t) => (
<section key={t.id} aria-label={`Preview ${t.id}`} className="flex flex-col gap-2">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{t.id}
{!t.ok ? <Tag tone="dan">Cannot apply</Tag> : t.status === 'same' ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</h3>
{t.error !== undefined && <Alert type="danger" title="Error">{t.error}</Alert>}
{t.ok && (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
<ListLine label="Blocked" c={t.blocked} />
<ListLine label="Allowed" c={t.allowed} />
</ul>
)}
</section>
))}
<Check
label="Also remove extras"
help={`Deletes the ${extras} domain(s) a server has that the baseline does not. The server's lists are saved to the hub's history first.`}
checked={remove}
onChange={(on) => {
opts.remove = on
setRemove(on)
}}
/>
</div>
)
}
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: <ListsReview targets={outcome.data.response.targets} opts={opts} /> }
},
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<Compare | null>(null)
const [lists, setLists] = useState<ListsCompare | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [tab, setTab] = useState<Tab>('compare')
const [request, setRequest] = useState<RunRequest | null>(null)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [saved, setSaved] = useState<string | null>(null)
const [baselineText, setBaselineText] = useState('')
const [extra, setExtra] = useState<string[]>([])
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<string, string> = {}) => 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<SettingsRow>[] = [
{
id: 'name',
...head({ children: 'Field' }),
cell: (r) => (
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
{r.name}
{r.local && <Tag tone="info">Local</Tag>}
{r.secret && <Tag tone="neutral">Secret</Tag>}
</span>
),
},
...liveServers.map(
(s): Col<SettingsRow> => ({
id: `s-${s.id}`,
...head({ children: s.id }),
cell: (r) => {
const c = r.cells[s.id]
if (c === undefined) return <span className="text-xs text-muted-foreground">Not reported</span>
if (c.secretChanged === true) return <Tag tone="warn">Secret changed</Tag>
return <span className={`font-mono text-xs ${c.differs ? 'font-semibold' : 'text-muted-foreground'}`}>{show(c.value)}</span>
},
}),
),
...(hasBaseline
? [
{
id: 'expected',
...head({ children: 'Baseline' }),
cell: (r: SettingsRow) => (r.local ? <span className="text-xs text-muted-foreground">Local</span> : <span className="font-mono text-xs text-muted-foreground">{show(r.expected)}</span>),
} satisfies Col<SettingsRow>,
]
: []),
]
const retry = (
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
)
return (
<>
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
size="wide"
title={blocking ? 'Compare blocking across servers' : 'Compare settings across servers'}
actions={
<>
{blocking && (
<Button disabled={busy || lists?.baseline == null} onClick={() => setRequest(listsApplyRequest(token, pickable(), lists?.servers.filter((s) => s.status === 'differs').map((s) => s.id) ?? []))}>
Apply lists…
</Button>
)}
<Button variant="primary" disabled={!hasBaseline || busy} onClick={() => setRequest(settingsApplyRequest(token, pickable(), differsIds, scope))}>
{blocking ? 'Apply blocking settings…' : 'Apply to servers…'}
</Button>
</>
}
>
{failure !== null && data === null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the settings. {failure}</span>
{retry}
</span>
</Failure>
) : data === null ? (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the settings">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="flex-col">
<TabsList variant="line">
<TabsTrigger value="compare">Compare</TabsTrigger>
<TabsTrigger value="baseline">Baseline</TabsTrigger>
<TabsTrigger value="local">Local fields</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{saved !== null && <Alert type="success" title="Saved." onDismiss={() => setSaved(null)}>{saved}</Alert>}
<TabsContent value="compare" className="flex flex-col gap-4 pt-3">
<div className="flex flex-wrap items-center gap-4">
{servers.map((s) => (
<span key={s.id} className="flex items-center gap-2 text-sm">
<span className="font-medium">{s.id}</span>
<StatusTag status={s.status} differing={s.differing} />
{s.paused && <Tag tone="warn">Blocking paused</Tag>}
</span>
))}
</div>
{servers.filter((s) => s.error !== undefined).map((s) => (
<Alert key={s.id} type="warning" title={s.id}>
{s.error}
</Alert>
))}
{!hasBaseline && (
<Empty
title="No settings baseline yet"
actions={
<span className="flex flex-wrap justify-center gap-2">
{liveServers.map((s) => (
<Button key={s.id} variant="primary" disabled={busy} onClick={() => void run(() => importSettingsBaseline(token, s.id, blocking ? 'blocking' : undefined), `Baseline taken from ${s.id}.`)}>
Accept {s.id} as baseline
</Button>
))}
</span>
}
>
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.
</Empty>
)}
{rows.length === 0 ? (
<Empty compact>No differences in {blocking ? 'the blocking settings' : 'the settings'}.</Empty>
) : (
<DataTable data={rows} columns={columns} getRowId={(r) => r.name} />
)}
{hasBaseline && (
<div className="flex flex-wrap items-center gap-2">
{liveServers.map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importSettingsBaseline(token, s.id, blocking ? 'blocking' : undefined), `Baseline taken from ${s.id}.`)}>
Accept {s.id} as baseline
</Button>
))}
</div>
)}
{blocking && lists !== null && (
<section aria-label="Blocked and Allowed lists" className="flex flex-col gap-3">
<h3 className="m-0 text-sm font-semibold">Blocked and Allowed lists</h3>
<p className="m-0 text-xs text-muted-foreground">
{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}`}.`}
</p>
{lists.servers.map((s) => (
<div key={s.id} className="flex flex-col gap-1">
<span className="flex items-center gap-2 text-sm font-medium">
{s.id}
{s.status === 'unknown' ? <Tag tone="dan">Did not answer</Tag> : s.status === 'same' ? <Tag tone="ok">Same</Tag> : <Tag tone="warn">Differs</Tag>}
</span>
{s.error !== undefined && <span className="text-xs text-muted-foreground">{s.error}</span>}
{s.ok && (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
<ListLine label="Blocked" c={s.blocked} />
<ListLine label="Allowed" c={s.allowed} />
</ul>
)}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
{lists.servers.filter((s) => s.ok).map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importListsBaseline(token, s.id), `Lists taken from ${s.id}.`)}>
Accept {s.id}&apos;s lists as baseline
</Button>
))}
</div>
</section>
)}
</TabsContent>
<TabsContent value="baseline" className="flex flex-col gap-3 pt-3">
<p className="m-0 text-xs text-muted-foreground">
{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 ${'<secret>'}; leave them as they are to keep them.`}
</p>
<LabeledTextarea label="Baseline settings (JSON)" mono rows={16} spellCheck={false} value={baselineText} onChange={(e) => setBaselineText(e.target.value)} />
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || baselineText.trim() === ''} onClick={() => void run(() => saveSettingsBaseline(token, baselineText), 'Baseline saved.')}>
Save baseline
</Button>
{liveServers.map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importSettingsBaseline(token, s.id), `Baseline taken from ${s.id}.`)}>
Import all from {s.id}
</Button>
))}
</div>
</TabsContent>
<TabsContent value="local" className="flex flex-col gap-4 pt-3">
<p className="m-0 text-xs text-muted-foreground">
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 <code>webService*</code> setting. Read-only and runtime values (version, uptime, cluster, the temporary blocking switch) are never compared.
</p>
<ul className="m-0 flex list-none flex-wrap gap-2 p-0" aria-label="Built-in local fields">
{data.localFields.builtIn.map((f) => (
<li key={f}>
<Tag tone="info">{f}</Tag>
</li>
))}
{data.localFields.builtInPatterns.map((f) => (
<li key={f}>
<Tag tone="info">{f}</Tag>
</li>
))}
</ul>
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Extra local fields">
{extra.length === 0 && <li className="text-sm text-muted-foreground">No extra local fields.</li>}
{extra.map((f) => (
<li key={f} className="flex items-center gap-2 font-mono text-xs">
{f}
<Button size="sm" onClick={() => setExtra((old) => old.filter((x) => x !== f))}>
Remove {f}
</Button>
</li>
))}
</ul>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs">
<span>New local field</span>
<Input mono aria-label="New local field" placeholder="forwarderTimeout" value={newField} onChange={(e) => setNewField(e.target.value)} />
</label>
<Button
disabled={newField.trim() === '' || extra.includes(newField.trim())}
onClick={() => {
setExtra((old) => [...old, newField.trim()])
setNewField('')
}}
>
Add field
</Button>
<Button variant="primary" disabled={busy} onClick={() => void run(() => saveSettingsOverrides(token, extra), 'Local fields saved.')}>
Save local fields
</Button>
</div>
{data.fields.some((f) => f.local) && (
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Local values that differ</h3>
<ul className="m-0 flex list-none flex-col gap-1 p-0" aria-label="Local values">
{data.fields.filter((f) => f.local).map((f) => (
<li key={f.name} className="font-mono text-xs">
{f.name}: {Object.entries(f.cells).map(([id, c]) => `${id} = ${show(c.value)}`).join(', ')}
</li>
))}
</ul>
</div>
)}
</TabsContent>
<TabsContent value="history" className="pt-3">
<HistoryTab token={token} active={tab === 'history'} version={String(loads)} />
</TabsContent>
</Tabs>
)}
</Dialog>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void load()
}}
/>
)}
</>
)
}
const KIND: Record<string, string> = {
'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<HistoryEntry[] | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [picked, setPicked] = useState<string | null>(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 (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the history. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
)
}
if (entries === null) return <Skeleton className="h-24 w-full" />
if (entries.length === 0) {
return (
<Empty title="No history yet">
The hub keeps a server&apos;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.
</Empty>
)
}
return (
<div className="flex flex-col gap-4">
<ul className="m-0 flex max-h-56 list-none flex-col gap-1 overflow-auto p-0" aria-label="History">
{entries.map((e) => (
<li key={e.id}>
<button
type="button"
aria-pressed={e.id === picked}
aria-label={`${new Date(e.at).toLocaleString()} ${e.server} ${KIND[e.kind] ?? e.kind}`}
className="flex w-full cursor-pointer flex-wrap items-center gap-3 border-0 border-b border-border bg-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-muted aria-pressed:bg-muted"
onClick={() => {
setPicked(e.id)
setText('')
}}
>
<span className="tabular-nums">{new Date(e.at).toLocaleString()}</span>
<Tag tone={e.server === 'baseline' ? 'info' : 'neutral'}>{e.server === 'baseline' ? 'Baseline' : e.server}</Tag>
<span className="text-muted-foreground">{KIND[e.kind] ?? e.kind}</span>
</button>
</li>
))}
</ul>
{picked !== null && <LabeledTextarea label="Saved version" mono rows={12} readOnly spellCheck={false} value={text} onChange={() => {}} />}
</div>
)
}
/* 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 (
<>
<Button onClick={() => setOpen(true)}>Compare servers</Button>
{open && <SettingsCompare token={token} scope={scope} onClose={() => setOpen(false)} />}
</>
)
}
+18 -3
View File
@@ -1,6 +1,7 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect' import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useContext, useState } from 'react' 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 { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint' import { HubWriteHint } from '../../ui/HubWriteHint'
import type { ResourceRecord, ZoneDetails } from '../../api/records' import type { ResourceRecord, ZoneDetails } from '../../api/records'
@@ -90,6 +91,8 @@ export function Zones({
const write = useHubWrite(node, mayModify) const write = useHubWrite(node, mayModify)
const canModify = write.allowed const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).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<string | null>(null) const [open, setOpen] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null) const [notice, setNotice] = useState<Notice | null>(null)
const [confirmation, setConfirmation] = useState<Confirmation | null>(null) const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
@@ -154,6 +157,7 @@ export function Zones({
<ClusterNodeSelect <ClusterNodeSelect
nodes={nodes} nodes={nodes}
initialised={clusterInitialised} initialised={clusterInitialised}
aggregate={hub}
value={node} value={node}
onChange={(n) => { onChange={(n) => {
setNode(n) setNode(n)
@@ -162,9 +166,20 @@ export function Zones({
disabled={open != null} disabled={open != null}
label="Cluster Node" label="Cluster Node"
/> />
<HubWriteHint write={write} /> {!matrixMode && <HubWriteHint write={write} />}
{open == null ? ( {matrixMode ? (
<ZonesMatrix
token={token}
canWrite={mayModify}
refresh={listRefresh}
onOpen={(server, zone) => {
setNode(server)
setNotice(null)
setOpen(zone)
}}
/>
) : open == null ? (
<ZoneList <ZoneList
token={token} token={token}
node={node} node={node}
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react'
import { copyZone, makeSecondary, previewSecondary, previewZoneCopy, type CopyPreview, type CopyTargetPreview } from '../../api/hub-zones'
import { getTsigKeyNames } from '../../api/settings'
import { Alert } from '../../ui/Alert'
import { Check } from '../../ui/Check'
import { LabeledInput, LabeledSelect } from '../../ui/Field'
import { Tag } from '../../ui/Tag'
import type { PickerServer, RunRequest } from '../apps/RunFlow'
/*
The two changes to zones across servers that go through the picker, the review and the results:
copy a zone to other servers, and make it a Secondary of this server's Primary. The review step
reads what would happen first; nothing is written before its confirm button.
The review and the fields live inside the dialog, so what they hold reaches `run` through a small
mutable object the request closes over (`overwrite`, `fields`); RunFlow keeps only what the review
hands it as `context`.
*/
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
function TargetReview({ t, zone, ticked, onTick }: { t: CopyTargetPreview; zone: string; ticked: boolean; onTick: (on: boolean) => void }) {
return (
<section aria-label={`Preview ${t.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{t.server}
{!t.ok ? <Tag tone="dan">Cannot copy</Tag> : t.exists ? <Tag tone="warn">Zone exists</Tag> : <Tag tone="ok">Will be created</Tag>}
</h3>
{t.error !== undefined && (
<Alert type="danger" title="Error">
{t.error}
</Alert>
)}
{t.ok && t.exists && (
<>
<p className="m-0 text-sm text-muted-foreground">
{zone} is a {t.type} zone here with serial {t.serial ?? 'none'}. Copying replaces all of its records with the source's.
</p>
<Check label={`Overwrite ${zone} on ${t.server}`} checked={ticked} onChange={onTick} />
</>
)}
</section>
)
}
function CopyReview({ preview, overwrite, setReady }: { preview: CopyPreview; overwrite: Set<string>; 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 (
<div className="flex flex-col gap-6">
<p className="m-0 text-sm text-muted-foreground">
{preview.zone} on {preview.source.server}: {preview.source.type}, serial {preview.source.serial ?? 'none'}, about {preview.source.records} records.
</p>
{preview.warnings.map((w) => (
<Alert key={w} type="warning" title="Note">
{w}
</Alert>
))}
{preview.targets.map((t) => (
<TargetReview
key={t.server}
t={t}
zone={preview.zone}
ticked={overwrite.has(t.server)}
onTick={(on) => {
if (on) overwrite.add(t.server)
else overwrite.delete(t.server)
setReady(answered())
rerender((n) => n + 1)
}}
/>
))}
</div>
)
}
/* 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<string>()
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: <CopyReview preview={preview} overwrite={overwrite} setReady={setReady} /> }
},
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<string[] | null>(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 (
<div className="flex flex-col gap-3">
<p className="m-0 text-sm text-muted-foreground">
{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.
</p>
<LabeledInput
label={`Address of ${source} as ${target} reaches it`}
mono
value={primary}
onChange={(e) => {
setPrimary(e.target.value)
fields.primaryAddress = e.target.value
}}
/>
<LabeledInput
label={`IP address of ${target} as ${source} sees it`}
mono
placeholder="203.0.113.10"
value={address}
onChange={(e) => {
setAddress(e.target.value)
fields.targetAddress = e.target.value
}}
/>
<p className="m-0 text-xs text-muted-foreground">It goes into the zone transfer and notify lists of {source}. The hub cannot know it for you.</p>
<LabeledSelect
label="TSIG key (optional)"
value={key}
onChange={(e) => {
setKey(e.target.value)
fields.tsigKeyName = e.target.value
}}
>
<option value="">None</option>
{(keys ?? []).map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</LabeledSelect>
</div>
)
}
/* `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: <SecondaryFields token={token} source={source} target={target} fields={fields} />,
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: (
<div className="flex flex-col gap-4">
{p.warnings.map((w) => (
<Alert key={w} type="warning" title="Note">
{w}
</Alert>
))}
<ol className="m-0 flex list-decimal flex-col gap-3 pl-5 text-sm" aria-label="Steps">
{p.steps.map((s) => (
<li key={s.role}>
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{s.role === 'source' ? 'Source' : 'Target'}</span>
<Tag tone={s.changes ? 'warn' : 'ok'}>{s.changes ? 'Will change' : 'Already set'}</Tag>
</span>
<span className="text-muted-foreground">{s.summary}</span>
</li>
))}
</ol>
<p className="m-0 text-xs text-muted-foreground">
The source is changed first. If creating the zone on {target} then fails, the change on {source} stays, and running this again is safe.
</p>
</div>
),
}
},
// Both steps always run (they are safe to repeat), so a retry of one server runs the pair again.
run: () => makeSecondary(token, input()),
}
}
+172
View File
@@ -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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={onOpen} />)
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(<ZonesMatrix token="t" onOpen={() => {}} 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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
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)
})
})
+273
View File
@@ -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<Filter, (z: MatrixZone) => 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 (
<span className="flex flex-wrap items-center gap-2">
<Badge variant="success-light">Same</Badge>
{zone.note === 'primary-secondary' && <Badge variant="info-light">Primary and Secondary</Badge>}
{zone.note === 'secondary-behind' && <Badge variant="warning-light">Secondary behind</Badge>}
</span>
)
case 'serial-differs':
return <Badge variant="warning-light">Serial differs</Badge>
case 'type-differs':
return <Badge variant="warning-light">Type differs</Badge>
case 'only-on':
return <Badge variant="info-light">Only on {zone.onlyOn}</Badge>
case 'missing-on':
return <Badge variant="info-light">Missing on {(zone.missingOn ?? []).join(', ')}</Badge>
default:
return (
<Tooltip text="A server did not answer, so this zone cannot be compared." placement="top">
<Badge variant="outline" tabIndex={0}>
Unknown
</Badge>
</Tooltip>
)
}
}
function Presence({ cell }: { cell: ZoneCell | undefined }) {
if (cell === undefined || cell.state === 'unknown') return <span className="text-sm text-muted-foreground">Unknown</span>
if (cell.state === 'absent') return <span className="text-sm text-muted-foreground">Not present</span>
return (
<span className="flex flex-wrap items-center gap-2 text-sm">
<span>{typeLabel(cell.type ?? '')}</span>
{typeof cell.serial === 'number' && <span className="text-xs text-muted-foreground tabular-nums">serial {cell.serial}</span>}
{cell.disabled === true && <Badge variant="destructive-light">Disabled</Badge>}
{cell.dnssec !== undefined && cell.dnssec !== 'Unsigned' && <Badge variant="info-light">DNSSEC</Badge>}
</span>
)
}
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<ZonesMatrixData | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [filter, setFilter] = useState<Filter>('all')
const [request, setRequest] = useState<RunRequest | null>(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<Filter, number>),
[data],
)
if (failure !== null && data === null) {
return (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not load the zones of the servers. {failure}</span>
<Button
variant="primary"
onClick={() => {
setFailure(null)
void load()
}}
>
Retry
</Button>
</span>
</Failure>
)
}
if (data === null) {
return (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the zones of every server">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-40 w-full" />
</div>
)
}
if (data.zones.length === 0) {
return (
<Empty
title="No zones"
actions={
<Button variant="primary" onClick={() => void load()}>
Refresh
</Button>
}
>
None of the servers that answered has a zone. Choose a server to add one.
</Empty>
)
}
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 (
<Menu label={`Actions: ${zone.name}`}>
{(close) => (
<>
{holders.map((s) => (
<Button
key={s.id}
size="sm"
onClick={() => {
close()
onOpen(s.id, zone.name)
}}
>
Open on {s.id}
</Button>
))}
{/* 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 (
<Button
key={`copy-${dst.id}`}
size="sm"
disabled={source === undefined || !ok}
onClick={() => {
close()
if (source !== undefined) setRequest(copyRequest(token, source.id, zone.name, pickable(source.id), [dst.id]))
}}
>
Copy to {dst.id}
</Button>
)
})}
{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 (
<Button
key={`secondary-${dst.id}`}
size="sm"
disabled={source === undefined || !free}
onClick={() => {
close()
if (source === undefined) return
setRequest(secondaryRequest(token, source.id, dst.id, zone.name, source.host, pickerServers([dst], {})))
}}
>
Make secondary on {dst.id}
</Button>
)
})}
</>
)}
</Menu>
)
}
const columns: Col<MatrixZone>[] = [
{ id: 'name', ...head({ children: 'Zone' }), cell: (z) => <span className="text-sm font-medium">{z.name === '' ? '<root>' : z.name}</span> },
...servers.map((s): Col<MatrixZone> => ({ id: `server-${s.id}`, ...head({ children: s.id }), cell: (z) => <Presence cell={z.cells[s.id]} /> })),
{ id: 'status', ...head({ children: 'Status' }), cell: (z) => <Status zone={z} /> },
{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (z) => actions(z, data) },
]
return (
<div className="flex flex-col gap-4">
<Segmented
label="Filter zones"
active={filter}
onChoose={setFilter}
options={[
{ id: 'all', label: `All · ${counts.all}` },
{ id: 'differs', label: `Differs · ${counts.differs}` },
{ id: 'only', label: `Only on one server · ${counts.only}` },
]}
/>
<Panel title="Zones by server" description="Where each zone is, its type and SOA serial">
{rows.length === 0 ? (
<Empty
title="No zones match"
actions={
<Button variant="primary" onClick={() => setFilter('all')}>
Show all
</Button>
}
>
No zone fits this filter.
</Empty>
) : (
<DataTable data={rows} columns={columns} getRowId={(z) => z.name} />
)}
</Panel>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void load()
}}
/>
)}
</div>
)
}