-
-
Description
+
+
+
+ rsync remote
+
+
+
+ SSH key
+
+
+
+
Notify these secondaries
+
+
+ Also written into the generated config as the transfer ACL.
+
+
+
+
TSIG key
+
+
+ Optional. Leave empty to authorize the notify source by address.
+
+
+
+
How it fetches
+
+
+ A note for whoever reads this later. NicTool sends nothing.
+
+
+
+
Interval (seconds)
+
+
0 publishes only when the store changes.
+
+
+ Cooldown (seconds)
+
+
+
+
+
+
+
+
+
+
+ Algorithm
+
+
+
+ Key directory
+
+
+
+
+
Cancel
diff --git a/index.js b/index.js
index 06c438d..ad34fa9 100644
--- a/index.js
+++ b/index.js
@@ -13,6 +13,7 @@ import {
buildRemoteApiConfig,
normalizeApiMode,
readApiConfig,
+ storeTypeToEnv,
toJson,
writeApiConfig,
writeBootstrap,
@@ -49,6 +50,8 @@ const MIME = {
* @param {object} [opts.suggestedPorts] Random port suggestions { api, client }.
* @param {Function} [opts.onSaved] Called with (config, ctx) after a successful save, once the
* response has flushed. May set ctx.apiServer.
+ * @param {Function} [opts.startApi] Called with (config); resolves { apiServer, apiRemoteUrl, error }.
+ * @param {Function} [opts.stopApi] Called with (ctx) to shut down a locally started API.
* @returns {Promise}
*/
export async function startServer({
@@ -59,8 +62,11 @@ export async function startServer({
nicConfig = null,
apiServer = null,
apiRemoteUrl = null,
+ apiPid = null,
suggestedPorts = null,
onSaved = null,
+ startApi = null,
+ stopApi = null,
supervisor = null,
}) {
// ctx is mutated as services start/stop
@@ -70,12 +76,20 @@ export async function startServer({
nicConfig,
apiServer,
apiRemoteUrl,
+ apiPid,
suggestedPorts,
host,
onSaved,
+ startApi,
+ stopApi,
supervisor,
}
+ // An API started before us came up on the api.json already on disk.
+ if (apiServer || apiRemoteUrl) {
+ ctx.storeConfig = (await readApiConfig(configDir).catch(() => null))?.store ?? null
+ }
+
const server = https.createServer({ cert: tls.cert, key: tls.key }, (req, res) =>
handleRequest(req, res, ctx),
)
@@ -104,7 +118,7 @@ async function handleRequest(req, res, ctx) {
return await serveFile(res, page)
}
- if (url === '/nt/config' && method === 'GET') return serveConfig(res, ctx)
+ if (url === '/nt/config' && method === 'GET') return await serveConfig(res, ctx)
if (url === '/nt/config' && method === 'POST') return await saveConfig(req, res, ctx)
if (url?.startsWith('/nt/check-path') && method === 'GET')
return await checkPath(req, res, ctx)
@@ -116,6 +130,8 @@ async function handleRequest(req, res, ctx) {
if (url === '/nt/api-config' && method === 'GET')
return await serveApiConfig(res, ctx)
if (url === '/nt/service' && method === 'GET') return serveService(res, ctx)
+ if (url === '/nt/service' && method === 'POST')
+ return await controlService(req, res, ctx)
if (url === '/nt/status' && method === 'GET') return await serveStatus(res, ctx)
if (url === '/nt/nameservers/status' && method === 'GET')
return serveNameserversStatus(res, ctx)
@@ -193,27 +209,124 @@ async function serveStatic(req, res, rootDir, urlPrefix) {
}
}
-function serveConfig(res, { nicConfig, suggestedPorts, host }) {
- if (nicConfig) {
- respond(
- res,
- 200,
- 'application/json',
- JSON.stringify({ ...nicConfig, _hostname: host }, null, 2),
- )
- } else {
- respond(
+/**
+ * The configurator's view of what is already on disk. The store lives in
+ * api.json rather than the bootstrap file, so it is read back from there —
+ * without it the form comes up blank on every visit and the operator retypes
+ * a connection the server already has.
+ */
+async function serveConfig(res, { configDir, nicConfig, suggestedPorts, host }) {
+ const store = (await readApiConfig(configDir).catch(() => null))?.store ?? null
+
+ const config = nicConfig ? { ...nicConfig } : { _suggested: suggestedPorts ?? {} }
+ if (store?.type && !config.store) config.store = store
+ config._hostname = host
+
+ respond(res, 200, 'application/json', JSON.stringify(config, null, 2))
+}
+
+/**
+ * The store a running API loaded, as a URI with the password redacted. Built
+ * from the individual fields rather than store.dsn, which carries the password
+ * in clear — this is reported to a page served before anyone authenticates.
+ */
+function storeUri(store) {
+ if (!store?.type) return null
+
+ if (storeTypeToEnv(store.type) === 'mysql') {
+ const credentials = store.user
+ ? `${encodeURIComponent(store.user)}${store.password ? ':***' : ''}@`
+ : ''
+ const port = store.port ? `:${store.port}` : ''
+ return `mysql://${credentials}${store.host ?? '127.0.0.1'}${port}/${store.database ?? ''}`
+ }
+
+ return store.path ? `file://${store.path}` : null
+}
+
+// A tcp-mode API is reached by URL rather than by injection, so apiServer alone
+// does not tell us whether one is up.
+function serveService(res, ctx) {
+ const api = { running: Boolean(ctx.apiServer || ctx.apiRemoteUrl) }
+ if (ctx.apiError) api.error = ctx.apiError
+
+ // What the API actually came up on, so the configurator can show that its
+ // etc/api.json is the one in use rather than leaving the operator guessing.
+ if (api.running) {
+ api.mode = ctx.apiMode ?? normalizeApiMode(ctx.nicConfig?.api?.mode)
+ if (ctx.apiPid) api.pid = ctx.apiPid
+ const store = storeUri(ctx.storeConfig)
+ if (store) api.store = store
+ }
+
+ respond(res, 200, 'application/json', JSON.stringify({ api }, null, 2))
+}
+
+/**
+ * Start or stop the local API from the configurator, before anything is saved.
+ * Starting writes etc/api.json first — the API reads its store from there at
+ * module load, so it has to be on disk before the process or import happens.
+ */
+async function controlService(req, res, ctx) {
+ let body
+ try {
+ body = JSON.parse(await readBody(req))
+ } catch {
+ return respond(
res,
- 200,
+ 400,
'application/json',
- JSON.stringify({ _suggested: suggestedPorts ?? {}, _hostname: host }, null, 2),
+ JSON.stringify({ error: 'Invalid JSON' }),
)
}
-}
-function serveService(res, { apiServer }) {
- const api = { running: apiServer != null }
- respond(res, 200, 'application/json', JSON.stringify({ api }, null, 2))
+ const fail = (status, error) =>
+ respond(res, status, 'application/json', JSON.stringify({ error }))
+
+ if (body?.action === 'stop') {
+ try {
+ await ctx.stopApi?.(ctx)
+ } catch (err) {
+ return fail(500, err.message)
+ }
+ ctx.apiServer = null
+ ctx.apiRemoteUrl = null
+ ctx.apiError = null
+ ctx.apiMode = null
+ ctx.apiPid = null
+ return respond(res, 200, 'application/json', JSON.stringify({ running: false }))
+ }
+
+ if (body?.action !== 'start') return fail(400, 'action must be "start" or "stop"')
+ if (!ctx.startApi) return fail(501, 'This server cannot start an API')
+ if (ctx.apiServer || ctx.apiRemoteUrl)
+ return respond(res, 200, 'application/json', JSON.stringify({ running: true }))
+
+ const mode = normalizeApiMode(body?.api?.mode)
+ if (mode === 'remote') return fail(400, 'A remote API is not ours to start')
+
+ const invalid = validateConfig({ api: { ...body.api, mode }, store: body.store })
+ if (invalid) return fail(400, invalid)
+
+ try {
+ await writeApiConfig(ctx.configDir, body.store)
+ const started = await ctx.startApi({ api: { ...body.api, mode } })
+ ctx.apiServer = started?.apiServer ?? null
+ ctx.apiRemoteUrl = started?.apiRemoteUrl ?? null
+ ctx.storeConfig = body.store
+
+ if (!ctx.apiServer && !ctx.apiRemoteUrl) {
+ ctx.apiError = started?.error || 'The API did not start — see the server log'
+ return fail(500, ctx.apiError)
+ }
+ ctx.apiError = null
+ ctx.apiMode = mode
+ ctx.apiPid = started?.pid ?? null
+ respond(res, 200, 'application/json', JSON.stringify({ running: true }))
+ } catch (err) {
+ ctx.apiError = err.message
+ fail(500, err.message)
+ }
}
async function checkPath(req, res, { configDir }) {
@@ -385,11 +498,6 @@ async function saveConfig(req, res, ctx) {
configured: true,
api: { ...submitted.api, mode },
}
- // Nameservers belong in the store, which does not exist until the API starts.
- // onSaved persists them once it is up.
- ctx.pendingNameservers = Array.isArray(submitted.nameserver)
- ? submitted.nameserver
- : null
try {
// The store connection belongs to the API, not to the server. In remote
@@ -435,7 +543,6 @@ function validateConfig(config) {
})
.unknown(true)
.required(),
- nameserver: Joi.array().items(Joi.object().unknown(true)).optional(),
}).unknown(true)
const { error } = schema.validate(config, { abortEarly: true })
diff --git a/lib/config.js b/lib/config.js
index acd5425..6c2c545 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -95,7 +95,14 @@ export function splitLegacyConfig(legacy) {
}
// Nameserver topology still rides along here; moving it into the store is a
// separate change, gated on reconciling the API's nameserver schema.
- if (Array.isArray(legacy.nameserver)) bootstrap.nameserver = legacy.nameserver
+ //
+ // `engine` was an early v3 spelling of `type`, which the export types already
+ // named. Rewritten once, here, so nothing downstream has to know both.
+ if (Array.isArray(legacy.nameserver)) {
+ bootstrap.nameserver = legacy.nameserver.map(({ engine, ...ns }) =>
+ ns.type === undefined && engine !== undefined ? { ...ns, type: engine } : ns,
+ )
+ }
return { bootstrap, store: legacy.store ?? null }
}
diff --git a/lib/config.test.js b/lib/config.test.js
index 691aaa7..d70bc7e 100644
--- a/lib/config.test.js
+++ b/lib/config.test.js
@@ -141,7 +141,11 @@ engine = "native"
const cfg = await readBootstrap(dir)
assert.equal(cfg.nameserver.length, 1)
- assert.equal(cfg.nameserver[0].engine, 'native')
+ assert.equal(
+ cfg.nameserver[0].type,
+ 'native',
+ 'the legacy engine spelling is rewritten',
+ )
})
it(`writes nictool.json so the migration runs only once`, async () => {
diff --git a/lib/nameserver-config.js b/lib/nameserver-config.js
index 28da329..12db5a7 100644
--- a/lib/nameserver-config.js
+++ b/lib/nameserver-config.js
@@ -32,7 +32,7 @@ export async function loadFromStore() {
return runnableHere(await repo.get({}))
}
-export async function saveToStore(nameservers) {
+async function saveToStore(nameservers) {
if (!Array.isArray(nameservers) || !nameservers.length) return 0
const repo = await openRepo()
diff --git a/lib/nameserver-migrate.test.js b/lib/nameserver-migrate.test.js
index 6fc3417..21accc7 100644
--- a/lib/nameserver-migrate.test.js
+++ b/lib/nameserver-migrate.test.js
@@ -28,7 +28,7 @@ after(() => {
const nameserver = {
name: 'ns1.example.com.',
- engine: 'native',
+ type: 'native',
listen: [{ address: '127.0.0.1', port: 5353, proto: 'udp' }],
publisher: { type: 'memory' },
}
@@ -64,7 +64,7 @@ describe('nameserver migration into the store', () => {
assert.equal(cfg.store.type, 'json')
assert.equal(cfg.nameserver.length, 1)
- assert.equal(cfg.nameserver[0].engine, 'native')
+ assert.equal(cfg.nameserver[0].type, 'native')
})
it('does not duplicate on a second run', async () => {
diff --git a/lib/nameservers.js b/lib/nameservers.js
index 98b9a14..2e1fecd 100644
--- a/lib/nameservers.js
+++ b/lib/nameservers.js
@@ -5,38 +5,48 @@ import {
BindNS,
DbReplicationTransport,
KnotNS,
+ CorednsNS,
+ CorednsRedisPublisher,
MaradnsNS,
MaradnsPublisher,
MemoryPublisher,
MemorySigner,
MysqlSource,
NativeNS,
+ strategyFor,
+ NonePublisher,
NoneSigner,
NoopTransport,
+ PullTransport,
NsdNS,
PowerdnsDbPublisher,
PowerdnsNS,
+ DjbdnsNS,
Rfc1035Publisher,
Rfc1035Signer,
RsyncTransport,
TinydnsCdbPublisher,
- TinydnsNS,
FileSource,
} from '@nictool/dns-nameserver'
+import { nameserver } from '@nictool/validate'
-const FILE_ENGINE_CLASSES = {
+// The stored type may be a 2.x alias (tinydns, bind-nsupdate). @nictool/validate
+// owns that mapping, so the API and the supervisor cannot disagree about it.
+const { resolveType } = nameserver
+
+const FILE_NS_CLASSES = {
bind: BindNS,
knot: KnotNS,
nsd: NsdNS,
powerdns: PowerdnsNS,
- tinydns: TinydnsNS,
+ coredns: CorednsNS,
+ djbdns: DjbdnsNS,
maradns: MaradnsNS,
}
-// MaraDNS reads csv2, not RFC 1035, and tinydns reads a compiled cdb.
const DEFAULT_PUBLISHER = {
native: 'memory',
- tinydns: 'tinydns-cdb',
+ djbdns: 'tinydns-cdb',
maradns: 'maradns',
powerdns: 'rfc1035',
}
@@ -52,23 +62,23 @@ function formatListen(listen) {
}
/**
- * NameserverSupervisor – starts/stops the set of nameserver engines held in the
+ * NameserverSupervisor – starts/stops the set of nameservers held in the
* data store. Built to support many instances (so the deployment can run
- * several native NSes side-by-side, or mix native with export engines).
+ * several native NSes side-by-side, or mix native with exporting ones).
*
- * start() takes { store, nameserver } — the store connection the engines read
+ * start() takes { store, nameserver } — the store connection the nameservers read
* zone data from, and the nameserver records. Each record may override the
* default Source via its own `source`.
*
* Emits:
- * started(engine) — after successful start
- * stopped(engine) — after stop
- * error(engine, err)
+ * started(ns) — after successful start
+ * stopped(ns) — after stop
+ * error(ns, err)
*/
export class NameserverSupervisor extends EventEmitter {
constructor() {
super()
- this.engines = []
+ this.nameservers = []
}
async start(nicConfig) {
@@ -81,14 +91,15 @@ export class NameserverSupervisor extends EventEmitter {
for (let i = 0; i < list.length; i++) {
const nsCfg = list[i]
try {
- const engine = this._build(nsCfg, nicConfig, i)
- engine.on('error', (err) => this.emit('error', engine, err))
- await engine.start()
- this.engines.push(engine)
+ const ns = this._build(nsCfg, nicConfig, i)
+ ns.on('error', (err) => this.emit('error', ns, err))
+ await ns.start()
+ this._warnIfStale(ns, nsCfg.transport)
+ this.nameservers.push(ns)
console.log(
- `NS ${trimDot(engine.name ?? i)} started: engine=${engine.engine} listen=${formatListen(engine.listen)}`,
+ `NS ${trimDot(ns.name ?? i)} started: type=${ns.type} listen=${formatListen(ns.listen)}`,
)
- this.emit('started', engine)
+ this.emit('started', ns)
} catch (err) {
console.error(`Nameserver[${nsCfg.name ?? i}] failed to start: ${err.message}`)
this.emit('error', null, err)
@@ -97,24 +108,77 @@ export class NameserverSupervisor extends EventEmitter {
}
async stop() {
- for (const engine of this.engines) {
+ for (const ns of this.nameservers) {
try {
- await engine.stop()
- this.emit('stopped', engine)
+ await ns.stop()
+ this.emit('stopped', ns)
} catch (err) {
- console.error(`Nameserver[${engine.name}] stop failed: ${err.message}`)
+ console.error(`Nameserver[${ns.name}] stop failed: ${err.message}`)
}
}
- this.engines = []
+ this.nameservers = []
+ }
+
+ /**
+ * Tell every running nameserver the zone data changed, so those in event mode
+ * publish. Called by whoever performed the write — sources over MySQL cannot
+ * notice it themselves.
+ */
+ notifyZoneChanged(detail) {
+ for (const ns of this.nameservers) {
+ ns.source?.notifyZoneChanged?.(detail)
+ }
+ }
+
+ // Event mode with a source that neither watches nor gets notified publishes
+ // once at startup and then serves that snapshot forever.
+ _warnIfStale(ns, transportCfg) {
+ // A pull nameserver is meant to sit idle; nothing here publishes to it.
+ if (transportCfg?.type === 'pull') return
+ const interval = Number(transportCfg?.interval ?? 0)
+ if (interval > 0) return
+ if (ns.source?.watches) return
+ console.warn(
+ `NS ${trimDot(ns.name ?? ns.id)}: interval 0 with a source that does not ` +
+ `watch — it will publish once and then only on an external change notification`,
+ )
+ }
+
+ // 2.x's bind-nsupdate pushed changes into a running BIND with RFC 2136. That
+ // is a transport here, and resolving the type to plain bind does not carry it
+ // over — an adopted record left on noop would quietly write files nobody reads.
+ _warnIfPushLost(nsCfg) {
+ if (nsCfg.type !== 'bind-nsupdate') return
+ const transport = nsCfg.transport?.type ?? 'noop'
+ if (transport !== 'noop') return
+ console.warn(
+ `NS ${trimDot(nsCfg.name ?? '')}: adopted from 2.x bind-nsupdate, which pushed ` +
+ `updates over the network. Publishing zone files locally instead — set ` +
+ `transport.type (axfr or rsync) to deliver them.`,
+ )
+ }
+
+ // NOTIFY tells a secondary to come and transfer. PowerDNS at NATIVE is not
+ // replicating, so there may be nothing for it to transfer from.
+ _warnIfAxfrUnserved(publisherType, publisherCfg, transportCfg) {
+ if (transportCfg?.type !== 'axfr') return
+ if (publisherType !== 'powerdns-db') return
+ const domainType = (publisherCfg?.domainType ?? 'NATIVE').toUpperCase()
+ if (domainType === 'NATIVE') {
+ console.warn(
+ 'axfr transport with publisher.domainType=NATIVE — PowerDNS will not serve the ' +
+ 'transfer the NOTIFY asks for; set publisher.domainType=MASTER',
+ )
+ }
}
status() {
- return this.engines.map((e) => e.status())
+ return this.nameservers.map((ns) => ns.status())
}
_build(nsCfg, nicConfig, idx) {
- const engineName =
- nsCfg.engine ?? nsCfg.type ?? (nsCfg.exportType ? 'bind' : 'native')
+ const nsType = resolveType(nsCfg.type ?? 'native')
+ this._warnIfPushLost(nsCfg)
const source = this._buildSource(nsCfg, nicConfig)
// Normalize listen[] from either the richer form or the legacy {host,port} shape.
@@ -126,24 +190,28 @@ export class NameserverSupervisor extends EventEmitter {
: []
const transportCfg = nsCfg.transport ?? { type: 'noop', interval: 0, cooldown: 5 }
- const publisherCfg = nsCfg.publisher ?? {
- type: engineName === 'native' ? 'memory' : 'rfc1035',
- }
+ // Resolve the type here rather than in _buildPublisher: substituting a
+ // default cfg object with a type baked in meant DEFAULT_PUBLISHER was only
+ // consulted for `publisher: {}`, never for an omitted publisher, so djbdns
+ // and maradns silently got rfc1035.
+ const publisherCfg = nsCfg.publisher ?? {}
+ const publisherType = publisherCfg.type ?? DEFAULT_PUBLISHER[nsType] ?? 'rfc1035'
- const signer = this._buildSigner(nsCfg)
+ const signer = this._buildSigner(nsCfg, nsType, publisherType)
- if (engineName === 'native') {
- if (publisherCfg.type !== 'memory') {
- throw new Error(
- `native engine requires publisher.type=memory (got "${publisherCfg.type}")`,
- )
+ if (nsType === 'native') {
+ if (publisherType !== 'memory') {
+ throw new Error(`native requires publisher.type=memory (got "${publisherType}")`)
}
+ const memoryPublisher = new MemoryPublisher()
+ // MemorySigner edits the live zone map, which only the publisher holds.
+ signer.attach?.(memoryPublisher)
return new NativeNS({
id: nsCfg.id ?? idx + 1,
name: nsCfg.name,
listen,
source,
- publisher: new MemoryPublisher(),
+ publisher: memoryPublisher,
signer,
transport: new NoopTransport({
interval: Number(transportCfg.interval ?? 0),
@@ -153,11 +221,18 @@ export class NameserverSupervisor extends EventEmitter {
})
}
- const Klass = FILE_ENGINE_CLASSES[engineName]
- if (!Klass) throw new Error(`Unknown nameserver engine: "${engineName}"`)
+ const Klass = FILE_NS_CLASSES[nsType]
+ if (!Klass) throw new Error(`Unknown nameserver type: "${nsType}"`)
- const publisher = this._buildPublisher(publisherCfg, engineName)
+ const publisher = this._buildPublisher(
+ publisherCfg,
+ publisherType,
+ nsType,
+ transportCfg,
+ this._dnssecConfig(nsCfg, nsType),
+ )
const transport = this._buildTransport(transportCfg)
+ this._warnIfAxfrUnserved(publisherType, publisherCfg, transportCfg)
return new Klass({
id: nsCfg.id ?? idx + 1,
@@ -171,15 +246,25 @@ export class NameserverSupervisor extends EventEmitter {
})
}
- _buildPublisher(cfg, engineName) {
- const type = cfg?.type ?? DEFAULT_PUBLISHER[engineName] ?? 'rfc1035'
- switch (type) {
+ _buildPublisher(cfg, publisherType, nsType, transportCfg, dnssec) {
+ // A notify list configured on the transport also has to reach the generated
+ // server config: sending NOTIFY achieves nothing if the primary refuses the
+ // transfer that follows.
+ const notify =
+ transportCfg?.type === 'axfr'
+ ? (transportCfg.notify ?? (transportCfg.master ? [transportCfg.master] : []))
+ : []
+
+ switch (publisherType) {
case 'rfc1035':
// bind/knot/nsd all read RFC 1035 zone files but declare them in their
- // own config format, so the engine picks the config generator.
+ // own config format, so the nameserver type picks the config generator.
return new Rfc1035Publisher({
...cfg,
- config: cfg?.config === false ? null : { format: engineName, ...cfg?.config },
+ config:
+ cfg?.config === false
+ ? null
+ : { format: nsType, notify, dnssec, ...cfg?.config },
})
case 'maradns':
return new MaradnsPublisher({
@@ -190,8 +275,12 @@ export class NameserverSupervisor extends EventEmitter {
return new TinydnsCdbPublisher({ ...cfg })
case 'powerdns-db':
return new PowerdnsDbPublisher({ ...cfg })
+ case 'coredns-redis':
+ return new CorednsRedisPublisher({ ...cfg })
+ case 'none':
+ return new NonePublisher({ ...cfg })
default:
- throw new Error(`Unknown publisher.type: "${type}"`)
+ throw new Error(`Unknown publisher.type: "${publisherType}"`)
}
}
@@ -211,24 +300,67 @@ export class NameserverSupervisor extends EventEmitter {
})
case 'axfr':
return new AxfrTransport({
+ notify: cfg.notify,
master: cfg.master,
tsigKey: cfg.tsigKey,
+ port: cfg.port,
+ timeoutMs: cfg.timeoutMs,
+ attempts: cfg.attempts,
interval,
cooldown,
})
case 'db-replication':
return new DbReplicationTransport({ interval, cooldown })
+ case 'pull':
+ return new PullTransport({ interval, cooldown, source: cfg.source })
default:
throw new Error(`Unknown transport.type: "${type}"`)
}
}
- _buildSigner(nsCfg) {
+ /**
+ * DNSSEC is done with whatever the nameserver already provides, so this picks a
+ * strategy rather than a signer:
+ *
+ * signer bind/nsd/coredns read a signed zone file, so sign it here.
+ * engine Knot and PowerDNS sign their own zones; the config generator
+ * tells them to, and no signer runs.
+ * none djbdns and MaraDNS have no DNSSEC, and native would mean
+ * implementing the crypto here.
+ */
+ _buildSigner(nsCfg, nsType, publisherType) {
const d = nsCfg.dnssec
if (!d || !d.enabled) return new NoneSigner()
- const engineName = nsCfg.engine ?? nsCfg.type ?? 'native'
- if (engineName === 'native') return new MemorySigner(d)
- return new Rfc1035Signer(d)
+
+ switch (strategyFor(nsType)) {
+ case 'signer':
+ if (publisherType !== 'rfc1035') {
+ throw new Error(
+ `dnssec: ${nsType} can only be signed when publishing zone files ` +
+ `(publisher.type=rfc1035, got "${publisherType}")`,
+ )
+ }
+ return new Rfc1035Signer(d)
+ case 'memory':
+ // No external tool because there is no external server: MemorySigner
+ // signs the live map that NativeNS answers from.
+ return new MemorySigner(d)
+ case 'self':
+ // Signing happens on the far side; see _dnssecConfig.
+ return new NoneSigner()
+ default:
+ throw new Error(
+ `dnssec: ${nsType} has no signing support — ` +
+ `no tool ships with it, and NicTool does not sign zones itself`,
+ )
+ }
+ }
+
+ /** The policy a nameserver that signs for itself needs in its config. */
+ _dnssecConfig(nsCfg, nsType) {
+ const d = nsCfg.dnssec
+ if (!d?.enabled || strategyFor(nsType) !== 'self') return null
+ return { algorithm: d.algorithm, nsec3: Boolean(d.nsec3) }
}
_buildSource(nsCfg, nicConfig) {
diff --git a/lib/nameservers.test.js b/lib/nameservers.test.js
index 7dc7f49..c69ac22 100644
--- a/lib/nameservers.test.js
+++ b/lib/nameservers.test.js
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
+import dgram from 'node:dgram'
import dnsNode from 'node:dns/promises'
import fs from 'node:fs/promises'
import net from 'node:net'
@@ -85,14 +86,14 @@ ttl = 300
nameserver: [
{
name: 'ns1.example.com.',
- engine: 'native',
+ type: 'native',
listen: [{ address: '127.0.0.1', port: portA, proto: 'udp' }],
publisher: { type: 'memory' },
transport: { type: 'noop', interval: 0 },
},
{
name: 'ns2.example.com.',
- engine: 'native',
+ type: 'native',
listen: [{ address: '127.0.0.1', port: portB, proto: 'udp' }],
publisher: { type: 'memory' },
transport: { type: 'noop', interval: 0 },
@@ -106,7 +107,7 @@ ttl = 300
await fs.rm(storeDir, { recursive: true, force: true })
})
- it('starts two native engines and each answers queries', async () => {
+ it('starts two native nameservers and each answers queries', async () => {
const a = await makeResolver(portA).resolve4('www.example.com')
assert.deepEqual(a, ['192.0.2.20'])
@@ -114,12 +115,12 @@ ttl = 300
assert.deepEqual(b, ['10.0.0.1'])
})
- it('status() reports both engines as running', () => {
+ it('status() reports both nameservers as running', () => {
const s = supervisor.status()
assert.equal(s.length, 2)
for (const row of s) {
assert.equal(row.state, 'running')
- assert.equal(row.engine, 'native')
+ assert.equal(row.type, 'native')
}
})
@@ -138,7 +139,7 @@ ttl = 300
}
})
- it('rejects unknown engine types with a clear error', async () => {
+ it('rejects an unknown nameserver type with a clear error', async () => {
const sup2 = new NameserverSupervisor()
let errored = false
sup2.on('error', () => {
@@ -149,17 +150,17 @@ ttl = 300
nameserver: [
{
name: 'nope.',
- engine: 'nonesuch',
+ type: 'nonesuch',
publisher: { type: 'rfc1035', path: '/tmp' },
},
],
})
- assert.equal(sup2.status().length, 0, 'unknown engine should not have started')
+ assert.equal(sup2.status().length, 0, 'unknown type should not have started')
assert.equal(errored, true, 'supervisor should have emitted an error')
await sup2.stop()
})
- it('bind engine publishes RFC1035 zone files', async () => {
+ it('bind publishes RFC1035 zone files', async () => {
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-bind-'))
const sup2 = new NameserverSupervisor()
try {
@@ -168,7 +169,7 @@ ttl = 300
nameserver: [
{
name: 'bind1.',
- engine: 'bind',
+ type: 'bind',
publisher: { type: 'rfc1035', path: outDir },
transport: { type: 'noop', interval: 0, cooldown: 0 },
},
@@ -193,7 +194,7 @@ ttl = 300
}
})
- it('maradns engine publishes csv2 zone files and a mararc', async () => {
+ it('maradns publishes csv2 zone files and a mararc', async () => {
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-mara-'))
const sup2 = new NameserverSupervisor()
try {
@@ -202,7 +203,7 @@ ttl = 300
nameserver: [
{
name: 'mara1.',
- engine: 'maradns',
+ type: 'maradns',
// No publisher.type: maradns must not default to rfc1035, whose
// zone files MaraDNS cannot read.
publisher: { path: outDir },
@@ -224,4 +225,598 @@ ttl = 300
await fs.rm(outDir, { recursive: true, force: true })
}
})
+
+ // Omitting `publisher` entirely used to substitute {type:'rfc1035'} before
+ // DEFAULT_PUBLISHER was consulted, so tinydns and maradns got zone files
+ // their software cannot read.
+ it('picks the per-type default when no publisher is given at all', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-defpub-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'mara2.',
+ type: 'maradns',
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 1)
+ const ns = sup2.nameservers[0]
+ assert.equal(ns.publisher.constructor.name, 'MaradnsPublisher')
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ it('republishes on a store change when in event mode', async () => {
+ const watchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-evt-store-'))
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-evt-out-'))
+ await fs.writeFile(
+ path.join(watchDir, 'zone.json'),
+ JSON.stringify({ zone: [{ id: 1, zone: 'evt.example', ttl: 300, serial: 1 }] }),
+ )
+ await fs.writeFile(
+ path.join(watchDir, 'zone_record.json'),
+ JSON.stringify({
+ zone_record: [
+ { id: 1, zid: 1, type: 'A', owner: 'a', address: '192.0.2.1', ttl: 300 },
+ ],
+ }),
+ )
+
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'json', path: watchDir },
+ nameserver: [
+ {
+ name: 'evt1.',
+ type: 'bind',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ },
+ ],
+ })
+
+ const zoneFile = path.join(outDir, 'evt.example.zone')
+ assert.match(await fs.readFile(zoneFile, 'utf8'), /192\.0\.2\.1/)
+
+ // Wait on the nameserver rather than polling the file: how long fs.watch takes
+ // to deliver is the OS's business, and a wall-clock deadline turns a busy
+ // machine into a test failure.
+ const republished = new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('no republish within 20s')),
+ 20000,
+ )
+ sup2.nameservers[0].once('published', () => {
+ clearTimeout(timer)
+ resolve()
+ })
+ })
+
+ await fs.writeFile(
+ path.join(watchDir, 'zone_record.json'),
+ JSON.stringify({
+ zone_record: [
+ { id: 1, zid: 1, type: 'A', owner: 'a', address: '192.0.2.99', ttl: 300 },
+ ],
+ }),
+ )
+ await republished
+
+ assert.match(
+ await fs.readFile(zoneFile, 'utf8'),
+ /192\.0\.2\.99/,
+ 'edit should have triggered a republish',
+ )
+ } finally {
+ await sup2.stop()
+ await fs.rm(watchDir, { recursive: true, force: true })
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ // 2.x had bind-nsupdate as an export type of its own. It is BIND fed over the
+ // network (RFC 2136) rather than by file copy — a transport choice here, so
+ // the nameserver it resolves to is plain bind.
+ it('resolves the 2.x bind-nsupdate export type to bind', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-nsupdate-'))
+ const sup2 = new NameserverSupervisor()
+ const warnings = []
+ const realWarn = console.warn
+ console.warn = (...a) => warnings.push(a.join(' '))
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'nsupdate1.',
+ type: 'bind-nsupdate',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 1, 'bind-nsupdate should have started')
+ assert.equal(sup2.nameservers[0].type, 'bind')
+ assert.match(
+ await fs.readFile(path.join(outDir, 'named.conf'), 'utf8'),
+ /zone "example\.com"/,
+ )
+ assert.match(
+ warnings.join(),
+ /pushed updates over the network/,
+ 'and says the push mechanism did not come with it',
+ )
+ } finally {
+ console.warn = realWarn
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ // dynect was a SaaS provider; nothing here publishes to it. An adopted record
+ // keeps the type, but starting it would mean silently serving from elsewhere.
+ it('refuses a 2.x type nothing here implements', async () => {
+ const sup2 = new NameserverSupervisor()
+ const errors = []
+ sup2.on('error', (_e, err) => errors.push(err))
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [{ name: 'dyn1.', type: 'dynect', publisher: { type: 'none' } }],
+ })
+ assert.equal(sup2.status().length, 0)
+ assert.match(errors.at(-1).message, /Unknown nameserver type: "dynect"/)
+ await sup2.stop()
+ })
+
+ // djbdns is the package name; tinydns and axfrdns are its daemons, both
+ // reading the data.cdb this writes.
+ it('publishes a data.cdb for djbdns', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-djbdns-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'djbdns1.',
+ type: 'djbdns',
+ publisher: { path: outDir, compile: false },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 1)
+ assert.equal(sup2.nameservers[0].type, 'djbdns')
+ assert.equal(
+ sup2.nameservers[0].publisher.constructor.name,
+ 'TinydnsCdbPublisher',
+ 'djbdns must default to the cdb publisher, not rfc1035',
+ )
+ const data = await fs.readFile(path.join(outDir, 'data'), 'utf8')
+ assert.match(data, /example\.com/)
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ it('bind over axfr notifies the secondary and authorizes the transfer', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-axfr-'))
+ const secondary = dgram.createSocket('udp4')
+ const notified = []
+ await new Promise((r) => secondary.bind(0, '127.0.0.1', r))
+ secondary.on('message', (msg, rinfo) => {
+ // Question name starts at byte 12.
+ const labels = []
+ let off = 12
+ while (msg[off] !== 0) {
+ labels.push(msg.subarray(off + 1, off + 1 + msg[off]).toString())
+ off += 1 + msg[off]
+ }
+ notified.push(labels.join('.'))
+ const res = Buffer.alloc(12)
+ res.writeUInt16BE(msg.readUInt16BE(0), 0)
+ res.writeUInt16BE(0x8000 | (4 << 11), 2)
+ secondary.send(res, rinfo.port, rinfo.address)
+ })
+ const target = `127.0.0.1:${secondary.address().port}`
+
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'axfr1.',
+ type: 'bind',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: {
+ type: 'axfr',
+ notify: [target],
+ interval: 0,
+ cooldown: 0,
+ timeoutMs: 500,
+ attempts: 1,
+ },
+ },
+ ],
+ })
+
+ assert.equal(sup2.status().length, 1, 'the axfr nameserver should have started')
+ assert.deepEqual(notified.sort(), ['example.com', 'test.local'])
+
+ // The notify list must also reach named.conf, or the transfer the NOTIFY
+ // asks for gets refused.
+ const named = await fs.readFile(path.join(outDir, 'named.conf'), 'utf8')
+ assert.match(named, /allow-transfer \{ 127\.0\.0\.1; \};/)
+ assert.match(named, /also-notify \{ 127\.0\.0\.1; \};/)
+ assert.doesNotMatch(
+ named,
+ /127\.0\.0\.1:/,
+ 'the port belongs to NOTIFY, not the ACL',
+ )
+
+ const m = sup2.status()[0].publish
+ assert.equal(m.failures, 0)
+ assert.equal(m.last.zoneCount, 2)
+ } finally {
+ await sup2.stop()
+ secondary.close()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ it('reports a nameserver whose secondary never answers', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-axfr-dead-'))
+ const dead = dgram.createSocket('udp4')
+ await new Promise((r) => dead.bind(0, '127.0.0.1', r))
+
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'axfr2.',
+ type: 'nsd',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: {
+ type: 'axfr',
+ notify: [`127.0.0.1:${dead.address().port}`],
+ interval: 0,
+ cooldown: 0,
+ timeoutMs: 40,
+ attempts: 1,
+ },
+ },
+ ],
+ })
+
+ // The zones still published; only the notify leg failed.
+ const conf = await fs.readFile(path.join(outDir, 'nsd.conf'), 'utf8')
+ assert.match(conf, /provide-xfr: 127\.0\.0\.1 NOKEY/)
+ assert.match(
+ await fs.readFile(path.join(outDir, 'example.com.zone'), 'utf8'),
+ /SOA/,
+ )
+ } finally {
+ await sup2.stop()
+ dead.close()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ it('coredns publishes zone files and a Corefile', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-coredns-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'coredns1.',
+ type: 'coredns',
+ publisher: { path: outDir, config: { reload: '15s' } },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 1)
+
+ // Same RFC 1035 zone files bind reads.
+ const zone = await fs.readFile(path.join(outDir, 'example.com.zone'), 'utf8')
+ assert.match(zone, /\$ORIGIN example\.com\./)
+ assert.match(zone, /www\s+300\s+IN\s+A\s+192\.0\.2\.20/)
+
+ const corefile = await fs.readFile(path.join(outDir, 'Corefile'), 'utf8')
+ assert.match(corefile, /^example\.com \{$/m)
+ assert.match(corefile, /file example\.com\.zone \{/)
+ assert.match(corefile, /reload 15s/)
+ assert.match(corefile, /^test\.local \{$/m)
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ it('coredns over axfr writes a transfer block from the notify list', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-coredns-xfr-'))
+ const secondary = dgram.createSocket('udp4')
+ await new Promise((r) => secondary.bind(0, '127.0.0.1', r))
+ secondary.on('message', (msg, rinfo) => {
+ const res = Buffer.alloc(12)
+ res.writeUInt16BE(msg.readUInt16BE(0), 0)
+ res.writeUInt16BE(0x8000 | (4 << 11), 2)
+ secondary.send(res, rinfo.port, rinfo.address)
+ })
+
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'coredns2.',
+ type: 'coredns',
+ publisher: { path: outDir },
+ transport: {
+ type: 'axfr',
+ notify: [`127.0.0.1:${secondary.address().port}`],
+ interval: 0,
+ cooldown: 0,
+ timeoutMs: 500,
+ attempts: 1,
+ },
+ },
+ ],
+ })
+
+ const corefile = await fs.readFile(path.join(outDir, 'Corefile'), 'utf8')
+ assert.match(corefile, /transfer \{/)
+ assert.match(corefile, /to 127\.0\.0\.1/)
+ assert.equal(sup2.status()[0].publish.last.deliveryFailures, 0)
+ } finally {
+ await sup2.stop()
+ secondary.close()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ // A nameserver NicTool does not push to still deserves a record: a MaraDNS
+ // secondary running fetchzone from cron, or CoreDNS polling its own store.
+ it('a pull nameserver is a real record that writes nothing', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-pull-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'mara-secondary.',
+ type: 'maradns',
+ publisher: { type: 'none' },
+ transport: { type: 'pull', source: 'fetchzone from cron', interval: 0 },
+ },
+ ],
+ })
+
+ const s = sup2.status()[0]
+ assert.equal(s.state, 'running', 'it starts like any other nameserver')
+ assert.equal(s.publisher, 'NonePublisher')
+ assert.equal(s.transport, 'PullTransport')
+
+ // Zones are counted so the record says what the far side should hold...
+ assert.equal(s.publish.last.zoneCount, 2)
+ assert.equal(s.publish.failures, 0)
+ // ...and nothing counts as a failed delivery, because nothing was sent.
+ assert.equal(s.publish.last.deliveryFailures, 0)
+
+ const written = await fs.readdir(outDir)
+ assert.deepEqual(written, [], 'a pull nameserver writes no artifacts')
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ // DNSSEC uses whatever the nameserver ships. bind/nsd/coredns read a signed zone
+ // file, so the signing happens here; knot and powerdns sign for themselves and
+ // only need telling; the rest have no tool at all.
+ it('signs zone files for a nameserver that reads them', async (t) => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-sign-'))
+ const keyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-keys-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'signed.',
+ type: 'bind',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ dnssec: { enabled: true, keyset: keyDir },
+ },
+ ],
+ })
+
+ if (!sup2.status().length) return t.skip('dnssec-signzone not installed')
+
+ const zone = await fs.readFile(path.join(outDir, 'example.com.zone'), 'utf8')
+ assert.match(zone, /RRSIG/, 'the published zone is signed')
+ assert.match(zone, /DNSKEY/)
+
+ // The config still names the same file, which now holds signed content.
+ const named = await fs.readFile(path.join(outDir, 'named.conf'), 'utf8')
+ assert.match(named, /file "example\.com\.zone";/)
+
+ const keys = await fs.readdir(keyDir)
+ assert.ok(keys.length >= 4, 'a KSK and ZSK per zone')
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ await fs.rm(keyDir, { recursive: true, force: true })
+ }
+ })
+
+ it('tells knot to sign for itself rather than signing here', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-knot-dnssec-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'knot1.',
+ type: 'knot',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ dnssec: { enabled: true, algorithm: 'ED25519', nsec3: true },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 1)
+
+ const conf = await fs.readFile(path.join(outDir, 'knot.conf'), 'utf8')
+ assert.match(conf, /policy:/)
+ assert.match(conf, /algorithm: ed25519/)
+ assert.match(conf, /nsec3: on/)
+ assert.match(conf, /dnssec-signing: on/)
+
+ // Knot signs on load, so what we wrote is deliberately unsigned.
+ const zone = await fs.readFile(path.join(outDir, 'example.com.zone'), 'utf8')
+ assert.doesNotMatch(zone, /RRSIG/)
+ assert.equal(sup2.nameservers[0].signer.constructor.name, 'NoneSigner')
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
+
+ // native has no external tool because it is not an external server:
+ // MemorySigner signs the live zone map, which NativeNS answers from directly.
+ it('signs the native zone map in process', async (t) => {
+ const keyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-memkeys-'))
+ const port = await freePort()
+ const sup2 = new NameserverSupervisor()
+ try {
+ const { ensureKeys } = await import('@nictool/dns-nameserver/lib/dnssec.js')
+ try {
+ // Every zone in the store, because MemorySigner signs the whole map and
+ // fails the cycle on the first zone it has no key for.
+ for (const zone of ['example.com', 'test.local']) {
+ await ensureKeys({ keyDir, zone, algorithm: 'ECDSAP256SHA256' })
+ }
+ } catch {
+ return t.skip('dnssec-keygen not installed')
+ }
+
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'native-signed.',
+ type: 'native',
+ listen: [{ address: '127.0.0.1', port, proto: 'udp' }],
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ dnssec: { enabled: true, keyset: keyDir },
+ },
+ ],
+ })
+
+ assert.equal(sup2.status().length, 1, 'native accepts DNSSEC')
+ assert.equal(sup2.nameservers[0].signer.constructor.name, 'MemorySigner')
+
+ const records = sup2.nameservers[0].publisher.zones.get('example.com').records
+ const types = new Set(records.map((r) => r.type))
+ assert.ok(types.has('DNSKEY'), 'DNSKEY at the apex')
+ assert.ok(types.has('RRSIG'), 'the RRsets are signed')
+ assert.ok(types.has('NSEC'), 'an NSEC chain for authenticated denial')
+ } finally {
+ await sup2.stop()
+ await fs.rm(keyDir, { recursive: true, force: true })
+ }
+ })
+
+ it('refuses DNSSEC on a type that has no signing support', async () => {
+ for (const type of ['djbdns', 'maradns']) {
+ const sup2 = new NameserverSupervisor()
+ const errors = []
+ sup2.on('error', (_e, err) => errors.push(err))
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: `${type}-dnssec.`,
+ type,
+ listen: [{ address: '127.0.0.1', port: 15999, proto: 'udp' }],
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ dnssec: { enabled: true },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 0, `${type} should not have started`)
+ assert.match(errors.at(-1).message, /no signing support/, type)
+ await sup2.stop()
+ }
+ })
+
+ it('refuses to sign a publisher that writes no zone files', async () => {
+ const sup2 = new NameserverSupervisor()
+ const errors = []
+ sup2.on('error', (_e, err) => errors.push(err))
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'redis-dnssec.',
+ type: 'coredns',
+ publisher: { type: 'coredns-redis', address: '127.0.0.1:6399' },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ dnssec: { enabled: true },
+ },
+ ],
+ })
+ assert.equal(sup2.status().length, 0)
+ assert.match(errors.at(-1).message, /only be signed when publishing zone files/)
+ await sup2.stop()
+ })
+
+ it('publishes when told a MySQL-backed store changed', async () => {
+ const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-notify-'))
+ const sup2 = new NameserverSupervisor()
+ try {
+ await sup2.start({
+ store: { type: 'directory', path: storeDir },
+ nameserver: [
+ {
+ name: 'notify1.',
+ type: 'bind',
+ publisher: { type: 'rfc1035', path: outDir },
+ transport: { type: 'noop', interval: 0, cooldown: 0 },
+ },
+ ],
+ })
+
+ let published = 0
+ sup2.nameservers[0].on('published', () => published++)
+ sup2.notifyZoneChanged({ reason: 'test' })
+
+ const deadline = Date.now() + 5000
+ while (Date.now() < deadline && published === 0) {
+ await new Promise((r) => setTimeout(r, 25))
+ }
+ assert.equal(published > 0, true, 'notifyZoneChanged should drive a publish')
+ } finally {
+ await sup2.stop()
+ await fs.rm(outDir, { recursive: true, force: true })
+ }
+ })
})
diff --git a/package.json b/package.json
index 8ec155c..fe981d3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@nictool/server",
- "version": "0.2.2",
+ "version": "0.6.0",
"description": "NicTool server, the DNS managers server component",
"main": "index.js",
"type": "module",
@@ -60,12 +60,13 @@
},
"homepage": "https://github.com/NicTool/server#readme",
"dependencies": {
- "@nictool/api": "^3.0.2",
- "@nictool/dns-nameserver": "^0.9.0",
- "@nictool/dns-resource-record": "^1.8.1",
- "@nictool/dns-zone": "^1.2.3",
+ "@nictool/api": "^3.0.3",
+ "@nictool/dns-nameserver": "^0.10.0",
+ "@nictool/dns-resource-record": "^1.9.0",
+ "@nictool/dns-zone": "^1.2.4",
+ "@nictool/validate": "^0.9.2",
"joi": "^18.2.3",
- "mysql2": "^3.23.1",
+ "mysql2": "^3.23.2",
"smol-toml": "^1.7.1"
},
"prettier": {
@@ -77,9 +78,9 @@
"@eslint/js": "^10.0.1",
"eslint": "^10.8.0",
"globals": "^17.8.0",
- "@playwright/test": "^1.62.0",
- "jsdom": "^29.1.1",
+ "@playwright/test": "^1.62.1",
+ "jsdom": "^30.0.1",
"lit": "^3.3.3",
- "vite": "^8.1.5"
+ "vite": "^8.2.0"
}
}
diff --git a/web/e2e/app.auth.spec.js b/web/e2e/app.auth.spec.js
new file mode 100644
index 0000000..ba809bb
--- /dev/null
+++ b/web/e2e/app.auth.spec.js
@@ -0,0 +1,66 @@
+import { expect, test } from '@playwright/test'
+
+const json = (route, body) =>
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
+
+// The navbar is a sibling of #loggedInMain rather than a child, so nothing about
+// showing the login form hides it on its own.
+test.describe('logged out', () => {
+ test('shows the login form without the tabs or the profile menu', async ({ page }) => {
+ await page.route('**/nt/config', (r) => json(r, {}))
+ await page.goto('/')
+
+ await expect(page.locator('#login_div')).toBeVisible()
+ await expect(page.locator('#mainTabs')).toBeHidden()
+ await expect(page.locator('#profileMenu')).toBeHidden()
+ await expect(page.locator('#logout_button')).toBeHidden()
+ })
+
+ test('an expired token falls back to the login form, still without menus', async ({
+ page,
+ }) => {
+ await page
+ .context()
+ .addCookies([{ name: 'nt-token', value: 'stale', url: 'http://localhost:5175' }])
+ await page.route('**/nt/config', (r) => json(r, {}))
+ await page.route(/\/api\/session/, (r) =>
+ json(r, { error: true, message: 'Token expired' }),
+ )
+
+ await page.goto('/')
+
+ await expect(page.locator('#login_div')).toBeVisible()
+ await expect(page.locator('#mainTabs')).toBeHidden()
+ await expect(page.locator('#profileMenu')).toBeHidden()
+ })
+
+ test('logging in reveals the tabs and the profile menu', async ({ page }) => {
+ await page
+ .context()
+ .addCookies([
+ { name: 'nt-token', value: 'test-token', url: 'http://localhost:5175' },
+ ])
+ await page.route('**/nt/config', (r) => json(r, {}))
+ await page.route(/\/api\/session/, (r) =>
+ json(r, { user: { id: 1, username: 'admin' }, group: { id: 1, name: 'Test' } }),
+ )
+ await page.route(/\/api\/group\?/, (r) => json(r, { group: [] }))
+ await page.route(/\/api\/group\/\d+/, (r) =>
+ json(r, { group: { id: 1, name: 'Test', parent_gid: 0 } }),
+ )
+ await page.route(/\/api\/nameserver/, (r) => json(r, { nameserver: [] }))
+ await page.route(/\/api\/user(\?|$)/, (r) => json(r, { user: [] }))
+ await page.route(/\/api\/zone\?/, (r) =>
+ json(r, {
+ zone: [],
+ meta: { pagination: { total: 0, filtered: 0, limit: 50, offset: 0 } },
+ }),
+ )
+
+ await page.goto('/')
+
+ await expect(page.locator('#login_div')).toBeHidden()
+ await expect(page.locator('#mainTabs')).toBeVisible()
+ await expect(page.locator('#profileMenu')).toBeVisible()
+ })
+})
diff --git a/web/e2e/app.ns-form.spec.js b/web/e2e/app.ns-form.spec.js
new file mode 100644
index 0000000..2596ab5
--- /dev/null
+++ b/web/e2e/app.ns-form.spec.js
@@ -0,0 +1,344 @@
+import { expect, test } from '@playwright/test'
+
+const json = (route, body) =>
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
+
+let saved
+let nameservers
+let store
+
+async function setup(page) {
+ saved = null
+ nameservers = []
+ store = { type: 'json', path: '/var/lib/nictool' }
+
+ await page
+ .context()
+ .addCookies([{ name: 'nt-token', value: 't', url: 'http://localhost:5175' }])
+ await page.route('**/nt/config', (r) => json(r, { store }))
+ await page.route(/\/api\/session/, (r) =>
+ json(r, { user: { id: 1, username: 'admin' }, group: { id: 1, name: 'Test' } }),
+ )
+ await page.route(/\/api\/group\?/, (r) => json(r, { group: [] }))
+ await page.route(/\/api\/group\/\d+/, (r) =>
+ json(r, { group: { id: 1, name: 'Test', parent_gid: 0 } }),
+ )
+ await page.route(/\/api\/user(\?|$)/, (r) => json(r, { user: [] }))
+ await page.route(/\/api\/zone\?/, (r) =>
+ json(r, {
+ zone: [],
+ meta: { pagination: { total: 0, filtered: 0, limit: 50, offset: 0 } },
+ }),
+ )
+ await page.route(/\/api\/nameserver/, async (r) => {
+ if (r.request().method() === 'GET') {
+ return json(r, {
+ nameserver: nameservers,
+ meta: { pagination: { total: nameservers.length, filtered: nameservers.length } },
+ })
+ }
+ saved = r.request().postDataJSON()
+ return json(r, { nameserver: [{ id: 1, ...saved }] })
+ })
+
+ await page.goto('/')
+ await page.locator('#tab-nameservers').click()
+}
+
+const openCreate = async (page) => {
+ await page.locator('#nsTable').getByRole('button', { name: '+ Create' }).click()
+ await expect(page.locator('#nsEditPane')).toBeVisible()
+}
+
+/** Fill the always-required fields so a test can focus on one thing. */
+async function fillBasics(page) {
+ await page.fill('#nsEditName', 'ns1.example.com.')
+ await page.fill('#nsEditAddress', '192.0.2.1')
+}
+
+test.describe('nameserver form', () => {
+ test('offers every type, and only publishers that type can read', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+
+ const types = await page.locator('#nsEditType option').allTextContents()
+ expect(types.length).toBe(8)
+
+ await page.selectOption('#nsEditType', 'maradns')
+ let publishers = await page
+ .locator('#nsEditPublisher option')
+ .evaluateAll((els) => els.map((e) => e.value))
+ // MaraDNS reads csv2; offering rfc1035 here would fail at start().
+ expect(publishers).toContain('maradns')
+ expect(publishers).not.toContain('rfc1035')
+
+ await page.selectOption('#nsEditType', 'coredns')
+ publishers = await page
+ .locator('#nsEditPublisher option')
+ .evaluateAll((els) => els.map((e) => e.value))
+ expect(publishers).toEqual(['rfc1035', 'coredns-redis', 'none'])
+ })
+
+ test('switching type replaces a publisher the new type cannot use', async ({
+ page,
+ }) => {
+ await setup(page)
+ await openCreate(page)
+
+ await page.selectOption('#nsEditType', 'coredns')
+ await page.selectOption('#nsEditPublisher', 'coredns-redis')
+ await expect(page.locator('#nsEditPublisher')).toHaveValue('coredns-redis')
+
+ await page.selectOption('#nsEditType', 'bind')
+ await expect(page.locator('#nsEditPublisher')).toHaveValue('rfc1035')
+ })
+
+ test('shows only the fields the chosen publisher needs', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+
+ // bind + rfc1035: a path, no Redis or DSN.
+ await expect(page.locator('[data-field="path"]')).toBeVisible()
+ await expect(page.locator('[data-field="address"]')).toBeHidden()
+ await expect(page.locator('[data-field="dsn"]')).toBeHidden()
+
+ await page.selectOption('#nsEditType', 'coredns')
+ await page.selectOption('#nsEditPublisher', 'coredns-redis')
+ await expect(page.locator('[data-field="address"]')).toBeVisible()
+ await expect(page.locator('[data-field="path"]')).toBeHidden()
+
+ await page.selectOption('#nsEditType', 'powerdns')
+ await page.selectOption('#nsEditPublisher', 'powerdns-db')
+ await expect(page.locator('[data-field="dsn"]')).toBeVisible()
+ await expect(page.locator('[data-field="domainType"]')).toBeVisible()
+ })
+
+ test('native asks for listen sockets and nothing else', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await page.selectOption('#nsEditType', 'native')
+
+ await expect(page.locator('#nsEditListenSection')).toBeVisible()
+ await expect(page.locator('#nsEditListenBody tr')).toHaveCount(1)
+ await expect(page.locator('#nsEditPublisher')).toHaveValue('memory')
+ await expect(page.locator('[data-field="path"]')).toBeHidden()
+
+ await page.locator('#nsEditAddListen').click()
+ await expect(page.locator('#nsEditListenBody tr')).toHaveCount(2)
+
+ await page.locator('#nsEditListenBody tr').first().getByRole('button').click()
+ await expect(page.locator('#nsEditListenBody tr')).toHaveCount(1)
+ })
+
+ test('blocks saving until the required fields are there', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+
+ await expect(page.locator('#nsEditSaveBtn')).toBeDisabled()
+ await expect(page.locator('#nsEditErrors')).toContainText('Name is required')
+
+ await page.fill('#nsEditName', 'ns1.example.com')
+ await expect(page.locator('#nsEditErrors')).toContainText('ending in a dot')
+
+ await page.fill('#nsEditName', 'ns1.example.com.')
+ await page.fill('#nsEditAddress', '192.0.2.1')
+ await page.fill('#nsPubPath', '/etc/bind/zones')
+ await expect(page.locator('#nsEditSaveBtn')).toBeEnabled()
+ await expect(page.locator('#nsEditErrors')).toBeHidden()
+ })
+
+ test('requires a remote for rsync and a target for AXFR', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+ await page.fill('#nsPubPath', '/etc/bind/zones')
+
+ await page.selectOption('#nsEditTransport', 'rsync')
+ await expect(page.locator('#nsEditErrors')).toContainText('rsync needs a remote')
+ await page.fill('#nsTrRemote', 'bind@ns1:/etc/bind/zones')
+ await expect(page.locator('#nsEditSaveBtn')).toBeEnabled()
+
+ await page.selectOption('#nsEditTransport', 'axfr')
+ await expect(page.locator('#nsEditErrors')).toContainText('notify target')
+ await page.fill('#nsTrNotify', '192.0.2.53')
+ await expect(page.locator('#nsEditSaveBtn')).toBeEnabled()
+ })
+
+ test('signs bind zone files here, and says so', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+ await page.fill('#nsPubPath', '/etc/bind/zones')
+
+ await expect(page.locator('#nsEditDnssec')).toBeEnabled()
+ await page.locator('#nsEditDnssec').check()
+
+ await expect(page.locator('#nsEditDnssecHelp')).toContainText('dnssec-signzone')
+ await expect(page.locator('#nsDsAlgorithm')).toBeVisible()
+ // Signing here means keys live here.
+ await expect(page.locator('[data-dnssec="keyset"]')).toBeVisible()
+ await expect(page.locator('#nsEditSaveBtn')).toBeEnabled()
+
+ await page.fill('#nsDsKeyset', '/var/lib/nictool/dnssec')
+ await page.selectOption('#nsDsAlgorithm', 'ED25519')
+ await page.locator('#nsDsNsec3').check()
+ await page.locator('#nsEditSaveBtn').click()
+
+ await expect.poll(() => saved).not.toBeNull()
+ expect(saved.dnssec).toEqual({
+ enabled: true,
+ nsec3: true,
+ algorithm: 'ED25519',
+ keyset: '/var/lib/nictool/dnssec',
+ })
+ })
+
+ test('leaves knot to sign for itself, with no key directory to fill in', async ({
+ page,
+ }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+ await page.selectOption('#nsEditType', 'knot')
+ await page.fill('#nsPubPath', '/etc/knot/zones')
+
+ await page.locator('#nsEditDnssec').check()
+ await expect(page.locator('#nsEditDnssecHelp')).toContainText('signs its own zones')
+ // Knot keeps its own keys, so asking for a directory would be a lie.
+ await expect(page.locator('[data-dnssec="keyset"]')).toBeHidden()
+ await expect(page.locator('#nsEditSaveBtn')).toBeEnabled()
+ })
+
+ test('cannot enable DNSSEC on a type that has none', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+
+ for (const type of ['djbdns', 'maradns']) {
+ await page.selectOption('#nsEditType', type)
+ await expect(page.locator('#nsEditDnssec')).toBeDisabled()
+ await expect(page.locator('#nsEditDnssecHelp')).toContainText('Not available')
+ }
+ })
+
+ test('cannot sign a publisher that writes no zone files', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+
+ await page.selectOption('#nsEditType', 'coredns')
+ await page.fill('#nsPubPath', '/etc/coredns/zones')
+ await page.locator('#nsEditDnssec').check()
+ await expect(page.locator('#nsEditDnssec')).toBeChecked()
+
+ // Redis holds rows, not zone files, so there is nothing to sign.
+ await page.selectOption('#nsEditPublisher', 'coredns-redis')
+ await expect(page.locator('#nsEditDnssec')).toBeDisabled()
+ await expect(page.locator('#nsEditDnssecHelp')).toContainText('only zone files')
+ })
+
+ test('warns that interval 0 over MySQL publishes once', async ({ page }) => {
+ await setup(page)
+ store = { type: 'mysql', host: '127.0.0.1' }
+ await page.reload()
+ await page.locator('#tab-nameservers').click()
+ await openCreate(page)
+ await fillBasics(page)
+ await page.fill('#nsPubPath', '/etc/bind/zones')
+
+ await page.fill('#nsTrInterval', '0')
+ await expect(page.locator('#nsEditWarnings')).toContainText('publish once at startup')
+
+ await page.fill('#nsTrInterval', '300')
+ await expect(page.locator('#nsEditWarnings')).toBeHidden()
+ })
+
+ test('warns that PowerDNS at NATIVE has no transfer to trigger', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+
+ await page.selectOption('#nsEditType', 'powerdns')
+ // rfc1035 is valid for powerdns too, so switching type keeps it; the
+ // database publisher is a deliberate choice.
+ await page.selectOption('#nsEditPublisher', 'powerdns-db')
+ await page.fill('#nsPubDsn', 'mysql://u:p@127.0.0.1/pdns')
+ await page.selectOption('#nsEditTransport', 'axfr')
+ await page.fill('#nsTrNotify', '192.0.2.53')
+
+ await expect(page.locator('#nsEditWarnings')).toContainText(
+ 'NATIVE does not replicate',
+ )
+ await page.selectOption('#nsPubDomainType', 'MASTER')
+ await expect(page.locator('#nsEditWarnings')).toBeHidden()
+ })
+
+ test('a pull nameserver publishes nothing and sends nothing', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+
+ await page.selectOption('#nsEditPublisher', 'none')
+ // Nothing was published, so there is nothing to deliver.
+ await expect(page.locator('#nsEditTransport')).toHaveValue('pull')
+ await expect(page.locator('[data-field="path"]')).toBeHidden()
+ await expect(page.locator('[data-field="pullSource"]')).toBeVisible()
+
+ await page.fill('#nsTrPullSource', 'fetchzone from cron')
+ await page.locator('#nsEditSaveBtn').click()
+
+ await expect.poll(() => saved).not.toBeNull()
+ expect(saved.publisher).toEqual({ type: 'none' })
+ expect(saved.transport.type).toBe('pull')
+ expect(saved.transport.source).toBe('fetchzone from cron')
+ })
+
+ test('saves the full runtime config, not just the 2.x fields', async ({ page }) => {
+ await setup(page)
+ await openCreate(page)
+ await fillBasics(page)
+
+ await page.selectOption('#nsEditType', 'bind')
+ await page.fill('#nsPubPath', '/etc/bind/zones')
+ await page.selectOption('#nsEditTransport', 'axfr')
+ await page.fill('#nsTrNotify', '192.0.2.53, 192.0.2.54:5353')
+ await page.fill('#nsTrInterval', '600')
+
+ await page.locator('#nsEditSaveBtn').click()
+ await expect.poll(() => saved).not.toBeNull()
+
+ expect(saved.type).toBe('bind')
+ expect(saved.publisher).toEqual({ type: 'rfc1035', path: '/etc/bind/zones' })
+ expect(saved.transport.type).toBe('axfr')
+ expect(saved.transport.notify).toEqual(['192.0.2.53', '192.0.2.54:5353'])
+ expect(saved.transport.interval).toBe(600)
+ expect(saved.gid).toBe(1)
+ })
+
+ test('reopens an existing record with its settings intact', async ({ page }) => {
+ await setup(page)
+ nameservers = [
+ {
+ id: 4,
+ gid: 1,
+ name: 'ns9.example.com.',
+ ttl: 3600,
+ address: '192.0.2.9',
+ type: 'coredns',
+ publisher: { type: 'coredns-redis', address: 'redis.internal:6379' },
+ transport: { type: 'rsync', remote: 'x@y:/z', interval: 120 },
+ },
+ ]
+ await page.reload()
+ await page.locator('#tab-nameservers').click()
+ await page.locator('#nsTable').locator('.ns-edit-btn').first().click()
+
+ await expect(page.locator('#nsEditPane')).toBeVisible()
+ await expect(page.locator('#nsEditName')).toHaveValue('ns9.example.com.')
+ await expect(page.locator('#nsEditType')).toHaveValue('coredns')
+ await expect(page.locator('#nsEditPublisher')).toHaveValue('coredns-redis')
+ await expect(page.locator('#nsPubAddress')).toHaveValue('redis.internal:6379')
+ await expect(page.locator('#nsEditTransport')).toHaveValue('rsync')
+ await expect(page.locator('#nsTrInterval')).toHaveValue('120')
+ })
+})
diff --git a/web/e2e/configure.spec.js b/web/e2e/configure.spec.js
index 024ee17..1342cdc 100644
--- a/web/e2e/configure.spec.js
+++ b/web/e2e/configure.spec.js
@@ -1,30 +1,51 @@
import { expect, test } from '@playwright/test'
// Drives the real configure.html against mocked /nt/* endpoints. Covers the
-// four-card flow: install type, API location, API status, data store.
+// three-card flow: install type, API service, data store.
// Mutable per-test state rather than re-registering routes: two handlers for the
// same pattern make precedence ambiguous.
let apiRunning
let posted
+let serviceCalls
+let startFails
+let runningStore
+let savedStore
test.beforeEach(async ({ page }) => {
apiRunning = false
posted = null
+ serviceCalls = []
+ startFails = null
+ savedStore = null
+ runningStore = 'file:///var/lib/nictool'
await page.route('**/nt/config', async (route) => {
if (route.request().method() === 'GET') {
- return route.fulfill({
- json: { _hostname: 'nt.example.com', _suggested: { api: 4321 } },
- })
+ const json = { _hostname: 'nt.example.com', _suggested: { api: 4321 } }
+ if (savedStore) json.store = savedStore
+ return route.fulfill({ json })
}
posted = route.request().postDataJSON()
await route.fulfill({ json: { ok: true } })
})
- await page.route('**/nt/service', (route) =>
- route.fulfill({ json: { api: { running: apiRunning } } }),
- )
+ await page.route('**/nt/service', async (route) => {
+ if (route.request().method() === 'GET') {
+ const api = { running: apiRunning }
+ if (apiRunning)
+ Object.assign(api, { mode: 'in_process', pid: 50495, store: runningStore })
+ return route.fulfill({ json: { api } })
+ }
+ const body = route.request().postDataJSON()
+ serviceCalls.push(body)
+ if (body.action === 'start' && startFails) {
+ return route.fulfill({ status: 500, json: { error: startFails } })
+ }
+ apiRunning = body.action === 'start'
+ if (apiRunning) runningStore = 'file://' + body.store.path
+ await route.fulfill({ json: { running: apiRunning } })
+ })
await page.route('**/nt/check-path*', (route) =>
route.fulfill({ json: { ok: true, exists: true, resolved: '/var/lib/nictool' } }),
@@ -40,10 +61,9 @@ test.describe('configurator flow', () => {
await expect(page.locator('.card h2')).toHaveText([
'Installation',
'API Service',
- 'API Status',
'Data Store',
- 'Nameservers',
])
+ await expect(page.locator('#api-status')).toHaveText('not started')
})
test('defaults to a new install with a JSON file store', async ({ page }) => {
@@ -149,11 +169,174 @@ test.describe('configurator flow', () => {
await expect(page.locator('#api-remote-fields')).toBeHidden()
})
- test('the API status card reflects /nt/service', async ({ page }) => {
+ test('the API status reflects /nt/service on load', async ({ page }) => {
+ apiRunning = true
+ await page.goto('/configure.html')
+
+ await expect(page.locator('#api-status')).toHaveText('running')
+ await expect(page.locator('#api-status')).toHaveClass(/\bok\b/)
+ await expect(page.locator('#btn-api-toggle')).toHaveText('Stop API')
+ })
+
+ test('the toggle is offered for local modes only', async ({ page }) => {
+ await page.goto('/configure.html')
+ await expect(page.locator('#btn-api-toggle')).toBeVisible()
+
+ await page.selectOption('#api-mode', 'tcp')
+ await expect(page.locator('#btn-api-toggle')).toBeVisible()
+
+ await page.selectOption('#api-mode', 'remote')
+ await expect(page.locator('#btn-api-toggle')).toBeHidden()
+ })
+
+ test('the toggle stays disabled until the store is usable', async ({ page }) => {
+ await page.goto('/configure.html')
+ await expect(page.locator('#btn-api-toggle')).toBeDisabled()
+ await expect(page.locator('#n-api-toggle')).toContainText('Finish the settings')
+
+ await page.fill('#store-path', '/var/lib/nictool')
+ await expect(page.locator('#btn-api-toggle')).toBeEnabled()
+ })
+
+ test('the toggle starts the API with the current store and api settings', async ({
+ page,
+ }) => {
+ await page.goto('/configure.html')
+ await page.fill('#store-path', '/var/lib/nictool')
+ await page.click('#btn-api-toggle')
+
+ await expect(page.locator('#api-status')).toHaveText('running')
+ await expect(page.locator('#api-status')).toHaveClass(/\bok\b/)
+
+ expect(serviceCalls).toHaveLength(1)
+ expect(serviceCalls[0].action).toBe('start')
+ expect(serviceCalls[0].api.mode).toBe('in_process')
+ expect(serviceCalls[0].store).toMatchObject({
+ type: 'json',
+ path: '/var/lib/nictool',
+ })
+ })
+
+ test('a running API reports the store it loaded from api.json', async ({ page }) => {
+ apiRunning = true
+ runningStore = 'mysql://nictool:***@127.0.0.1:3306/nictool'
+
+ await page.goto('/configure.html')
+
+ await expect(page.locator('#n-api-detail')).toHaveText(
+ 'pid: 50495, store: mysql://nictool:***@127.0.0.1:3306/nictool',
+ )
+ })
+
+ test('a running API freezes the store fields', async ({ page }) => {
apiRunning = true
await page.goto('/configure.html')
- await expect(page.locator('#boot-status')).toHaveText('API running')
+ await expect(page.locator('#store-type')).toBeDisabled()
+ await expect(page.locator('#store-path')).toBeDisabled()
+ await expect(page.locator('#n-store-lock')).toContainText('stop it to make changes')
+
+ await page.click('#btn-api-toggle')
+
+ await expect(page.locator('#store-type')).toBeEnabled()
+ await expect(page.locator('#store-path')).toBeEnabled()
+ await expect(page.locator('#n-store-lock')).toHaveText('')
+ })
+
+ test('a store already on disk comes back into the form', async ({ page }) => {
+ savedStore = {
+ type: 'mysql',
+ host: 'db.example.com',
+ port: 3306,
+ user: 'nictool',
+ password: 'secret',
+ database: 'nictool',
+ }
+
+ await page.goto('/configure.html')
+
+ await expect(page.locator('#store-type')).toHaveValue('mysql')
+ await expect(page.locator('#db-host')).toHaveValue('db.example.com')
+ await expect(page.locator('#db-user')).toHaveValue('nictool')
+ await expect(page.locator('#db-password')).toHaveValue('secret')
+ await expect(page.locator('#db-name')).toHaveValue('nictool')
+ })
+
+ test('an upgrade probes the schema as soon as the DSN connects', async ({ page }) => {
+ let probes = 0
+ await page.route('**/nt/detect-schema*', (route) => {
+ probes++
+ route.fulfill({ json: { ok: true, found: true, version: '2.41' } })
+ })
+
+ await page.goto('/configure.html')
+ await page.selectOption('#install-type', 'upgrade')
+ // No click on the probe button — the connection check drives it.
+ await page.fill('#db-dsn', 'mysql://nictool:pw@127.0.0.1:3306/nictool')
+
+ await expect(page.locator('#n-detect')).toContainText('2.41')
+ await expect(page.locator('#btn-save')).toBeEnabled()
+ expect(probes).toBe(1)
+ })
+
+ test('a store that connects but has no schema still blocks an upgrade', async ({
+ page,
+ }) => {
+ await page.route('**/nt/detect-schema*', (route) =>
+ route.fulfill({ json: { ok: true, found: false } }),
+ )
+
+ await page.goto('/configure.html')
+ await page.selectOption('#install-type', 'upgrade')
+ await page.fill('#db-dsn', 'mysql://nictool:pw@127.0.0.1:3306/nictool')
+
+ await expect(page.locator('#n-detect')).toContainText('no NicTool schema')
+ await expect(page.locator('#save-reason')).toContainText('holding a NicTool schema')
+ })
+
+ test('a blocked save says what is missing', async ({ page }) => {
+ await page.goto('/configure.html')
+ await expect(page.locator('#save-reason')).toContainText('complete the data store')
+
+ await page.fill('#store-path', '/var/lib/nictool')
+ await expect(page.locator('#btn-save')).toBeEnabled()
+ await expect(page.locator('#save-reason')).toHaveText('')
+ })
+
+ test('an upgrade with no connection yet says so', async ({ page }) => {
+ await page.goto('/configure.html')
+ await page.selectOption('#install-type', 'upgrade')
+
+ await expect(page.locator('#btn-save')).toBeDisabled()
+ await expect(page.locator('#save-reason')).toContainText(
+ 'connect to the 2.x database',
+ )
+ })
+
+ test('the toggle stops a running API', async ({ page }) => {
+ apiRunning = true
+ await page.goto('/configure.html')
+ await expect(page.locator('#btn-api-toggle')).toHaveText('Stop API')
+
+ await page.click('#btn-api-toggle')
+
+ await expect(page.locator('#api-status')).toHaveText('not started')
+ await expect(page.locator('#api-status')).not.toHaveClass(/\b(ok|err)\b/)
+ expect(serviceCalls).toEqual([{ action: 'stop' }])
+ })
+
+ test('a failed start turns the state red and reports why', async ({ page }) => {
+ startFails = 'ECONNREFUSED 127.0.0.1:3306'
+
+ await page.goto('/configure.html')
+ await page.fill('#store-path', '/var/lib/nictool')
+ await page.click('#btn-api-toggle')
+
+ await expect(page.locator('#api-status')).toHaveText('failed')
+ await expect(page.locator('#api-status')).toHaveClass(/\berr\b/)
+ await expect(page.locator('#n-api-detail')).toHaveText(startFails)
+ await expect(page.locator('#n-api-detail')).toHaveClass(/\berr\b/)
+ await expect(page.locator('#btn-api-toggle')).toHaveText('Start API')
})
test('saving posts install, store and api, then waits for the API', async ({
diff --git a/web/src/app.js b/web/src/app.js
index 448dd3d..a34e37a 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -9,6 +9,17 @@ import {
parseInputValue,
parseOptionalTtlValue,
} from './lib/format.js'
+import {
+ DNSSEC_ALGORITHMS,
+ NS_TYPES,
+ fieldsFor,
+ fromRecord,
+ publishersFor,
+ reconcile,
+ toPayload,
+ transportsFor,
+ validate,
+} from './lib/ns-form.js'
import { parseHumanTime, secondsToHuman } from './lib/time.js'
import { normalizeOwnerForZone } from './lib/zone-name.js'
import './components/zone-records.js'
@@ -234,10 +245,19 @@ function initGroupNav() {
})
}
+// The navbar lives outside #loggedInMain, so it has to be toggled explicitly —
+// otherwise the tabs and the Profile/Logout menu sit above the login form.
+function setNavVisible(visible) {
+ for (const id of ['mainTabs', 'profileMenu']) {
+ document.getElementById(id)?.classList.toggle('d-none', !visible)
+ }
+}
+
function onLoggedIn(response) {
document.getElementById('login_div').style.display = 'none'
document.getElementById('loggedInMain').style.display = 'block'
document.getElementById('loggedInMain').classList.add('show')
+ setNavVisible(true)
currentUser = response.user ?? null
currentGroupId = response.group?.id ?? null
@@ -254,6 +274,8 @@ function onLoggedIn(response) {
.then((r) => r.json())
.then((cfg) => {
if (cfg?.zone) zoneDefaults = { ...zoneDefaults, ...cfg.zone }
+ // Which store decides whether interval 0 is a working configuration.
+ storeType = cfg?.store?.type ?? null
})
.catch(() => {})
@@ -265,6 +287,7 @@ function onLoggedIn(response) {
function onLoggedOut() {
document.getElementById('loggedInMain').style.display = 'none'
document.getElementById('login_div').style.display = 'block'
+ setNavVisible(false)
}
const ZONE_SUBGROUPS_COOKIE = 'nt-zone-include-subgroups'
@@ -334,73 +357,350 @@ function initNsTable() {
}
let activeNsContext = null
+let nsForm = null
+
+const nsEl = (id) => document.getElementById(id)
+
+/** Which store the API is on, so the form can warn about event mode. */
+let storeType = null
function openNsPane(ns) {
activeNsContext = ns ? { mode: 'edit', ns } : { mode: 'create' }
- const isCreate = !ns
- document.getElementById('nsEditPaneLabel').textContent = isCreate
- ? 'Create Nameserver'
- : 'Edit Nameserver'
- document.getElementById('nsEditName').value = ns?.name ?? ''
- document.getElementById('nsEditDescription').value = ns?.description ?? ''
- document.getElementById('nsEditTtl').value = ns?.ttl ?? 86400
- document.getElementById('nsEditAddress').value = ns?.address ?? ''
- document.getElementById('nsEditAddress6').value = ns?.address6 ?? ''
- document.getElementById('nsEditExportType').value = ns?.export?.type ?? 'bind'
- document.getElementById('nsEditSaveBtn').textContent = isCreate ? 'Create' : 'Save'
- bootstrap.Modal.getOrCreateInstance(document.getElementById('nsEditPane')).show()
+ nsForm = fromRecord(ns)
+
+ nsEl('nsEditPaneLabel').textContent = ns ? 'Edit Nameserver' : 'Create Nameserver'
+ nsEl('nsEditSaveBtn').textContent = ns ? 'Save' : 'Create'
+
+ fillSelect(nsEl('nsEditType'), NS_TYPES)
+ renderNsForm()
+ bootstrap.Modal.getOrCreateInstance(nsEl('nsEditPane')).show()
}
-function initNsControls() {
- const saveBtn = document.getElementById('nsEditSaveBtn')
- if (saveBtn && !saveBtn.dataset.initialized) {
- saveBtn.dataset.initialized = 'true'
- saveBtn.addEventListener('click', async () => {
- const ctx = activeNsContext
- if (!ctx) return
+function fillSelect(select, options) {
+ select.innerHTML = ''
+ for (const opt of options) {
+ const el = document.createElement('option')
+ el.value = opt.value
+ el.textContent = opt.label ?? opt.value
+ select.appendChild(el)
+ }
+}
- const name = document.getElementById('nsEditName').value.trim()
- if (!name) {
- alert('Name is required.')
- return
- }
+/**
+ * Redraw from nsForm. Every change reconciles first, so a publisher the newly
+ * chosen type cannot read is replaced rather than left selected — the
+ * supervisor would refuse it at start(), long after the operator left.
+ */
+function renderNsForm() {
+ nsForm = reconcile(nsForm)
+ const fields = fieldsFor(nsForm)
+ const nsType = NS_TYPES.find((t) => t.value === nsForm.type)
+
+ nsEl('nsEditName').value = nsForm.name
+ nsEl('nsEditTtl').value = nsForm.ttl
+ nsEl('nsEditAddress').value = nsForm.address
+ nsEl('nsEditAddress6').value = nsForm.address6
+ nsEl('nsEditDescription').value = nsForm.description
+ nsEl('nsEditDnssec').checked = Boolean(nsForm.dnssec?.enabled)
+ if (!nsEl('nsDsAlgorithm').options.length) {
+ fillSelect(
+ nsEl('nsDsAlgorithm'),
+ DNSSEC_ALGORITHMS.map((value) => ({ value })),
+ )
+ }
+ nsEl('nsDsAlgorithm').value = nsForm.dnssec.algorithm
+ nsEl('nsDsNsec3').checked = Boolean(nsForm.dnssec.nsec3)
+ nsEl('nsDsKeyset').value = nsForm.dnssec.keyset
+
+ nsEl('nsEditType').value = nsForm.type
+ nsEl('nsEditTypeHelp').textContent =
+ nsType.serves === 'in-process'
+ ? 'Answers queries from this process.'
+ : 'Runs elsewhere; NicTool produces what it reads.'
+
+ fillSelect(nsEl('nsEditPublisher'), publishersFor(nsForm.type))
+ nsEl('nsEditPublisher').value = nsForm.publisherType
+ nsEl('nsEditPublisherHelp').textContent = fields.config
+ ? `Writes zone files and ${fields.config}.`
+ : ''
+
+ fillSelect(nsEl('nsEditTransport'), transportsFor(nsForm.type))
+ nsEl('nsEditTransport').value = nsForm.transportType
+ nsEl('nsEditTransportHelp').textContent =
+ nsForm.transportType === 'pull'
+ ? 'NicTool sends nothing; this server collects on its own schedule.'
+ : ''
+
+ showFields('nsEditPublisherFields', fields.publisher)
+ showFields('nsEditTransportFields', fields.transport)
+ nsEl('nsPubConfigName').textContent = fields.config ?? 'the server config'
+
+ nsEl('nsPubPath').value = nsForm.publisher.path
+ nsEl('nsPubWriteConfig').checked = nsForm.publisher.writeConfig
+ nsEl('nsPubCompile').checked = nsForm.publisher.compile
+ nsEl('nsPubDsn').value = nsForm.publisher.dsn
+ nsEl('nsPubDomainType').value = nsForm.publisher.domainType
+ nsEl('nsPubAddress').value = nsForm.publisher.address
+ nsEl('nsPubPassword').value = nsForm.publisher.password
+ nsEl('nsPubKeyPrefix').value = nsForm.publisher.keyPrefix
+
+ nsEl('nsTrRemote').value = nsForm.transport.remote
+ nsEl('nsTrSshKey').value = nsForm.transport.sshKey
+ nsEl('nsTrNotify').value = nsForm.transport.notify
+ nsEl('nsTrTsigKey').value = nsForm.transport.tsigKey
+ nsEl('nsTrPullSource').value = nsForm.transport.pullSource
+ nsEl('nsTrInterval').value = nsForm.transport.interval
+ nsEl('nsTrCooldown').value = nsForm.transport.cooldown
+
+ nsEl('nsEditListenSection').classList.toggle('d-none', !fields.listen)
+ if (fields.listen) renderListenRows()
+
+ renderDnssec(fields.dnssec)
+
+ renderNsMessages()
+}
- const ttlRaw = parseInt(document.getElementById('nsEditTtl').value, 10)
- const payload = {
- name,
- description: document.getElementById('nsEditDescription').value.trim(),
- ttl: Number.isFinite(ttlRaw) ? ttlRaw : 86400,
- address: document.getElementById('nsEditAddress').value.trim() || undefined,
- address6: document.getElementById('nsEditAddress6').value.trim() || undefined,
- export: { type: document.getElementById('nsEditExportType').value },
- }
- if (ctx.mode === 'create') payload.gid = currentGroupId
+/**
+ * DNSSEC looks different per nameserver type, so say which it is. bind/nsd/coredns are
+ * signed here and need somewhere to keep keys; knot and powerdns sign for
+ * themselves and manage their own, so a key directory would be a lie.
+ */
+function renderDnssec(dnssec) {
+ const checkbox = nsEl('nsEditDnssec')
+ checkbox.disabled = !dnssec.available
+ if (!dnssec.available) checkbox.checked = false
+
+ const on = dnssec.available && checkbox.checked
+ nsEl('nsEditDnssecFields').classList.toggle('d-none', !on)
+ for (const el of nsEl('nsEditDnssecFields').querySelectorAll(
+ '[data-dnssec="keyset"]',
+ )) {
+ el.classList.toggle('d-none', dnssec.strategy !== 'signer')
+ }
- const method = ctx.mode === 'create' ? 'POST' : 'PUT'
- const url =
- ctx.mode === 'create'
- ? `${API_URI}/nameserver`
- : `${API_URI}/nameserver/${ctx.ns.id}`
+ nsEl('nsEditDnssecHelp').textContent = !dnssec.available
+ ? `Not available: ${dnssec.reason}.`
+ : dnssec.strategy === 'signer'
+ ? 'Zone files are signed here with dnssec-signzone before delivery.'
+ : 'This server signs its own zones; NicTool writes the policy into its config.'
+}
- saveBtn.disabled = true
- try {
- const response = await ajax({ method, url, payload })
- if (!response || response?.error) {
- alert(response?.message ?? 'Save failed. See console for details.')
- return
- }
- bootstrap.Modal.getOrCreateInstance(document.getElementById('nsEditPane')).hide()
- showNameservers()
- } catch (err) {
- console.error('NS save failed:', err)
- alert('Save failed due to a network error.')
- } finally {
- saveBtn.disabled = false
- }
+function showFields(containerId, wanted) {
+ for (const el of nsEl(containerId).querySelectorAll('[data-field]')) {
+ el.classList.toggle('d-none', !wanted.includes(el.dataset.field))
+ }
+}
+
+function renderListenRows() {
+ const body = nsEl('nsEditListenBody')
+ body.innerHTML = ''
+
+ nsForm.listen.forEach((row, index) => {
+ const tr = document.createElement('tr')
+
+ const address = document.createElement('input')
+ address.type = 'text'
+ address.className = 'form-control form-control-sm'
+ address.value = row.address
+ address.placeholder = '127.0.0.1'
+ address.addEventListener('input', () => {
+ nsForm.listen[index].address = address.value
+ renderNsMessages()
+ })
+
+ const port = document.createElement('input')
+ port.type = 'number'
+ port.className = 'form-control form-control-sm'
+ port.value = row.port
+ port.min = 1
+ port.max = 65535
+ port.addEventListener('input', () => {
+ nsForm.listen[index].port = port.value
+ renderNsMessages()
+ })
+
+ const proto = document.createElement('select')
+ proto.className = 'form-select form-select-sm'
+ for (const value of ['udp', 'tcp']) {
+ const opt = document.createElement('option')
+ opt.value = value
+ opt.textContent = value
+ if (row.proto === value) opt.selected = true
+ proto.appendChild(opt)
+ }
+ proto.addEventListener('change', () => {
+ nsForm.listen[index].proto = proto.value
+ })
+
+ const remove = document.createElement('button')
+ remove.type = 'button'
+ remove.className = 'btn btn-sm btn-outline-danger'
+ remove.textContent = '\u00d7'
+ remove.title = 'Remove'
+ remove.addEventListener('click', () => {
+ nsForm.listen.splice(index, 1)
+ renderListenRows()
+ renderNsMessages()
})
+
+ for (const [child, cls] of [
+ [address, ''],
+ [port, ''],
+ [proto, ''],
+ [remove, 'text-end'],
+ ]) {
+ const td = document.createElement('td')
+ if (cls) td.className = cls
+ td.appendChild(child)
+ tr.appendChild(td)
+ }
+ body.appendChild(tr)
+ })
+}
+
+function renderNsMessages() {
+ readNsForm()
+ const { errors, warnings } = validate(nsForm, { storeType })
+
+ const errorBox = nsEl('nsEditErrors')
+ errorBox.classList.toggle('d-none', errors.length === 0)
+ errorBox.replaceChildren(...errors.map(asListItem))
+
+ const warnBox = nsEl('nsEditWarnings')
+ warnBox.classList.toggle('d-none', warnings.length === 0)
+ warnBox.replaceChildren(...warnings.map(asListItem))
+
+ nsEl('nsEditSaveBtn').disabled = errors.length > 0
+}
+
+function asListItem(text) {
+ const div = document.createElement('div')
+ div.textContent = text
+ return div
+}
+
+/** Pull the inputs back into nsForm, without redrawing. */
+function readNsForm() {
+ nsForm.name = nsEl('nsEditName').value
+ nsForm.ttl = nsEl('nsEditTtl').value
+ nsForm.address = nsEl('nsEditAddress').value
+ nsForm.address6 = nsEl('nsEditAddress6').value
+ nsForm.description = nsEl('nsEditDescription').value
+ nsForm.dnssec = {
+ enabled: nsEl('nsEditDnssec').checked,
+ algorithm: nsEl('nsDsAlgorithm').value,
+ nsec3: nsEl('nsDsNsec3').checked,
+ keyset: nsEl('nsDsKeyset').value,
+ }
+
+ nsForm.publisher = {
+ ...nsForm.publisher,
+ path: nsEl('nsPubPath').value,
+ writeConfig: nsEl('nsPubWriteConfig').checked,
+ compile: nsEl('nsPubCompile').checked,
+ dsn: nsEl('nsPubDsn').value,
+ domainType: nsEl('nsPubDomainType').value,
+ address: nsEl('nsPubAddress').value,
+ password: nsEl('nsPubPassword').value,
+ keyPrefix: nsEl('nsPubKeyPrefix').value,
+ }
+ nsForm.transport = {
+ ...nsForm.transport,
+ remote: nsEl('nsTrRemote').value,
+ sshKey: nsEl('nsTrSshKey').value,
+ notify: nsEl('nsTrNotify').value,
+ tsigKey: nsEl('nsTrTsigKey').value,
+ pullSource: nsEl('nsTrPullSource').value,
+ interval: nsEl('nsTrInterval').value,
+ cooldown: nsEl('nsTrCooldown').value,
}
}
+function initNsControls() {
+ const saveBtn = nsEl('nsEditSaveBtn')
+ if (!saveBtn || saveBtn.dataset.initialized === 'true') return
+ saveBtn.dataset.initialized = 'true'
+
+ // Changing any of these can change which fields apply, so they redraw.
+ for (const id of ['nsEditType', 'nsEditPublisher', 'nsEditTransport']) {
+ nsEl(id).addEventListener('change', () => {
+ readNsForm()
+ nsForm[
+ { nsEditType: 'type', nsEditPublisher: 'publisherType' }[id] ?? 'transportType'
+ ] = nsEl(id).value
+ renderNsForm()
+ })
+ }
+
+ // The rest only affect validity.
+ for (const id of [
+ 'nsEditName',
+ 'nsEditTtl',
+ 'nsEditAddress',
+ 'nsPubPath',
+ 'nsPubDsn',
+ 'nsPubAddress',
+ 'nsTrRemote',
+ 'nsTrNotify',
+ 'nsTrTsigKey',
+ 'nsTrInterval',
+ ]) {
+ nsEl(id).addEventListener('input', renderNsMessages)
+ }
+ nsEl('nsEditDnssec').addEventListener('change', () => {
+ readNsForm()
+ renderNsForm()
+ })
+ for (const id of ['nsPubDomainType', 'nsDsAlgorithm', 'nsDsNsec3']) {
+ nsEl(id).addEventListener('change', renderNsMessages)
+ }
+ nsEl('nsDsKeyset').addEventListener('input', renderNsMessages)
+
+ nsEl('nsEditAddListen').addEventListener('click', () => {
+ nsForm.listen.push({ address: '127.0.0.1', port: 53, proto: 'udp' })
+ renderListenRows()
+ renderNsMessages()
+ })
+
+ saveBtn.addEventListener('click', async () => {
+ const ctx = activeNsContext
+ if (!ctx) return
+
+ readNsForm()
+ const { errors } = validate(nsForm, { storeType })
+ if (errors.length) return renderNsMessages()
+
+ const payload = toPayload(nsForm)
+ if (ctx.mode === 'create') payload.gid = currentGroupId
+
+ const method = ctx.mode === 'create' ? 'POST' : 'PUT'
+ const url =
+ ctx.mode === 'create'
+ ? `${API_URI}/nameserver`
+ : `${API_URI}/nameserver/${ctx.ns.id}`
+
+ saveBtn.disabled = true
+ try {
+ const response = await ajax({ method, url, payload })
+ if (!response || response?.error) {
+ const box = nsEl('nsEditErrors')
+ box.classList.remove('d-none')
+ box.replaceChildren(asListItem(response?.message ?? 'Save failed.'))
+ return
+ }
+ bootstrap.Modal.getOrCreateInstance(nsEl('nsEditPane')).hide()
+ showNameservers()
+ } catch (err) {
+ console.error('NS save failed:', err)
+ const box = nsEl('nsEditErrors')
+ box.classList.remove('d-none')
+ box.replaceChildren(asListItem('Save failed due to a network error.'))
+ } finally {
+ saveBtn.disabled = false
+ }
+ })
+}
+
function showUsers() {
const el = document.getElementById('userTable')
if (!el) return
@@ -887,6 +1187,13 @@ function initZoneRecordModalActions() {
})
}
+// @nictool/dns-resource-record exports helper functions (classFor, applyMap, …)
+// alongside the record classes. Skipping them by name meant every helper added
+// later broke this loop — `new applyMap(null)` threw and took the page with it.
+function isRecordClass(exported) {
+ return typeof exported?.prototype?.getDescription === 'function'
+}
+
function populateZrEditType() {
const sel = document.getElementById('zrEditType')
@@ -907,7 +1214,7 @@ function populateZrEditType() {
}
for (const rr in RR) {
- if (['default', 'typeMap'].includes(rr)) continue
+ if (!isRecordClass(RR[rr])) continue
if (rr === 'SOA') continue // SOA is defined by the zone itself
const instance = new RR[rr](null)
const option = document.createElement('option')
diff --git a/web/src/lib/ns-form.js b/web/src/lib/ns-form.js
new file mode 100644
index 0000000..88abb69
--- /dev/null
+++ b/web/src/lib/ns-form.js
@@ -0,0 +1,508 @@
+/**
+ * The nameserver edit form's model: which pieces can be combined, what each
+ * needs, and what is worth warning about.
+ *
+ * Kept apart from the DOM because the rules are the interesting part and they
+ * come from the nameserver type, not from the markup — a publisher it cannot
+ * read, or a transport with nothing configured to deliver, fails at start()
+ * with a message nobody sees. The form's job is to make those combinations
+ * unreachable.
+ */
+
+export const DNSSEC_ALGORITHMS = [
+ 'ECDSAP256SHA256',
+ 'ECDSAP384SHA384',
+ 'ED25519',
+ 'ED448',
+ 'RSASHA256',
+ 'RSASHA512',
+]
+
+/** Publishers every external nameserver can use, whatever it reads. */
+const UNIVERSAL = ['none']
+
+export const NS_TYPES = [
+ {
+ value: 'native',
+ label: 'native (in-process)',
+ serves: 'in-process',
+ publishers: ['memory'],
+ transports: ['noop'],
+ needsListen: true,
+ dnssec: 'memory',
+ },
+ {
+ value: 'bind',
+ label: 'BIND',
+ serves: 'external',
+ publishers: ['rfc1035', ...UNIVERSAL],
+ transports: ['rsync', 'axfr', 'noop', 'pull'],
+ config: 'named.conf',
+ dnssec: 'signer',
+ },
+ {
+ value: 'knot',
+ label: 'Knot',
+ serves: 'external',
+ publishers: ['rfc1035', ...UNIVERSAL],
+ transports: ['rsync', 'axfr', 'noop', 'pull'],
+ config: 'knot.conf',
+ dnssec: 'self',
+ },
+ {
+ value: 'nsd',
+ label: 'NSD',
+ serves: 'external',
+ publishers: ['rfc1035', ...UNIVERSAL],
+ transports: ['rsync', 'axfr', 'noop', 'pull'],
+ config: 'nsd.conf',
+ dnssec: 'signer',
+ },
+ {
+ value: 'coredns',
+ label: 'CoreDNS',
+ serves: 'external',
+ publishers: ['rfc1035', 'coredns-redis', ...UNIVERSAL],
+ transports: ['rsync', 'axfr', 'noop', 'pull'],
+ config: 'Corefile',
+ dnssec: 'signer',
+ },
+ {
+ value: 'powerdns',
+ label: 'PowerDNS',
+ serves: 'external',
+ publishers: ['powerdns-db', 'rfc1035', ...UNIVERSAL],
+ transports: ['db-replication', 'rsync', 'axfr', 'noop', 'pull'],
+ dnssec: 'self',
+ },
+ {
+ value: 'djbdns',
+ label: 'djbdns (tinydns + axfrdns)',
+ serves: 'external',
+ publishers: ['tinydns-cdb', ...UNIVERSAL],
+ // No transfer config is generated for djbdns, so offering axfr here would
+ // promise a far side this cannot set up.
+ transports: ['rsync', 'noop', 'pull'],
+ dnssec: 'none',
+ },
+ {
+ value: 'maradns',
+ label: 'MaraDNS',
+ serves: 'external',
+ publishers: ['maradns', ...UNIVERSAL],
+ transports: ['rsync', 'noop', 'pull'],
+ config: 'mararc',
+ dnssec: 'none',
+ },
+]
+
+export const PUBLISHERS = {
+ memory: { label: 'memory (in-process map)', fields: [] },
+ rfc1035: { label: 'RFC 1035 zone files', fields: ['path', 'writeConfig'] },
+ maradns: { label: 'csv2 zone files', fields: ['path', 'writeConfig'] },
+ 'tinydns-cdb': { label: 'tinydns data.cdb', fields: ['path', 'compile'] },
+ 'powerdns-db': { label: 'PowerDNS database', fields: ['dsn', 'domainType'] },
+ 'coredns-redis': {
+ label: 'Redis (CoreDNS redis plugin)',
+ fields: ['address', 'password', 'keyPrefix'],
+ },
+ none: { label: 'none — this server fetches for itself', fields: [] },
+}
+
+export const TRANSPORTS = {
+ noop: { label: 'none needed', fields: ['interval', 'cooldown'] },
+ rsync: {
+ label: 'rsync over ssh',
+ fields: ['remote', 'sshKey', 'interval', 'cooldown'],
+ },
+ axfr: {
+ label: 'AXFR (send NOTIFY)',
+ fields: ['notify', 'tsigKey', 'interval', 'cooldown'],
+ },
+ 'db-replication': { label: 'database replication', fields: ['interval', 'cooldown'] },
+ pull: { label: 'the far side fetches', fields: ['pullSource', 'interval'] },
+}
+
+/** Per-type defaults, matching the supervisor's DEFAULT_PUBLISHER. */
+const DEFAULT_PUBLISHER = {
+ native: 'memory',
+ bind: 'rfc1035',
+ knot: 'rfc1035',
+ nsd: 'rfc1035',
+ coredns: 'rfc1035',
+ powerdns: 'powerdns-db',
+ djbdns: 'tinydns-cdb',
+ maradns: 'maradns',
+}
+
+export const nsTypeFor = (value) => NS_TYPES.find((t) => t.value === value) ?? NS_TYPES[0]
+
+export function publishersFor(nsType) {
+ return nsTypeFor(nsType).publishers.map((type) => ({
+ value: type,
+ ...PUBLISHERS[type],
+ }))
+}
+
+export function transportsFor(nsType) {
+ return nsTypeFor(nsType).transports.map((type) => ({
+ value: type,
+ ...TRANSPORTS[type],
+ }))
+}
+
+/**
+ * Keep a publisher/transport choice if the new type can still use it,
+ * otherwise fall back — switching bind to maradns must not leave rfc1035
+ * selected, which start() would reject.
+ */
+export function reconcile(form) {
+ const nsType = nsTypeFor(form.type)
+ const publisher = nsType.publishers.includes(form.publisherType)
+ ? form.publisherType
+ : (DEFAULT_PUBLISHER[nsType.value] ?? nsType.publishers[0])
+ const transport = nsType.transports.includes(form.transportType)
+ ? form.transportType
+ : nsType.transports[0]
+
+ // A publisher that writes nothing has nothing to deliver.
+ const forced = publisher === 'none' && transport !== 'pull' ? 'pull' : transport
+
+ // A nameserver that binds sockets needs somewhere to bind them. Seeding a row
+ // here rather than leaving the section empty means switching to native shows
+ // what it wants instead of only complaining that it is missing.
+ const listen =
+ nsType.needsListen && !(form.listen ?? []).length
+ ? [{ address: '127.0.0.1', port: 53, proto: 'udp' }]
+ : (form.listen ?? [])
+
+ return { ...form, publisherType: publisher, transportType: forced, listen }
+}
+
+/**
+ * Reconciles first, like validate and toPayload: a caller that changed the
+ * type without touching the publisher would otherwise be asked for fields
+ * belonging to a publisher that type cannot use.
+ */
+export function fieldsFor(formIn) {
+ const form = reconcile(formIn)
+ const nsType = nsTypeFor(form.type)
+ return {
+ publisher: PUBLISHERS[form.publisherType]?.fields ?? [],
+ transport: TRANSPORTS[form.transportType]?.fields ?? [],
+ listen: nsType.needsListen,
+ config: nsType.config ?? null,
+ dnssec: dnssecFor(form),
+ }
+}
+
+/**
+ * How this combination gets signed, and whether it can be at all.
+ *
+ * `signer` needs zone files to sign — a publisher that writes rows or keys has
+ * nothing for dnssec-signzone to read. `memory` signs the zone map in process,
+ * so it needs the memory publisher and no external tool.
+ */
+export function dnssecFor(formIn) {
+ const form = reconcile(formIn)
+ const strategy = nsTypeFor(form.type).dnssec ?? 'none'
+
+ if (strategy === 'signer' && form.publisherType !== 'rfc1035') {
+ return { available: false, strategy, reason: 'only zone files can be signed' }
+ }
+ if (strategy === 'memory' && form.publisherType !== 'memory') {
+ return { available: false, strategy, reason: 'only the zone map can be signed here' }
+ }
+ if (strategy === 'none') {
+ return { available: false, strategy, reason: 'no DNSSEC support' }
+ }
+ return { available: true, strategy }
+}
+
+const isPort = (v) => Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= 65535
+
+/**
+ * @returns {{ errors: string[], warnings: string[] }} errors block saving;
+ * warnings describe a configuration that will start and then disappoint.
+ */
+export function validate(formIn, { storeType } = {}) {
+ const form = reconcile(formIn)
+ const fields = fieldsFor(form)
+ const errors = []
+ const warnings = []
+
+ if (!form.name?.trim()) errors.push('Name is required.')
+ else if (!form.name.trim().endsWith('.')) {
+ errors.push('Name must be fully qualified, ending in a dot.')
+ }
+ if (!form.address?.trim()) errors.push('IPv4 address is required.')
+
+ if (fields.listen) {
+ const rows = (form.listen ?? []).filter((l) => l.address?.trim())
+ if (!rows.length)
+ errors.push('A native nameserver needs at least one listen address.')
+ else if (!rows.every((l) => isPort(l.port))) {
+ errors.push('Every listen row needs a port between 1 and 65535.')
+ }
+ }
+
+ if (fields.publisher.includes('path') && !form.publisher?.path?.trim()) {
+ errors.push('The publisher needs an output path.')
+ }
+ if (form.publisherType === 'powerdns-db' && !form.publisher?.dsn?.trim()) {
+ errors.push('The PowerDNS publisher needs a database DSN.')
+ }
+ if (form.publisherType === 'coredns-redis' && !form.publisher?.address?.trim()) {
+ errors.push('The Redis publisher needs an address.')
+ }
+
+ if (form.transportType === 'rsync' && !form.transport?.remote?.trim()) {
+ errors.push('rsync needs a remote, such as user@host:/etc/bind/zones.')
+ }
+ if (form.transportType === 'axfr' && !notifyList(form).length) {
+ errors.push('AXFR needs at least one notify target.')
+ }
+
+ const interval = Number(form.transport?.interval ?? 0)
+ if (form.transport?.interval !== undefined && interval < 0) {
+ errors.push('Interval cannot be negative.')
+ }
+
+ // --- warnings: these start, and then behave in a way worth knowing about ---
+
+ if (form.dnssec?.enabled) {
+ const dnssec = dnssecFor(form)
+ if (!dnssec.available) {
+ errors.push(
+ dnssec.strategy === 'none'
+ ? `${form.type} has no DNSSEC support, so it cannot be signed.`
+ : 'DNSSEC needs a publisher that writes zone files.',
+ )
+ } else if (dnssec.strategy === 'signer') {
+ warnings.push(
+ 'Zone files are signed here with dnssec-signzone; it must be installed on this host.',
+ )
+ } else if (dnssec.strategy === 'memory') {
+ // NSEC3 is unimplemented in MemorySigner, which throws rather than
+ // publishing an NSEC chain to someone who asked for NSEC3.
+ if (form.dnssec.nsec3) {
+ errors.push('The in-process signer supports NSEC only, not NSEC3.')
+ }
+ warnings.push(
+ 'NicTool signs the zone map itself, reading keys from the keyset directory. ' +
+ 'Generate them with dnssec-keygen; NicTool does not roll them.',
+ )
+ } else {
+ warnings.push(
+ `${form.type} signs its own zones — NicTool writes the policy into its config and manages no keys.`,
+ )
+ }
+ }
+
+ if (
+ interval === 0 &&
+ form.transportType !== 'pull' &&
+ storeType === 'mysql' &&
+ form.publisherType !== 'memory'
+ ) {
+ warnings.push(
+ 'Interval 0 waits for a change event, and a MySQL store sends none. ' +
+ 'This will publish once at startup and then only when something calls notifyZoneChanged.',
+ )
+ }
+
+ if (
+ form.publisherType === 'powerdns-db' &&
+ form.transportType === 'axfr' &&
+ (form.publisher?.domainType ?? 'NATIVE').toUpperCase() === 'NATIVE'
+ ) {
+ warnings.push(
+ 'PowerDNS at domain type NATIVE does not replicate, so there is no transfer for the NOTIFY to trigger. Use MASTER.',
+ )
+ }
+
+ if (form.publisherType === 'coredns-redis') {
+ warnings.push(
+ 'The CoreDNS redis plugin serves nine record types and PTR is not among them; reverse zones cannot be published here.',
+ )
+ }
+
+ if (form.transportType === 'axfr' && form.transport?.tsigKey?.trim()) {
+ const parts = form.transport.tsigKey.split(':')
+ if (parts.length < 2 || parts.length > 3) {
+ errors.push('TSIG key must be name:secret or algorithm:name:secret.')
+ }
+ }
+
+ return { errors, warnings }
+}
+
+function notifyList(form) {
+ const raw = form.transport?.notify
+ if (Array.isArray(raw)) return raw.filter((t) => String(t).trim())
+ return String(raw ?? '')
+ .split(/[\s,]+/)
+ .filter(Boolean)
+}
+
+/** Form state -> the record the API stores. */
+export function toPayload(formIn) {
+ const form = reconcile(formIn)
+ const fields = fieldsFor(form)
+
+ const payload = {
+ name: form.name.trim(),
+ ttl: Number(form.ttl) || 86400,
+ description: form.description?.trim() || '',
+ address: form.address?.trim() || undefined,
+ address6: form.address6?.trim() || undefined,
+ type: form.type,
+ publisher: { type: form.publisherType },
+ transport: { type: form.transportType },
+ }
+
+ if (fields.listen) {
+ payload.listen = (form.listen ?? [])
+ .filter((l) => l.address?.trim())
+ .map((l) => ({
+ address: l.address.trim(),
+ port: Number(l.port) || 53,
+ proto: l.proto || 'udp',
+ }))
+ }
+
+ const p = form.publisher ?? {}
+ for (const field of fields.publisher) {
+ switch (field) {
+ case 'path':
+ if (p.path?.trim()) payload.publisher.path = p.path.trim()
+ break
+ case 'writeConfig':
+ // `false` is meaningful — it suppresses the server config entirely.
+ if (p.writeConfig === false) payload.publisher.config = false
+ break
+ case 'compile':
+ if (p.compile === false) payload.publisher.compile = false
+ break
+ case 'dsn':
+ if (p.dsn?.trim()) payload.publisher.dsn = p.dsn.trim()
+ break
+ case 'domainType':
+ if (p.domainType) payload.publisher.domainType = p.domainType
+ break
+ case 'address':
+ if (p.address?.trim()) payload.publisher.address = p.address.trim()
+ break
+ case 'password':
+ if (p.password) payload.publisher.password = p.password
+ break
+ case 'keyPrefix':
+ if (p.keyPrefix?.trim()) payload.publisher.keyPrefix = p.keyPrefix.trim()
+ break
+ }
+ }
+
+ const t = form.transport ?? {}
+ for (const field of fields.transport) {
+ switch (field) {
+ case 'remote':
+ if (t.remote?.trim()) payload.transport.remote = t.remote.trim()
+ break
+ case 'sshKey':
+ if (t.sshKey?.trim()) payload.transport.sshKey = t.sshKey.trim()
+ break
+ case 'notify': {
+ const list = notifyList(form)
+ if (list.length) payload.transport.notify = list
+ break
+ }
+ case 'tsigKey':
+ if (t.tsigKey?.trim()) payload.transport.tsigKey = t.tsigKey.trim()
+ break
+ case 'pullSource':
+ if (t.pullSource?.trim()) payload.transport.source = t.pullSource.trim()
+ break
+ case 'interval':
+ payload.transport.interval = Number(t.interval ?? 0)
+ break
+ case 'cooldown':
+ payload.transport.cooldown = Number(t.cooldown ?? 5)
+ break
+ }
+ }
+
+ if (form.dnssec?.enabled) {
+ payload.dnssec = { enabled: true, nsec3: Boolean(form.dnssec.nsec3) }
+ if (form.dnssec.algorithm) payload.dnssec.algorithm = form.dnssec.algorithm
+ if (form.dnssec.keyset?.trim()) payload.dnssec.keyset = form.dnssec.keyset.trim()
+ }
+
+ return payload
+}
+
+/** A stored record -> form state, filling in what the record leaves out. */
+export function fromRecord(ns) {
+ const type = knownType(ns?.type)
+ const base = {
+ name: ns?.name ?? '',
+ ttl: ns?.ttl ?? 86400,
+ description: ns?.description ?? '',
+ address: ns?.address ?? '',
+ address6: ns?.address6 ?? '',
+ type,
+ publisherType: ns?.publisher?.type ?? DEFAULT_PUBLISHER[type] ?? 'rfc1035',
+ transportType: ns?.transport?.type ?? 'noop',
+ publisher: {
+ path: ns?.publisher?.path ?? '',
+ writeConfig: ns?.publisher?.config !== false,
+ compile: ns?.publisher?.compile !== false,
+ dsn: ns?.publisher?.dsn ?? '',
+ domainType: ns?.publisher?.domainType ?? 'NATIVE',
+ address: ns?.publisher?.address ?? '127.0.0.1:6379',
+ password: ns?.publisher?.password ?? '',
+ keyPrefix: ns?.publisher?.keyPrefix ?? '',
+ },
+ transport: {
+ remote: ns?.transport?.remote ?? '',
+ sshKey: ns?.transport?.sshKey ?? '',
+ notify: (ns?.transport?.notify ?? []).join(', '),
+ tsigKey: ns?.transport?.tsigKey ?? '',
+ pullSource: ns?.transport?.source ?? '',
+ interval: ns?.transport?.interval ?? 300,
+ cooldown: ns?.transport?.cooldown ?? 5,
+ },
+ listen: (ns?.listen ?? []).map((l) => ({
+ address: l.address ?? '',
+ port: l.port ?? 53,
+ proto: l.proto ?? 'udp',
+ })),
+ dnssec: {
+ enabled: Boolean(ns?.dnssec?.enabled),
+ algorithm: ns?.dnssec?.algorithm ?? 'ECDSAP256SHA256',
+ nsec3: Boolean(ns?.dnssec?.nsec3),
+ keyset: ns?.dnssec?.keyset ?? '',
+ },
+ }
+
+ if (nsTypeFor(type).needsListen && !base.listen.length) {
+ base.listen = [{ address: '127.0.0.1', port: 53, proto: 'udp' }]
+ }
+ return reconcile(base)
+}
+
+/**
+ * 2.x export types that name how a nameserver is fed, not a different one.
+ * Mirrors ALIASES in @nictool/validate, which the supervisor resolves with —
+ * duplicated because this file is bundled for the browser.
+ */
+const ALIASES = { 'bind-nsupdate': 'bind' }
+
+/**
+ * A 2.x install can also hold `dynect`, which nothing here implements. The
+ * select can only offer what it can build, so that lands on bind rather than
+ * on a value the select cannot show.
+ */
+function knownType(type) {
+ const resolved = ALIASES[type] ?? type
+ return NS_TYPES.some((t) => t.value === resolved) ? resolved : 'bind'
+}
diff --git a/web/src/lib/ns-form.test.js b/web/src/lib/ns-form.test.js
new file mode 100644
index 0000000..35a457c
--- /dev/null
+++ b/web/src/lib/ns-form.test.js
@@ -0,0 +1,414 @@
+// The form's rules exist so that combinations the supervisor would reject at
+// start() cannot be selected in the first place — a failed start is a line in a
+// log nobody is watching.
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import {
+ NS_TYPES,
+ dnssecFor,
+ fieldsFor,
+ fromRecord,
+ publishersFor,
+ reconcile,
+ toPayload,
+ transportsFor,
+ validate,
+} from './ns-form.js'
+
+/** A form that already validates, so a test can assert on one thing at a time. */
+const base = (over = {}) => {
+ const form = fromRecord({
+ name: 'ns1.example.com.',
+ address: '192.0.2.1',
+ type: 'bind',
+ publisher: { type: 'rfc1035', path: '/etc/bind/zones' },
+ })
+ return { ...form, ...over }
+}
+
+describe('what each type can be paired with', () => {
+ it('offers native only the in-process publisher', () => {
+ assert.deepEqual(
+ publishersFor('native').map((p) => p.value),
+ ['memory'],
+ )
+ assert.deepEqual(
+ transportsFor('native').map((t) => t.value),
+ ['noop'],
+ )
+ })
+
+ it('gives every external type a publisher its own software can read', () => {
+ const readable = {
+ bind: 'rfc1035',
+ knot: 'rfc1035',
+ nsd: 'rfc1035',
+ coredns: 'rfc1035',
+ powerdns: 'powerdns-db',
+ djbdns: 'tinydns-cdb',
+ maradns: 'maradns',
+ }
+ for (const [type, expected] of Object.entries(readable)) {
+ const offered = publishersFor(type).map((p) => p.value)
+ assert.ok(offered.includes(expected), `${type} should offer ${expected}`)
+ }
+ })
+
+ it('never offers a publisher the type cannot read', () => {
+ // The bug this prevents: maradns reads csv2, not RFC 1035 zone files.
+ assert.equal(publishersFor('maradns').includes('rfc1035'), false)
+ assert.equal(
+ publishersFor('djbdns')
+ .map((p) => p.value)
+ .includes('rfc1035'),
+ false,
+ )
+ })
+
+ it('offers redis only where a plugin exists to read it', () => {
+ for (const t of NS_TYPES) {
+ const offered = t.publishers.includes('coredns-redis')
+ assert.equal(offered, t.value === 'coredns', t.value)
+ }
+ })
+
+ it('offers AXFR only where the far-side config is generated', () => {
+ const withAxfr = NS_TYPES.filter((e) => e.transports.includes('axfr')).map(
+ (e) => e.value,
+ )
+ assert.deepEqual(withAxfr.sort(), ['bind', 'coredns', 'knot', 'nsd', 'powerdns'])
+ })
+})
+
+describe('reconcile', () => {
+ it('drops a publisher the new type cannot read', () => {
+ const out = reconcile(base({ type: 'maradns', publisherType: 'rfc1035' }))
+ assert.equal(out.publisherType, 'maradns')
+ })
+
+ it('keeps a choice that is still valid', () => {
+ const out = reconcile(base({ type: 'coredns', publisherType: 'coredns-redis' }))
+ assert.equal(out.publisherType, 'coredns-redis')
+ })
+
+ it('drops a transport the new type cannot use', () => {
+ const out = reconcile(base({ type: 'maradns', transportType: 'axfr' }))
+ assert.notEqual(out.transportType, 'axfr')
+ })
+
+ it('forces pull when nothing is published, since there is nothing to send', () => {
+ const out = reconcile(base({ publisherType: 'none', transportType: 'rsync' }))
+ assert.equal(out.transportType, 'pull')
+ })
+
+ it('seeds a listen row when the type needs one', () => {
+ const out = reconcile(base({ type: 'native', listen: [] }))
+ assert.deepEqual(out.listen, [{ address: '127.0.0.1', port: 53, proto: 'udp' }])
+ // Not for a type that binds nothing.
+ assert.deepEqual(reconcile(base({ listen: [] })).listen, [])
+ })
+
+ it('switching to native lands on memory and noop', () => {
+ const out = reconcile(
+ base({ type: 'native', publisherType: 'rfc1035', transportType: 'rsync' }),
+ )
+ assert.equal(out.publisherType, 'memory')
+ assert.equal(out.transportType, 'noop')
+ })
+})
+
+describe('validate', () => {
+ const errorsOf = (form, opts) => validate(form, opts).errors
+ const warningsOf = (form, opts) => validate(form, opts).warnings
+
+ it('requires a fully qualified name', () => {
+ assert.match(errorsOf(base({ name: '' })).join(), /Name is required/)
+ assert.match(errorsOf(base({ name: 'ns1.example.com' })).join(), /ending in a dot/)
+ assert.deepEqual(errorsOf(base({ name: 'ns1.example.com.' })), [])
+ })
+
+ it('requires an address', () => {
+ assert.match(errorsOf(base({ address: '' })).join(), /IPv4 address is required/)
+ })
+
+ it('requires a listen row for native, with a usable port', () => {
+ // A blank address survives reconcile's seeding, so this is still reachable.
+ const native = base({
+ type: 'native',
+ listen: [{ address: '', port: 53, proto: 'udp' }],
+ })
+ assert.match(errorsOf(native).join(), /at least one listen address/)
+
+ const badPort = base({
+ type: 'native',
+ listen: [{ address: '127.0.0.1', port: 99999, proto: 'udp' }],
+ })
+ assert.match(errorsOf(badPort).join(), /port between 1 and 65535/)
+ })
+
+ it('requires an output path where files are written', () => {
+ const form = base()
+ form.publisher = { ...form.publisher, path: '' }
+ assert.match(errorsOf(form).join(), /needs an output path/)
+ })
+
+ it('requires a remote for rsync and a target for axfr', () => {
+ const rsync = base({ transportType: 'rsync' })
+ rsync.transport.remote = ''
+ assert.match(errorsOf(rsync).join(), /rsync needs a remote/)
+
+ const axfr = base({ transportType: 'axfr' })
+ axfr.transport.notify = ''
+ assert.match(errorsOf(axfr).join(), /at least one notify target/)
+ })
+
+ it('accepts notify as a comma or space separated list', () => {
+ const form = base({ transportType: 'axfr' })
+ form.transport.notify = '192.0.2.1, 192.0.2.2:5353'
+ assert.deepEqual(errorsOf(form), [])
+ assert.deepEqual(toPayload(form).transport.notify, ['192.0.2.1', '192.0.2.2:5353'])
+ })
+
+ it('rejects a TSIG key that is not name:secret', () => {
+ const form = base({ transportType: 'axfr' })
+ form.transport.notify = '192.0.2.1'
+ form.transport.tsigKey = 'just-a-name'
+ assert.match(errorsOf(form).join(), /name:secret/)
+
+ form.transport.tsigKey = 'hmac-sha256:k:c2VjcmV0'
+ assert.deepEqual(errorsOf(form), [])
+ })
+
+ it('says who does the signing, per type', () => {
+ const bind = base({ dnssec: { enabled: true } })
+ assert.match(warningsOf(bind).join(), /signed here with dnssec-signzone/)
+ assert.deepEqual(errorsOf(bind), [], 'signing zone files is supported')
+
+ const knot = base({ type: 'knot', dnssec: { enabled: true } })
+ assert.match(warningsOf(knot).join(), /signs its own zones/)
+ assert.deepEqual(errorsOf(knot), [])
+ })
+
+ it('blocks DNSSEC on a type that has none', () => {
+ for (const type of ['djbdns', 'maradns']) {
+ const form = base({ type, dnssec: { enabled: true } })
+ assert.match(errorsOf(form).join(), /no DNSSEC support/, type)
+ }
+ })
+
+ it('signs native in process, with no external tool', () => {
+ const form = base({ type: 'native', dnssec: { enabled: true } })
+ assert.deepEqual(errorsOf(form), [])
+ assert.match(warningsOf(form).join(), /signs the zone map itself/)
+ })
+
+ it('rejects NSEC3 on the in-process signer, which only does NSEC', () => {
+ const form = base({ type: 'native', dnssec: { enabled: true, nsec3: true } })
+ assert.match(errorsOf(form).join(), /NSEC only, not NSEC3/)
+ })
+
+ it('blocks signing a publisher that writes no zone files', () => {
+ const form = base({
+ type: 'coredns',
+ publisherType: 'coredns-redis',
+ dnssec: { enabled: true },
+ })
+ form.publisher = { ...form.publisher, address: '127.0.0.1:6379' }
+ assert.match(errorsOf(form).join(), /needs a publisher that writes zone files/)
+ })
+
+ it('warns that interval 0 over MySQL publishes once and stops', () => {
+ const form = base()
+ form.transport.interval = 0
+ assert.match(
+ warningsOf(form, { storeType: 'mysql' }).join(),
+ /publish once at startup/,
+ )
+ // A file store watches, so the same setting is fine there.
+ assert.deepEqual(warningsOf(form, { storeType: 'json' }), [])
+ })
+
+ it('warns that PowerDNS at NATIVE has no transfer to trigger', () => {
+ const form = base({
+ type: 'powerdns',
+ publisherType: 'powerdns-db',
+ transportType: 'axfr',
+ })
+ form.publisher.dsn = 'mysql://u:p@127.0.0.1/pdns'
+ form.transport.notify = '192.0.2.1'
+ assert.match(warningsOf(form).join(), /NATIVE does not replicate/)
+
+ form.publisher.domainType = 'MASTER'
+ assert.equal(warningsOf(form).join().includes('NATIVE'), false)
+ })
+
+ it('warns that redis cannot hold reverse zones', () => {
+ const form = base({ type: 'coredns', publisherType: 'coredns-redis' })
+ assert.match(warningsOf(form).join(), /PTR is not among them/)
+ })
+})
+
+describe('toPayload', () => {
+ it('writes the shape the supervisor reads', () => {
+ const form = base()
+ form.transportType = 'rsync'
+ form.transport.remote = 'bind@ns1:/etc/bind/zones'
+ form.transport.interval = 300
+
+ assert.deepEqual(toPayload(form), {
+ name: 'ns1.example.com.',
+ ttl: 86400,
+ description: '',
+ address: '192.0.2.1',
+ address6: undefined,
+ type: 'bind',
+ publisher: { type: 'rfc1035', path: '/etc/bind/zones' },
+ transport: {
+ type: 'rsync',
+ remote: 'bind@ns1:/etc/bind/zones',
+ interval: 300,
+ cooldown: 5,
+ },
+ })
+ })
+
+ it('carries listen rows for native and nothing else', () => {
+ const native = base({
+ type: 'native',
+ listen: [
+ { address: '127.0.0.1', port: 53, proto: 'udp' },
+ { address: '127.0.0.1', port: 53, proto: 'tcp' },
+ ],
+ })
+ assert.equal(toPayload(native).listen.length, 2)
+ assert.equal(toPayload(base()).listen, undefined)
+ })
+
+ it('sends config:false only when the server config is switched off', () => {
+ const form = base()
+ form.publisher.path = '/tmp/z'
+ assert.equal(toPayload(form).publisher.config, undefined)
+
+ form.publisher.writeConfig = false
+ assert.equal(toPayload(form).publisher.config, false)
+ })
+
+ it('omits an empty optional rather than sending a blank string', () => {
+ const form = base({ transportType: 'rsync' })
+ form.transport.remote = 'host:/z'
+ form.transport.sshKey = ''
+ assert.equal('sshKey' in toPayload(form).transport, false)
+ })
+
+ it('only sends dnssec when it is turned on', () => {
+ assert.equal(toPayload(base()).dnssec, undefined)
+
+ const signed = toPayload(
+ base({
+ dnssec: {
+ enabled: true,
+ algorithm: 'ED25519',
+ nsec3: true,
+ keyset: '/var/lib/nictool/dnssec',
+ },
+ }),
+ )
+ assert.deepEqual(signed.dnssec, {
+ enabled: true,
+ nsec3: true,
+ algorithm: 'ED25519',
+ keyset: '/var/lib/nictool/dnssec',
+ })
+ })
+})
+
+describe('fromRecord', () => {
+ it('round-trips a stored record', () => {
+ const stored = {
+ name: 'ns2.example.com.',
+ ttl: 3600,
+ address: '192.0.2.9',
+ type: 'coredns',
+ publisher: { type: 'coredns-redis', address: 'redis:6379', keyPrefix: '_dns:' },
+ transport: { type: 'axfr', notify: ['192.0.2.1', '192.0.2.2'], interval: 600 },
+ }
+ const form = fromRecord(stored)
+ assert.equal(form.type, 'coredns')
+ assert.equal(form.publisherType, 'coredns-redis')
+ assert.equal(form.transport.notify, '192.0.2.1, 192.0.2.2')
+
+ const back = toPayload(form)
+ assert.equal(back.publisher.address, 'redis:6379')
+ assert.deepEqual(back.transport.notify, ['192.0.2.1', '192.0.2.2'])
+ })
+
+ it('gives a native record a starter listen row', () => {
+ const form = fromRecord({ name: 'ns.', address: '192.0.2.1', type: 'native' })
+ assert.equal(form.listen.length, 1)
+ assert.equal(form.listen[0].port, 53)
+ })
+
+ it('maps a 2.x record onto its type', () => {
+ const form = fromRecord({ name: 'old.', address: '192.0.2.1', type: 'djbdns' })
+ assert.equal(form.type, 'djbdns')
+ assert.equal(form.publisherType, 'tinydns-cdb')
+ })
+
+ // dynect and bind-nsupdate are export types a 2.x install can hold that no v3
+ // nameserver implements. The select can only offer what it can build.
+ it('falls back to bind for a type nothing here implements', () => {
+ for (const type of ['dynect', 'bind-nsupdate']) {
+ const form = fromRecord({ name: 'old.', address: '192.0.2.1', type })
+ assert.equal(form.type, 'bind', type)
+ }
+ })
+
+ it('starts a new record on defaults that validate', () => {
+ const form = fromRecord(null)
+ form.name = 'ns1.example.com.'
+ form.address = '192.0.2.1'
+ form.publisher.path = '/etc/bind/zones'
+ assert.deepEqual(validate(form).errors, [])
+ })
+})
+
+describe('dnssecFor', () => {
+ it('routes each type to the tool that ships with it', () => {
+ assert.deepEqual(dnssecFor(base()), { available: true, strategy: 'signer' })
+ assert.deepEqual(dnssecFor(base({ type: 'knot' })), {
+ available: true,
+ strategy: 'self',
+ })
+ assert.deepEqual(dnssecFor(base({ type: 'powerdns' })), {
+ available: true,
+ strategy: 'self',
+ })
+ assert.deepEqual(dnssecFor(base({ type: 'native' })), {
+ available: true,
+ strategy: 'memory',
+ })
+ })
+
+ it('reports why it is unavailable rather than just hiding it', () => {
+ assert.match(dnssecFor(base({ type: 'maradns' })).reason, /no DNSSEC support/)
+ assert.match(
+ dnssecFor(base({ type: 'coredns', publisherType: 'coredns-redis' })).reason,
+ /only zone files/,
+ )
+ })
+})
+
+describe('fieldsFor', () => {
+ it('asks for a path where files are written and nothing where they are not', () => {
+ assert.ok(fieldsFor(base()).publisher.includes('path'))
+ assert.deepEqual(fieldsFor(base({ type: 'native' })).publisher, [])
+ assert.deepEqual(fieldsFor(base({ publisherType: 'none' })).publisher, [])
+ })
+
+ it('names the config file the type will get', () => {
+ assert.equal(fieldsFor(base()).config, 'named.conf')
+ assert.equal(fieldsFor(base({ type: 'coredns' })).config, 'Corefile')
+ assert.equal(fieldsFor(base({ type: 'native' })).config, null)
+ })
+})