From 5521ef92da8ada5f6f2ab1c07df1f7b88168bd44 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 30 Jul 2026 17:34:30 -0700 Subject: [PATCH 1/2] release 0.6.0 lots of nameserver plumbing --- .gitignore | 1 + CHANGELOG.md | 4 + CONTRIBUTORS.md | 2 +- README.md | 42 +- bin/start.js | 124 ++++- html/configure.html | 928 ++++++++------------------------- html/index.html | 325 ++++++++++-- index.js | 153 +++++- lib/config.js | 9 +- lib/config.test.js | 6 +- lib/nameserver-config.js | 2 +- lib/nameserver-migrate.test.js | 5 +- lib/nameservers.js | 230 ++++++-- lib/nameservers.test.js | 619 +++++++++++++++++++++- package.json | 19 +- web/e2e/app.auth.spec.js | 66 +++ web/e2e/app.ns-form.spec.js | 344 ++++++++++++ web/e2e/configure.spec.js | 205 +++++++- web/src/app.js | 417 +++++++++++++-- web/src/lib/ns-form.js | 508 ++++++++++++++++++ web/src/lib/ns-form.test.js | 414 +++++++++++++++ 21 files changed, 3463 insertions(+), 960 deletions(-) create mode 100644 web/e2e/app.auth.spec.js create mode 100644 web/e2e/app.ns-form.spec.js create mode 100644 web/src/lib/ns-form.js create mode 100644 web/src/lib/ns-form.test.js diff --git a/.gitignore b/.gitignore index 6648e1e..a1802e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +etc # Logs logs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b7db0..f717bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [0.6.0] - 2026-07-30 + ### [0.5.0] - 2026-07-27 - doc(README): updated installed instructions @@ -33,3 +35,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [0.2.0]: https://github.com/NicTool/server/releases/tag/v0.2.0 [0.0.2]: https://github.com/NicTool/server/releases/tag/v0.0.2 [0.2.2]: https://github.com/NicTool/server/releases/tag/v0.2.2 +[0.5.0]: https://github.com/NicTool/server/releases/tag/v0.5.0 +[0.6.0]: https://github.com/NicTool/server/releases/tag/v0.6.0 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 89b3948..8557854 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,7 +2,7 @@ This handcrafted artisanal software is brought to you by: -|
msimerson (14) | +|
msimerson (22) | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is generated by [.release](https://github.com/msimerson/.release). diff --git a/README.md b/README.md index d35e0d4..6fdcc86 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ NicTool is an open-source DNS management system. This package provides the **ser ## Nameserver supervisor (lib/nameservers.js) -- Reads nameserver records from the data store, starts/stops each DNS engine. - Each record carries its own runtime config (engine, listen, publisher, - transport, dnssec) alongside the legacy 2.x fields. -- Supports a native in-memory authoritative server plus export engines (bind, knot, nsd, powerdns, tinydns, maradns), wiring up the right Source (json / toml / mysql), Publisher (memory / rfc1035 / tinydns-cdb / powerdns-db), Transport (noop / rsync / axfr / db-replication), and DNSSEC Signer per engine. +- Reads nameserver records from the data store, starts/stops each one. + Each record carries its own runtime config (type, listen, publisher, + transport, dnssec) alongside the legacy 2.x fields. `type` is the + nt_nameserver_export_type name — which software this is, stored once. +- Supports a native in-memory authoritative server plus the exporting types (bind, knot, nsd, powerdns, coredns, djbdns, maradns), wiring up the right Source (json / toml / mysql), Publisher (memory / rfc1035 / tinydns-cdb / powerdns-db / coredns-redis), Transport (noop / rsync / axfr / db-replication / pull), and DNSSEC Signer per type. Publisher options, set under `[nameserver.publisher]`: @@ -37,11 +38,9 @@ Publisher options, set under `[nameserver.publisher]`: | `maradns` | `path` — directory for `.csv2` files. MaraDNS reads csv2, not RFC 1035, so this is its own format. `config: {}` also writes a `mararc` declaring the zones, with `chroot_dir` pointing at `path`; `config.bindAddress` and `config.globals` set the rest. **`terminator: ''` for MaraDNS 1.2**, whose csv2 has no record terminator and rejects the `~` that 2.x uses | | `powerdns-db` | `dsn` (or `host`/`port`/`user`/`password`/`database`) for a PowerDNS **gmysql** backend; `domainType` (default `NATIVE`). This is the push model — the alternative is `nt-powerdns`, the pipe backend below, which leaves the data in NicTool. Use one or the other. | -Publishers default by engine: `native` → memory, `tinydns` → tinydns-cdb, +Publishers default by nameserver type: `native` → memory, `djbdns` → tinydns-cdb, `maradns` → maradns, everything else → rfc1035. -Not yet implemented, and they throw if selected: the DNSSEC signers, `axfr` transport. - ## Data model / config Config is layered so each file holds only what is needed to reach the next layer: @@ -209,8 +208,8 @@ Supports A, AAAA, CNAME, MX, NS, TXT, PTR, SRV, CAA, SOA over UDP and TCP. ```toml [[nameserver]] -name = "ns1.example.com." -engine = "native" +name = "ns1.example.com." +type = "native" # Bind UDP and TCP on all interfaces, port 53. [[nameserver.listen]] @@ -238,18 +237,35 @@ cooldown = 5 # minimum seconds between consecutive publishes (burst protec Multiple native nameservers can run side-by-side by adding more `[[nameserver]]` blocks — each gets its own UDP/TCP listener set. -### DNSSEC (native engine — future) +### DNSSEC (native engine) -Attach an optional signing stage between the publisher and transport: +Attach a signing stage between the publisher and transport. The native engine +has no external signing tool, so `MemorySigner` signs the in-process zone map +directly and `NativeNS` answers with RRSIG, DNSKEY and NSEC when the query +carries the DO bit. ```toml [nameserver.dnssec] -enabled = false # set true once MemorySigner is implemented +enabled = true algorithm = "ECDSAP256SHA256" keyset = "/var/lib/nictool/dnssec/ns1" -nsec3 = true +nsec3 = false # NSEC only; the in-process signer has no NSEC3 +``` + +`keyset` is a directory of BIND-format key files — the same +`K.++.{key,private}` pair `dnssec-keygen` writes, so a zone can +move between this signer and the file signer without re-keying: + +```sh +dnssec-keygen -a ECDSAP256SHA256 -K /var/lib/nictool/dnssec/ns1 -n ZONE example.com +dnssec-keygen -a ECDSAP256SHA256 -K /var/lib/nictool/dnssec/ns1 -f KSK -n ZONE example.com ``` +NicTool signs with those keys but never generates or rolls them: rollover is an +operational decision, not something a publish cycle should make. Elliptic-curve +algorithms only (`ECDSAP256SHA256`, `ECDSAP384SHA384`, `ED25519`, `ED448`) — +RSA keys are reported as unusable rather than silently skipped. + --- ### PowerDNS co-process backend diff --git a/bin/start.js b/bin/start.js index 43aaa7e..f90238a 100755 --- a/bin/start.js +++ b/bin/start.js @@ -8,6 +8,8 @@ import path from 'node:path' import process from 'node:process' import { parseArgs, promisify } from 'node:util' +import { AxfrServer, FileSource, MysqlSource } from '@nictool/dns-nameserver' + import { startServer } from '../index.js' import ApiProcess from '../lib/api-process.js' import NameserverSupervisor from '../lib/nameservers.js' @@ -18,11 +20,7 @@ import { readBootstrap, writeBootstrap, } from '../lib/config.js' -import { - migrateNameservers, - resolveNameserverConfig, - saveToStore, -} from '../lib/nameserver-config.js' +import { migrateNameservers, resolveNameserverConfig } from '../lib/nameserver-config.js' const execFileAsync = promisify(execFile) @@ -106,6 +104,7 @@ const port = const supervisor = new NameserverSupervisor() let apiProcess = null +let axfrServer = null async function shutdown() { try { @@ -113,6 +112,11 @@ async function shutdown() { } catch { /* ignore */ } + try { + await axfrServer?.stop() + } catch { + /* ignore */ + } try { await apiProcess?.stop() } catch { @@ -125,7 +129,7 @@ process.once('SIGINT', shutdown) if (nicConfig?.configured === true) { console.log('Already configured — starting services.') - const { apiServer, apiRemoteUrl } = await startAPI(nicConfig) + const { apiServer, apiRemoteUrl, pid } = await startAPI(nicConfig) await startServer({ configDir, tls, @@ -134,7 +138,10 @@ if (nicConfig?.configured === true) { nicConfig, apiServer, apiRemoteUrl, + apiPid: pid, supervisor, + startApi: startAPI, + stopApi: stopAPI, }) await startNameservers(nicConfig) } else { @@ -156,21 +163,16 @@ if (nicConfig?.configured === true) { nicConfig, supervisor, suggestedPorts: { api: suggestedApiPort }, + startApi: startAPI, + stopApi: stopAPI, onSaved: async (config, ctx) => { if (!ctx.apiServer && !ctx.apiRemoteUrl) { const started = await startAPI(config) ctx.apiServer = started.apiServer ctx.apiRemoteUrl = started.apiRemoteUrl + ctx.apiMode = normalizeApiMode(config.api?.mode) + ctx.apiPid = started.pid ?? null } - // The configurator collects nameservers, but they belong in the store, - // which only exists once the API is up. - if (ctx.pendingNameservers?.length) { - await saveToStore(ctx.pendingNameservers).catch((err) => - console.error(`Could not save nameservers: ${err.message}`), - ) - ctx.pendingNameservers = null - } - try { await ctx.supervisor?.stop() } catch { @@ -276,15 +278,71 @@ async function startNameservers(config) { const migrated = await migrateNameservers(configDir, config, writeBootstrap) const nsConfig = await resolveNameserverConfig(configDir, migrated) await supervisor.start(nsConfig) + await startAxfrServer(config, nsConfig) } catch (err) { console.error(`Supervisor start failed: ${err.message}`) } } +/** + * Answer zone transfers, when `axfr` is configured, so a secondary can pull + * from NicTool rather than from a primary NicTool feeds. + * + * It is not a nameserver and does not go through the supervisor: it publishes + * nothing and reads the Source directly, so it serves whatever is current + * rather than whatever was last published. Authorization comes from the same + * nameserver records the supervisor uses — a peer is matched by address, and + * gets exactly the zones assigned to it. + */ +async function startAxfrServer(config, nsConfig) { + const axfr = config?.axfr + if (!axfr?.listen?.length) return + + try { + const source = buildAxfrSource(nsConfig.store) + axfrServer = new AxfrServer({ + listen: axfr.listen, + source, + nameservers: nsConfig.nameserver ?? [], + maxMessageSize: axfr.maxMessageSize, + }) + axfrServer.on('error', (err) => console.error(`AXFR: ${err.message}`)) + axfrServer.on('refused', (d) => + console.warn(`AXFR refused ${d.zone} to ${d.peer}: ${d.reason}`), + ) + await axfrServer.start() + const where = axfrServer + .addresses() + .map((a) => `${a.address}:${a.port}`) + .join(', ') + console.log(`AXFR listener: ${where} (${axfrServer.status().authorized} peers)`) + } catch (err) { + console.error(`AXFR listener failed to start: ${err.message}`) + axfrServer = null + } +} + +function buildAxfrSource(store) { + switch (store?.type) { + case 'mysql': + return new MysqlSource(store) + case 'json': + return new FileSource({ path: store.path, format: 'json' }) + case 'toml': + case 'directory': + return new FileSource({ path: store.path, format: 'toml' }) + default: + throw new Error(`unsupported store type for AXFR: ${store?.type ?? '(missing)'}`) + } +} + /** * Bring up the API in whichever mode the bootstrap config selects. * - * @returns {Promise<{apiServer: import('@hapi/hapi').Server|null, apiRemoteUrl: string|null}>} + * Failures are reported rather than thrown: at boot the configurator must stay + * reachable so the operator can correct the config that broke the start. + * + * @returns {Promise<{apiServer: import('@hapi/hapi').Server|null, apiRemoteUrl: string|null, error?: string}>} */ async function startAPI(config) { if (!config?.api) return { apiServer: null, apiRemoteUrl: null } @@ -299,21 +357,39 @@ async function startAPI(config) { if (mode === 'tcp') { const port = config.api.port if (!port) { - console.error('api.mode is "tcp" but no api.port is set') - return { apiServer: null, apiRemoteUrl: null } + const error = 'api.mode is "tcp" but no api.port is set' + console.error(error) + return { apiServer: null, apiRemoteUrl: null, error } } apiProcess = new ApiProcess({ configDir, port }) apiProcess.on('error', (err) => console.error(`API process error: ${err.message}`)) try { await apiProcess.start() - return { apiServer: null, apiRemoteUrl: apiProcess.url } + return { + apiServer: null, + apiRemoteUrl: apiProcess.url, + pid: apiProcess.child?.pid ?? null, + } } catch (err) { console.error(`API process failed to start: ${err.message}`) - return { apiServer: null, apiRemoteUrl: null } + apiProcess = null + return { apiServer: null, apiRemoteUrl: null, error: err.message } } } - return { apiServer: await initInProcessAPI(), apiRemoteUrl: null } + return initInProcessAPI() +} + +/** + * Shut down whichever local API startAPI brought up. A remote API is not ours + * to stop, so only the caller's reference to it is dropped. + */ +async function stopAPI(ctx) { + if (apiProcess) { + await apiProcess.stop() + apiProcess = null + } + await ctx.apiServer?.stop() } /** @@ -329,12 +405,12 @@ function applyApiEnv() { async function initInProcessAPI() { try { const { init: initAPI } = await import('@nictool/api/routes/index.js') - const hapiServer = await initAPI() + const apiServer = await initAPI() console.log('API initialized in-process') - return hapiServer + return { apiServer, apiRemoteUrl: null, pid: process.pid } } catch (err) { console.error(`API init failed: ${err.message}`) - return null + return { apiServer: null, apiRemoteUrl: null, error: err.message } } } diff --git a/html/configure.html b/html/configure.html index 5091da8..8c4304f 100644 --- a/html/configure.html +++ b/html/configure.html @@ -117,6 +117,13 @@ box-shadow 0.1s; } + input:disabled, + select:disabled { + background: #f6f8fa; + color: #57606a; + cursor: not-allowed; + } + input:focus, select:focus { outline: none; @@ -171,152 +178,65 @@ color: #57606a; } - /* ---- separator between store sub-sections ---- */ - .db-sep { - width: 100%; - border: none; - border-top: 1px dashed #d0d7de; - margin: 0.75rem 0 0.25rem; - } - - /* ---- nameserver table ---- */ - .ns-grid { - width: 100%; - border-collapse: collapse; - } - - .ns-grid th { - text-align: left; - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: #57606a; - padding: 0 6px 6px; - } - - .ns-grid td { - padding: 3px 6px; - vertical-align: middle; - } - - .ns-grid td:last-child { - padding-right: 0; - width: 28px; - } - - .ns-grid input, - .ns-grid select { - height: 28px; - font-size: 0.8rem; - } - - .ns-grid tr.ns-row:not(:first-child) > td { - padding-top: 4px; - } - - .ns-expand { - background: #f6f8fa; - border: 1px solid #d0d7de; - border-radius: 6px; - padding: 10px 12px; - margin: 4px 6px 10px; - display: none; - } - .ns-expand.open { - display: block; - } - .ns-expand h3 { + /* ---- API run state: dim = stopped, green = running, red = failed ---- */ + .status { + display: inline-flex; + align-items: center; + gap: 5px; font-size: 0.72rem; font-weight: 600; - color: #57606a; - text-transform: uppercase; - letter-spacing: 0.04em; - margin: 6px 0 4px; - } - .ns-expand .fields { - gap: 0.5rem 1rem; - } - - .listen-grid { - width: 100%; - border-collapse: collapse; - margin-bottom: 4px; - } - .listen-grid th { - padding: 0 4px 2px; - font-size: 0.65rem; - color: #57606a; - text-align: left; + color: #8c959f; } - .listen-grid td { - padding: 2px 4px; + .status::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; } - .listen-grid input, - .listen-grid select { - height: 26px; - font-size: 0.78rem; + .status.ok { + color: #1a7f37; } - .listen-grid td:last-child { - width: 24px; - padding-right: 0; + .status.err { + color: #cf222e; } - .btn-add-inline { - padding: 2px 8px; - font-size: 0.72rem; - border: 1px dashed #d0d7de; - border-radius: 4px; - background: none; - cursor: pointer; - color: #0969da; + .inline-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.6rem; } - .btn-expand { - background: none; - border: 1px solid #d0d7de; - border-radius: 4px; - cursor: pointer; - font-size: 0.75rem; - color: #57606a; - padding: 2px 6px; - } - .btn-expand.open { - background: #ddf4ff; - color: #0969da; - border-color: #0969da; + /* ---- separator between store sub-sections ---- */ + .db-sep { + width: 100%; + border: none; + border-top: 1px dashed #d0d7de; + margin: 0.75rem 0 0.25rem; } /* ---- buttons ---- */ - .btn-add { - margin-top: 0.75rem; - padding: 4px 10px; + .btn-toggle { + height: 30px; + padding: 0 14px; font-size: 0.8rem; font-family: inherit; - border: 1px dashed #d0d7de; + font-weight: 500; + border: 1px solid #d0d7de; border-radius: 6px; - background: none; + background: #f6f8fa; + color: #1f2328; cursor: pointer; - color: #0969da; + white-space: nowrap; } - .btn-add:hover { - background: #f0f7ff; - border-color: #0969da; + .btn-toggle:not(:disabled):hover { + background: #eef0f2; + border-color: #8c959f; } - - .btn-remove { - background: none; - border: none; - cursor: pointer; + .btn-toggle:disabled { color: #8c959f; - font-size: 1rem; - line-height: 1; - padding: 2px 4px; - border-radius: 4px; - } - .btn-remove:hover { - background: #ffebe9; - color: #cf222e; + cursor: not-allowed; } .btn-save { @@ -383,12 +303,16 @@

Installation

@@ -397,6 +321,7 @@

Installation

API Service

+ not started
@@ -408,9 +333,16 @@

API Service

- How the API is reached + +
+
+ +
+