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

+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 { AppsConfig, UserError } from './apps-config.ts'
import { Drift } from './drift.ts'
import { SettingsConfig } from './settings-config.ts'
import { BLOCKING_FIELDS } from './settings-sync.ts'
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
import {
mergeQueryLogs,
mergeSettled,
@@ -147,6 +150,8 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
const writer = new AppsWriter(config, upstream)
const runner = new ServersRunner(config, upstream)
const appsConfig = new AppsConfig(config, upstream)
const zonesSync = new ZonesSync(config, upstream)
const settingsConfig = new SettingsConfig(config, upstream)
const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
@@ -369,6 +374,120 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
}
/* The merged zone list and the previews of copying a zone and of making it a Secondary: no change is made. */
async function zonesRead(res: http.ServerResponse, apiPath: string, params: Params): Promise<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. */
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') {
@@ -386,6 +505,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await configWrite(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsWrite(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/zones/copy' || apiPath === 'hub/zones/secondary') {
await zonesWrite(res, apiPath, params, who, ip)
return
}
if (apiPath === 'hub/servers/run') {
const planned = planAction(params.action, params)
const line = { user: who.user, role: who.role, ip, action: 'error' in planned ? 'servers.run' : `servers.${planned.plan.action}` }
@@ -695,6 +822,14 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await configRead(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsRead(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/zones/')) {
await zonesRead(res, apiPath, params)
return
}
if (apiPath === 'hub/drift/get') {
send(res, 200, ok(drift.get()))
return