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: 'staging.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 4, disabled: true, lastModified: ago(60 * 24 * 20) },
].map((z) => ({ notifyFailed: false, notifyFailedFor: [], ...z }))
if (VARIANT === 'b') {
// The second server: a zone the first lacks, a serial that moved on, and a Secondary of the first server's Primary.
zones = zones.filter((z) => z.name !== 'dev.example.net' && z.name !== 'vpn.example.net')
zones = zones.map((z) => (z.name === 'lan' ? { ...z, soaSerial: 44 } : z.name === 'home.example.net' ? { ...z, type: 'Secondary', soaSerial: 16, catalog: null } : z))
zones.push({ name: 'ndns-only.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 3, disabled: false, lastModified: ago(60 * 3), notifyFailed: false, notifyFailedFor: [] })
}
// Zone files (zones/export, zones/import) and options (zones/options/get, set) are kept per zone.
const zoneFiles = {}
const zoneOptions = {}
const zoneFile = (name) => zoneFiles[name] ?? `$ORIGIN ${name}.\n@ IN SOA ns1.${name}. hostmaster.${name}. ${(zones.find((z) => z.name === name) ?? {}).soaSerial ?? 1} 900 300 604800 900\n@ IN NS ns1.${name}.\nwww IN A 203.0.113.${VARIANT === 'b' ? 20 : 10}\n`
const optionsOf = (name) => ({ zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], ...(zoneOptions[name] ?? {}) })
function recordsFor(zone) {
const z = zone.name
@@ -127,6 +139,10 @@ const cacheRecords = (domain) =>
]
const allowed = new Set(['updates.example.net', 'api.github.com', 'time.cloudflare.com'])
const blocked = new Set(['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com'])
if (VARIANT === 'b') {
blocked.delete('beacon.analytics-example.com'); blocked.add('only-ndns.tracker-example.com')
allowed.add('extra-allowed.example.net')
}
const listNode = (set, domain) => {
const all = [...set].sort()
return { domain, zones: domain === '' ? all : [], records: domain !== '' && set.has(domain) ? [{ name: domain, type: 'SOA', ttl: 60, rData: {}, dnssecStatus: 'Disabled' }] : [] }
@@ -166,6 +182,15 @@ const settings = {
loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365,
}
if (VARIANT === 'b') {
// The second server: other shared values (these show up in the compare), and its own identity and log folder (local fields).
Object.assign(settings, {
dnsServerDomain: 'ndns.example.net', dnsServerLocalEndPoints: ['10.0.0.9:53'], logFolder: 'logs-ndns', webServiceHttpPort: 5381,
defaultRecordTtl: 7200, forwarders: ['8.8.8.8'], blockingType: 'AnyAddress', serveStale: false,
tsigKeys: [{ keyName: 'transfer.example.net', sharedSecret: 'bmRucy1vdGhlci1zZWNyZXQ=', algorithmName: 'hmac-sha256' }],
})
}
// ── DHCP ───────────────────────────────────────────────────────────────────
let scopes = [
{ name: 'Main', enabled: true, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', networkAddress: '192.168.10.0', broadcastAddress: '192.168.10.255', interfaceAddress: '192.168.10.2' },
@@ -246,10 +271,10 @@ const userDetails = (u) => ({ ...u, sessionTimeoutSeconds: 1800, remotelyManaged
// ── Router ─────────────────────────────────────────────────────────────────
const addedRecords = {}
// Answers that are files, not JSON (the downloads and the Rules table's reads).
const fileFor = (path) => ({
const fileFor = (path, q = new URLSearchParams()) => ({
'allowed/export': [...allowed].join('\n') + '\n',
'blocked/export': [...blocked].join('\n') + '\n',
'zones/export': '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 1 900 300 604800 900\n',
'zones/export': zones.some((z) => z.name === q.get('zone')) ? zoneFile(q.get('zone')) : undefined,
'logs/download': '[2026-10-26 10:00:00 UTC] [127.0.0.1:5380] [] DNS Server was started.\n',
})[path]
const ok = (response = {}) => ({ status: 'ok', response })
@@ -282,8 +307,31 @@ function route(path, q) {
}
case 'zones/enable': case 'zones/disable': zones = zones.map((z) => (q.get('zone') === z.name ? { ...z, disabled: path === 'zones/disable' } : z)); return ok()
case 'zones/delete': { const gone = (q.get('zones') ?? q.get('zone') ?? '').split(',').filter(Boolean); zones = zones.filter((z) => !gone.includes(z.name)); return ok({ deleted: gone, failed: {} }) }
case 'zones/create': zones = [{ name: domain, type: q.get('type') ?? 'Primary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]; return ok({ domain })
case 'zones/options/get': return ok({ name: domain, type: 'Primary', dnssecStatus: 'Unsigned', disabled: false, catalog: null, queryAccess: 'Allow', queryAccessNetworkACL: [], zoneTransfer: 'AllowOnlyZoneNameServers', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'] })
case 'zones/create': {
if (zones.some((z) => z.name === domain)) return { status: 'error', errorMessage: `Zone already exists: ${domain}` }
const type = q.get('type') ?? 'Primary'
zones = [{ name: domain, type, dnssecStatus: type === 'Primary' ? 'Unsigned' : undefined, soaSerial: type === 'Forwarder' ? undefined : 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]
if (type === 'Secondary') zoneOptions[domain] = { primaryNameServerAddresses: (q.get('primaryNameServerAddresses') ?? '').split(','), primaryZoneTransferProtocol: q.get('zoneTransferProtocol') ?? 'Tcp', primaryZoneTransferTsigKeyName: q.get('tsigKeyName') ?? '' }
return ok({ domain })
}
case 'zones/import': {
const z = zones.find((x) => x.name === domain)
if (!z) return { status: 'error', errorMessage: `No such zone was found: ${domain}` }
zoneFiles[domain] = q.get('__text') ?? zoneFile(domain)
const serial = /SOA\s+\S+\s+\S+\s+(\d+)/.exec(zoneFiles[domain])
if (serial) zones = zones.map((x) => (x.name === domain ? { ...x, soaSerial: Number(serial[1]), lastModified: iso(Date.now()) } : x))
return ok()
}
case 'zones/options/get': { const z = zones.find((x) => x.name === domain); return ok({ name: domain, type: z?.type ?? 'Primary', dnssecStatus: z?.dnssecStatus ?? 'Unsigned', disabled: false, catalog: z?.catalog ?? null, queryAccess: 'Allow', queryAccessNetworkACL: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'], ...optionsOf(domain) }) }
case 'zones/options/set': {
const next = { ...(zoneOptions[domain] ?? {}) }
for (const [k, v] of q) {
if (['zone', 'node', 'token'].includes(k)) continue
next[k] = /ACL$|Names$|Servers$|Addresses$/.test(k) ? (v === 'false' || v === '' ? [] : v.split(',')) : v
}
zoneOptions[domain] = next
return ok()
}
case 'zones/permissions/get': return ok({ zone: domain, userPermissions: [{ username: 'admin', canView: true, canModify: true, canDelete: true }], groupPermissions: [{ name: 'DNS Administrators', canView: true, canModify: true, canDelete: false }], users: users.map((u) => u.username), groups: groups.map((g) => g.name) })
case 'zones/catalogs/list': return ok({ catalogZoneNames: ['catalog.example.net'] })
case 'cache/list': return ok({ domain, zones: cacheTree[domain] ?? [], records: cacheRecords(domain) })
@@ -299,6 +347,12 @@ function route(path, q) {
// Changes only what is sent and already there, by its kind (a flag, a number, a list or text); the rest is kept.
for (const [k, v] of q) {
if (!(k in settings) || k === 'node' || k === 'token') continue
if (k === 'tsigKeys') {
// Rows of keyName|sharedSecret|algorithmName joined with a pipe, or "false" for none.
const cells = v === 'false' ? [] : v.split('|')
settings.tsigKeys = Array.from({ length: Math.floor(cells.length / 3) }, (_, i) => ({ keyName: cells[i * 3], sharedSecret: cells[i * 3 + 1], algorithmName: cells[i * 3 + 2] }))
continue
}
const old = settings[k]
if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null
settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v
@@ -314,7 +368,7 @@ function route(path, q) {
return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill })
}
case 'user/createSingleUseToken': return ok({ token: 'mock-once' })
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: settings.tsigKeys.map((k) => k.keyName) })
case 'dhcp/leases/list': return ok({ leases })
case 'dhcp/scopes/list': return ok({ scopes })
case 'dhcp/scopes/get': return ok(scopeDetail(q.get('name') ?? 'Main'))
@@ -383,7 +437,8 @@ http.createServer((req, res) => {
req.on('end', () => {
const q = new URLSearchParams(url.search)
if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v)
const file = fileFor(path)
if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body)
const file = fileFor(path, q)
if (file !== undefined) {
res.setHeader('content-type', 'text/plain')
res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`)