diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ee4a9..08b7db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [0.5.0] - 2026-07-27 + +- doc(README): updated installed instructions +- change: bin/nt_powerdns, use @dns-rr +- change: bin/nt_powerdns: moved to @nictool/dns-nameserver +- change: default file format is JSON (0 dep) +- change: NS config removed to API + ### [0.2.2] - 2026-07-25 - test and start fixes diff --git a/README.md b/README.md index 876a13b..d35e0d4 100644 --- a/README.md +++ b/README.md @@ -14,25 +14,54 @@ NicTool is an open-source DNS management system. This package provides the **ser ## Process supervisor / launcher (bin/start.js) -- CLI entry (nictool-server -c ), reads nictool.toml. When api.mode = - - local: hosts the @nictool/api server in-process - - remote: it proxies /api/* and /doc to the remote API -- Handles SIGINT/SIGTERM to cleanly stop nameservers. +- CLI entry (nictool-server -c ), reads etc/nictool.json. When api.mode = + - in_process: hosts the @nictool/api server in-process (no socket) + - tcp: forks the API as a supervised child on api.port and proxies to it + - remote: it proxies /api/\* and /doc to the remote API +- Handles SIGINT/SIGTERM to cleanly stop nameservers and the API child. ## Nameserver supervisor (lib/nameservers.js) -- Reads nameserver configs from data store, starts/stops each DNS engine -- Supports a native in-memory authoritative server plus export engines (bind, knot, nsd, powerdns, tinydns, maradns), wiring up the right Source (mysql / toml-directory), 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 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. -## PowerDNS co-process backend (bin/nt_powerdns.js) +Publisher options, set under `[nameserver.publisher]`: -- Standalone nt-powerdns binary implementing the PowerDNS pipe/co-process v1 protocol over stdin/stdout. -- Queries the NicTool MySQL schema directly to answer Q / SOA / NS / ANY / AXFR, with a TTL cache and reconnect-on-drop. +| type | options | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `memory` | — (required by the native engine) | +| `rfc1035` | `path` — directory for `.zone` files. Also writes the server's own config declaring them — `named.conf`, `knot.conf` or `nsd.conf` per engine. `config.file` overrides the path; `config: false` disables it | +| `tinydns-cdb` | `path` — directory for `data` + `data.cdb`; `compile` (default true) runs `tinydns-data`; `tinydnsData` overrides the binary. tinydns keeps one global data file, so any change recompiles every zone. Set `compile = false` when the binary lives on the target and a Transport compiles there. | +| `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, +`maradns` → maradns, everything else → rfc1035. + +Not yet implemented, and they throw if selected: the DNSSEC signers, `axfr` transport. ## Data model / config -- Everything is driven by nictool.toml ([store], [api], [[nameserver]]). Store can be MySQL or a file directory (see data/zone.toml, data/zone_record.toml). -- Depends on the sibling workspace packages via file:../ links: @nictool/api, dns-nameserver, dns-zone, dns-resource-record. +Config is layered so each file holds only what is needed to reach the next layer: + +| File | Holds | +| ------------------------------- | ---------------------------------------------- | +| `/etc/nictool.json` | `configured`, and how to reach the API | +| `/etc/api.json` | the API's store connection and its own secrets | +| the store | nameservers, zones, records, users, groups | + +A pre-existing `etc/nictool.toml` is migrated to this layout on first start, and +nameservers still listed in `nictool.json` are moved into the store. + +The one place the server reads `api.json` is to build nameserver Sources: the +DNS engines stream zone data straight from the store rather than through the +API, so the supervisor needs the store connection as well as the records. + +Store types: `json` (default) and `toml` write one file per entity into +`store.path`; `mysql` uses a DSN. Depends on the sibling workspace packages: +@nictool/api, dns-nameserver, dns-zone, dns-resource-record. ## Prerequisites @@ -57,9 +86,13 @@ mkdir -p /var/lib/nictool ### 3. Start the server ```sh -npm run server +nictool-server -c /var/lib/nictool ``` +No build step is involved — the published package ships the built UI. (`npm run +server` is the in-repo development equivalent; it runs Vite first and needs the +dev dependencies.) + On first run the server will: 1. Generate a self-signed TLS certificate for your hostname and save it to `/var/lib/nictool/etc/tls/`. @@ -67,7 +100,14 @@ On first run the server will: ### 4. Complete setup in the browser -Open the URL printed to the console, fill in the configuration form, and click **Save**. The configurator writes `/var/lib/nictool/etc/nictool.toml` and starts the API automatically. +Open the URL printed to the console and work through the four cards — installation +type, API location, API status, and data store — then click **Save**. The +configurator writes `/var/lib/nictool/etc/nictool.json` plus the API's +`etc/api.json`, and starts the API automatically. + +Choosing **Upgrade from NicTool 2.x** points NicTool at an existing 2.x MySQL +database. Use **Detect** to confirm the schema is found; NicTool will not create +tables in a database that already has them. > **TLS warning** – The auto-generated certificate is self-signed. Accept the browser security warning for the initial setup, then replace it with a trusted certificate (see [TLS](#tls) below). @@ -75,46 +115,64 @@ Open the URL printed to the console, fill in the configuration form, and click * ## Configuration -All settings live in `/etc/nictool.toml`. The file is created by the web configurator but can also be edited by hand. The server reads it on every start. +The server's own settings live in `/etc/nictool.json`, and the API's +store connection in `/etc/api.json`. Both are created by the web +configurator but can also be edited by hand. The server reads them on every start. + +To run the API on a different host, set its mode to **remote** and use the +configurator's **Download api.json** button; drop that file into the API host's +config directory, or point `NICTOOL_CONF_DIR` at it. ### Data store options -| `store.type` | Description | -| ------------ | ------------------------------------------- | -| `mysql` | Production-ready; requires MySQL 8+ | -| `directory` | File-based TOML store; good for development | +Set in `etc/api.json`, since the store belongs to the API. + +| `store.type` | Description | +| ------------ | -------------------------------------------------------- | +| `json` | Default. One file per entity, zero dependencies | +| `toml` | Same layout, TOML codec (`directory` is the legacy name) | +| `mysql` | Production-ready; requires MySQL 8+ | #### MySQL example -```toml -[store] -type = "mysql" -host = "127.0.0.1" -port = 3306 -user = "nictool" -password = "secret" -database = "nictool" +```json +{ + "store": { + "type": "mysql", + "host": "127.0.0.1", + "port": 3306, + "user": "nictool", + "password": "secret", + "database": "nictool" + } +} ``` -#### Directory (file) example +#### File example -```toml -[store] -type = "directory" -path = "/var/lib/nictool/zones" +```json +{ + "store": { "type": "json", "path": "/var/lib/nictool/zones" } +} ``` ### API mode -The API can run **in-process** (default) or as a **remote** service: +Set in `etc/nictool.json`: -```toml -[api] -mode = "local" # "local" | "remote" -port = 3000 # only used for remote mode -host = "" # only used for remote mode +```json +{ + "configured": true, + "api": { "mode": "in_process" } +} ``` +| `api.mode` | Behavior | +| ------------ | ----------------------------------------------------------------- | +| `in_process` | Default. Hosted inside the server, no socket. `local` is an alias | +| `tcp` | Forked as a supervised child on `api.port`, proxied over HTTP | +| `remote` | An API elsewhere; needs `api.host` and `api.port` | + --- ## TLS @@ -196,69 +254,32 @@ nsec3 = true ### PowerDNS co-process backend -`nt-powerdns` (`bin/nt_powerdns.js`) implements the +`nt-powerdns` implements the [PowerDNS pipe/co-process backend](https://doc.powerdns.com/authoritative/backends/pipe.html) -protocol (v1). PowerDNS forks the script and communicates with it over -stdin/stdout; the script queries the NicTool MySQL database directly. +protocol (v1). PowerDNS forks it and asks a question at a time over +stdin/stdout, and it answers from the NicTool database directly — the pull +alternative to the `powerdns-db` publisher above. Use one or the other. -#### 1. Configure `pdns.conf` +It ships with **[@nictool/dns-nameserver](https://github.com/NicTool/dns-nameserver)**, +alongside the rest of the PowerDNS integration, and that package's README +documents its environment variables and supported query types. ```ini launch=pipe -pipe-command=/usr/bin/nt-powerdns +pipe-command=/path/to/node_modules/.bin/nt-powerdns +pipe-abi-version=1 pipe-timeout=2000 -# If you need more than one backend process: -# pipe-command=/usr/bin/nt-powerdns --workers 4 -``` - -Or, with the path from a local install: - -```ini -launch=pipe -pipe-command=node /var/lib/nictool/bin/nt_powerdns.js -pipe-timeout=2000 -``` - -#### 2. Set environment variables - -```sh -export NT_PDNS_DB_HOST=127.0.0.1 -export NT_PDNS_DB_PORT=3306 -export NT_PDNS_DB_USER=nictool -export NT_PDNS_DB_PASS=secret # required -export NT_PDNS_DB_NAME=nictool -export NT_PDNS_NS_ID=1 # nt_nameserver.nt_nameserver_id to serve -export NT_PDNS_CACHE_TTL=20 # seconds to cache query results -export NT_PDNS_LOG=0 # set to 1 for verbose stderr logging -``` - -Set these in `/etc/default/pdns` (Debian/Ubuntu) so PowerDNS picks them up when it forks the co-process. - -#### 3. Test manually - -```sh -printf 'HELO\t1\nQ\texample.com\tIN\tSOA\t-1\t127.0.0.1\n' \ - | NT_PDNS_DB_PASS=secret node bin/nt_powerdns.js -``` - -Expected output: - -``` -OK NicTool PowerDNS backend ready -DATA example.com IN SOA 3600 1 ns1.example.com hostmaster.example.com 2026010101 86400 7200 1209600 3600 -END +# Required from PowerDNS 4.5 on. The pipe backend cannot enumerate zones, so +# the zone cache has nothing to populate from and pdns_server exits at startup +# with "One of the backends does not support zone caching". +zone-cache-refresh-interval=0 ``` -#### Supported query types +Verified against PowerDNS Authoritative Server 5.1.3. -| Type | Source | -| --------------------------------- | ----------------------------------------------- | -| A, AAAA, CNAME, MX, PTR, TXT, SRV | `nt_zone_record` | -| NS | `nt_nameserver` via `nt_zone_nameserver` | -| SOA | `nt_zone` (serial, refresh, retry, expire, ttl) | -| ANY | union of records + NS rows | -| AXFR | all records for the requested zone id | +Set `NT_PDNS_DB_PASS` and friends in `/etc/default/pdns` (Debian/Ubuntu) so +PowerDNS passes them to the co-process when it forks. --- diff --git a/bin/nt_powerdns.js b/bin/nt_powerdns.js deleted file mode 100755 index 45613ee..0000000 --- a/bin/nt_powerdns.js +++ /dev/null @@ -1,372 +0,0 @@ -#!/usr/bin/env node -/** - * nt_powerdns.js — NicTool PowerDNS Pipe/Co-process backend (v1 protocol) - * - * Drop-in JS replacement for nt_powerdns.pl. Invoked by PowerDNS as a - * pipe-command; communicates over stdin/stdout using tab-delimited lines. - * - * PowerDNS pipe backend v1 protocol: - * IN: HELO\t - * OUT: OK\t - * - * IN: Q\t\t\t\t\t - * OUT: DATA\t\t\t\t\t\t - * END - * - * IN: AXFR\t - * OUT: DATA\t... (all records for zone) - * END - * - * IN: PING - * OUT: END - * - * Environment variables: - * NT_PDNS_DB_HOST (default: 127.0.0.1) - * NT_PDNS_DB_PORT (default: 3306) - * NT_PDNS_DB_USER (default: nictool) - * NT_PDNS_DB_PASS (required) - * NT_PDNS_DB_NAME (default: nictool) - * NT_PDNS_NS_ID nameserver id to serve (default: 1) - * NT_PDNS_LOG set to 1 for verbose stderr logging - * NT_PDNS_CACHE_TTL seconds to cache query results (default: 20) - */ - -import readline from 'node:readline' -import mysql from 'mysql2/promise' - -const NS_ID = Number(process.env.NT_PDNS_NS_ID ?? 1) -const CACHE_TTL = Number(process.env.NT_PDNS_CACHE_TTL ?? 20) -const LOG = process.env.NT_PDNS_LOG === '1' - -const dbCfg = { - host: process.env.NT_PDNS_DB_HOST ?? '127.0.0.1', - port: Number(process.env.NT_PDNS_DB_PORT ?? 3306), - user: process.env.NT_PDNS_DB_USER ?? 'nictool', - password: process.env.NT_PDNS_DB_PASS, - database: process.env.NT_PDNS_DB_NAME ?? 'nictool', - ssl: { rejectUnauthorized: false }, -} - -if (!dbCfg.password) { - write('FAIL') - stderr('NT_PDNS_DB_PASS is required') - process.exit(1) -} - -// Simple TTL cache: Map -const cache = new Map() -function cacheGet(key) { - const entry = cache.get(key) - if (!entry) return null - if (entry.expire < Date.now() / 1000) { - cache.delete(key) - return null - } - return entry.data -} -function cacheSet(key, data) { - cache.set(key, { data, expire: Date.now() / 1000 + CACHE_TTL }) -} - -// ------------------------------------------------------------------------- -// DB connection (reconnects on drop) -// ------------------------------------------------------------------------- - -let dbh = null - -async function query(sql, params = []) { - if (!dbh) { - dbh = await mysql.createConnection(dbCfg) - log(`MySQL connected (id ${dbh.connection.connectionId})`) - } - try { - const [rows] = await dbh.execute(sql, params) - return rows - } catch (err) { - if (err.code === 'PROTOCOL_CONNECTION_LOST' || err.code === 'ECONNRESET') { - dbh = null - const [rows] = await (await mysql.createConnection(dbCfg)).execute(sql, params) - return rows - } - throw err - } -} - -// ------------------------------------------------------------------------- -// Protocol I/O -// ------------------------------------------------------------------------- - -function write(line) { - process.stdout.write(line + '\n') -} - -function log(msg) { - if (LOG) process.stderr.write(`[nt_powerdns] ${msg}\n`) -} - -function stderr(msg) { - process.stderr.write(`[nt_powerdns] ${msg}\n`) -} - -function dataLine(qname, qclass, type, ttl, zoneId, ...rdata) { - return ['DATA', qname, qclass, type, ttl, zoneId, ...rdata].join('\t') -} - -// ------------------------------------------------------------------------- -// Content helpers -// ------------------------------------------------------------------------- - -function rrContentFields(r) { - const address = (r.address ?? '').replace(/\.$/, '') - if (r.type === 'MX') { - return [r.weight ?? 0, address] - } - if (r.type === 'SRV') { - const priority = r.priority ?? 0 - const weight = r.weight ?? 0 - const port = r.other ?? 0 - return [priority, `${weight} ${port} ${address}`] - } - return [address] -} - -function uniqRows(rows) { - const seen = new Set() - return rows.filter((r) => { - const k = r.join('\t') - if (seen.has(k)) return false - seen.add(k) - return true - }) -} - -// ------------------------------------------------------------------------- -// Query handlers -// ------------------------------------------------------------------------- - -async function getRecords(qname, qclass, qtype) { - const cacheKey = `${qname}:${qtype}` - const cached = cacheGet(cacheKey) - if (cached) { - log(`cache hit ${cacheKey}`) - return cached - } - - // Build ordered list of (zone, record) candidates from qname. - // We peel labels off the front to find the containing zone. - const labels = qname.replace(/\.$/, '').split('.') - const order = [] - let prefix = '' - - for (let n = 0; n < labels.length; n++) { - const zoneName = labels.slice(n).join('.') - const recordName = prefix ? prefix : qname.replace(/\.$/, '') + '.' - order.push({ zone: zoneName, record: recordName }) - // wildcard entry for parent zones - if (n > 0) order.push({ zone: zoneName, record: `*.${zoneName}.` }) - prefix = prefix ? `${prefix}.${labels[n - 1]}` : labels[n - 1] - } - - // Look up which zones this nameserver serves among the candidates. - const wantedZones = [...new Set(order.map((e) => e.zone))] - if (!wantedZones.length) return [] - - const placeholders = wantedZones.map(() => '?').join(',') - const zoneRows = await query( - `SELECT z.nt_zone_id, z.zone - FROM nt_zone z - INNER JOIN nt_zone_nameserver ns ON ns.nt_zone_id = z.nt_zone_id - AND ns.nt_nameserver_id = ? - WHERE z.deleted = 0 - AND z.zone IN (${placeholders})`, - [NS_ID, ...wantedZones], - ) - - const zoneIdFor = {} - for (const z of zoneRows) zoneIdFor[z.zone] = z.nt_zone_id - - const pairs = [] - for (const e of order) { - const zid = zoneIdFor[e.zone] - if (zid !== undefined) pairs.push([zid, e.record]) - } - if (!pairs.length) return [] - - const pairPh = pairs.map(() => '(?,?)').join(',') - const pairParams = pairs.flat() - - let typeClause = '' - const extraParams = [] - if (qtype !== 'ANY') { - typeClause = " AND (t.name = ? OR t.name = 'CNAME')" - extraParams.push(qtype) - } - - const rows = await query( - `SELECT r.nt_zone_id, t.name AS type, - r.name, r.ttl, r.address, r.weight, r.priority, r.other, - r.nt_zone_record_id - FROM nt_zone_record r - INNER JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id AND z.deleted = 0 - INNER JOIN nt_zone_nameserver ns - ON ns.nt_zone_id = z.nt_zone_id AND ns.nt_nameserver_id = ? - LEFT JOIN resource_record_type t ON r.type_id = t.id - WHERE (r.nt_zone_id, r.name) IN (${pairPh}) - AND r.deleted = 0${typeClause}`, - [NS_ID, ...pairParams, ...extraParams], - ) - - const seen = new Set() - const result = [] - for (const r of rows) { - const content = rrContentFields(r) - const line = ['DATA', qname, qclass, r.type, r.ttl, r.nt_zone_id, ...content] - const key = line.join('\t') - if (!seen.has(key)) { - seen.add(key) - result.push(line) - } - } - - cacheSet(cacheKey, result) - return result -} - -async function getNS(qname, qclass) { - const rows = await query( - `SELECT z.nt_zone_id, ns.ttl, ns.name, ns.address - FROM nt_zone z - INNER JOIN nt_zone_nameserver zns ON z.nt_zone_id = zns.nt_zone_id - INNER JOIN nt_nameserver ns ON zns.nt_nameserver_id = ns.nt_nameserver_id - WHERE z.zone = ? AND z.deleted = 0 AND ns.deleted = 0`, - [qname.replace(/\.$/, '')], - ) - - return rows.map((r) => ['DATA', qname, qclass, 'NS', r.ttl, r.nt_zone_id, r.name]) -} - -async function getSOA(qname, qclass) { - const rows = await query( - `SELECT ns.name, z.* - FROM nt_zone z - INNER JOIN nt_zone_nameserver zns ON z.nt_zone_id = zns.nt_zone_id - INNER JOIN nt_nameserver ns ON zns.nt_nameserver_id = ns.nt_nameserver_id - WHERE z.zone = ? AND z.deleted = 0 AND ns.deleted = 0 - LIMIT 1`, - [qname.replace(/\.$/, '')], - ) - - if (!rows.length) return [] - const z = rows[0] - return [ - [ - 'DATA', - qname, - qclass, - 'SOA', - z.ttl, - z.nt_zone_id, - z.name, - z.mailaddr, - z.serial, - z.refresh, - z.retry, - z.expire, - z.ttl, - ], - ] -} - -async function getAXFR(zoneId) { - const rows = await query( - `SELECT z.nt_zone_id, z.zone, t.name AS type, - r.name, r.ttl, r.address, r.weight, r.priority, r.other - FROM nt_zone z - INNER JOIN nt_zone_record r ON z.nt_zone_id = r.nt_zone_id - LEFT JOIN resource_record_type t ON r.type_id = t.id - WHERE z.nt_zone_id = ? AND z.deleted = 0 AND r.deleted = 0`, - [zoneId], - ) - - return rows.map((r) => { - const fqdn = /\.$/.test(r.name) ? r.name : `${r.name}.${r.zone}` - const content = rrContentFields(r) - return ['DATA', fqdn, 'IN', r.type, r.ttl, r.nt_zone_id, ...content] - }) -} - -// ------------------------------------------------------------------------- -// Main loop -// ------------------------------------------------------------------------- - -const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }) - -// HELO handshake — must be the first line -let heloReceived = false - -for await (const line of rl) { - const trimmed = line.trim() - if (!trimmed) continue - - if (!heloReceived) { - if (/^HELO\t\d+$/.test(trimmed)) { - heloReceived = true - write('OK\tNicTool PowerDNS backend ready') - log(`HELO received — NS_ID=${NS_ID}`) - } else { - write('FAIL') - stderr(`Expected HELO, got: ${trimmed}`) - process.exit(1) - } - continue - } - - const parts = trimmed.split('\t') - const type = parts[0] - - log(`received: ${trimmed}`) - - try { - if (type === 'Q') { - const [, qname, qclass, qtype] = parts - let rows = [] - - if (qtype === 'SOA') { - rows = await getSOA(qname, qclass) - cacheSet(`${qname}:${qtype}`, rows) - } else if (qtype === 'NS') { - rows = uniqRows([ - ...(await getNS(qname, qclass)), - ...(await getRecords(qname, qclass, qtype)), - ]) - cacheSet(`${qname}:${qtype}`, rows) - } else if (qtype === 'ANY') { - rows = uniqRows([ - ...(await getRecords(qname, qclass, qtype)), - ...(await getNS(qname, qclass)), - ]) - cacheSet(`${qname}:${qtype}`, rows) - } else { - rows = await getRecords(qname, qclass, qtype) - } - - for (const r of rows) write(r.join('\t')) - } else if (type === 'AXFR') { - const zoneId = parts[1] - const rows = await getAXFR(zoneId) - for (const r of rows) write(r.join('\t')) - } else if (type === 'PING') { - // no-op - } else { - stderr(`Unknown request type: ${type}`) - } - } catch (err) { - stderr(`Error handling ${type}: ${err.message}`) - write(`LOG\tError: ${err.message}`) - } - - write('END') -} - -if (dbh) await dbh.end() -log('Exiting') diff --git a/bin/start.js b/bin/start.js index 7a23079..43aaa7e 100755 --- a/bin/start.js +++ b/bin/start.js @@ -8,11 +8,21 @@ import path from 'node:path' import process from 'node:process' import { parseArgs, promisify } from 'node:util' -import { parse, stringify } from 'smol-toml' - import { startServer } from '../index.js' +import ApiProcess from '../lib/api-process.js' import NameserverSupervisor from '../lib/nameservers.js' -import { init as initAPI } from '@nictool/api/routes/index.js' +import { + buildRemoteUrl, + etcDir, + normalizeApiMode, + readBootstrap, + writeBootstrap, +} from '../lib/config.js' +import { + migrateNameservers, + resolveNameserverConfig, + saveToStore, +} from '../lib/nameserver-config.js' const execFileAsync = promisify(execFile) @@ -34,7 +44,7 @@ Options: -c, --config Path to the NicTool data root (required). TLS certificates are read from /etc/tls/ and auto-generated for ${os.hostname()} if absent. - The configurator will build /etc/nictool.toml. + The configurator will build /etc/nictool.json. Example: nictool-server -c /var/lib/nictool`) @@ -76,11 +86,10 @@ if (discovered) { } // --------------------------------------------------------------------------- -// NicTool config (nictool.toml) +// NicTool bootstrap config (nictool.json) // --------------------------------------------------------------------------- -const tomlPath = path.join(configDir, 'etc', 'nictool.toml') -const nicConfig = await readNicToolToml(tomlPath) +const nicConfig = await readBootstrap(configDir) // --------------------------------------------------------------------------- // Port selection – prefer 443, fall back to 8443 @@ -96,6 +105,7 @@ const port = // --------------------------------------------------------------------------- const supervisor = new NameserverSupervisor() +let apiProcess = null async function shutdown() { try { @@ -103,6 +113,11 @@ async function shutdown() { } catch { /* ignore */ } + try { + await apiProcess?.stop() + } catch { + /* ignore */ + } process.exit(0) } process.once('SIGTERM', shutdown) @@ -110,8 +125,7 @@ process.once('SIGINT', shutdown) if (nicConfig?.configured === true) { console.log('Already configured — starting services.') - const apiServer = await maybeInitAPI(nicConfig) - const apiRemoteUrl = buildRemoteUrl(nicConfig) + const { apiServer, apiRemoteUrl } = await startAPI(nicConfig) await startServer({ configDir, tls, @@ -122,11 +136,7 @@ if (nicConfig?.configured === true) { apiRemoteUrl, supervisor, }) - try { - await supervisor.start(nicConfig) - } catch (err) { - console.error(`Supervisor start failed: ${err.message}`) - } + await startNameservers(nicConfig) } else { // --------------------------------------------------------------------------- // Pre-select a random port to suggest for the API in the configuration form @@ -148,22 +158,25 @@ if (nicConfig?.configured === true) { suggestedPorts: { api: suggestedApiPort }, onSaved: async (config, ctx) => { if (!ctx.apiServer && !ctx.apiRemoteUrl) { - ctx.apiServer = await maybeInitAPI(config) - ctx.apiRemoteUrl = buildRemoteUrl(config) + const started = await startAPI(config) + ctx.apiServer = started.apiServer + ctx.apiRemoteUrl = started.apiRemoteUrl } - if (ctx.supervisor) { - // Restart nameserver engines with the freshly saved config. - try { - await ctx.supervisor.stop() - } catch { - /* ignore */ - } - try { - await ctx.supervisor.start(config) - } catch (err) { - console.error(`Supervisor start failed: ${err.message}`) - } + // 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 { + /* ignore */ } + await startNameservers(config) }, }) } @@ -255,72 +268,67 @@ async function generateTLS(dir, hostname) { } /** - * Read nictool.toml if it exists. Returns null if absent — the file is only - * written when the user submits the configuration form. + * Start the nameserver engines described by the store. Nameservers still + * carried in an older bootstrap config are moved into the store first. */ -async function readNicToolToml(tomlPath) { +async function startNameservers(config) { try { - return parse(await fs.readFile(tomlPath, 'utf8')) + const migrated = await migrateNameservers(configDir, config, writeBootstrap) + const nsConfig = await resolveNameserverConfig(configDir, migrated) + await supervisor.start(nsConfig) } catch (err) { - if (err.code !== 'ENOENT') throw err - return null + console.error(`Supervisor start failed: ${err.message}`) } } -function storeTypeToEnv(type) { - if (type === 'directory') return 'toml' - return type ?? 'mysql' -} - /** - * Return the remote API base URL (e.g. "https://api.example.com:3000") when - * api.mode is "remote", otherwise null. - */ -function buildRemoteUrl(config) { - if (!config?.api || config.api.mode !== 'remote') return null - const { host, port } = config.api - if (!host || !port) return null - const scheme = /^(localhost|127\.|::1)/.test(host) ? 'http' : 'https' - return `${scheme}://${host}:${port}` -} - -/** - * When api.mode is "local" (or unset), patch the API's mysql.toml, set - * required env vars, and initialize the Hapi server in-process without - * binding to any port. + * Bring up the API in whichever mode the bootstrap config selects. * - * @returns {Promise} + * @returns {Promise<{apiServer: import('@hapi/hapi').Server|null, apiRemoteUrl: string|null}>} */ -async function maybeInitAPI(config) { - if (!config || !config.api) return null - if (config.api.mode === 'remote') return null +async function startAPI(config) { + if (!config?.api) return { apiServer: null, apiRemoteUrl: null } + + const mode = normalizeApiMode(config.api.mode) + if (mode === 'remote') { + return { apiServer: null, apiRemoteUrl: buildRemoteUrl(config) } + } - const apiPkgDir = new URL('../node_modules/@nictool/api', import.meta.url).pathname + applyApiEnv() - // Patch the API's mysql.toml when nictool.toml uses a mysql store - if (config.store?.type === 'mysql') { - const mysqlTomlPath = path.join(apiPkgDir, 'conf.d', 'mysql.toml') + 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 } + } + apiProcess = new ApiProcess({ configDir, port }) + apiProcess.on('error', (err) => console.error(`API process error: ${err.message}`)) try { - const content = await fs.readFile(mysqlTomlPath, 'utf8') - const mysqlCfg = parse(content) - const s = config.store - mysqlCfg.host = s.host ?? mysqlCfg.host - mysqlCfg.port = s.port ?? mysqlCfg.port - mysqlCfg.user = s.user ?? mysqlCfg.user - mysqlCfg.password = s.password ?? mysqlCfg.password - mysqlCfg.database = s.database ?? mysqlCfg.database - await fs.writeFile(mysqlTomlPath, stringify(mysqlCfg)) + await apiProcess.start() + return { apiServer: null, apiRemoteUrl: apiProcess.url } } catch (err) { - console.warn(`Could not update API mysql.toml: ${err.message}`) + console.error(`API process failed to start: ${err.message}`) + return { apiServer: null, apiRemoteUrl: null } } } - // Set process env vars the API reads at init time - process.env.NICTOOL_DATA_STORE = storeTypeToEnv(config.store?.type) - if (config.store?.path) process.env.NICTOOL_DATA_STORE_PATH = config.store.path - if (config.store?.dsn) process.env.NICTOOL_DATA_STORE_DSN = config.store.dsn + return { apiServer: await initInProcessAPI(), apiRemoteUrl: null } +} + +/** + * The API resolves its config directory and store backend at module load, so + * this must be set before it is first imported — hence the dynamic import in + * initInProcessAPI. The store itself is read by the API from etc/api.json; + * the server does not need to know where the data lives. + */ +function applyApiEnv() { + process.env.NICTOOL_CONF_DIR = etcDir(configDir) +} +async function initInProcessAPI() { try { + const { init: initAPI } = await import('@nictool/api/routes/index.js') const hapiServer = await initAPI() console.log('API initialized in-process') return hapiServer diff --git a/html/configure.html b/html/configure.html index 1144420..5091da8 100644 --- a/html/configure.html +++ b/html/configure.html @@ -363,6 +363,101 @@

NicTool Configurator

+ +
+
+

Installation

+ +
+
+
+
+ + + +
+
+ +
+
+ + +
+
+

API Service

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

API Status

+ +
+
+

not started

+

+
+
+
@@ -374,12 +469,20 @@

Data Store

@@ -459,52 +562,6 @@

Data Store

- -
-
-

API Service

- -
-
-
-
- - - How the API is reached -
-
- -
-
-
@@ -666,10 +723,16 @@

Nameservers

// Tracks the server-side path check result: null=pending, true=ok, false=fail let _pathCheckOk = null let _pathCheckTimer = null + let _pathChecked = null + + // json/toml keep one file per entity in a directory; "directory" is the + // legacy name for the same thing. + const FILE_STORES = ['json', 'toml', 'directory'] + const isFileStore = (type) => FILE_STORES.includes(type) function validateStore() { const type = document.getElementById('store-type').value - const isDir = type === 'directory' + const isDir = isFileStore(type) document.getElementById('f-store-path').style.display = isDir ? '' : 'none' document.getElementById('f-store-db').style.display = isDir ? 'none' : '' @@ -690,16 +753,21 @@

Nameservers

function checkStorePath() { const type = document.getElementById('store-type').value - if (type !== 'directory') return + if (!isFileStore(type)) return const val = document.getElementById('store-path').value.trim() if (!val) { _pathCheckOk = false + _pathChecked = null setField('store-path', 'n-store-path', false, 'Path is required') validateAll() return } + // Clicking Save blurs this field, firing change with an unchanged value. + // Re-checking would reset the result to pending and swallow the click. + if (val === _pathChecked && _pathCheckOk !== null) return + // debounce clearTimeout(_pathCheckTimer) document.getElementById('n-store-path').textContent = '…' @@ -711,6 +779,7 @@

Nameservers

const res = await fetch('/nt/check-path?path=' + encodeURIComponent(val)) const data = await res.json() _pathCheckOk = data.ok + _pathChecked = val const msg = data.ok ? data.exists ? 'Directory exists' @@ -733,26 +802,99 @@

Nameservers

function validateAPI() { const mode = document.getElementById('api-mode').value const isRemote = mode === 'remote' - document.getElementById('api-remote-fields').style.display = isRemote + const needsPort = isRemote || mode === 'tcp' + + document.getElementById('api-remote-fields').style.display = needsPort + ? '' + : 'none' + // A TCP-mode API is forked on this host, so only the port is asked for. + document.getElementById('api-host').closest('.field').style.display = isRemote + ? '' + : 'none' + document.getElementById('download-api-config').style.display = isRemote ? '' : 'none' - if (!isRemote) { + if (!needsPort) { setBadge('b-api', true, false) return true } - const hostOk = isValidHost(document.getElementById('api-host').value) const portOk = isValidPort(document.getElementById('api-port').value) - - setField('api-host', 'n-api-host', hostOk, hostOk ? '' : 'Required') setField('api-port', 'n-api-port', portOk, portOk ? '' : '1 – 65535') - const ok = hostOk && portOk + let ok = portOk + if (isRemote) { + const hostOk = isValidHost(document.getElementById('api-host').value) + setField('api-host', 'n-api-host', hostOk, hostOk ? '' : 'Required') + ok = ok && hostOk + } + setBadge('b-api', ok, false) return ok } + // ------------------------------------------------------------------------- + // Install type — an upgrade adopts an existing 2.x MySQL database + // ------------------------------------------------------------------------- + + // null = not checked yet, true = schema found, false = check failed + let _schemaFound = null + + function isUpgrade() { + return document.getElementById('install-type').value === 'upgrade' + } + + function validateInstall() { + const upgrade = isUpgrade() + document.getElementById('upgrade-note').style.display = upgrade ? '' : 'none' + + const storeSel = document.getElementById('store-type') + if (upgrade) { + storeSel.value = 'mysql' + storeSel.disabled = true + } else { + storeSel.disabled = false + } + + const ok = !upgrade || _schemaFound === true + setBadge('b-install', ok, upgrade && _schemaFound === null) + return ok + } + + async function detectSchema() { + const dsn = dsnFromFields() + const note = document.getElementById('n-detect') + if (!dsn) { + note.textContent = 'Enter the database connection below first' + note.className = 'field-note bad' + return + } + + note.textContent = 'checking…' + note.className = 'field-note' + try { + const res = await fetch('/nt/detect-schema?dsn=' + encodeURIComponent(dsn)) + const data = await res.json() + if (!data.ok) { + _schemaFound = false + note.textContent = data.error || 'Could not connect' + } else if (!data.found) { + _schemaFound = false + note.textContent = 'Connected, but no NicTool schema found in that database' + } else { + _schemaFound = true + note.textContent = `Found NicTool schema${data.version ? ' v' + data.version : ''}` + } + note.className = 'field-note ' + (_schemaFound ? 'ok' : 'bad') + } catch (err) { + _schemaFound = false + note.textContent = err.message + note.className = 'field-note bad' + } + validateAll() + } + const ENGINE_OPTIONS = [ 'native', 'bind', @@ -769,7 +911,8 @@

Nameservers

nsd: 'rfc1035', powerdns: 'rfc1035', tinydns: 'tinydns-cdb', - maradns: 'rfc1035', + // MaraDNS reads csv2 zone files, not RFC 1035. + maradns: 'maradns', } const TRANSPORT_OPTIONS = ['noop', 'axfr', 'rsync', 'db-replication'] const DNSSEC_ALGOS = [ @@ -842,12 +985,13 @@

Nameservers

} function validateAll() { + const i = validateInstall() const s = validateStore() const a = validateAPI() validateNS() - // Only Store + API required to enable Save - document.getElementById('btn-save').disabled = !(s && a) - return s && a + // Only Install + Store + API required to enable Save + document.getElementById('btn-save').disabled = !(i && s && a) + return i && s && a } // ------------------------------------------------------------------------- @@ -1239,6 +1383,7 @@

DNSSEC

if (apiHost) api.host = apiHost if (apiPort) api.port = apiPort const config = { + install: document.getElementById('install-type').value, store, api, nameserver: [], @@ -1310,8 +1455,9 @@

DNSSEC

function populateForm(c) { if (!c || typeof c !== 'object') return + if (c.install) document.getElementById('install-type').value = c.install if (c.store) { - document.getElementById('store-type').value = c.store.type || 'directory' + document.getElementById('store-type').value = c.store.type || 'json' // Always populate all fields so switching types doesn't lose saved values if (c.store.path) { document.getElementById('store-path').value = c.store.path @@ -1332,7 +1478,9 @@

DNSSEC

} } if (c.api) { - document.getElementById('api-mode').value = c.api.mode || 'local' + // "local" is the pre-tcp name for in_process + const mode = c.api.mode === 'local' ? 'in_process' : c.api.mode + document.getElementById('api-mode').value = mode || 'in_process' if (c.api.host) document.getElementById('api-host').value = c.api.host if (c.api.port) document.getElementById('api-port').value = c.api.port } else if (c._suggested?.api) { @@ -1361,19 +1509,49 @@

DNSSEC

msg.textContent = 'Saving…' msg.className = '' + const config = collectConfig() + try { + // A brand-new MySQL install needs its schema before the API can start. + // The server refuses this outright if the database is already populated. + if (config.install === 'new' && config.store.type === 'mysql') { + setBoot('creating schema…', null) + const initRes = await fetch('/nt/init-schema', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ install: config.install, dsn: config.store.dsn }), + }) + const initData = await initRes.json() + if (!initRes.ok) { + setBoot('schema not created', false, initData.error) + msg.textContent = '✗ ' + (initData.error || 'Schema creation failed') + msg.className = 'err' + validateAll() + return + } + setBoot(`created ${initData.applied?.length ?? 0} tables`, null) + } + const res = await fetch('/nt/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(collectConfig()), + body: JSON.stringify(config), }) const data = await res.json() - if (res.ok) { - window.location.href = '/index.html' - } else { + if (!res.ok) { msg.textContent = '✗ ' + (data.error || 'Save failed') msg.className = 'err' validateAll() + return + } + + msg.textContent = 'Saved. Starting the API…' + if (await waitForApi()) { + window.location.href = '/index.html' + } else { + msg.textContent = '✗ Saved, but the API did not start — see the server log' + msg.className = 'err' + btn.disabled = false } } catch (err) { msg.textContent = '✗ ' + err.message @@ -1382,16 +1560,62 @@

DNSSEC

} }) + function setBoot(text, ok, detail) { + document.getElementById('boot-status').textContent = text + document.getElementById('boot-detail').textContent = detail ?? '' + setBadge('b-boot', ok === true, ok === null) + } + + async function refreshBootStatus() { + try { + const data = await (await fetch('/nt/service')).json() + const running = Boolean(data?.api?.running) + setBoot(running ? 'API running' : 'API not started', running ? true : null) + return running + } catch { + return false + } + } + + // The API is initialized after the config response flushes, so poll rather + // than assuming it is up the moment the save returns. + async function waitForApi() { + for (let i = 0; i < 20; i++) { + if (await refreshBootStatus()) return true + await new Promise((r) => setTimeout(r, 250)) + } + return false + } + // ------------------------------------------------------------------------- // Wire up static field listeners // ------------------------------------------------------------------------- - for (const id of ['store-type', 'api-mode', 'api-host', 'api-port']) { + for (const id of [ + 'store-type', + 'api-mode', + 'api-host', + 'api-port', + 'install-type', + ]) { const el = document.getElementById(id) if (el) el.addEventListener('input', validateAll) if (el) el.addEventListener('change', validateAll) } + // Switching install type invalidates any previous schema probe. + document.getElementById('install-type').addEventListener('change', () => { + _schemaFound = null + document.getElementById('n-detect').textContent = '' + validateAll() + }) + + document.getElementById('detect-schema').addEventListener('click', detectSchema) + + document.getElementById('download-api-config').addEventListener('click', () => { + window.location = '/nt/api-config' + }) + // store-path triggers async filesystem check const storePathEl = document.getElementById('store-path') storePathEl.addEventListener('input', () => { @@ -1404,8 +1628,7 @@

DNSSEC

// store-type toggle: reset state, re-run relevant check document.getElementById('store-type').addEventListener('change', () => { _pathCheckOk = null - const type = document.getElementById('store-type').value - if (type === 'directory') checkStorePath() + if (isFileStore(document.getElementById('store-type').value)) checkStorePath() validateAll() }) @@ -1429,6 +1652,7 @@

DNSSEC

if (data?._hostname) _serverHostname = data._hostname populateForm(data) + refreshBootStatus() // If no nameservers were populated (fresh install), add an empty starter row if (document.getElementById('ns-body').children.length === 0) addNsRow() diff --git a/index.js b/index.js index ac7f9cc..06c438d 100644 --- a/index.js +++ b/index.js @@ -4,9 +4,20 @@ import path from 'node:path' import fs from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { stringify } from 'smol-toml' +import Joi from 'joi' import mysql from 'mysql2/promise' +import { + API_MODES, + bootstrapPath, + buildRemoteApiConfig, + normalizeApiMode, + readApiConfig, + toJson, + writeApiConfig, + writeBootstrap, +} from './lib/config.js' + // .pathname is drive-relative on Windows ("/D:/a/server") const __dirname = fileURLToPath(new URL('.', import.meta.url)) @@ -52,11 +63,10 @@ export async function startServer({ onSaved = null, supervisor = null, }) { - const tomlPath = path.join(configDir, 'etc', 'nictool.toml') // ctx is mutated as services start/stop const ctx = { configDir, - tomlPath, + bootstrapFile: bootstrapPath(configDir), nicConfig, apiServer, apiRemoteUrl, @@ -100,6 +110,11 @@ async function handleRequest(req, res, ctx) { return await checkPath(req, res, ctx) if (url?.startsWith('/nt/check-dsn') && method === 'GET') return await checkDsn(req, res) + if (url?.startsWith('/nt/detect-schema') && method === 'GET') + return await detectSchema(req, res) + if (url === '/nt/init-schema' && method === 'POST') return await initSchema(req, res) + 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/status' && method === 'GET') return await serveStatus(res, ctx) if (url === '/nt/nameservers/status' && method === 'GET') @@ -107,16 +122,10 @@ async function handleRequest(req, res, ctx) { if (url?.startsWith('/api/') || url?.startsWith('/doc')) { if (ctx.apiServer) return await forwardToAPI(req, res, ctx.apiServer) - if (ctx.apiRemoteUrl) return forwardToRemote(req, res, ctx.apiRemoteUrl) + // Must be awaited: `return promise` inside try/catch escapes the catch. + if (ctx.apiRemoteUrl) return await forwardToRemote(req, res, ctx.apiRemoteUrl) } - if (method === 'GET' && url?.startsWith('/nictool/')) - return await serveStatic( - req, - res, - path.join(__dirname, 'node_modules', '@nictool'), - '/nictool/', - ) if (method === 'GET') return await serveStatic(req, res, path.join(__dirname, 'html'), '/') @@ -330,8 +339,8 @@ async function checkDsn(req, res) { } } -async function serveStatus(res, { tomlPath, nicConfig, apiServer, supervisor }) { - const configured = await fileExists(tomlPath) +async function serveStatus(res, { bootstrapFile, nicConfig, apiServer, supervisor }) { + const configured = await fileExists(bootstrapFile) const api = { running: apiServer != null } const nameservers = supervisor?.status?.() ?? [] respond( @@ -339,7 +348,7 @@ async function serveStatus(res, { tomlPath, nicConfig, apiServer, supervisor }) 200, 'application/json', JSON.stringify( - { configured, tomlPath, config: nicConfig, api, nameservers }, + { configured, bootstrapFile, config: nicConfig, api, nameservers }, null, 2, ), @@ -364,38 +373,257 @@ async function saveConfig(req, res, ctx) { ) } - // Strip runtime-only flags from the toml payload - const { startApi: _startApi, _hostname: _h, _suggested: _s, ...config } = body + const { startApi: _startApi, _hostname: _h, _suggested: _s, ...submitted } = body + + const invalid = validateConfig(submitted) + if (invalid) { + return respond(res, 400, 'application/json', JSON.stringify({ error: invalid })) + } + + const mode = normalizeApiMode(submitted.api?.mode) + const config = { + 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 + // mode there is no local API to configure — the operator downloads the + // same file from /nt/api-config and drops it in on the API host. + if (mode !== 'remote') await writeApiConfig(ctx.configDir, submitted.store) + + await writeBootstrap(ctx.configDir, config) + ctx.nicConfig = config + ctx.storeConfig = submitted.store + res.on('finish', () => ctx.onSaved?.(config, ctx)) + respond(res, 200, 'application/json', JSON.stringify({ ok: true })) + } catch (err) { + respond(res, 500, 'application/json', JSON.stringify({ error: err.message })) + } +} + +function validateConfig(config) { + const schema = Joi.object({ + install: Joi.string().valid('new', 'upgrade').default('new'), + api: Joi.object({ + mode: Joi.string() + .valid(...API_MODES, 'local') + .required(), + host: Joi.when('mode', { + is: 'remote', + then: Joi.string().hostname().required(), + otherwise: Joi.string().allow('').optional(), + }), + port: Joi.when('mode', { + is: Joi.valid('remote', 'tcp'), + then: Joi.number().port().required(), + otherwise: Joi.number().port().optional(), + }), + }).required(), + store: Joi.object({ + type: Joi.string().valid('json', 'toml', 'directory', 'mysql').required(), + path: Joi.when('type', { + is: Joi.valid('json', 'toml', 'directory'), + then: Joi.string().required(), + otherwise: Joi.string().allow('').optional(), + }), + }) + .unknown(true) + .required(), + nameserver: Joi.array().items(Joi.object().unknown(true)).optional(), + }).unknown(true) + + const { error } = schema.validate(config, { abortEarly: true }) + return error ? error.message : null +} + +// Tables whose presence means the target database already holds NicTool data. +const NICTOOL_TABLES = ['nt_options', 'nt_zone'] + +/** + * @param {object} [opts] + * @param {boolean} [opts.multipleStatements] Required to apply a schema file, + * which holds many statements. Left off elsewhere so probe queries that + * interpolate operator input keep the single-statement guarantee. + */ +async function withMysql(dsn, fn, opts = {}) { + const cfg = parseMysqlDsn(dsn) + let conn + try { + conn = await mysql.createConnection({ + ...cfg, + connectTimeout: 4000, + multipleStatements: Boolean(opts.multipleStatements), + }) + return await fn(conn, cfg) + } finally { + try { + await conn?.end() + } catch { + /* ignore */ + } + } +} + +async function findNicToolTables(conn, database) { + const [rows] = await conn.query( + 'SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? AND table_name IN (?)', + [database, NICTOOL_TABLES], + ) + return rows.map((r) => r.t ?? r.table_name) +} - if (!config.store?.type) { +/** + * Report whether a database already holds a NicTool schema, and which version. + * Drives the upgrade path: an existing 2.x database is adopted, never rebuilt. + */ +async function detectSchema(req, res) { + const dsn = new URL(req.url, 'http://x').searchParams.get('dsn') + if (!dsn) { return respond( res, 400, 'application/json', - JSON.stringify({ error: 'Missing required fields' }), + JSON.stringify({ error: 'dsn required' }), ) } - if (config.api?.mode === 'remote' && (!config.api?.host || !(config.api?.port > 0))) { + + try { + const result = await withMysql(dsn, async (conn, cfg) => { + const tables = await findNicToolTables(conn, cfg.database) + if (!tables.length) return { ok: true, found: false, tables: [] } + + let version = null + try { + const [rows] = await conn.query( + "SELECT option_value AS v FROM nt_options WHERE option_name = 'db_version'", + ) + version = rows[0]?.v ?? null + } catch { + /* nt_zone without nt_options — still an existing install */ + } + return { ok: true, found: true, version, tables } + }) + respond(res, 200, 'application/json', JSON.stringify(result)) + } catch (err) { + const error = err.code ? `${err.code}: ${err.message}` : err.message + respond(res, 200, 'application/json', JSON.stringify({ ok: false, error })) + } +} + +/** + * Create the NicTool schema in an empty database. + * + * sql/*.sql is idempotent (CREATE TABLE IF NOT EXISTS / INSERT IGNORE) and the + * destructive cleanup lives in sql/upgrade/, which is not applied here. The + * refusal below is the second line of defence: adopting an existing database is + * the upgrade path's job, not the installer's. There is deliberately no override. + */ +async function initSchema(req, res) { + let body + try { + body = JSON.parse(await readBody(req)) + } catch { return respond( res, 400, 'application/json', - JSON.stringify({ error: 'Remote API mode requires host and port' }), + JSON.stringify({ error: 'Invalid JSON' }), ) } - // Mark configuration as complete — this is the flag that skips the configurator on next run - config.configured = true + if (body.install === 'upgrade') { + return respond( + res, + 409, + 'application/json', + JSON.stringify({ error: 'Refusing to initialize a schema during an upgrade' }), + ) + } + if (!body.dsn) { + return respond( + res, + 400, + 'application/json', + JSON.stringify({ error: 'dsn required' }), + ) + } try { - await fs.mkdir(path.dirname(ctx.tomlPath), { recursive: true }) - await fs.writeFile(ctx.tomlPath, stringify(config)) - ctx.nicConfig = config - res.on('finish', () => ctx.onSaved?.(config, ctx)) - respond(res, 200, 'application/json', JSON.stringify({ ok: true })) + const result = await withMysql( + body.dsn, + async (conn, cfg) => { + const existing = await findNicToolTables(conn, cfg.database) + if (existing.length) return { conflict: existing } + + const applied = [] + for (const file of await sqlFiles()) { + await conn.query(await fs.readFile(file, 'utf8')) + applied.push(path.basename(file)) + } + return { applied } + }, + { multipleStatements: true }, + ) + + if (result.conflict) { + return respond( + res, + 409, + 'application/json', + JSON.stringify({ + error: `Database already contains NicTool tables (${result.conflict.join(', ')}). Choose "upgrade" to adopt it.`, + }), + ) + } + respond( + res, + 200, + 'application/json', + JSON.stringify({ ok: true, applied: result.applied }), + ) } catch (err) { - respond(res, 500, 'application/json', JSON.stringify({ error: err.message })) + const error = err.code ? `${err.code}: ${err.message}` : err.message + respond(res, 500, 'application/json', JSON.stringify({ ok: false, error })) + } +} + +async function sqlFiles() { + const dir = path.join( + path.dirname(fileURLToPath(import.meta.resolve('@nictool/api/server.js'))), + 'sql', + ) + const entries = await fs.readdir(dir) + return entries + .filter((f) => f.endsWith('.sql')) + .sort() + .map((f) => path.join(dir, f)) +} + +/** + * The API's config file, for an operator to drop in on a remote API host. + */ +async function serveApiConfig(res, ctx) { + const store = ctx.storeConfig ?? (await readApiConfig(ctx.configDir))?.store + if (!store) { + return respond( + res, + 404, + 'application/json', + JSON.stringify({ error: 'No store configured yet' }), + ) } + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="api.json"', + }) + res.end(toJson(buildRemoteApiConfig(store))) } // --------------------------------------------------------------------------- @@ -445,7 +673,23 @@ function forwardToRemote(req, res, remoteBaseUrl) { if (req.headers[hdr]) forwardHeaders[hdr] = req.headers[hdr] } - return new Promise((resolve, reject) => { + return new Promise((resolve) => { + // An unreachable API is an expected state — it restarts, and a remote one + // can be down — so answer 502 rather than faulting the whole server. + const fail = (err) => { + if (!res.headersSent) { + respond( + res, + 502, + 'application/json', + JSON.stringify({ error: `API unreachable: ${err.code ?? err.message}` }), + ) + } else { + res.end() + } + resolve() + } + const upReq = mod.request( { hostname: target.hostname, @@ -465,10 +709,10 @@ function forwardToRemote(req, res, remoteBaseUrl) { res.end(Buffer.concat(chunks)) resolve() }) - upRes.on('error', reject) + upRes.on('error', fail) }, ) - upReq.on('error', reject) + upReq.on('error', fail) req.pipe(upReq) }) } diff --git a/lib/api-process.js b/lib/api-process.js new file mode 100644 index 0000000..f9ed55c --- /dev/null +++ b/lib/api-process.js @@ -0,0 +1,73 @@ +import { EventEmitter } from 'node:events' +import { fork } from 'node:child_process' + +import { etcDir } from './config.js' + +const RESTART_DELAY_MS = 1000 + +/** + * Runs @nictool/api as a supervised child bound to a TCP port, restarting it if + * it exits unexpectedly. The alternative in_process mode dispatches through + * Hapi's inject() instead and never binds a socket. + */ +export class ApiProcess extends EventEmitter { + constructor({ configDir, host = 'localhost', port }) { + super() + this.configDir = configDir + this.host = host + this.port = port + this.child = null + this.stopping = false + } + + get url() { + return `http://${this.host}:${this.port}` + } + + async start() { + // import.meta.resolve finds the API wherever npm placed it — hoisted to a + // workspace root or nested under this package. + const entry = import.meta.resolve('@nictool/api/server.js') + + this.stopping = false + this.child = fork(new URL(entry), [], { + env: { + ...process.env, + NICTOOL_CONF_DIR: etcDir(this.configDir), + NICTOOL_HTTP_HOST: this.host, + NICTOOL_HTTP_PORT: String(this.port), + }, + stdio: 'inherit', + }) + + this.child.once('exit', (code, signal) => { + this.child = null + if (this.stopping) return + + console.error(`API exited unexpectedly (code=${code} signal=${signal}); restarting`) + this.emit('exit', code, signal) + setTimeout(() => { + this.start().catch((err) => this.emit('error', err)) + }, RESTART_DELAY_MS).unref() + }) + + console.log(`API listening on ${this.url} (pid ${this.child.pid})`) + this.emit('started', this.child.pid) + return this.child.pid + } + + async stop() { + this.stopping = true + const child = this.child + if (!child) return + + await new Promise((resolve) => { + child.once('exit', resolve) + child.kill('SIGTERM') + }) + this.child = null + this.emit('stopped') + } +} + +export default ApiProcess diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..acd5425 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,156 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { parse } from 'smol-toml' + +// The server's own config holds only what it needs to reach the API. Store +// connection details belong to the API (etc/api.json); everything else belongs +// in the store. +export const API_MODES = ['in_process', 'tcp', 'remote'] + +export const etcDir = (configDir) => path.join(configDir, 'etc') +export const bootstrapPath = (configDir) => path.join(etcDir(configDir), 'nictool.json') +export const apiConfigPath = (configDir) => path.join(etcDir(configDir), 'api.json') +const legacyTomlPath = (configDir) => path.join(etcDir(configDir), 'nictool.toml') + +/** "local" was the pre-tcp name for running the API inside this process. */ +export function normalizeApiMode(mode) { + if (!mode || mode === 'local') return 'in_process' + return mode +} + +/** + * A file store keeps rows in one file per entity; "directory" was the original + * name for that, before the codec became selectable. + */ +export function storeTypeToEnv(type) { + if (type === 'directory') return 'toml' + return type ?? 'mysql' +} + +/** + * Recursively order object keys so serialized output is stable — an unsorted + * dump reshuffles on unrelated edits and makes diffs unreadable. + */ +export function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys) + if (value === null || typeof value !== 'object') return value + if (Object.getPrototypeOf(value) !== Object.prototype) return value + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, sortKeys(value[k])]), + ) +} + +/** Human-friendly JSON: sorted, shallow-indented, newline-terminated. */ +export function toJson(value) { + return `${JSON.stringify(sortKeys(value), null, 1)}\n` +} + +async function readJson(file) { + try { + return JSON.parse(await fs.readFile(file, 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') return null + throw err + } +} + +/** + * Read the server bootstrap config, migrating a pre-existing nictool.toml on + * first sight. Returns null when the server has never been configured. + */ +export async function readBootstrap(configDir) { + const existing = await readJson(bootstrapPath(configDir)) + if (existing) return existing + + let legacy + try { + legacy = parse(await fs.readFile(legacyTomlPath(configDir), 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') return null + throw err + } + + const { bootstrap, store } = splitLegacyConfig(legacy) + if (store) await writeApiConfig(configDir, store) + await writeBootstrap(configDir, bootstrap) + console.log(`Migrated ${legacyTomlPath(configDir)} to nictool.json + api.json`) + return bootstrap +} + +/** + * Split a v0.2-era nictool.toml into the server's bootstrap config and the + * API's store connection. + */ +export function splitLegacyConfig(legacy) { + const bootstrap = { + configured: legacy.configured === true, + api: { + ...legacy.api, + mode: normalizeApiMode(legacy.api?.mode), + }, + } + // 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 + + return { bootstrap, store: legacy.store ?? null } +} + +export async function readApiConfig(configDir) { + return readJson(apiConfigPath(configDir)) +} + +export async function writeBootstrap(configDir, config) { + await fs.mkdir(etcDir(configDir), { recursive: true }) + await fs.writeFile(bootstrapPath(configDir), toJson(config)) +} + +/** + * Write the store connection into the API's config, preserving any sections + * the API generated for itself — clobbering them would rotate the JWT and + * cookie secrets on every save and log every session out. + */ +export async function writeApiConfig(configDir, store) { + const file = apiConfigPath(configDir) + const existing = (await readJson(file)) ?? {} + const merged = { ...existing, store: buildStoreConfig(store) } + + await fs.mkdir(etcDir(configDir), { recursive: true }) + await fs.writeFile(file, toJson(merged), { mode: 0o600 }) + return merged +} + +/** + * Normalize a configurator store payload into what the API needs to connect. + * Only the keys relevant to the chosen type survive. + */ +export function buildStoreConfig(store = {}) { + const type = storeTypeToEnv(store.type) + + if (type === 'mysql') { + const { host, port, user, password, database, dsn } = store + return { type, host, port, user, password, database, dsn } + } + + return { type, path: store.path } +} + +/** The drop-in api.json handed to an operator running the API on another host. */ +export function buildRemoteApiConfig(store) { + return { store: buildStoreConfig(store) } +} + +/** + * Base URL for a remotely-hosted API, or null when it runs locally. + */ +export function buildRemoteUrl(config) { + if (normalizeApiMode(config?.api?.mode) !== 'remote') return null + const { host, port } = config.api ?? {} + if (!host || !port) return null + const scheme = /^(localhost|127\.|::1)/.test(host) ? 'http' : 'https' + return `${scheme}://${host}:${port}` +} diff --git a/lib/config.test.js b/lib/config.test.js new file mode 100644 index 0000000..691aaa7 --- /dev/null +++ b/lib/config.test.js @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { describe, it, beforeEach, after } from 'node:test' + +import { + apiConfigPath, + bootstrapPath, + buildRemoteApiConfig, + buildRemoteUrl, + buildStoreConfig, + normalizeApiMode, + readBootstrap, + splitLegacyConfig, + storeTypeToEnv, + toJson, + writeApiConfig, + writeBootstrap, +} from './config.js' + +describe('server bootstrap config', () => { + const tmpDirs = [] + let dir + + const mkConfigDir = () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-server-cfg-')) + tmpDirs.push(d) + return d + } + + beforeEach(() => { + dir = mkConfigDir() + }) + + after(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }) + }) + + describe('normalizeApiMode', () => { + it(`maps the legacy "local" to in_process`, () => { + assert.equal(normalizeApiMode('local'), 'in_process') + assert.equal(normalizeApiMode(undefined), 'in_process') + }) + + it(`passes the current modes through`, () => { + for (const mode of ['in_process', 'tcp', 'remote']) { + assert.equal(normalizeApiMode(mode), mode) + } + }) + }) + + describe('storeTypeToEnv', () => { + it(`maps the legacy "directory" to toml`, () => { + assert.equal(storeTypeToEnv('directory'), 'toml') + }) + + it(`defaults to mysql when unset`, () => { + assert.equal(storeTypeToEnv(undefined), 'mysql') + }) + }) + + describe('buildStoreConfig', () => { + it(`keeps only connection keys for mysql`, () => { + const store = buildStoreConfig({ + type: 'mysql', + host: 'db', + port: 3306, + user: 'nt', + password: 'pw', + database: 'nictool', + path: '/should/not/survive', + }) + + assert.equal(store.path, undefined) + assert.equal(store.host, 'db') + assert.equal(store.database, 'nictool') + }) + + it(`keeps only the path for a file store`, () => { + const store = buildStoreConfig({ type: 'json', path: '/data', password: 'pw' }) + + assert.deepEqual(store, { type: 'json', path: '/data' }) + }) + }) + + describe('readBootstrap', () => { + it(`returns null when nothing is configured`, async () => { + assert.equal(await readBootstrap(dir), null) + }) + + it(`round-trips a written bootstrap`, async () => { + await writeBootstrap(dir, { configured: true, api: { mode: 'tcp', port: 3000 } }) + + const cfg = await readBootstrap(dir) + + assert.equal(cfg.configured, true) + assert.equal(cfg.api.mode, 'tcp') + }) + }) + + describe('legacy nictool.toml migration', () => { + const legacyToml = `configured = true + +[store] +type = "directory" +path = "/var/lib/nictool/data" + +[api] +mode = "local" +host = "nt.example.com" +port = 3000 + +[[nameserver]] +name = "ns1.example.com." +engine = "native" +` + + const writeLegacy = async () => { + await fsp.mkdir(path.join(dir, 'etc'), { recursive: true }) + await fsp.writeFile(path.join(dir, 'etc', 'nictool.toml'), legacyToml) + } + + it(`splits the store out into api.json and normalizes the mode`, async () => { + await writeLegacy() + + const cfg = await readBootstrap(dir) + + assert.equal(cfg.api.mode, 'in_process') + assert.equal(cfg.configured, true) + assert.equal(cfg.store, undefined, 'store no longer lives in the server config') + + const apiJson = JSON.parse(await fsp.readFile(apiConfigPath(dir), 'utf8')) + assert.deepEqual(apiJson.store, { type: 'toml', path: '/var/lib/nictool/data' }) + }) + + it(`carries nameservers across, pending their move into the store`, async () => { + await writeLegacy() + + const cfg = await readBootstrap(dir) + + assert.equal(cfg.nameserver.length, 1) + assert.equal(cfg.nameserver[0].engine, 'native') + }) + + it(`writes nictool.json so the migration runs only once`, async () => { + await writeLegacy() + await readBootstrap(dir) + assert.equal(fs.existsSync(bootstrapPath(dir)), true) + + // A later edit to the json must win over the stale toml still on disk. + await writeBootstrap(dir, { configured: true, api: { mode: 'remote' } }) + assert.equal((await readBootstrap(dir)).api.mode, 'remote') + }) + + it(`splitLegacyConfig leaves the store for the API`, () => { + const { bootstrap, store } = splitLegacyConfig({ + configured: true, + store: { type: 'mysql', host: 'db' }, + api: { mode: 'local' }, + }) + + assert.equal(bootstrap.store, undefined) + assert.equal(store.host, 'db') + }) + }) + + describe('writeApiConfig', () => { + it(`preserves sections the API generated for itself`, async () => { + // Rotating these on every config save would log every session out. + await fsp.mkdir(path.join(dir, 'etc'), { recursive: true }) + await fsp.writeFile( + apiConfigPath(dir), + JSON.stringify({ + http: { jwt: { key: 'abc123' }, cookie: { password: 'secret' } }, + }), + ) + + await writeApiConfig(dir, { type: 'json', path: '/data' }) + + const apiJson = JSON.parse(await fsp.readFile(apiConfigPath(dir), 'utf8')) + assert.equal(apiJson.http.jwt.key, 'abc123') + assert.equal(apiJson.http.cookie.password, 'secret') + assert.deepEqual(apiJson.store, { type: 'json', path: '/data' }) + }) + + it(`replaces a previously configured store rather than merging into it`, async () => { + await writeApiConfig(dir, { type: 'mysql', host: 'db', database: 'nictool' }) + await writeApiConfig(dir, { type: 'json', path: '/data' }) + + const apiJson = JSON.parse(await fsp.readFile(apiConfigPath(dir), 'utf8')) + assert.equal(apiJson.store.host, undefined, 'stale mysql keys are gone') + assert.equal(apiJson.store.type, 'json') + }) + + it(`writes the file owner-only`, async (t) => { + // The file holds the store password. Windows has no POSIX mode bits — + // node reports 0o666 whatever is requested, and access is governed by + // ACLs — so there is nothing to assert there. + if (process.platform === 'win32') { + return t.skip('POSIX modes are not enforced on Windows') + } + + await writeApiConfig(dir, { type: 'mysql', password: 'pw' }) + + const mode = fs.statSync(apiConfigPath(dir)).mode & 0o777 + assert.equal(mode, 0o600) + }) + }) + + describe('buildRemoteUrl', () => { + it(`is null unless the API is remote`, () => { + assert.equal(buildRemoteUrl({ api: { mode: 'in_process' } }), null) + assert.equal(buildRemoteUrl({ api: { mode: 'local' } }), null) + }) + + it(`uses http for loopback and https otherwise`, () => { + assert.equal( + buildRemoteUrl({ api: { mode: 'remote', host: 'localhost', port: 3000 } }), + 'http://localhost:3000', + ) + assert.equal( + buildRemoteUrl({ api: { mode: 'remote', host: 'api.example.com', port: 3000 } }), + 'https://api.example.com:3000', + ) + }) + + it(`is null when the remote is underspecified`, () => { + assert.equal( + buildRemoteUrl({ api: { mode: 'remote', host: 'api.example.com' } }), + null, + ) + }) + }) + + describe('toJson', () => { + it('sorts, indents by one space, and ends with a newline', () => { + assert.equal(toJson({ b: 1, a: 2 }), '{\n "a": 2,\n "b": 1\n}\n') + }) + }) + + describe('buildRemoteApiConfig', () => { + it(`is a drop-in api.json holding only the store`, () => { + const cfg = buildRemoteApiConfig({ type: 'mysql', host: 'db', database: 'nictool' }) + + assert.deepEqual(Object.keys(cfg), ['store']) + assert.equal(cfg.store.database, 'nictool') + }) + }) +}) diff --git a/lib/nameserver-config.js b/lib/nameserver-config.js new file mode 100644 index 0000000..28da329 --- /dev/null +++ b/lib/nameserver-config.js @@ -0,0 +1,91 @@ +import { readApiConfig } from './config.js' + +/** + * Nameserver topology lives in the data store alongside zones, not in the + * server's bootstrap config. This module bridges the two: it reads nameservers + * out of the store for the supervisor, and migrates any that are still sitting + * in an older nictool.json. + * + * The supervisor needs the store connection as well as the records — the DNS + * engines stream zone data straight from the store rather than through the API, + * so this is the one place the server legitimately reads the API's config. + */ + +async function openRepo() { + // Imported lazily: the API resolves its store backend at module load, so this + // must happen after NICTOOL_CONF_DIR is set. + const mod = await import('@nictool/api/lib/nameserver/index.js') + return mod.default +} + +/** + * A NicTool install lists every nameserver it knows about, most of which are + * export targets running elsewhere. Only records carrying listen sockets are + * ones this host is being asked to serve. + */ +export function runnableHere(nameservers) { + return nameservers.filter((ns) => Array.isArray(ns.listen) && ns.listen.length > 0) +} + +export async function loadFromStore() { + const repo = await openRepo() + return runnableHere(await repo.get({})) +} + +export async function saveToStore(nameservers) { + if (!Array.isArray(nameservers) || !nameservers.length) return 0 + + const repo = await openRepo() + const existing = await repo.get({}) + const nextId = existing.reduce((max, ns) => Math.max(max, ns.id ?? 0), 0) + 1 + + let created = 0 + for (const [i, ns] of nameservers.entries()) { + if (existing.some((e) => e.name === ns.name)) continue + await repo.create({ id: ns.id ?? nextId + i, ...ns }) + created++ + } + return created +} + +/** + * Assemble what NameserverSupervisor.start() needs: the store connection plus + * the nameserver records. Falls back to any records still carried in the + * bootstrap config when the store cannot be reached. + */ +export async function resolveNameserverConfig(configDir, bootstrap) { + const apiConfig = await readApiConfig(configDir) + const store = apiConfig?.store ?? null + const carried = Array.isArray(bootstrap?.nameserver) ? bootstrap.nameserver : [] + + if (!store) return { store, nameserver: carried } + + try { + const stored = await loadFromStore() + return { store, nameserver: stored.length ? stored : carried } + } catch (err) { + console.warn(`Could not read nameservers from the store: ${err.message}`) + return { store, nameserver: carried } + } +} + +/** + * Move nameservers out of the bootstrap config and into the store, once. The + * bootstrap file is only rewritten after the store write succeeds, so a failure + * leaves the records where they are rather than losing them. + */ +export async function migrateNameservers(configDir, bootstrap, writeBootstrap) { + const carried = Array.isArray(bootstrap?.nameserver) ? bootstrap.nameserver : [] + if (!carried.length) return bootstrap + + try { + const created = await saveToStore(carried) + const { nameserver: _moved, ...rest } = bootstrap + await writeBootstrap(configDir, rest) + console.log(`Moved ${created} nameserver(s) from nictool.json into the data store`) + return rest + } catch (err) { + console.warn(`Could not move nameservers into the store: ${err.message}`) + return bootstrap + } +} diff --git a/lib/nameserver-config.test.js b/lib/nameserver-config.test.js new file mode 100644 index 0000000..f71ffc5 --- /dev/null +++ b/lib/nameserver-config.test.js @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { describe, it, beforeEach, after } from 'node:test' + +import { apiConfigPath, writeApiConfig, writeBootstrap, bootstrapPath } from './config.js' +import { + migrateNameservers, + resolveNameserverConfig, + runnableHere, +} from './nameserver-config.js' + +describe('nameserver config', () => { + const tmpDirs = [] + let dir + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-nscfg-')) + tmpDirs.push(dir) + }) + + after(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }) + }) + + const nameserver = { + name: 'ns1.example.com.', + engine: 'native', + listen: [{ address: '127.0.0.1', port: 5353, proto: 'udp' }], + } + + describe('runnableHere', () => { + it('keeps only records with listen sockets', () => { + // A NicTool install lists every nameserver it knows about; most are + // export targets running on other hosts. + const kept = runnableHere([ + nameserver, + { name: 'ns2.example.com.', address: '192.0.2.2' }, + { name: 'ns3.example.com.', listen: [] }, + ]) + + assert.deepEqual( + kept.map((n) => n.name), + ['ns1.example.com.'], + ) + }) + + it('is empty when nothing is configured to run locally', () => { + assert.deepEqual(runnableHere([{ name: 'ns9.example.com.' }]), []) + }) + }) + + describe('resolveNameserverConfig', () => { + it('pairs the store connection with the nameserver records', async () => { + await writeApiConfig(dir, { type: 'json', path: path.join(dir, 'data') }) + + const cfg = await resolveNameserverConfig(dir, { nameserver: [nameserver] }) + + assert.equal(cfg.store.type, 'json') + assert.equal(cfg.nameserver.length, 1) + }) + + it('reports no store when the API has not been configured', async () => { + const cfg = await resolveNameserverConfig(dir, { nameserver: [nameserver] }) + + assert.equal(cfg.store, null) + assert.deepEqual(cfg.nameserver, [nameserver], 'carried records are still returned') + }) + + it('yields an empty list when nothing is configured anywhere', async () => { + const cfg = await resolveNameserverConfig(dir, null) + + assert.deepEqual(cfg.nameserver, []) + }) + }) + + describe('migrateNameservers', () => { + it('is a no-op when the bootstrap carries none', async () => { + const bootstrap = { configured: true, api: { mode: 'in_process' } } + + const after = await migrateNameservers(dir, bootstrap, writeBootstrap) + + assert.equal(after, bootstrap) + }) + + it('leaves the bootstrap intact when the store write fails', async () => { + // No api.json, so the store cannot be opened. + const bootstrap = { configured: true, api: {}, nameserver: [nameserver] } + let wrote = false + + const after = await migrateNameservers(dir, bootstrap, async () => { + wrote = true + }) + + assert.equal( + wrote, + false, + 'the bootstrap is only rewritten after a successful move', + ) + assert.deepEqual(after.nameserver, [nameserver], 'records are not lost') + }) + }) + + describe('api.json', () => { + it('is where the store connection is read from', async () => { + await writeApiConfig(dir, { type: 'mysql', host: 'db', database: 'nictool' }) + + const onDisk = JSON.parse(await fsp.readFile(apiConfigPath(dir), 'utf8')) + assert.equal(onDisk.store.host, 'db') + }) + }) +}) diff --git a/lib/nameserver-migrate.test.js b/lib/nameserver-migrate.test.js new file mode 100644 index 0000000..6fc3417 --- /dev/null +++ b/lib/nameserver-migrate.test.js @@ -0,0 +1,79 @@ +// Exercises the real store-backed migration: nameservers carried in an older +// nictool.json move into the data store and are dropped from the bootstrap. +// +// Lives in its own file because the API binds its store backend at module load. +// Setting these env vars must happen before anything imports the repo, and +// node --test gives each file its own process. +import assert from 'node:assert/strict' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { describe, it, after } from 'node:test' + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-nsmig-')) +const store = path.join(dir, 'data') + +process.env.NICTOOL_CONF_DIR = path.join(dir, 'etc') +process.env.NICTOOL_DATA_STORE = 'json' +process.env.NICTOOL_DATA_STORE_PATH = store + +const { writeApiConfig, writeBootstrap, bootstrapPath } = await import('./config.js') +const { migrateNameservers, resolveNameserverConfig } = + await import('./nameserver-config.js') + +after(() => { + fs.rmSync(dir, { recursive: true, force: true }) +}) + +const nameserver = { + name: 'ns1.example.com.', + engine: 'native', + listen: [{ address: '127.0.0.1', port: 5353, proto: 'udp' }], + publisher: { type: 'memory' }, +} + +describe('nameserver migration into the store', () => { + it('moves records out of the bootstrap and into the store', async () => { + await writeApiConfig(dir, { type: 'json', path: store }) + const bootstrap = { + configured: true, + api: { mode: 'in_process' }, + nameserver: [nameserver], + } + + const migrated = await migrateNameservers(dir, bootstrap, writeBootstrap) + + assert.equal(migrated.nameserver, undefined, 'dropped from the returned config') + + const onDisk = JSON.parse(await fsp.readFile(bootstrapPath(dir), 'utf8')) + assert.equal(onDisk.nameserver, undefined, 'dropped from nictool.json') + assert.equal(onDisk.configured, true, 'the rest of the bootstrap survives') + + const stored = JSON.parse( + await fsp.readFile(path.join(store, 'nameserver.json'), 'utf8'), + ) + assert.equal(stored.nameserver.length, 1) + assert.equal(stored.nameserver[0].name, nameserver.name) + assert.deepEqual(stored.nameserver[0].listen, nameserver.listen) + assert.deepEqual(stored.nameserver[0].publisher, nameserver.publisher) + }) + + it('then serves those records back to the supervisor', async () => { + const cfg = await resolveNameserverConfig(dir, { configured: true, api: {} }) + + assert.equal(cfg.store.type, 'json') + assert.equal(cfg.nameserver.length, 1) + assert.equal(cfg.nameserver[0].engine, 'native') + }) + + it('does not duplicate on a second run', async () => { + const bootstrap = { configured: true, api: {}, nameserver: [nameserver] } + await migrateNameservers(dir, bootstrap, writeBootstrap) + + const stored = JSON.parse( + await fsp.readFile(path.join(store, 'nameserver.json'), 'utf8'), + ) + assert.equal(stored.nameserver.length, 1, 'matched on name, not appended again') + }) +}) diff --git a/lib/nameservers.js b/lib/nameservers.js index 00c2855..98b9a14 100644 --- a/lib/nameservers.js +++ b/lib/nameservers.js @@ -6,6 +6,7 @@ import { DbReplicationTransport, KnotNS, MaradnsNS, + MaradnsPublisher, MemoryPublisher, MemorySigner, MysqlSource, @@ -20,7 +21,7 @@ import { RsyncTransport, TinydnsCdbPublisher, TinydnsNS, - TomlSource, + FileSource, } from '@nictool/dns-nameserver' const FILE_ENGINE_CLASSES = { @@ -32,6 +33,14 @@ const FILE_ENGINE_CLASSES = { maradns: MaradnsNS, } +// MaraDNS reads csv2, not RFC 1035, and tinydns reads a compiled cdb. +const DEFAULT_PUBLISHER = { + native: 'memory', + tinydns: 'tinydns-cdb', + maradns: 'maradns', + powerdns: 'rfc1035', +} + const trimDot = (name) => `${name}`.replace(/\.$/, '') // Collapse listen[] to a compact "addr:port" list, deduping the udp/tcp pair. @@ -43,12 +52,13 @@ function formatListen(listen) { } /** - * NameserverSupervisor – starts/stops the set of nameserver engines declared - * in nictool.toml. Built to support many instances (so the deployment can run + * NameserverSupervisor – starts/stops the set of nameserver engines 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). * - * The top-level [store] block is the default Source for every engine; each - * [[nameserver]] may override via [nameserver.source]. + * start() takes { store, nameserver } — the store connection the engines 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 @@ -162,12 +172,22 @@ export class NameserverSupervisor extends EventEmitter { } _buildPublisher(cfg, engineName) { - const type = cfg?.type ?? (engineName === 'tinydns' ? 'tinydns-cdb' : 'rfc1035') + const type = cfg?.type ?? DEFAULT_PUBLISHER[engineName] ?? 'rfc1035' switch (type) { case 'rfc1035': - return new Rfc1035Publisher({ path: cfg?.path }) + // bind/knot/nsd all read RFC 1035 zone files but declare them in their + // own config format, so the engine picks the config generator. + return new Rfc1035Publisher({ + ...cfg, + config: cfg?.config === false ? null : { format: engineName, ...cfg?.config }, + }) + case 'maradns': + return new MaradnsPublisher({ + ...cfg, + config: cfg?.config === false ? null : { ...cfg?.config }, + }) case 'tinydns-cdb': - return new TinydnsCdbPublisher({ path: cfg?.path }) + return new TinydnsCdbPublisher({ ...cfg }) case 'powerdns-db': return new PowerdnsDbPublisher({ ...cfg }) default: @@ -218,9 +238,11 @@ export class NameserverSupervisor extends EventEmitter { const type = srcCfg?.type switch (type) { + case 'json': + return new FileSource({ path: srcCfg.path, format: 'json' }) case 'directory': case 'toml': - return new TomlSource({ path: srcCfg.path }) + return new FileSource({ path: srcCfg.path, format: 'toml' }) case 'mysql': return new MysqlSource(srcCfg) default: diff --git a/lib/nameservers.test.js b/lib/nameservers.test.js index 97758d3..7dc7f49 100644 --- a/lib/nameservers.test.js +++ b/lib/nameservers.test.js @@ -36,7 +36,7 @@ describe('NameserverSupervisor', function () { const zoneToml = ` [[zone]] id = 1 -name = "example.com" +zone = "example.com" ttl = 300 serial = 2026010101 mailaddr = "ns1.example.com" @@ -44,7 +44,7 @@ rname = "hostmaster.example.com" [[zone]] id = 2 -name = "test.local" +zone = "test.local" ttl = 300 serial = 2026010102 ` @@ -53,7 +53,7 @@ serial = 2026010102 id = 10 zid = 1 type = "A" -name = "@" +owner = "@" address = "192.0.2.10" ttl = 300 @@ -61,7 +61,7 @@ ttl = 300 id = 11 zid = 1 type = "A" -name = "www" +owner = "www" address = "192.0.2.20" ttl = 300 @@ -69,7 +69,7 @@ ttl = 300 id = 20 zid = 2 type = "A" -name = "@" +owner = "@" address = "10.0.0.1" ttl = 300 ` @@ -181,6 +181,44 @@ ttl = 300 assert.match(exampleZone, /SOA/) const testZone = await fs.readFile(path.join(outDir, 'test.local.zone'), 'utf8') assert.match(testZone, /\$ORIGIN test\.local\./) + + // The server also needs to be told the zones exist. + const named = await fs.readFile(path.join(outDir, 'named.conf'), 'utf8') + assert.match(named, /zone "example\.com" \{/) + assert.match(named, /file "example\.com\.zone";/) + assert.match(named, /zone "test\.local" \{/) + } finally { + await sup2.stop() + await fs.rm(outDir, { recursive: true, force: true }) + } + }) + + it('maradns engine publishes csv2 zone files and a mararc', async () => { + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nictool-mara-')) + const sup2 = new NameserverSupervisor() + try { + await sup2.start({ + store: { type: 'directory', path: storeDir }, + nameserver: [ + { + name: 'mara1.', + engine: 'maradns', + // No publisher.type: maradns must not default to rfc1035, whose + // zone files MaraDNS cannot read. + publisher: { path: outDir }, + transport: { type: 'noop', interval: 0, cooldown: 0 }, + }, + ], + }) + assert.equal(sup2.status().length, 1) + + const zone = await fs.readFile(path.join(outDir, 'example.com.csv2'), 'utf8') + assert.match(zone, /^www\.example\.com\.\s+\+300\s+A\s+192\.0\.2\.20 ~$/m) + assert.doesNotMatch(zone, /\bIN\b/, 'csv2 has no class field') + + const mararc = await fs.readFile(path.join(outDir, 'mararc'), 'utf8') + assert.match(mararc, /csv2 = \{\}/) + assert.match(mararc, /csv2\["example\.com\."\] = "example\.com\.csv2"/) } finally { await sup2.stop() await fs.rm(outDir, { recursive: true, force: true }) diff --git a/package.json b/package.json index 08ec41a..8ec155c 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,20 @@ "description": "NicTool server, the DNS managers server component", "main": "index.js", "type": "module", + "engines": { + "node": ">=20" + }, + "files": [ + "CHANGELOG.md", + "README.md", + "bin", + "html", + "index.js", + "lib", + "!**/*.test.js" + ], "bin": { - "nictool-server": "bin/start.js", - "nt-powerdns": "bin/nt_powerdns.js" + "nictool-server": "bin/start.js" }, "scripts": { "build": "npm run web:build", @@ -16,8 +27,10 @@ "lint": "npx eslint .", "lint:fix": "npm run lint -- --fix", "prettier": "npx prettier --ignore-path .gitignore --check .", + "prepack": "npm run web:build", "prettier:fix": "npx prettier --ignore-path .gitignore --write .", "server": "npm run build && node bin/start.js -c ./data", + "start": "node bin/start.js", "server:watch": "node --watch bin/start.js -c ./data", "test": "node --test", "test:coverage": "node --test --experimental-test-coverage", @@ -48,7 +61,7 @@ "homepage": "https://github.com/NicTool/server#readme", "dependencies": { "@nictool/api": "^3.0.2", - "@nictool/dns-nameserver": "^0.8.0", + "@nictool/dns-nameserver": "^0.9.0", "@nictool/dns-resource-record": "^1.8.1", "@nictool/dns-zone": "^1.2.3", "joi": "^18.2.3", diff --git a/playwright.config.js b/playwright.config.js index fd5d25e..ea36723 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -31,5 +31,10 @@ export default defineConfig({ testMatch: /app\.[^/]*\.spec\.js/, use: { baseURL: 'http://localhost:5175' }, }, + { + name: 'configurator', + testMatch: /configure\.spec\.js/, + use: { baseURL: 'http://localhost:5175' }, + }, ], }) diff --git a/test/proxy-down.test.js b/test/proxy-down.test.js new file mode 100644 index 0000000..b631be3 --- /dev/null +++ b/test/proxy-down.test.js @@ -0,0 +1,110 @@ +// An unreachable API must not take the server down with it. +// +// Regression guard: forwardToRemote rejected on ECONNREFUSED, and the call site +// did `return forwardToRemote(...)` without await — so the rejection escaped the +// enclosing try/catch, became an unhandled rejection, and killed the process. +// A tcp-mode API restart was enough to trigger it. +import assert from 'node:assert/strict' +import { test, before, after } from 'node:test' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-proxydown-')) + +let tls = null +try { + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + path.join(tmp, 'key.pem'), + '-out', + path.join(tmp, 'cert.pem'), + '-days', + '2', + '-subj', + '/CN=127.0.0.1', + '-addext', + 'subjectAltName=IP:127.0.0.1', + ], + { stdio: 'ignore' }, + ) + tls = { + cert: fs.readFileSync(path.join(tmp, 'cert.pem'), 'utf8'), + key: fs.readFileSync(path.join(tmp, 'key.pem'), 'utf8'), + } +} catch { + tls = null +} +const skip = tls ? false : 'openssl not available to generate a test cert' + +/** A port nothing listens on, so the proxy is guaranteed ECONNREFUSED. */ +function closedPort() { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.once('error', reject) + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address() + srv.close(() => resolve(port)) + }) + }) +} + +let server, base + +before(async () => { + if (skip) return + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + const { startServer } = await import(new URL('../index.js', import.meta.url)) + server = await startServer({ + configDir: tmp, + tls, + host: '127.0.0.1', + port: 0, + nicConfig: { configured: true, api: { mode: 'tcp' } }, + apiRemoteUrl: `http://127.0.0.1:${await closedPort()}`, + }) + base = `https://127.0.0.1:${server.address().port}` +}) + +after(async () => { + if (server) await new Promise((r) => server.close(r)) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('answers 502 when the API is unreachable', { skip }, async () => { + const res = await fetch(`${base}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'someone', password: 'secret' }), + }) + + assert.equal(res.status, 502) + assert.match((await res.json()).error, /API unreachable/) +}) + +test('stays up and keeps serving after the failed proxy', { skip }, async () => { + // The crash showed up on the *next* request, once the process had died. + await fetch(`${base}/api/session`, { method: 'POST', body: '{}' }).catch(() => {}) + + const res = await fetch(`${base}/nt/service`) + assert.equal(res.status, 200, 'the server survived an unreachable upstream') +}) + +test('survives repeated failures without leaking a crash', { skip }, async () => { + for (let i = 0; i < 5; i++) { + const res = await fetch(`${base}/api/zone`) + assert.equal(res.status, 502) + } + + assert.equal((await fetch(`${base}/nt/service`)).status, 200) +}) diff --git a/test/schema-endpoints.test.js b/test/schema-endpoints.test.js new file mode 100644 index 0000000..0a01fe5 --- /dev/null +++ b/test/schema-endpoints.test.js @@ -0,0 +1,192 @@ +// Covers the upgrade path's two endpoints against a real MySQL database. +// +// The safety property under test: POST /nt/init-schema must refuse to run +// against a database that already holds NicTool tables — adopting one is the +// upgrade path's job. This test points it at a populated database and asserts +// both the 409 and that every table survives. +// +// Skips when MySQL is unreachable, so `npm test` stays DB-free by default. +import assert from 'node:assert/strict' +import { test, before, after } from 'node:test' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import mysql from 'mysql2/promise' + +const DSN = + process.env.NICTOOL_TEST_DSN ?? 'mysql://nictool:lootcin!mysql@127.0.0.1:3306/nictool' + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-schema-')) + +let tls = null +try { + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + path.join(tmp, 'key.pem'), + '-out', + path.join(tmp, 'cert.pem'), + '-days', + '2', + '-subj', + '/CN=127.0.0.1', + '-addext', + 'subjectAltName=IP:127.0.0.1', + ], + { stdio: 'ignore' }, + ) + tls = { + cert: fs.readFileSync(path.join(tmp, 'cert.pem'), 'utf8'), + key: fs.readFileSync(path.join(tmp, 'key.pem'), 'utf8'), + } +} catch { + tls = null +} + +async function tablesIn(dsn) { + const u = new URL(dsn) + const conn = await mysql.createConnection({ + host: u.hostname, + port: Number(u.port) || 3306, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: u.pathname.replace(/^\//, ''), + connectTimeout: 3000, + }) + try { + const [rows] = await conn.query( + 'SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ?', + [u.pathname.replace(/^\//, '')], + ) + return rows.map((r) => r.t ?? r.table_name) + } finally { + await conn.end() + } +} + +let skip = tls ? false : 'openssl not available to generate a test cert' +let populated = [] + +if (!skip) { + try { + populated = await tablesIn(DSN) + if (!populated.includes('nt_options')) { + skip = 'test database has no NicTool schema to guard against' + } + } catch (err) { + skip = `MySQL unreachable: ${err.code ?? err.message}` + } +} + +let server, base + +before(async () => { + if (skip) return + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + const { startServer } = await import(new URL('../index.js', import.meta.url)) + server = await startServer({ + configDir: tmp, + tls, + host: '127.0.0.1', + port: 0, + nicConfig: null, + }) + base = `https://127.0.0.1:${server.address().port}` +}) + +after(async () => { + if (server) await new Promise((r) => server.close(r)) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('GET /nt/detect-schema finds an existing NicTool schema', { skip }, async () => { + const res = await fetch(`${base}/nt/detect-schema?dsn=${encodeURIComponent(DSN)}`) + assert.equal(res.status, 200) + + const body = await res.json() + assert.equal(body.ok, true) + assert.equal(body.found, true) + assert.ok(body.tables.includes('nt_options')) + assert.match(String(body.version), /^\d+\.\d+/, 'reports a db_version') +}) + +test( + 'GET /nt/detect-schema reports a reachable but empty database', + { skip }, + async () => { + const u = new URL(DSN) + u.pathname = '/information_schema' + const res = await fetch( + `${base}/nt/detect-schema?dsn=${encodeURIComponent(u.toString())}`, + ) + + const body = await res.json() + assert.equal(body.ok, true) + assert.equal(body.found, false) + assert.deepEqual(body.tables, []) + }, +) + +test( + 'GET /nt/detect-schema reports connection failure without throwing', + { skip }, + async () => { + const bad = 'mysql://nobody:wrong@127.0.0.1:3306/nictool' + const res = await fetch(`${base}/nt/detect-schema?dsn=${encodeURIComponent(bad)}`) + + assert.equal(res.status, 200) + const body = await res.json() + assert.equal(body.ok, false) + assert.ok(body.error) + }, +) + +test( + 'POST /nt/init-schema refuses a populated database and leaves it intact', + { skip }, + async () => { + const res = await fetch(`${base}/nt/init-schema`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ install: 'new', dsn: DSN }), + }) + + assert.equal(res.status, 409, 'refuses rather than dropping tables') + const body = await res.json() + assert.match(body.error, /already contains NicTool tables/) + + const after = await tablesIn(DSN) + assert.deepEqual(after.sort(), populated.sort(), 'every table survived') + }, +) + +test('POST /nt/init-schema is unreachable during an upgrade', { skip }, async () => { + const res = await fetch(`${base}/nt/init-schema`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ install: 'upgrade', dsn: DSN }), + }) + + assert.equal(res.status, 409) + const body = await res.json() + assert.match(body.error, /during an upgrade/) +}) + +test('POST /nt/init-schema requires a dsn', { skip }, async () => { + const res = await fetch(`${base}/nt/init-schema`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ install: 'new' }), + }) + + assert.equal(res.status, 400) +}) diff --git a/web/e2e/configure.spec.js b/web/e2e/configure.spec.js new file mode 100644 index 0000000..024ee17 --- /dev/null +++ b/web/e2e/configure.spec.js @@ -0,0 +1,222 @@ +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. + +// Mutable per-test state rather than re-registering routes: two handlers for the +// same pattern make precedence ambiguous. +let apiRunning +let posted + +test.beforeEach(async ({ page }) => { + apiRunning = false + posted = null + + await page.route('**/nt/config', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ + json: { _hostname: 'nt.example.com', _suggested: { api: 4321 } }, + }) + } + 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/check-path*', (route) => + route.fulfill({ json: { ok: true, exists: true, resolved: '/var/lib/nictool' } }), + ) + + await page.route('**/nt/check-dsn*', (route) => route.fulfill({ json: { ok: true } })) +}) + +test.describe('configurator flow', () => { + test('cards appear in the documented order', async ({ page }) => { + await page.goto('/configure.html') + + await expect(page.locator('.card h2')).toHaveText([ + 'Installation', + 'API Service', + 'API Status', + 'Data Store', + 'Nameservers', + ]) + }) + + test('defaults to a new install with a JSON file store', async ({ page }) => { + const pageErrors = [] + page.on('pageerror', (err) => pageErrors.push(err.message)) + + await page.goto('/configure.html') + + await expect(page.locator('#install-type')).toHaveValue('new') + await expect(page.locator('#store-type')).toHaveValue('json') + await expect(page.locator('#f-store-path')).toBeVisible() + await expect(page.locator('#f-store-db')).toBeHidden() + await expect(page.locator('#upgrade-note')).toBeHidden() + expect(pageErrors).toEqual([]) + }) + + test('TOML remains selectable alongside JSON', async ({ page }) => { + await page.goto('/configure.html') + await page.selectOption('#store-type', 'toml') + + await expect(page.locator('#f-store-path')).toBeVisible() + }) + + test('unimplemented backends are visible but disabled', async ({ page }) => { + await page.goto('/configure.html') + + for (const value of ['sqlite', 'mongodb', 'elasticsearch']) { + await expect(page.locator(`#store-type option[value="${value}"]`)).toBeDisabled() + } + }) + + test('choosing upgrade locks the store to MySQL and asks for a schema probe', async ({ + page, + }) => { + await page.goto('/configure.html') + await page.selectOption('#install-type', 'upgrade') + + await expect(page.locator('#upgrade-note')).toBeVisible() + await expect(page.locator('#store-type')).toHaveValue('mysql') + await expect(page.locator('#store-type')).toBeDisabled() + await expect(page.locator('#f-store-db')).toBeVisible() + // Not yet probed, so saving is still blocked. + await expect(page.locator('#btn-save')).toBeDisabled() + }) + + test('a successful probe reports the detected version and unblocks save', async ({ + page, + }) => { + await page.route('**/nt/detect-schema*', (route) => + route.fulfill({ + json: { ok: true, found: true, version: '2.41', tables: ['nt_zone'] }, + }), + ) + + 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 page.click('#detect-schema') + + await expect(page.locator('#n-detect')).toContainText('2.41') + await expect(page.locator('#btn-save')).toBeEnabled() + }) + + test('an empty database fails the probe and keeps save blocked', async ({ page }) => { + await page.route('**/nt/detect-schema*', (route) => + route.fulfill({ json: { ok: true, found: false, tables: [] } }), + ) + + 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 page.click('#detect-schema') + + await expect(page.locator('#n-detect')).toContainText('no NicTool schema') + await expect(page.locator('#btn-save')).toBeDisabled() + }) + + test('tcp mode asks for a port but not a host', async ({ page }) => { + await page.goto('/configure.html') + await page.selectOption('#api-mode', 'tcp') + + await expect(page.locator('#api-port')).toBeVisible() + await expect(page.locator('#api-host')).toBeHidden() + await expect(page.locator('#download-api-config')).toBeHidden() + }) + + test('remote mode asks for host, port, and offers the drop-in config', async ({ + page, + }) => { + await page.goto('/configure.html') + await page.selectOption('#api-mode', 'remote') + + await expect(page.locator('#api-host')).toBeVisible() + await expect(page.locator('#api-port')).toBeVisible() + await expect(page.locator('#download-api-config')).toBeVisible() + }) + + test('in_process mode hides the API connection fields', async ({ page }) => { + await page.goto('/configure.html') + await page.selectOption('#api-mode', 'remote') + await page.selectOption('#api-mode', 'in_process') + + await expect(page.locator('#api-remote-fields')).toBeHidden() + }) + + test('the API status card reflects /nt/service', async ({ page }) => { + apiRunning = true + await page.goto('/configure.html') + + await expect(page.locator('#boot-status')).toHaveText('API running') + }) + + test('saving posts install, store and api, then waits for the API', async ({ + page, + }) => { + await page.goto('/configure.html') + await page.fill('#store-path', '/var/lib/nictool') + await expect(page.locator('#btn-save')).toBeEnabled() + + // The API comes up only after the save, which is what waitForApi polls for. + apiRunning = true + await page.click('#btn-save') + + await expect.poll(() => posted).not.toBeNull() + expect(posted.install).toBe('new') + expect(posted.store.type).toBe('json') + expect(posted.store.path).toBe('/var/lib/nictool') + expect(posted.api.mode).toBe('in_process') + }) + + test('a new MySQL install initializes the schema before saving', async ({ page }) => { + const calls = [] + await page.route('**/nt/init-schema', async (route) => { + calls.push(route.request().postDataJSON()) + await route.fulfill({ json: { ok: true, applied: ['01_nt_group.sql'] } }) + }) + + await page.goto('/configure.html') + await page.selectOption('#store-type', 'mysql') + await page.fill('#db-dsn', 'mysql://nictool:pw@127.0.0.1:3306/nictool') + await expect(page.locator('#btn-save')).toBeEnabled() + + apiRunning = true + await page.click('#btn-save') + + await expect.poll(() => calls.length).toBe(1) + expect(calls[0].install).toBe('new') + expect(calls[0].dsn).toContain('nictool') + }) + + test('an upgrade never calls init-schema', async ({ page }) => { + let initCalled = false + await page.route('**/nt/init-schema', async (route) => { + initCalled = true + await route.fulfill({ json: { ok: true, applied: [] } }) + }) + await page.route('**/nt/detect-schema*', (route) => + route.fulfill({ + json: { ok: true, found: true, version: '2.41', tables: ['nt_zone'] }, + }), + ) + + 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 page.click('#detect-schema') + await expect(page.locator('#btn-save')).toBeEnabled() + + apiRunning = true + await page.click('#btn-save') + + await expect.poll(() => posted).not.toBeNull() + expect(posted.install).toBe('upgrade') + expect(initCalled).toBe(false) + }) +})