Files
DenozordecandClaude Opus 5.5 392b644faa 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>
2026-11-01 01:20:05 +07:00

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),
}
}