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]>
657 lines
27 KiB
TypeScript
657 lines
27 KiB
TypeScript
/*
|
|
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),
|
|
}
|
|
}
|