/* Zones across the servers (stage 7): the merged list, copying a zone to another server, and making a zone a Secondary of another server's Primary (zone transfer, AXFR, done by Technitium itself rather than by copying records over and over). * `buildZoneMatrix` reads `zones/list` of every server (all pages, read token) and puts one row per zone name. `classifyRow` is the pure part that decides the status of a row. * `ZonesSync.copyPreview` / `copy` move a zone as a zone file: `zones/export` from the source with the READ token, then `zones/create` (when the zone is missing) and `zones/import` on each target with that server's ADMIN token. The parameter names are the ones the console's own Import zone dialog sends (`importZone` and `saveZoneFile` in src/api/zones.ts) and its Add zone dialog sends (add-zone.ts). * `ZonesSync.secondaryPreview` / `makeSecondary` do the two steps of a transfer: on the source `zones/options/set` adds the target to the transfer ACL and to the notify list (what is there stays), then on the target `zones/create` type Secondary. Each step can run again with no harm. Only Primary and Forwarder zones are copied; a Secondary, Stub or Catalog zone is refused with a reason. A DNSSEC-signed Primary is refused too: its keys do not travel in a zone file, so the copy would carry signatures nobody can renew. No zone content is written to the audit log. */ import { isIP } from 'node:net' import type { HubConfig, HubServer } from './config.ts' import { cleanDomain } from './servers-run.ts' import { Upstream, UpstreamError, type RawAnswer } from './upstream.ts' export class ZoneUserError extends Error { constructor(message: string) { super(message) this.name = 'ZoneUserError' } } const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') export const PAGE_SIZE = 500 export const MAX_PAGES = 40 /* The largest zone file the hub carries between servers. */ export const MAX_ZONE_BYTES = 5 * 1024 * 1024 const EXPORT_TIMEOUT_MS = 30_000 /* ---- The merged list ---- */ export interface ListedZone { name: string type: string lastModified?: string disabled?: boolean soaSerial?: number | null dnssecStatus?: string catalog?: string | null internal?: boolean } export interface ZoneCell { state: 'present' | 'absent' | 'unknown' type?: string serial?: number | null disabled?: boolean dnssec?: string lastModified?: string } export type RowStatus = 'same' | 'serial-differs' | 'type-differs' | 'only-on' | 'missing-on' | 'unknown' export interface MatrixRow { name: string cells: Record status: RowStatus /* The server that has the zone, for `only-on`. */ onlyOn?: string /* The reachable servers that lack it, for `missing-on`. */ missingOn?: string[] /* A Primary with a Secondary of it is the intended setup: said here, not counted as a divergence. */ note?: 'primary-secondary' | 'secondary-behind' } export interface ZoneMatrix { /* `host` is where the server's address points, to prefill the primary's address of a Secondary. */ servers: { id: string; ok: boolean; writable: boolean; host: string; error?: string }[] zones: MatrixRow[] } /* The pairs where one side follows the other: not a type difference. */ const FOLLOWS: Record = { Secondary: 'Primary', SecondaryForwarder: 'Forwarder', SecondaryCatalog: 'Catalog' } function pairedTypes(types: Set): boolean { if (types.size !== 2) return false const [a, b] = [...types] return FOLLOWS[a] === b || FOLLOWS[b] === a } /* The status of one row, from its cells. Pure. */ export function classifyRow(name: string, cells: Record): MatrixRow { const entries = Object.entries(cells) const present = entries.filter(([, c]) => c.state === 'present') const absent = entries.filter(([, c]) => c.state === 'absent').map(([id]) => id) const unknown = entries.some(([, c]) => c.state === 'unknown') const row: MatrixRow = { name, cells, status: 'same' } if (present.length < 2) { // Whether it is "only on" one server cannot be said while a server that might have it did not answer. if (unknown || present.length === 0) return { ...row, status: 'unknown' } return { ...row, status: 'only-on', onlyOn: present[0][0] } } const types = new Set(present.map(([, c]) => c.type ?? '')) if (types.size > 1 && !pairedTypes(types)) return { ...row, status: 'type-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) } if (types.size > 1) { // Primary and its Secondary: lagging is expected, ahead is not. const master = present.find(([, c]) => FOLLOWS[c.type ?? ''] === undefined)![1] const followers = present.filter(([, c]) => FOLLOWS[c.type ?? ''] !== undefined) const ahead = followers.some(([, c]) => typeof c.serial === 'number' && typeof master.serial === 'number' && c.serial > master.serial) const behind = followers.some(([, c]) => typeof c.serial === 'number' && typeof master.serial === 'number' && c.serial < master.serial) if (ahead) return { ...row, status: 'serial-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) } return finish({ ...row, note: behind ? 'secondary-behind' : 'primary-secondary' }, absent, unknown) } const [type] = [...types] const serials = new Set(present.map(([, c]) => c.serial)) if (type === 'Primary' && serials.size > 1) return { ...row, status: 'serial-differs', ...(absent.length > 0 ? { missingOn: absent } : {}) } return finish(row, absent, unknown) } function finish(row: MatrixRow, absent: string[], unknown: boolean): MatrixRow { if (absent.length > 0) return { ...row, status: 'missing-on', missingOn: absent } return unknown ? { ...row, status: 'unknown' } : row } /* Every page of one server's zones, without the server's own internal zones. */ export async function listAllZones(upstream: Upstream, server: HubServer, fresh = false): Promise<{ zones: ListedZone[]; truncated: boolean }> { if (fresh) upstream.invalidate(server.id) const zones: ListedZone[] = [] let truncated = false for (let page = 1; ; page++) { const r = (await upstream.call(server, 'zones/list', { pageNumber: String(page), zonesPerPage: String(PAGE_SIZE) })) as { zones?: ListedZone[] totalPages?: number } | null for (const z of r?.zones ?? []) if (z.internal !== true) zones.push(z) const total = typeof r?.totalPages === 'number' ? r.totalPages : 1 if (page >= total) break if (page >= MAX_PAGES) { truncated = true break } } return { zones, truncated } } export async function buildZoneMatrix(config: HubConfig, upstream: Upstream): Promise<{ matrix: ZoneMatrix; warnings: string[] }> { const perServer = await Promise.all( config.servers.map(async (server) => { try { return { server, error: undefined, ...(await listAllZones(upstream, server)) } } catch (e) { return { server, error: reason(e), zones: [] as ListedZone[], truncated: false } } }), ) const warnings = perServer.filter((p) => p.error !== undefined).map((p) => `${p.server.id}: ${p.error}`) if (warnings.length === perServer.length) throw new Error(`No server answered. ${warnings.join('; ')}`.trim()) for (const p of perServer) if (p.truncated) warnings.push(`${p.server.id}: more than ${MAX_PAGES * PAGE_SIZE} zones, the list is cut short.`) const names = [...new Set(perServer.flatMap((p) => p.zones.map((z) => z.name)))].sort((a, b) => a.localeCompare(b)) const zones = names.map((name) => { const cells: Record = {} for (const p of perServer) { if (p.error !== undefined) { cells[p.server.id] = { state: 'unknown' } continue } const z = p.zones.find((x) => x.name === name) cells[p.server.id] = z === undefined ? { state: 'absent' } : { state: 'present', type: z.type, serial: typeof z.soaSerial === 'number' ? z.soaSerial : null, disabled: z.disabled === true, ...(z.dnssecStatus === undefined ? {} : { dnssec: z.dnssecStatus }), ...(z.lastModified === undefined ? {} : { lastModified: z.lastModified }), } } return classifyRow(name, cells) }) const servers = perServer.map((p) => ({ id: p.server.id, ok: p.error === undefined, writable: p.server.adminToken !== undefined, host: new URL(p.server.url).hostname, ...(p.error === undefined ? {} : { error: p.error }), })) return { matrix: { servers, zones }, warnings } } /* ---- Copy ---- */ const COPYABLE = new Set(['Primary', 'Forwarder']) /* A zone name as the hub accepts it: a plain domain, not a wildcard and not the root. */ export function cleanZone(value: string | undefined): string | null { const zone = cleanDomain(value) return zone === null || zone.startsWith('*') ? null : zone } /* Records in a zone file, roughly: the lines that are not blank, a comment or a `$` directive. */ export function countRecordLines(text: string): number { let n = 0 for (const line of text.split(/\r?\n/)) { const t = line.trim() if (t !== '' && !t.startsWith(';') && !t.startsWith('$')) n++ } return n } /* The body of an answer as text, stopping at `cap` bytes and `ms` milliseconds. */ export async function readCapped(file: RawAnswer, cap: number, ms: number, what = 'The zone file'): Promise { if (file.body === null) return '' const timer = setTimeout(() => file.abort(), ms) const reader = file.body.getReader() const chunks: Uint8Array[] = [] let size = 0 try { for (;;) { const { done, value } = await reader.read() if (done) break size += value.length if (size > cap) { file.abort() throw new ZoneUserError(`${what} is larger than ${Math.round(cap / 1024 / 1024)} MB.`) } chunks.push(value) } } catch (e) { if (e instanceof ZoneUserError) throw e throw new UpstreamError('unreachable', 'the zone file could not be read in time') } finally { clearTimeout(timer) } return Buffer.concat(chunks).toString('utf8') } export interface TargetResult { server: string ok: boolean message?: string } interface SourceZone { zone: ListedZone warnings: string[] } export interface CopyTargetPreview { server: string ok: boolean error?: string writable: boolean exists: boolean type?: string serial?: number | null /* The user has to tick Overwrite for this one. */ needsOverwrite: boolean } export interface CopyPreview { zone: string source: { server: string; type: string; serial: number | null; records: number; bytes: number } warnings: string[] targets: CopyTargetPreview[] } export class ZonesSync { #config: HubConfig #upstream: Upstream constructor(config: HubConfig, upstream: Upstream) { this.#config = config this.#upstream = upstream } #server(id: string, what = 'server'): HubServer { const found = this.#config.servers.find((s) => s.id === id) if (found === undefined) throw new ZoneUserError(`Unknown ${what}: ${id}.`) return found } #targets(ids: string[], source: string): HubServer[] { const unique = [...new Set(ids)] if (unique.length === 0) throw new ZoneUserError('Choose at least one server.') if (unique.includes(source)) throw new ZoneUserError('A zone cannot be copied to the server it comes from.') return unique.map((id) => this.#server(id)) } /* The zone as the source lists it, and why it may not be copied. */ async #source(server: HubServer, zone: string): Promise { let listed: ListedZone[] try { listed = (await listAllZones(this.#upstream, server, true)).zones } catch (e) { throw new ZoneUserError(`${server.id}: ${reason(e)}`) } const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase()) if (found === undefined) throw new ZoneUserError(`${server.id} has no zone ${zone}.`) if (!COPYABLE.has(found.type)) { throw new ZoneUserError(`${zone} is a ${found.type} zone on ${server.id}. Only Primary and Forwarder zones are copied; a ${found.type} zone gets its content from elsewhere.`) } if (found.dnssecStatus !== undefined && found.dnssecStatus !== 'Unsigned') { throw new ZoneUserError(`${zone} is DNSSEC-signed. The signing keys do not travel in a zone file, so a copy would carry signatures that cannot be renewed. Sign the copy on the target instead.`) } const warnings: string[] = [] if (found.disabled === true) warnings.push(`${zone} is disabled on ${server.id}; the copy is created enabled.`) if (typeof found.catalog === 'string' && found.catalog !== '') warnings.push(`${zone} is a member of the catalog ${found.catalog} on ${server.id}; the copy is not added to a catalog.`) return { zone: found, warnings } } async #readZone(server: HubServer, zone: string): Promise { let file: RawAnswer try { file = await this.#upstream.raw(server, 'zones/export', { zone }) } catch (e) { throw new ZoneUserError(`${server.id}: ${reason(e)}`) } try { return await readCapped(file, MAX_ZONE_BYTES, EXPORT_TIMEOUT_MS) } catch (e) { throw new ZoneUserError(`${server.id}: ${reason(e)}`) } } /* What is on a target for this zone, or why the target cannot take it. */ async #look(target: HubServer, zone: string, type: string): Promise { const base = { server: target.id, writable: target.adminToken !== undefined } if (target.adminToken === undefined) return { ...base, ok: false, error: 'The hub has no admin token for this server, so it cannot write to it.', exists: false, needsOverwrite: false } let listed: ListedZone[] try { listed = (await listAllZones(this.#upstream, target, true)).zones } catch (e) { return { ...base, ok: false, error: reason(e), exists: false, needsOverwrite: false } } const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase()) if (found === undefined) return { ...base, ok: true, exists: false, needsOverwrite: false } const here = { ...base, exists: true, type: found.type, serial: typeof found.soaSerial === 'number' ? found.soaSerial : null } if (found.type !== type) { return { ...here, ok: false, needsOverwrite: false, error: `${target.id} has ${zone} as a ${found.type} zone; a ${type} zone cannot replace it. Delete or convert it there first.` } } return { ...here, ok: true, needsOverwrite: true } } async copyPreview(sourceId: string, zoneRaw: string, ids: string[]): Promise { const zone = cleanZone(zoneRaw) if (zone === null) throw new ZoneUserError('A valid zone name is required.') const source = this.#server(sourceId, 'source server') const targets = this.#targets(ids, source.id) const { zone: listed, warnings } = await this.#source(source, zone) const text = await this.#readZone(source, zone) return { zone, source: { server: source.id, type: listed.type, serial: typeof listed.soaSerial === 'number' ? listed.soaSerial : null, records: countRecordLines(text), bytes: Buffer.byteLength(text), }, warnings, targets: await Promise.all(targets.map((t) => this.#look(t, zone, listed.type))), } } /* Copies the zone to each target. A target that has the zone is touched only when its id is in `overwrite` (checked here, not only by the console). Create and import are two calls: when the import fails after the create, the answer says an empty zone was left behind. */ async copy(sourceId: string, zoneRaw: string, ids: string[], overwrite: string[]): Promise<{ results: TargetResult[] }> { const zone = cleanZone(zoneRaw) if (zone === null) throw new ZoneUserError('A valid zone name is required.') const source = this.#server(sourceId, 'source server') const targets = this.#targets(ids, source.id) const { zone: listed } = await this.#source(source, zone) const text = await this.#readZone(source, zone) if (text.trim() === '') throw new ZoneUserError(`${source.id} exported an empty zone file for ${zone}.`) const results = await Promise.all( targets.map(async (target): Promise => { const look = await this.#look(target, zone, listed.type) if (!look.ok) return { server: target.id, ok: false, message: look.error } if (look.exists && !overwrite.includes(target.id)) { return { server: target.id, ok: false, message: `${zone} already exists here (${look.type}, serial ${look.serial ?? 'none'}). Tick Overwrite to replace its records.` } } try { if (!look.exists) { const params: Record = { zone, type: listed.type } // A Forwarder is created empty and filled by the import (add-zone.ts: `initializeForwarder=false`). if (listed.type === 'Forwarder') params.initializeForwarder = 'false' await this.#upstream.write(target, 'zones/create', params) } } catch (e) { return { server: target.id, ok: false, message: `the zone could not be created: ${reason(e)}` } } try { // The three switches and a text/plain body, as `importZone` sends them. await this.#upstream.write(target, 'zones/import', { zone, overwrite: 'true', overwriteZone: 'true', overwriteSoaSerial: 'true' }, text) } catch (e) { return { server: target.id, ok: false, message: look.exists ? `the import failed: ${reason(e)}` : `the zone was created but the import failed (${reason(e)}); an empty zone is left on ${target.id}`, } } return { server: target.id, ok: true, message: look.exists ? 'Records replaced.' : 'Zone created and filled.' } }), ) return { results } } /* ---- Make secondary ---- */ async #tsigNames(server: HubServer): Promise { this.#upstream.invalidate(server.id) const r = (await this.#upstream.call(server, 'settings/getTsigKeyNames', {})) as { tsigKeyNames?: string[] } | null return r?.tsigKeyNames ?? [] } async secondaryPreview(input: SecondaryInput): Promise { const plan = await this.#plan(input) return { zone: plan.zone, source: plan.source.id, target: plan.target.id, warnings: plan.warnings, steps: plan.steps } } async makeSecondary(input: SecondaryInput): Promise<{ results: TargetResult[] }> { const plan = await this.#plan(input) const results: TargetResult[] = [] const { source, target } = plan if (plan.sourceParams === null) { results.push({ server: source.id, ok: true, message: 'Transfer and notify already allow the target; nothing was changed.' }) } else { try { await this.#upstream.write(source, 'zones/options/set', plan.sourceParams) results.push({ server: source.id, ok: true, message: 'Zone transfer and notify now include the target.' }) } catch (e) { results.push({ server: source.id, ok: false, message: `${reason(e)}; nothing was changed on either server` }) results.push({ server: target.id, ok: false, message: 'Not attempted: the source step failed.' }) return { results } } } try { await this.#upstream.write(target, 'zones/create', plan.targetParams) results.push({ server: target.id, ok: true, message: 'Secondary zone created; it will fetch the zone from the primary.' }) } catch (e) { results.push({ server: target.id, ok: false, message: `the Secondary zone could not be created: ${reason(e)}. The change on ${source.id} (zone transfer and notify) was left in place; run this again after fixing the cause.`, }) } return { results } } async #plan(input: SecondaryInput): Promise { const zone = cleanZone(input.zone) if (zone === null) throw new ZoneUserError('A valid zone name is required.') const source = this.#server(input.source, 'source server') const target = this.#server(input.target, 'target server') if (source.id === target.id) throw new ZoneUserError('The source and the target must be two different servers.') if (source.adminToken === undefined || target.adminToken === undefined) { throw new ZoneUserError(`The hub has no admin token for ${source.adminToken === undefined ? source.id : target.id}, so it cannot change it.`) } const primaryAddress = input.primaryAddress.trim() const targetAddress = input.targetAddress.trim() if (!validNameServer(primaryAddress)) throw new ZoneUserError("Give the primary's address as the target reaches it: an IP address or a host name.") if (isIP(targetAddress) === 0) throw new ZoneUserError("Give the target's IP address as the source sees it (the zone transfer list takes addresses).") const key = (input.tsigKeyName ?? '').trim() const warnings: string[] = [] const { zone: listed } = await this.#sourceForSecondary(source, zone) let existsOnTarget: ListedZone | undefined try { existsOnTarget = (await listAllZones(this.#upstream, target, true)).zones.find((z) => z.name.toLowerCase() === zone.toLowerCase()) } catch (e) { throw new ZoneUserError(`${target.id}: ${reason(e)}`) } if (existsOnTarget !== undefined) { throw new ZoneUserError(`${target.id} already has ${zone} as a ${existsOnTarget.type} zone. Delete it there first if it should become a Secondary.`) } if (key !== '') { for (const s of [source, target]) { let names: string[] try { names = await this.#tsigNames(s) } catch (e) { throw new ZoneUserError(`${s.id}: ${reason(e)}`) } if (!names.includes(key)) throw new ZoneUserError(`The TSIG key "${key}" does not exist on ${s.id}. Add it in that server's Settings first.`) } } let options: ZoneOptionsLike try { this.#upstream.invalidate(source.id) options = (await this.#upstream.call(source, 'zones/options/get', { zone })) as ZoneOptionsLike } catch (e) { throw new ZoneUserError(`${source.id}: ${reason(e)}`) } if (typeof options.catalog === 'string' && options.catalog !== '' && options.overrideCatalogZoneTransfer !== true) { warnings.push(`${zone} follows the zone transfer settings of the catalog ${options.catalog} on ${source.id}; the change may have no effect until the zone overrides them.`) } const sourceParams = secondaryOptions(zone, options, targetAddress, key) const targetParams: Record = { zone, type: 'Secondary', catalog: '', primaryNameServerAddresses: primaryAddress, zoneTransferProtocol: 'Tcp', tsigKeyName: key, validateZone: 'false', } const steps: SecondaryStep[] = [ { server: source.id, role: 'source', summary: sourceParams === null ? `${zone} on ${source.id} already allows zone transfer and notify to ${targetAddress}.` : `On ${source.id}: allow zone transfer of ${zone} to ${targetAddress}${key === '' ? '' : ` with TSIG key ${key}`} and notify ${targetAddress}. What is already listed stays.`, changes: sourceParams !== null, }, { server: target.id, role: 'target', summary: `On ${target.id}: create ${zone} as a Secondary zone that fetches it from ${primaryAddress} over TCP${key === '' ? '' : ` signed with ${key}`}.`, changes: true, }, ] return { zone, source, target, warnings, steps, sourceParams, targetParams, type: listed.type } } async #sourceForSecondary(source: HubServer, zone: string): Promise { let listed: ListedZone[] try { listed = (await listAllZones(this.#upstream, source, true)).zones } catch (e) { throw new ZoneUserError(`${source.id}: ${reason(e)}`) } const found = listed.find((z) => z.name.toLowerCase() === zone.toLowerCase()) if (found === undefined) throw new ZoneUserError(`${source.id} has no zone ${zone}.`) if (found.type !== 'Primary') throw new ZoneUserError(`${zone} is a ${found.type} zone on ${source.id}. Only a Primary zone can be transferred to a Secondary.`) return { zone: found, warnings: [] } } } export interface SecondaryInput { source: string target: string zone: string primaryAddress: string targetAddress: string tsigKeyName?: string } export interface SecondaryStep { server: string role: 'source' | 'target' summary: string /* False when nothing would be written there. */ changes: boolean } export interface SecondaryPreview { zone: string source: string target: string warnings: string[] steps: SecondaryStep[] } interface SecondaryPlan { zone: string source: HubServer target: HubServer warnings: string[] steps: SecondaryStep[] /* Null when the source already allows it. */ sourceParams: Record | null targetParams: Record type: string } interface ZoneOptionsLike { catalog?: string | null overrideCatalogZoneTransfer?: boolean zoneTransfer?: string zoneTransferNetworkACL?: string[] zoneTransferTsigKeyNames?: string[] notify?: string notifyNameServers?: string[] } /* An address for a name server: an IP, a host name, either with a port. */ export function validNameServer(value: string): boolean { if (value === '' || value.length > 255 || /[\s,]/.test(value)) return false if (isIP(value) !== 0) return true const bracketed = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(value) if (bracketed !== null) return isIP(bracketed[1]) === 6 const [host, port, extra] = value.split(':') if (extra !== undefined || (port !== undefined && !/^\d{1,5}$/.test(port))) return false return isIP(host) === 4 || cleanZone(host) !== null } /* A transfer mode that also lets the listed networks in. */ const TRANSFER_WITH_ACL: Record = { Deny: 'UseSpecifiedNetworkACL', AllowOnlyZoneNameServers: 'AllowZoneNameServersAndUseSpecifiedNetworkACL' } const NOTIFY_WITH_LIST: Record = { None: 'SpecifiedNameServers', ZoneNameServers: 'BothZoneAndSpecifiedNameServers' } const joined = (list: string[]): string => (list.length === 0 ? 'false' : list.join(',')) /* The `zones/options/set` that lets `address` transfer and be notified, keeping what is there: modes that cannot hold a list are widened to the matching one that can, an address already listed is not added twice. Null when nothing needs to change. Only the transfer and notify fields are sent, so the rest of the zone's options is not touched. */ export function secondaryOptions(zone: string, current: ZoneOptionsLike, address: string, tsigKey: string): Record | null { const mode = current.zoneTransfer ?? 'Deny' const acl = [...(current.zoneTransferNetworkACL ?? [])] const keys = [...(current.zoneTransferTsigKeyNames ?? [])] const notify = current.notify ?? 'None' const notifyList = [...(current.notifyNameServers ?? [])] const nextMode = TRANSFER_WITH_ACL[mode] ?? mode if (nextMode !== 'Allow' && !acl.includes(address)) acl.push(address) if (tsigKey !== '' && !keys.includes(tsigKey)) keys.push(tsigKey) const nextNotify = NOTIFY_WITH_LIST[notify] ?? notify if (!notifyList.includes(address)) notifyList.push(address) const unchanged = nextMode === mode && nextNotify === notify && acl.length === (current.zoneTransferNetworkACL ?? []).length && keys.length === (current.zoneTransferTsigKeyNames ?? []).length && notifyList.length === (current.notifyNameServers ?? []).length if (unchanged) return null return { zone, zoneTransfer: nextMode, zoneTransferNetworkACL: joined(acl), zoneTransferTsigKeyNames: joined(keys), notify: nextNotify, notifyNameServers: joined(notifyList), } }