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 <noreply@anthropic.com>
This commit is contained in:
1 parent
34de910e04
commit
392b644faa
25 files changed
+4865
-22
No files matched your search
+61
-6
@@ -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"`)
|
||||
|
||||
Reference in new issue
Block a user