diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4c2af38..f8c75d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,7 +28,7 @@ jobs:
min: ${{ steps.get.outputs.min }}
test:
- needs: [ get-lts ]
+ needs: [get-lts]
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -46,7 +46,7 @@ jobs:
- run: npm test
test-mac:
- needs: [ get-lts ]
+ needs: [get-lts]
runs-on: macos-latest
strategy:
matrix:
@@ -83,7 +83,7 @@ jobs:
run: docker compose -f docker/docker-compose.yml down -v
test-win:
- needs: [ get-lts ]
+ needs: [get-lts]
runs-on: windows-latest
strategy:
matrix:
diff --git a/.gitignore b/.gitignore
index d5e7fe8..d81f377 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,5 +131,5 @@ dist
package-lock.json
.release/
-conf.d/*.pem
+conf.d/
CLAUDE.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afb7289..3151a4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,18 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### Unreleased
-### [3.0.0] - 2026-07-25
+### [3.0.3] - 2026-07-27
+
+- many updates for data stores and NS backends
+- feat(mysql): new NS columns: engine, listen, publisher, transport, dnssec
+- change: */store/toml -> */store/file
+- change(sql/*.sql): made setup non-destructive
+- feat: self describing password hashes (#56)
+
+### [3.0.2] - 2026-07-25
-- release 3.0.0
- dep(rr): bump version
+- fix: generate conf.d/http.toml on 1st start
-### [3.0.0-alpha.13] - 2026-07-24
+### 3.0.0-alpha.13 - 2026-07-24
- fix(sql): quote mysql 8 keyword rank
- fix: subgroup searching
- feat: paginate zone records (#53)
-### [3.0.0-alpha.12] - 2026-04-14
+### 3.0.0-alpha.12 - 2026-04-14
- toml backend (#49)
- add: TOML stores for group, nameserver, permission, session (#47)
@@ -29,7 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- ci: update permissions to be explicit (#42)
- docker compose support + env var config overrides (#40)
-### [3.0.0-alpha.11] - 2026-04-07
+### 3.0.0-alpha.11 - 2026-04-07
- decorate user & group with permissions
- add some missing PUT routes
@@ -39,24 +47,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- zone factory & subclasses
- user factory, toml, mysql, mongodb, elastic classes
-### [3.0.0-alpha.10] - 2026-03-25
+### 3.0.0-alpha.10 - 2026-03-25
- config: replace .yaml with .toml
- zone_record can be empty, default 0
- feat(zone records): create and delete
-### [3.0.0-alpha.9] - 2026-03-15
+### 3.0.0-alpha.9 - 2026-03-15
- feat(zone): use DataTable for list, added search/limit options
- routes/zr: add extra data about ZR parse failures
-### [3.0.0-alpha.8] - 2026-03-14
+### 3.0.0-alpha.8 - 2026-03-14
- lib/zone: add limit option
- lib/nameserver.js: handle null fields from DB
- routes/zone: report zone name on validation failure
-### [3.0.0-alpha.6] - 2025-04-08
+### 3.0.0-alpha.6 - 2025-04-08
- dep(eslint): upgraded to v9
- dep(\*): bump versions to latest
@@ -68,7 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- change(routes/ns): GET id is optional, result is always array
- change(routes/zone): GET id is optional, result is always array
-### [3.0.0-alpha.5] - 2024-03-06
+### 3.0.0-alpha.5 - 2024-03-06
- feat(lib/zone): added, with tests, fixes #22
- feat(lib/zone_record): added, with tests, fixes #23
@@ -77,12 +85,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- sql: return indicative boolean for delete
- test(zr): added maps from NT SQL 2 to dns-rr std formats
-### [3.0.0-alpha.4] - 2024-03-05
+### 3.0.0-alpha.4 - 2024-03-05
- feat(lib/nameserver): added, with tests
- feat(routes/nameserver): added, with tests
-### [3.0.0-alpha.3]
+### 3.0.0-alpha.3
- routes/permission: added GET, POST, DELETE
- permission.get: default search with deleted=0
@@ -92,6 +100,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- lib/group.get: convert booleans
- lib/user.get: convert booleans
+[3.0.2]: https://github.com/NicTool/api/releases/tag/v3.0.2
+[3.0.3]: https://github.com/NicTool/api/releases/tag/v3.0.3
[3.0.0-alpha.0]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.0
[3.0.0-alpha.1]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.1
[3.0.0-alpha.2]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.2
@@ -99,11 +109,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
[3.0.0-alpha.4]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.4
[3.0.0-alpha.5]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.5
[3.0.0-alpha.6]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.6
-[3.0.0-alpha.7]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.7
-[3.0.0-alpha.8]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8
-[3.0.0-alpha.9]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.9
+[3.0.0-alpha.9]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.9
+[3.0.0]: https://github.com/NicTool/api/releases/tag/v3.0.0
[3.0.0-alpha.10]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.10
[3.0.0-alpha.11]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.11
[3.0.0-alpha.12]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.12
[3.0.0-alpha.13]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.13
-[3.0.0]: https://github.com/NicTool/api/releases/tag/v3.0.0
+[3.0.0-alpha.7]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.7
+[3.0.0-alpha.8]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 1eee3b3..0800b32 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -2,7 +2,7 @@
This handcrafted artisanal software is brought to you by:
-| 
msimerson (30) | 
aberoham (1) |
+| 
msimerson (32) | 
aberoham (1) |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
this file is generated by [.release](https://github.com/msimerson/.release).
diff --git a/README.md b/README.md
index 8d0ef53..95a8335 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,6 @@ A RESTful JSON web service that exposes DNS management functions to users.
1. Install [Node.js](https://nodejs.org/en/download/)
2. Download the NicTool v3 API
-
```
mkdir nictool && cd nictool
git clone https://github.com/NicTool/api.git
@@ -36,7 +35,6 @@ or
will start up the HTTP service on the port specified in `conf.d/http.yml`. The default URL for the service is [http://localhost:3000](http://localhost:3000) and the API methods have documentation at [http://localhost:3000/documentation#/](http://localhost:3000/documentation#/).
-
## Using the API service
Until the NicTool 3.0 HTTP client is written, using a web browser (in Developer mode) or a CLI HTTP utility like curl can be used. Here's a quick tutorial:
@@ -46,7 +44,7 @@ Until the NicTool 3.0 HTTP client is written, using a web browser (in Developer
`curl -X POST http://localhost:3000/session`
```json
-{"statusCode":400,"error":"Bad Request","message":"Invalid request payload input"}
+{ "statusCode": 400, "error": "Bad Request", "message": "Invalid request payload input" }
```
The request was rejected because it's missing the required parameters, as shown in the documentation. Create a file called nt-auth.json and store the credentials of a NicTool user therein. Then try the auth request again:
@@ -63,26 +61,26 @@ That's not the easiest to read so lets pipe it through `json_pp`:
```json
{
- "group" : {
- "id" : 4096,
- "name" : "example.com"
- },
- "meta" : {
- "api" : {
- "version" : "3.0.0"
- },
- "msg" : "you are logged in"
- },
- "session" : {
- "id" : 162
- },
- "user" : {
- "email" : "unit-test@example.com",
- "first_name" : "Unit",
- "id" : 4096,
- "last_name" : "Test",
- "username" : "unit-test"
- }
+ "group": {
+ "id": 4096,
+ "name": "example.com"
+ },
+ "meta": {
+ "api": {
+ "version": "3.0.0"
+ },
+ "msg": "you are logged in"
+ },
+ "session": {
+ "id": 162
+ },
+ "user": {
+ "email": "unit-test@example.com",
+ "first_name": "Unit",
+ "id": 4096,
+ "last_name": "Test",
+ "username": "unit-test"
+ }
}
```
@@ -143,4 +141,3 @@ curl -b nt-session -X GET http://localhost:3000/user/4096 --header "Content-Type
}
}
```
-
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 2ab35d1..505c3aa 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -13,9 +13,9 @@ services:
- ../sql:/sql:ro
- ../sql/init-mysql.sh:/docker-entrypoint-initdb.d/init-mysql.sh:ro
ports:
- - "${DB_PORT:-3307}:3306"
+ - '${DB_PORT:-3307}:3306'
healthcheck:
- test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
interval: 5s
timeout: 5s
retries: 10
@@ -25,7 +25,7 @@ services:
context: ..
dockerfile: docker/Dockerfile
ports:
- - "${API_PORT:-3000}:3000"
+ - '${API_PORT:-3000}:3000'
depends_on:
db:
condition: service_healthy
@@ -35,9 +35,15 @@ services:
NICTOOL_DB_USER: ${NICTOOL_DB_USER:-nictool}
NICTOOL_DB_USER_PASSWORD: ${NICTOOL_DB_USER_PASSWORD}
NICTOOL_DB_NAME: ${NICTOOL_DB_NAME:-nictool}
- NICTOOL_HTTP_HOST: "0.0.0.0"
+ NICTOOL_HTTP_HOST: '0.0.0.0'
healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://localhost:3000/documentation').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+ test:
+ [
+ 'CMD',
+ 'node',
+ '-e',
+ "fetch('http://localhost:3000/documentation').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
+ ]
interval: 10s
timeout: 5s
retries: 10
@@ -50,7 +56,7 @@ services:
dockerfile: docker/Dockerfile
hostname: localhost
ports:
- - "${SERVER_PORT:-8080}:${SERVER_CONTAINER_PORT:-8080}"
+ - '${SERVER_PORT:-8080}:${SERVER_CONTAINER_PORT:-8080}'
depends_on:
api:
condition: service_healthy
@@ -66,7 +72,7 @@ services:
NICTOOL_DB_NAME: ${NICTOOL_DB_NAME:-nictool}
NICTOOL_API_HOST: api
NICTOOL_API_PORT: 3000
- NICTOOL_BIND_HOST: "0.0.0.0"
+ NICTOOL_BIND_HOST: '0.0.0.0'
volumes:
db-data:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index b5b5ee3..81aca84 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -13,7 +13,7 @@ export default [
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
- ...globals.node
+ ...globals.node,
},
},
rules: {
diff --git a/lib/config.apijson.test.js b/lib/config.apijson.test.js
new file mode 100644
index 0000000..261dafd
--- /dev/null
+++ b/lib/config.apijson.test.js
@@ -0,0 +1,140 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, it, before, after, beforeEach } from 'node:test'
+
+import Config, { storeConfig } from './config.js'
+import { clearConfigEnv, OVERRIDE_KEYS } from '../test/env.js'
+
+describe('api.json config', () => {
+ let tmp
+ let restoreEnv
+
+ before(() => {
+ // These assert what api.json holds, so the environment must not override it.
+ restoreEnv = clearConfigEnv()
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-apijson-'))
+ process.env.NICTOOL_CONF_DIR = tmp
+ })
+
+ after(() => {
+ restoreEnv()
+ fs.rmSync(tmp, { recursive: true, force: true })
+ Config.cfg = {}
+ })
+
+ beforeEach(() => {
+ Config.cfg = {}
+ for (const key of OVERRIDE_KEYS) {
+ if (key !== 'NICTOOL_CONF_DIR') delete process.env[key]
+ }
+ })
+
+ const writeApiJson = (obj) => fs.writeFileSync(path.join(tmp, 'api.json'), JSON.stringify(obj, null, 2))
+
+ it(`reads sections from NICTOOL_CONF_DIR/api.json`, async () => {
+ writeApiJson({ http: { host: '10.0.0.1', port: 8080, group: 'Ops' } })
+
+ const cfg = await Config.get('http')
+
+ assert.equal(cfg.host, '10.0.0.1')
+ assert.equal(cfg.port, 8080)
+ assert.equal(cfg.group, 'Ops')
+ })
+
+ it(`does not seed over an existing http section`, async () => {
+ writeApiJson({ http: { host: 'kept', port: 1234 } })
+
+ await Config.get('http')
+
+ const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'api.json'), 'utf8'))
+ assert.equal(onDisk.http.host, 'kept')
+ assert.equal(onDisk.http.jwt, undefined)
+ })
+
+ it(`mints http secrets into a drop-in api.json that carries only a store`, async () => {
+ writeApiJson({ store: { type: 'json', path: '/var/lib/nictool' } })
+
+ const cfg = await Config.get('http')
+
+ assert.match(cfg.jwt.key, /^[0-9a-f]{32}$/)
+
+ const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'api.json'), 'utf8'))
+ assert.deepEqual(onDisk.store, { type: 'json', path: '/var/lib/nictool' })
+ assert.ok(onDisk.http.cookie.password.length >= 32)
+ })
+
+ it(`derives the mysql section from a mysql store`, async () => {
+ writeApiJson({
+ store: { type: 'mysql', host: 'db.example.com', port: 3307, user: 'nt', database: 'ntdb' },
+ })
+
+ const cfg = await Config.get('mysql')
+
+ assert.equal(cfg.host, 'db.example.com')
+ assert.equal(cfg.port, 3307)
+ assert.equal(cfg.database, 'ntdb')
+ assert.equal(cfg.timezone, '+00:00', 'mysql2 defaults are merged in')
+ assert.equal(cfg.type, undefined, 'store-only keys are not leaked to mysql2')
+ })
+
+ it(`prefers an explicit mysql section over the derived one`, async () => {
+ writeApiJson({
+ store: { type: 'mysql', host: 'derived' },
+ mysql: { host: 'explicit', database: 'nictool' },
+ })
+
+ assert.equal((await Config.get('mysql')).host, 'explicit')
+ })
+
+ it(`does not derive mysql from a file store`, async () => {
+ writeApiJson({ store: { type: 'json', path: '/var/lib/nictool' } })
+
+ await assert.rejects(() => Config.get('mysql'), { code: 'ENOENT' })
+ })
+
+ it(`storeConfig reads the store section`, () => {
+ writeApiJson({ store: { type: 'json', path: '/var/lib/nictool' } })
+
+ assert.deepEqual(storeConfig(), { type: 'json', path: '/var/lib/nictool' })
+ })
+
+ it(`storeConfig lets env override api.json`, () => {
+ writeApiJson({ store: { type: 'json', path: '/from/file' } })
+ process.env.NICTOOL_DATA_STORE = 'toml'
+ process.env.NICTOOL_DATA_STORE_PATH = '/from/env'
+
+ assert.deepEqual(storeConfig(), { type: 'toml', path: '/from/env' })
+ })
+
+ it(`lets the environment override the http section`, async () => {
+ // docker-compose.yml sets these; a container's api.json is generic and the
+ // environment is what points it at the right host.
+ writeApiJson({ http: { host: '10.0.0.1', port: 8080 } })
+ process.env.NICTOOL_HTTP_HOST = '0.0.0.0'
+ process.env.NICTOOL_HTTP_PORT = '3000'
+
+ const cfg = await Config.get('http')
+
+ assert.equal(cfg.host, '0.0.0.0')
+ assert.equal(cfg.port, 3000, 'and is coerced to a number')
+ })
+
+ it(`lets the environment override the mysql section`, async () => {
+ writeApiJson({ mysql: { host: 'db.example.com', database: 'fromfile' } })
+ process.env.NICTOOL_DB_HOST = 'db'
+ process.env.NICTOOL_DB_NAME = 'fromenv'
+
+ const cfg = await Config.get('mysql')
+
+ assert.equal(cfg.host, 'db')
+ assert.equal(cfg.database, 'fromenv')
+ })
+
+ it(`storeConfig returns empty when nothing is configured`, () => {
+ fs.rmSync(path.join(tmp, 'api.json'), { force: true })
+
+ assert.deepEqual(storeConfig(), {})
+ })
+})
diff --git a/lib/config.js b/lib/config.js
index bf1a76a..25655ca 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -1,9 +1,20 @@
+import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import fsSync from 'node:fs'
import path from 'node:path'
import { parse } from 'smol-toml'
+import { toJson } from './util.js'
+
+// Resolved per call rather than at module load: the server passes the config
+// directory in after argv is parsed, and the tests chdir to a temp dir.
+function confDir() {
+ return process.env.NICTOOL_CONF_DIR ?? './conf.d'
+}
+
+const apiJsonPath = () => path.join(confDir(), 'api.json')
+
class Config {
constructor() {
this.cfg = {}
@@ -15,13 +26,15 @@ class Config {
if (this.cfg[name]) return this.cfg[name]
- const str = await fs.readFile(`./conf.d/${name}.toml`, 'utf8')
- const cfg = parse(str)
+ seedConfDir()
+
+ const dir = confDir()
+ let cfg = sectionFromApiJson(name, readApiJsonSync())
+ if (!cfg) cfg = parse(await fs.readFile(path.join(dir, `${name}.toml`), 'utf8'))
applyEnvOverrides(name, cfg)
- // if (this.debug) console.debug(cfg)
if (name === 'http') {
- const tls = await loadPEM('./conf.d')
+ const tls = await loadPEM(dir)
if (tls) cfg.tls = tls
}
@@ -34,13 +47,15 @@ class Config {
if (this.cfg[name]) return this.cfg[name]
- const str = fsSync.readFileSync(`./conf.d/${name}.toml`, 'utf8')
- const cfg = parse(str)
+ seedConfDir()
+
+ const dir = confDir()
+ let cfg = sectionFromApiJson(name, readApiJsonSync())
+ if (!cfg) cfg = parse(fsSync.readFileSync(path.join(dir, `${name}.toml`), 'utf8'))
applyEnvOverrides(name, cfg)
- // if (this.debug) console.debug(cfg)
if (name === 'http') {
- const tls = loadPEMSync('./conf.d')
+ const tls = loadPEMSync(dir)
if (tls) cfg.tls = tls
}
@@ -49,6 +64,87 @@ class Config {
}
}
+function readApiJsonSync() {
+ try {
+ return JSON.parse(fsSync.readFileSync(apiJsonPath(), 'utf8'))
+ } catch (err) {
+ if (err.code === 'ENOENT') return null
+ throw err
+ }
+}
+
+const mysqlDefaults = {
+ host: '127.0.0.1',
+ port: 3306,
+ socketPath: '',
+ database: 'nictool',
+ timezone: '+00:00',
+ dateStrings: ['DATETIME', 'TIMESTAMP'],
+ decimalNumbers: true,
+}
+
+/**
+ * Resolve a named section out of api.json. The mysql section is derived from
+ * [store] when the store is mysql, so database credentials live in exactly one
+ * place rather than being duplicated across two sections.
+ */
+function sectionFromApiJson(name, apiJson) {
+ if (!apiJson) return null
+ if (apiJson[name]) return structuredClone(apiJson[name])
+
+ if (name === 'mysql' && apiJson.store?.type === 'mysql') {
+ const { type: _type, path: _path, dsn: _dsn, ...conn } = apiJson.store
+ return { ...mysqlDefaults, ...conn }
+ }
+
+ return null
+}
+
+/**
+ * Ensure the API has an http section with its own secrets. Seeding is
+ * per-section rather than per-file: a drop-in api.json carrying only [store]
+ * is the documented way to point a remote API at its database, and that host
+ * still has to mint its own jwt/cookie secrets.
+ */
+function seedConfDir() {
+ const dir = confDir()
+ // An existing conf.d predating api.json keeps its *.toml files.
+ if (fsSync.existsSync(path.join(dir, 'http.toml'))) return
+
+ const existing = readApiJsonSync()
+ if (existing?.http) return
+
+ fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 })
+ const seeded = { ...(existing ?? {}), ...apiSeed() }
+ fsSync.writeFileSync(apiJsonPath(), toJson(seeded), { mode: 0o600 })
+ console.log(`seeded ${path.resolve(apiJsonPath())} with generated secrets`)
+}
+
+function apiSeed() {
+ const secret = (bytes) => crypto.randomBytes(bytes).toString('hex')
+
+ return {
+ http: {
+ host: 'localhost',
+ port: 3000,
+ keepAlive: false,
+ group: 'NicTool',
+ jwt: { key: secret(16) },
+ // https://hapi.dev/module/cookie/api/?v=12.0.1
+ cookie: {
+ name: 'sid-nictool',
+ ttl: 3600000,
+ path: '/',
+ clearInvalid: true,
+ isSameSite: 'Strict',
+ isSecure: true,
+ isHttpOnly: false,
+ password: secret(32),
+ },
+ },
+ }
+}
+
function parsePort(envVar) {
const raw = process.env[envVar]
if (!raw) return undefined
@@ -71,6 +167,27 @@ function applyEnvOverrides(name, cfg) {
if (process.env.NICTOOL_HTTP_HOST) cfg.host = process.env.NICTOOL_HTTP_HOST
if (process.env.NICTOOL_HTTP_PORT) cfg.port = parsePort('NICTOOL_HTTP_PORT')
}
+ if (name === 'store') {
+ if (process.env.NICTOOL_DATA_STORE) cfg.type = process.env.NICTOOL_DATA_STORE
+ if (process.env.NICTOOL_DATA_STORE_PATH) cfg.path = process.env.NICTOOL_DATA_STORE_PATH
+ if (process.env.NICTOOL_DATA_STORE_DSN) cfg.dsn = process.env.NICTOOL_DATA_STORE_DSN
+ }
+}
+
+/**
+ * Store selection, resolved synchronously so the per-entity modules can pick a
+ * backend at import time. Env wins over api.json; neither is required.
+ */
+export function storeConfig() {
+ const cfg = sectionFromApiJson('store', readApiJsonSync()) ?? {}
+ applyEnvOverrides('store', cfg)
+ return cfg
+}
+
+/** "directory" was the original name for a file store, before JSON was an option. */
+export function storeType() {
+ const type = storeConfig().type ?? 'mysql'
+ return type === 'directory' ? 'toml' : type
}
async function loadPEM(dir) {
diff --git a/lib/config.seed.test.js b/lib/config.seed.test.js
new file mode 100644
index 0000000..2bf333c
--- /dev/null
+++ b/lib/config.seed.test.js
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, it, before, after } from 'node:test'
+
+import Config from './config.js'
+import { clearConfigEnv } from '../test/env.js'
+
+describe('config seeding', () => {
+ const cwd = process.cwd()
+ let tmp
+ let restoreEnv
+
+ before(() => {
+ // Seeding writes ./conf.d and the defaults are asserted below, so neither
+ // NICTOOL_CONF_DIR nor the host/port overrides may be in play.
+ restoreEnv = clearConfigEnv()
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-conf-'))
+ process.chdir(tmp)
+ Config.cfg = {}
+ })
+
+ after(() => {
+ restoreEnv()
+ process.chdir(cwd)
+ fs.rmSync(tmp, { recursive: true, force: true })
+ Config.cfg = {}
+ })
+
+ it(`creates conf.d and seeds api.json when absent`, async () => {
+ assert.equal(fs.existsSync(path.join(tmp, 'conf.d')), false)
+
+ const cfg = await Config.get('http')
+
+ assert.equal(fs.existsSync(path.join(tmp, 'conf.d', 'api.json')), true)
+ assert.equal(cfg.host, 'localhost')
+ assert.equal(cfg.port, 3000)
+ })
+
+ it(`mints secrets rather than shipping a shared default`, () => {
+ const seeded = JSON.parse(fs.readFileSync(path.join(tmp, 'conf.d', 'api.json'), 'utf8'))
+
+ assert.match(seeded.http.jwt.key, /^[0-9a-f]{32}$/)
+ assert.notEqual(seeded.http.jwt.key, 'af1b926a5e21f535c4f5b6c42941c4cf')
+ assert.ok(seeded.http.cookie.password.length >= 32)
+ })
+
+ it(`leaves an existing conf.d alone`, async () => {
+ const file = path.join(tmp, 'conf.d', 'api.json')
+ const before = fs.readFileSync(file, 'utf8')
+
+ Config.cfg = {}
+ await Config.get('http')
+
+ assert.equal(fs.readFileSync(file, 'utf8'), before)
+ })
+
+ it(`seeds no store, so a missing mysql config still throws`, async () => {
+ Config.cfg = {}
+ await assert.rejects(() => Config.get('mysql'), { code: 'ENOENT' })
+ })
+})
diff --git a/lib/config.test.js b/lib/config.test.js
index a1a4c28..e08ada6 100644
--- a/lib/config.test.js
+++ b/lib/config.test.js
@@ -59,6 +59,7 @@ describe('config', () => {
const cfg = await Config.get('http')
const { tls, ...rest } = cfg
delete rest.password
+ delete rest.jwt.key
assert.deepEqual(rest, httpCfg)
})
@@ -66,12 +67,12 @@ describe('config', () => {
const cfg = Config.getSync('http')
const { tls, ...rest } = cfg
delete rest.password
+ delete rest.jwt.key
assert.deepEqual(rest, httpCfg)
})
it(`loads tls from conf.d/*.pem when present`, async () => {
const cfg = await Config.get('http')
- delete cfg.password
if (!cfg.tls) return // no PEM on this host — skip
assert.match(cfg.tls.key, /-----BEGIN.*PRIVATE KEY-----/)
assert.match(cfg.tls.cert, /-----BEGIN CERTIFICATE-----/)
@@ -104,9 +105,7 @@ const httpCfg = {
port: 3000,
keepAlive: false,
group: 'NicTool',
- jwt: {
- key: 'af1b926a5e21f535c4f5b6c42941c4cf',
- },
+ jwt: {},
cookie: {
name: 'sid-nictool',
ttl: 3600000,
diff --git a/lib/group/index.js b/lib/group/index.js
index 5338462..47d9769 100644
--- a/lib/group/index.js
+++ b/lib/group/index.js
@@ -1,18 +1,21 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
+ RepoClass = (await import('./store/file.js')).default
+ break
+ case 'mysql':
+ RepoClass = (await import('./store/mysql.js')).default
break
case 'mongodb':
RepoClass = (await import('./store/mongodb.js')).default
break
- case 'elasticsearch':
- RepoClass = (await import('./store/elasticsearch.js')).default
- break
default:
- RepoClass = (await import('./store/mysql.js')).default
+ throw new Error(`group: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/group/store/toml.js b/lib/group/store/file.js
similarity index 82%
rename from lib/group/store/toml.js
rename to lib/group/store/file.js
index 3d5873f..a5044c4 100644
--- a/lib/group/store/toml.js
+++ b/lib/group/store/file.js
@@ -1,13 +1,7 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
+import FileStore from '../../store/file.js'
import GroupBase from './base.js'
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
const defaultPermissions = {
inherit: false,
self_write: false,
@@ -18,32 +12,18 @@ const defaultPermissions = {
user: { create: false, write: false, delete: false },
}
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
-
-class GroupRepoTOML extends GroupBase {
+class GroupRepoFile extends GroupBase {
constructor(args = {}) {
super(args)
- this._filePath = resolveStorePath('group.toml')
+ this.file = new FileStore('group')
}
async _load() {
- try {
- const str = await fs.readFile(this._filePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.group) ? data.group : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.file.load('group')
}
async _save(groups) {
- await fs.mkdir(path.dirname(this._filePath), { recursive: true })
- await fs.writeFile(this._filePath, stringify({ group: groups }))
+ return this.file.save('group', groups)
}
_postProcess(row, deletedArg) {
@@ -184,4 +164,4 @@ class GroupRepoTOML extends GroupBase {
}
}
-export default GroupRepoTOML
+export default GroupRepoFile
diff --git a/lib/nameserver/index.js b/lib/nameserver/index.js
index 5338462..03c2b2e 100644
--- a/lib/nameserver/index.js
+++ b/lib/nameserver/index.js
@@ -1,18 +1,18 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
- break
- case 'mongodb':
- RepoClass = (await import('./store/mongodb.js')).default
+ RepoClass = (await import('./store/file.js')).default
break
- case 'elasticsearch':
- RepoClass = (await import('./store/elasticsearch.js')).default
+ case 'mysql':
+ RepoClass = (await import('./store/mysql.js')).default
break
default:
- RepoClass = (await import('./store/mysql.js')).default
+ throw new Error(`nameserver: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/nameserver/store/toml.js b/lib/nameserver/store/file.js
similarity index 76%
rename from lib/nameserver/store/toml.js
rename to lib/nameserver/store/file.js
index 6e90e1a..7088a0a 100644
--- a/lib/nameserver/store/toml.js
+++ b/lib/nameserver/store/file.js
@@ -1,44 +1,24 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
+import FileStore from '../../store/file.js'
import NameserverBase from './base.js'
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
const boolFields = ['deleted']
// Fields that default to empty string when absent or null
const emptyStringFields = ['description', 'address6', 'remote_login', 'logdir', 'datadir']
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
-
-class NameserverRepoTOML extends NameserverBase {
+class NameserverRepoFile extends NameserverBase {
constructor(args = {}) {
super(args)
- this._filePath = resolveStorePath('nameserver.toml')
+ this.file = new FileStore('nameserver')
}
async _load() {
- try {
- const str = await fs.readFile(this._filePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.nameserver) ? data.nameserver : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.file.load('nameserver')
}
async _save(nameservers) {
- await fs.mkdir(path.dirname(this._filePath), { recursive: true })
- await fs.writeFile(this._filePath, stringify({ nameserver: nameservers }))
+ return this.file.save('nameserver', nameservers)
}
_postProcess(row, deletedArg) {
@@ -121,4 +101,4 @@ class NameserverRepoTOML extends NameserverBase {
}
}
-export default NameserverRepoTOML
+export default NameserverRepoFile
diff --git a/lib/nameserver/store/mysql.js b/lib/nameserver/store/mysql.js
index 3702636..911cba5 100644
--- a/lib/nameserver/store/mysql.js
+++ b/lib/nameserver/store/mysql.js
@@ -5,6 +5,10 @@ import { mapToDbColumn } from '../../util.js'
const nsDbMap = { id: 'nt_nameserver_id', gid: 'nt_group_id' }
const boolFields = ['deleted', 'export_serials']
+// Runtime configuration the supervisor consumes. Structured, so it round-trips
+// through JSON columns rather than getting a column per leaf field.
+const jsonFields = ['listen', 'publisher', 'transport', 'dnssec']
+
class Nameserver extends NameserverBase {
constructor() {
super()
@@ -54,6 +58,11 @@ class Nameserver extends NameserverBase {
, ns.export_interval
, ns.export_serials
, ns.export_status
+ , ns.engine
+ , ns.listen
+ , ns.publisher
+ , ns.transport
+ , ns.dnssec
, ns.deleted
, t.name AS export_type
FROM nt_nameserver ns
@@ -76,7 +85,7 @@ class Nameserver extends NameserverBase {
delete args.id
// Mysql.debug(1)
const r = await Mysql.execute(
- ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, mapToDbColumn(args, nsDbMap)),
+ ...Mysql.update(`nt_nameserver`, `nt_nameserver_id=${id}`, mapToDbColumn(objectToDb(args), nsDbMap)),
)
return r.changedRows === 1
}
@@ -107,6 +116,19 @@ function dbToObject(rows) {
for (const f of ['description', 'address6', 'remote_login', 'datadir', 'logdir', 'export_status']) {
if ([undefined, null].includes(row[f])) row[f] = ''
}
+ for (const f of jsonFields) {
+ // mysql2 already decodes JSON columns; a string means an older driver or
+ // a TEXT column left over from a hand-run migration.
+ if (typeof row[f] === 'string') {
+ try {
+ row[f] = JSON.parse(row[f])
+ } catch {
+ row[f] = null
+ }
+ }
+ if (row[f] === null || row[f] === undefined) delete row[f]
+ }
+ if ([null, undefined, ''].includes(row.engine)) delete row.engine
for (const f of ['export']) {
for (const p of ['type', 'interval', 'serials', 'status']) {
if (![null, undefined].includes(row[`${f}_${p}`])) {
@@ -123,6 +145,11 @@ function dbToObject(rows) {
function objectToDb(row) {
row = JSON.parse(JSON.stringify(row)) // don't mutate the original
+ for (const f of jsonFields) {
+ if (row[f] === undefined) continue
+ row[f] = row[f] === null ? null : JSON.stringify(row[f])
+ }
+
for (const f of ['export']) {
for (const p of ['interval', 'serials', 'status']) {
if (row[f] === undefined) continue
diff --git a/lib/nameserver/test/runtime.js b/lib/nameserver/test/runtime.js
new file mode 100644
index 0000000..c2aef40
--- /dev/null
+++ b/lib/nameserver/test/runtime.js
@@ -0,0 +1,75 @@
+// The nameserver record carries the v3 runtime configuration the supervisor
+// consumes (engine, listen, publisher, transport, dnssec). It has to survive a
+// round-trip through whichever store is active: nested structures in the file
+// stores, JSON columns under MySQL.
+import assert from 'node:assert/strict'
+import { describe, it, after, before } from 'node:test'
+
+import Nameserver from '../index.js'
+
+const runtimeCase = {
+ id: 4242,
+ gid: 4096,
+ name: 'rt.ns.example.com.',
+ address: '1.2.3.9',
+ ttl: 3600,
+ export: { type: 'nsd', interval: 0, serials: true, status: '' },
+ engine: 'native',
+ listen: [
+ { address: '127.0.0.1', port: 5353, proto: 'udp' },
+ { address: '127.0.0.1', port: 5353, proto: 'tcp' },
+ ],
+ publisher: { type: 'memory' },
+ transport: { type: 'noop', interval: 0, cooldown: 5 },
+ dnssec: { enabled: true, algorithm: 'ED25519', nsec3: false },
+}
+
+before(async () => {
+ await Nameserver.destroy({ id: runtimeCase.id })
+ await Nameserver.create(runtimeCase)
+})
+
+after(async () => {
+ await Nameserver.destroy({ id: runtimeCase.id })
+ await Nameserver.disconnect()
+})
+
+describe('nameserver runtime config', () => {
+ it('round-trips listen sockets', async () => {
+ const [ns] = await Nameserver.get({ id: runtimeCase.id })
+
+ assert.deepEqual(ns.listen, runtimeCase.listen)
+ })
+
+ it('round-trips engine, publisher, transport and dnssec', async () => {
+ const [ns] = await Nameserver.get({ id: runtimeCase.id })
+
+ assert.equal(ns.engine, 'native')
+ assert.deepEqual(ns.publisher, runtimeCase.publisher)
+ assert.deepEqual(ns.transport, runtimeCase.transport)
+ assert.deepEqual(ns.dnssec, runtimeCase.dnssec)
+ })
+
+ it('updates runtime config in place', async () => {
+ const listen = [{ address: '127.0.0.1', port: 5354, proto: 'udp' }]
+ assert.ok(await Nameserver.put({ id: runtimeCase.id, listen, engine: 'bind' }))
+
+ const [ns] = await Nameserver.get({ id: runtimeCase.id })
+ assert.deepEqual(ns.listen, listen)
+ assert.equal(ns.engine, 'bind')
+ })
+
+ it('omits runtime keys entirely for a legacy record that has none', async () => {
+ const legacy = { ...runtimeCase, id: 4243, name: 'legacy.ns.example.com.' }
+ for (const k of ['engine', 'listen', 'publisher', 'transport', 'dnssec']) delete legacy[k]
+
+ await Nameserver.destroy({ id: legacy.id })
+ await Nameserver.create(legacy)
+ const [ns] = await Nameserver.get({ id: legacy.id })
+
+ for (const k of ['engine', 'listen', 'publisher', 'transport', 'dnssec']) {
+ assert.equal(ns[k], undefined, `${k} should be absent, not null`)
+ }
+ await Nameserver.destroy({ id: legacy.id })
+ })
+})
diff --git a/lib/permission/index.js b/lib/permission/index.js
index 05433fb..f301ceb 100644
--- a/lib/permission/index.js
+++ b/lib/permission/index.js
@@ -1,12 +1,18 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
+ RepoClass = (await import('./store/file.js')).default
break
- default:
+ case 'mysql':
RepoClass = (await import('./store/mysql.js')).default
+ break
+ default:
+ throw new Error(`permission: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/permission/store/toml.js b/lib/permission/store/file.js
similarity index 75%
rename from lib/permission/store/toml.js
rename to lib/permission/store/file.js
index e28ddaa..15e7cdd 100644
--- a/lib/permission/store/toml.js
+++ b/lib/permission/store/file.js
@@ -1,104 +1,56 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
+import FileStore from '../../store/file.js'
import PermissionBase from './base.js'
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
-
/**
- * TOML permission store.
+ * File-backed permission store (JSON or TOML, per store.type).
*
* Permissions are stored in one of three places:
*
- * 1. Inline in user.toml — for permissions tied to an existing user record.
- * Looked up via users[i].permissions.id === N.
+ * 1. Inline in the user file — for permissions tied to an existing user
+ * record. Looked up via users[i].permissions.id === N.
*
- * 2. Inline in group.toml — group-level permissions created by GroupRepoTOML.
- * Looked up via groups[i].permissions.id === N.
+ * 2. Inline in the group file — group-level permissions created by the group
+ * store. Looked up via groups[i].permissions.id === N.
*
- * 3. Standalone permission.toml — fallback for permission IDs that reference
- * users/groups not present in user.toml / group.toml.
+ * 3. Standalone permission file — fallback for permission IDs that reference
+ * users/groups not present in the user / group files.
*
* get({ uid }) → inline permissions of that user
* get({ gid }) → inline permissions of that group (uid absent)
* get({ id }) → search user → group → standalone by permissions.id
* getGroup({ uid }) → permissions of the group the user belongs to
*/
-class PermissionRepoTOML extends PermissionBase {
+class PermissionRepoFile extends PermissionBase {
constructor(args = {}) {
super(args)
- this._userPath = resolveStorePath('user.toml')
- this._groupPath = resolveStorePath('group.toml')
- this._standaloneFilePath = resolveStorePath('permission.toml')
+ this.userFile = new FileStore('user')
+ this.groupFile = new FileStore('group')
+ this.standaloneFile = new FileStore('permission')
}
- // ---------------------------------------------------------------------------
- // Raw file I/O — users
- // ---------------------------------------------------------------------------
-
async _loadUsers() {
- try {
- const str = await fs.readFile(this._userPath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.user) ? data.user : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.userFile.load('user')
}
async _saveUsers(users) {
- await fs.mkdir(path.dirname(this._userPath), { recursive: true })
- await fs.writeFile(this._userPath, stringify({ user: users }))
+ return this.userFile.save('user', users)
}
- // ---------------------------------------------------------------------------
- // Raw file I/O — groups
- // ---------------------------------------------------------------------------
-
async _loadGroups() {
- try {
- const str = await fs.readFile(this._groupPath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.group) ? data.group : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.groupFile.load('group')
}
async _saveGroups(groups) {
- await fs.mkdir(path.dirname(this._groupPath), { recursive: true })
- await fs.writeFile(this._groupPath, stringify({ group: groups }))
+ return this.groupFile.save('group', groups)
}
- // ---------------------------------------------------------------------------
- // Raw file I/O — standalone permission.toml
- // ---------------------------------------------------------------------------
-
async _loadStandalone() {
- try {
- const str = await fs.readFile(this._standaloneFilePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.permission) ? data.permission : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.standaloneFile.load('permission')
}
async _saveStandalone(permissions) {
- await fs.mkdir(path.dirname(this._standaloneFilePath), { recursive: true })
- await fs.writeFile(this._standaloneFilePath, stringify({ permission: permissions }))
+ return this.standaloneFile.save('permission', permissions)
}
// ---------------------------------------------------------------------------
@@ -370,4 +322,4 @@ function deepMerge(target, source) {
return result
}
-export default PermissionRepoTOML
+export default PermissionRepoFile
diff --git a/lib/session/index.js b/lib/session/index.js
index 05433fb..7ddc26c 100644
--- a/lib/session/index.js
+++ b/lib/session/index.js
@@ -1,12 +1,18 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
+ RepoClass = (await import('./store/file.js')).default
break
- default:
+ case 'mysql':
RepoClass = (await import('./store/mysql.js')).default
+ break
+ default:
+ throw new Error(`session: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/session/store/toml.js b/lib/session/store/file.js
similarity index 77%
rename from lib/session/store/toml.js
rename to lib/session/store/file.js
index 96bc2f1..d3d959f 100644
--- a/lib/session/store/toml.js
+++ b/lib/session/store/file.js
@@ -1,16 +1,4 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
+import FileStore from '../../store/file.js'
// Map legacy nt_* column names to the friendly API names used throughout.
function normalizeArgs(args) {
@@ -29,25 +17,17 @@ function normalizeArgs(args) {
return args
}
-class SessionRepoTOML {
+class SessionRepoFile {
constructor() {
- this._filePath = resolveStorePath('session.toml')
+ this.file = new FileStore('session')
}
async _load() {
- try {
- const str = await fs.readFile(this._filePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.session) ? data.session : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.file.load('session')
}
async _save(sessions) {
- await fs.mkdir(path.dirname(this._filePath), { recursive: true })
- await fs.writeFile(this._filePath, stringify({ session: sessions }))
+ return this.file.save('session', sessions)
}
async create(args) {
@@ -134,4 +114,4 @@ class SessionRepoTOML {
}
}
-export default SessionRepoTOML
+export default SessionRepoFile
diff --git a/lib/store/file.js b/lib/store/file.js
new file mode 100644
index 0000000..8ec2c9e
--- /dev/null
+++ b/lib/store/file.js
@@ -0,0 +1,73 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+import { storeConfig } from '../config.js'
+import { toJson } from '../util.js'
+
+const jsonCodec = {
+ ext: 'json',
+ parse: async (str) => JSON.parse(str),
+ stringify: async (data) => toJson(data),
+}
+
+// smol-toml is loaded only when a TOML store is actually selected, so a
+// JSON-only deployment never pays for it.
+const tomlCodec = {
+ ext: 'toml',
+ parse: async (str) => (await import('smol-toml')).parse(str),
+ stringify: async (data) => (await import('smol-toml')).stringify(data),
+}
+
+const codecs = { json: jsonCodec, toml: tomlCodec }
+
+export function resolveCodec(type = storeConfig().type) {
+ return codecs[type] ?? jsonCodec
+}
+
+/**
+ * One file per entity, holding an array of rows under a single top-level key.
+ * The codec is chosen by store.type; everything above this layer is identical
+ * for JSON and TOML.
+ */
+export class FileStore {
+ constructor(basename) {
+ this.basename = basename
+ }
+
+ get codec() {
+ return resolveCodec()
+ }
+
+ /**
+ * Resolved per call: the store path is configuration, not a build-time
+ * constant, and an unset path is a misconfiguration rather than a reason to
+ * fall back to somewhere inside the package.
+ */
+ get filePath() {
+ const base = storeConfig().path
+ if (!base) {
+ throw new Error(
+ 'file store selected but no path is configured — set store.path in api.json or NICTOOL_DATA_STORE_PATH',
+ )
+ }
+ return path.join(base, `${this.basename}.${this.codec.ext}`)
+ }
+
+ async load(key) {
+ try {
+ const data = await this.codec.parse(await fs.readFile(this.filePath, 'utf8'))
+ return Array.isArray(data?.[key]) ? data[key] : []
+ } catch (err) {
+ if (err.code === 'ENOENT') return []
+ throw err
+ }
+ }
+
+ async save(key, rows) {
+ const file = this.filePath
+ await fs.mkdir(path.dirname(file), { recursive: true })
+ await fs.writeFile(file, await this.codec.stringify({ [key]: rows }))
+ }
+}
+
+export default FileStore
diff --git a/lib/store/file.test.js b/lib/store/file.test.js
new file mode 100644
index 0000000..c76b6e2
--- /dev/null
+++ b/lib/store/file.test.js
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, it, before, after, beforeEach } from 'node:test'
+
+import FileStore, { resolveCodec } from './file.js'
+
+const envKeys = ['NICTOOL_DATA_STORE', 'NICTOOL_DATA_STORE_PATH', 'NICTOOL_CONF_DIR']
+
+describe('file store', () => {
+ const savedEnv = {}
+ let tmp
+
+ before(() => {
+ for (const key of envKeys) {
+ savedEnv[key] = process.env[key]
+ delete process.env[key]
+ }
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-filestore-'))
+ })
+
+ after(() => {
+ for (const [key, val] of Object.entries(savedEnv)) {
+ if (val !== undefined) process.env[key] = val
+ else delete process.env[key]
+ }
+ fs.rmSync(tmp, { recursive: true, force: true })
+ })
+
+ beforeEach(() => {
+ for (const key of envKeys) delete process.env[key]
+ // NICTOOL_CONF_DIR points somewhere without an api.json, so storeConfig
+ // resolves purely from the env vars each test sets.
+ process.env.NICTOOL_CONF_DIR = path.join(tmp, 'no-such-conf')
+ process.env.NICTOOL_DATA_STORE_PATH = tmp
+ })
+
+ it(`writes .json for a json store and .toml for a toml store`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ await new FileStore('zone').save('zone', [{ id: 1 }])
+ assert.equal(fs.existsSync(path.join(tmp, 'zone.json')), true)
+
+ process.env.NICTOOL_DATA_STORE = 'toml'
+ await new FileStore('zone').save('zone', [{ id: 1 }])
+ assert.equal(fs.existsSync(path.join(tmp, 'zone.toml')), true)
+ })
+
+ it(`round-trips rows through the json codec`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ const store = new FileStore('rt')
+
+ await store.save('rt', [{ id: 1, zone: 'example.com', deleted: false }])
+
+ assert.deepEqual(await store.load('rt'), [{ id: 1, zone: 'example.com', deleted: false }])
+ })
+
+ it(`preserves null through json, which TOML cannot`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ const jsonStore = new FileStore('nulls')
+ await jsonStore.save('nulls', [{ id: 1, last_publish: null }])
+
+ const [jsonRow] = await jsonStore.load('nulls')
+ assert.equal(jsonRow.last_publish, null, 'json keeps the null')
+
+ process.env.NICTOOL_DATA_STORE = 'toml'
+ const tomlStore = new FileStore('nulls')
+ await tomlStore.save('nulls', [{ id: 1, last_publish: null }])
+
+ const [tomlRow] = await tomlStore.load('nulls')
+ assert.equal(tomlRow.last_publish, undefined, 'toml drops it — hence the repair in the zone store')
+ })
+
+ it(`returns an empty list when the file does not exist`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ assert.deepEqual(await new FileStore('absent').load('absent'), [])
+ })
+
+ it(`returns an empty list when the key is missing or not an array`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ fs.writeFileSync(path.join(tmp, 'odd.json'), JSON.stringify({ odd: 'not-an-array' }))
+
+ assert.deepEqual(await new FileStore('odd').load('odd'), [])
+ })
+
+ it(`creates missing parent directories on save`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ process.env.NICTOOL_DATA_STORE_PATH = path.join(tmp, 'deep', 'nested')
+
+ await new FileStore('zone').save('zone', [{ id: 1 }])
+
+ assert.equal(fs.existsSync(path.join(tmp, 'deep', 'nested', 'zone.json')), true)
+ })
+
+ it(`fails loudly rather than writing inside the package when no path is set`, async () => {
+ process.env.NICTOOL_DATA_STORE = 'json'
+ delete process.env.NICTOOL_DATA_STORE_PATH
+
+ await assert.rejects(() => new FileStore('zone').load('zone'), /no path is configured/)
+ })
+
+ it(`defaults to the json codec for an unrecognized type`, () => {
+ process.env.NICTOOL_DATA_STORE = 'nonsense'
+ assert.equal(resolveCodec().ext, 'json')
+ })
+})
diff --git a/lib/user/index.js b/lib/user/index.js
index 5338462..2676bd6 100644
--- a/lib/user/index.js
+++ b/lib/user/index.js
@@ -1,9 +1,15 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
+ RepoClass = (await import('./store/file.js')).default
+ break
+ case 'mysql':
+ RepoClass = (await import('./store/mysql.js')).default
break
case 'mongodb':
RepoClass = (await import('./store/mongodb.js')).default
@@ -12,7 +18,7 @@ switch (storeType) {
RepoClass = (await import('./store/elasticsearch.js')).default
break
default:
- RepoClass = (await import('./store/mysql.js')).default
+ throw new Error(`user: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/user/store/toml.js b/lib/user/store/file.js
similarity index 81%
rename from lib/user/store/toml.js
rename to lib/user/store/file.js
index 3ec4715..4ee232e 100644
--- a/lib/user/store/toml.js
+++ b/lib/user/store/file.js
@@ -1,27 +1,14 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
-
+import FileStore from '../../store/file.js'
import Config from '../../config.js'
import Credentials from '../credentials.js'
import UserBase from './base.js'
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
const boolFields = ['is_admin', 'deleted']
-// Read group.toml directly (never via the Group module) to avoid circular imports.
-async function loadGroupPerm(groupPath, gid) {
- try {
- const str = await fs.readFile(groupPath, 'utf8')
- const data = parse(str)
- const groups = Array.isArray(data.group) ? data.group : []
- return groups.find((g) => g.id === gid)?.permissions ?? null
- } catch (err) {
- if (err.code === 'ENOENT') return null
- throw err
- }
+// Read the group file directly (never via the Group module) to avoid circular imports.
+async function loadGroupPerm(groupFile, gid) {
+ const groups = await groupFile.load('group')
+ return groups.find((g) => g.id === gid)?.permissions ?? null
}
const defaultPermissions = {
@@ -34,34 +21,20 @@ const defaultPermissions = {
user: { create: false, write: false, delete: false },
}
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
-
-class UserRepoTOML extends UserBase {
+class UserRepoFile extends UserBase {
constructor(args = {}) {
super(args)
this.cfg = Config.getSync('http')
- this._filePath = resolveStorePath('user.toml')
- this._groupPath = resolveStorePath('group.toml')
+ this.file = new FileStore('user')
+ this.groupFile = new FileStore('group')
}
async _load() {
- try {
- const str = await fs.readFile(this._filePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.user) ? data.user : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.file.load('user')
}
async _save(users) {
- await fs.mkdir(path.dirname(this._filePath), { recursive: true })
- await fs.writeFile(this._filePath, stringify({ user: users }))
+ return this.file.save('user', users)
}
_postProcess(u, deletedArg) {
@@ -131,7 +104,7 @@ class UserRepoTOML extends UserBase {
const r = this._postProcess(u, deletedArg)
if (!r.permissions) {
// Inheriting user: attach the group's inline permissions
- const groupPerm = await loadGroupPerm(this._groupPath, u.gid)
+ const groupPerm = await loadGroupPerm(this.groupFile, u.gid)
if (groupPerm) {
r.permissions = JSON.parse(JSON.stringify(groupPerm))
r.inherit_group_permissions = true
@@ -238,4 +211,4 @@ class UserRepoTOML extends UserBase {
}
}
-export default UserRepoTOML
+export default UserRepoFile
diff --git a/lib/util.js b/lib/util.js
index 4d28525..98b69d7 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -24,6 +24,28 @@ const meta = {
},
}
+/**
+ * Recursively order object keys so serialized output is stable — an unsorted
+ * dump reshuffles on unrelated edits and makes diffs unreadable.
+ */
+function sortKeys(value) {
+ if (Array.isArray(value)) return value.map(sortKeys)
+ if (value === null || typeof value !== 'object') return value
+ // Date and friends would be flattened to {} by the key walk below.
+ 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. */
+function toJson(value) {
+ return `${JSON.stringify(sortKeys(value), null, 1)}\n`
+}
+
function mapToDbColumn(args, maps) {
args = JSON.parse(JSON.stringify(args)) // don't mutate the original
@@ -36,4 +58,4 @@ function mapToDbColumn(args, maps) {
return args
}
-export { setEnv, meta, mapToDbColumn }
+export { setEnv, meta, mapToDbColumn, sortKeys, toJson }
diff --git a/lib/util.test.js b/lib/util.test.js
index 40ad143..c54a349 100644
--- a/lib/util.test.js
+++ b/lib/util.test.js
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
-import { setEnv, mapToDbColumn, meta } from './util.js'
+import { setEnv, mapToDbColumn, meta, sortKeys, toJson } from './util.js'
describe('util', function () {
if (process.env.NODE_ENV === undefined) {
@@ -15,7 +15,45 @@ describe('util', function () {
describe('meta', () => {
it('returns the package version', () => {
- assert.ok(/3.0.0/.test(meta.api.version))
+ assert.ok(/3.0/.test(meta.api.version))
+ })
+ })
+
+ describe('sortKeys', () => {
+ it('orders keys at every depth', () => {
+ const sorted = sortKeys({ b: 1, a: { d: 2, c: 3 } })
+
+ assert.deepEqual(Object.keys(sorted), ['a', 'b'])
+ assert.deepEqual(Object.keys(sorted.a), ['c', 'd'])
+ })
+
+ it('keeps array order while sorting the objects inside', () => {
+ const sorted = sortKeys([{ z: 1, a: 2 }, { y: 3 }])
+
+ assert.deepEqual(Object.keys(sorted[0]), ['a', 'z'])
+ assert.equal(sorted[1].y, 3)
+ })
+
+ it('passes scalars and null through', () => {
+ assert.equal(sortKeys(null), null)
+ assert.equal(sortKeys(5), 5)
+ assert.equal(sortKeys('x'), 'x')
+ })
+
+ it('leaves non-plain objects intact', () => {
+ const d = new Date('2020-01-01T00:00:00Z')
+ assert.equal(sortKeys(d), d)
+ })
+ })
+
+ 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')
+ })
+
+ it('round-trips through JSON.parse', () => {
+ const value = { zone: [{ id: 1, last_publish: null }] }
+ assert.deepEqual(JSON.parse(toJson(value)), value)
})
})
diff --git a/lib/zone/index.js b/lib/zone/index.js
index 5338462..cb09043 100644
--- a/lib/zone/index.js
+++ b/lib/zone/index.js
@@ -1,9 +1,15 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
+ RepoClass = (await import('./store/file.js')).default
+ break
+ case 'mysql':
+ RepoClass = (await import('./store/mysql.js')).default
break
case 'mongodb':
RepoClass = (await import('./store/mongodb.js')).default
@@ -12,7 +18,7 @@ switch (storeType) {
RepoClass = (await import('./store/elasticsearch.js')).default
break
default:
- RepoClass = (await import('./store/mysql.js')).default
+ throw new Error(`zone: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/zone/store/toml.js b/lib/zone/store/file.js
similarity index 82%
rename from lib/zone/store/toml.js
rename to lib/zone/store/file.js
index f9d941c..2db3eed 100644
--- a/lib/zone/store/toml.js
+++ b/lib/zone/store/file.js
@@ -1,41 +1,21 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
+import FileStore, { resolveCodec } from '../../store/file.js'
import ZoneBase from './base.js'
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
const zoneDefaults = { minimum: 3600, ttl: 3600, refresh: 86400, retry: 7200, expire: 1209600 }
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
-
-class ZoneRepoTOML extends ZoneBase {
+class ZoneRepoFile extends ZoneBase {
constructor(args = {}) {
super(args)
- this._filePath = resolveStorePath('zone.toml')
+ this.file = new FileStore('zone')
}
async _load() {
- try {
- const str = await fs.readFile(this._filePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.zone) ? data.zone : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.file.load('zone')
}
async _save(zones) {
- await fs.mkdir(path.dirname(this._filePath), { recursive: true })
- await fs.writeFile(this._filePath, stringify({ zone: zones }))
+ return this.file.save('zone', zones)
}
_postProcess(row, deletedArg) {
@@ -46,8 +26,9 @@ class ZoneRepoTOML extends ZoneBase {
if ([null, undefined].includes(r[f])) r[f] = val
}
if ([null, undefined].includes(r.serial)) r.serial = 0
- // TOML drops null on stringify; restore it on read-back
- if (r.last_publish === undefined) r.last_publish = null
+ // TOML drops null on stringify; restore it on read-back. JSON round-trips
+ // null faithfully and needs no such repair.
+ if (resolveCodec().ext === 'toml' && r.last_publish === undefined) r.last_publish = null
if (/00:00:00/.test(r.last_publish)) r.last_publish = null
if (deletedArg === false) delete r.deleted
return r
@@ -185,4 +166,4 @@ class ZoneRepoTOML extends ZoneBase {
}
}
-export default ZoneRepoTOML
+export default ZoneRepoFile
diff --git a/lib/zone_record/index.js b/lib/zone_record/index.js
index 05433fb..dd17289 100644
--- a/lib/zone_record/index.js
+++ b/lib/zone_record/index.js
@@ -1,12 +1,18 @@
-const storeType = process.env.NICTOOL_DATA_STORE ?? 'mysql'
+import { storeType } from '../config.js'
+
+const type = storeType()
let RepoClass
-switch (storeType) {
+switch (type) {
+ case 'json':
case 'toml':
- RepoClass = (await import('./store/toml.js')).default
+ RepoClass = (await import('./store/file.js')).default
break
- default:
+ case 'mysql':
RepoClass = (await import('./store/mysql.js')).default
+ break
+ default:
+ throw new Error(`zone_record: no store implementation for type "${type}"`)
}
export default new RepoClass()
diff --git a/lib/zone_record/store/toml.js b/lib/zone_record/store/file.js
similarity index 81%
rename from lib/zone_record/store/toml.js
rename to lib/zone_record/store/file.js
index 9717c8d..4ff5651 100644
--- a/lib/zone_record/store/toml.js
+++ b/lib/zone_record/store/file.js
@@ -1,39 +1,19 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-import { parse, stringify } from 'smol-toml'
+import FileStore from '../../store/file.js'
import ZoneRecordBase from './base.js'
-const __dirname = path.dirname(fileURLToPath(import.meta.url))
-
-function resolveStorePath(filename) {
- const base = process.env.NICTOOL_DATA_STORE_PATH
- if (base) return path.join(base, filename)
- return path.resolve(__dirname, '../../../conf.d', filename)
-}
-
-class ZoneRecordRepoTOML extends ZoneRecordBase {
+class ZoneRecordRepoFile extends ZoneRecordBase {
constructor(args = {}) {
super(args)
- this._filePath = resolveStorePath('zone_record.toml')
+ this.file = new FileStore('zone_record')
}
async _load() {
- try {
- const str = await fs.readFile(this._filePath, 'utf8')
- const data = parse(str)
- return Array.isArray(data.zone_record) ? data.zone_record : []
- } catch (err) {
- if (err.code === 'ENOENT') return []
- throw err
- }
+ return this.file.load('zone_record')
}
async _save(records) {
- await fs.mkdir(path.dirname(this._filePath), { recursive: true })
- await fs.writeFile(this._filePath, stringify({ zone_record: records }))
+ return this.file.save('zone_record', records)
}
async create(args) {
@@ -152,4 +132,4 @@ class ZoneRecordRepoTOML extends ZoneRecordBase {
}
}
-export default ZoneRecordRepoTOML
+export default ZoneRecordRepoFile
diff --git a/lib/zone_record/store/mysql.js b/lib/zone_record/store/mysql.js
index c961d41..3b4ed24 100644
--- a/lib/zone_record/store/mysql.js
+++ b/lib/zone_record/store/mysql.js
@@ -1,4 +1,5 @@
import * as RR from '@nictool/dns-resource-record'
+import { applyMap, getMap, unApplyMap } from '@nictool/dns-resource-record'
import ZoneRecordBase from './base.js'
import Mysql from '../../mysql.js'
@@ -201,208 +202,4 @@ function objectToDb(obj) {
return obj
}
-function applyMap(obj, map) {
- // map dns-r-r (RFC/IETF) field names to NicTool 2.0 DB fields
-
- for (const [key, value] of Object.entries(map)) {
- if (Array.isArray(value)) {
- obj[key] = `'${value.map((a) => obj[a]).join("','")}'`
- for (const f of value) {
- delete obj[f]
- }
- } else {
- obj[key] = obj[value]
- }
-
- delete obj[value]
- }
-}
-
-function unApplyMap(obj, map) {
- // map NicTool 2.0 DB fields to dns-r-r (RFC/IETF) field names
- if (obj.type === 'NAPTR') {
- const [flags, service, regexp] = obj.address.slice(1, -1).split("','")
- obj.flags = flags ?? ''
- obj.service = service ?? ''
- obj.regexp = regexp ?? ''
- delete obj.address
- delete map.address
- }
- if (obj.type === 'NSEC3') {
- const [algo, flags, iters, salt, bitmaps, next] = obj.address.slice(1, -1).split("','")
- obj['hash algorithm'] = /^\d+$/.test(algo) ? parseInt(algo) : (algo ?? '')
- obj.flags = /^\d+$/.test(flags) ? parseInt(flags) : (flags ?? '')
- obj.iterations = /^\d+$/.test(iters) ? parseInt(iters) : (iters ?? '')
- obj.salt = salt
- obj['type bit maps'] = bitmaps
- obj['next hashed owner name'] = next
- delete obj.address
- delete map.address
- }
- if (obj.type === 'NSEC3PARAM') {
- const [algo, flags, iters, salt] = obj.address.slice(1, -1).split("','")
- obj['hash algorithm'] = /^\d+$/.test(algo) ? parseInt(algo) : (algo ?? '')
- obj.flags = /^\d+$/.test(flags) ? parseInt(flags) : (flags ?? '')
- obj.iterations = /^\d+$/.test(iters) ? parseInt(iters) : (iters ?? '')
- obj.salt = salt
- delete obj.address
- delete map.address
- }
- if (obj.type === 'SOA') {
- const [one, two, three, four, five, six, seven] = obj.address.slice(1, -1).split("','")
- obj.mname = one
- obj.rname = two
- obj.serial = parseInt(three)
- obj.refresh = parseInt(four)
- obj.retry = parseInt(five)
- obj.expire = parseInt(six)
- obj.minimum = parseInt(seven)
- delete obj.address
- delete map.address
- }
-
- for (const [key, value] of Object.entries(map)) {
- switch (value) {
- case 'key tag': // DS record
- case 'port': // SRV
- case 'certificate usage': // SMIMEA
- case 'algorithm': // IPSECKEY
- case 'flags': // KEY
- case 'matching type': // TLSA
- obj[value] = parseInt(obj[key])
- break
- default:
- obj[value] = obj[key]
- }
- delete obj[key]
- }
-}
-
// map of NicTool 2.0 fields to RR field names
-function getMap(rrType) {
- switch (rrType) {
- case 'CAA':
- return {
- weight: 'flags',
- other: 'tag',
- address: 'value',
- }
- case 'CERT':
- return {
- other: 'cert type',
- priority: 'key tag',
- weight: 'algorithm',
- address: 'certificate',
- }
- case 'CNAME':
- return { address: 'cname' }
- case 'DNAME':
- return { address: 'target' }
- case 'DNSKEY':
- return {
- address: 'publickey',
- weight: 'flags',
- priority: 'protocol',
- other: 'algorithm',
- }
- case 'DS':
- return {
- address: 'digest',
- weight: 'digest type',
- priority: 'algorithm',
- other: 'key tag',
- }
- case 'HINFO':
- return { address: 'os', other: 'cpu' }
- case 'HTTPS':
- return {
- address: 'target name',
- other: 'params',
- }
- case 'IPSECKEY':
- return {
- address: 'gateway',
- description: 'publickey',
- weight: 'precedence',
- priority: 'gateway type',
- other: 'algorithm',
- }
- case 'KEY':
- return {
- address: 'publickey',
- weight: 'protocol',
- priority: 'algorithm',
- other: 'flags',
- }
- case 'MX':
- return { weight: 'preference', address: 'exchange' }
- case 'NAPTR':
- return {
- weight: 'order',
- priority: 'preference',
- address: ['flags', 'service', 'regexp'],
- description: 'replacement',
- }
- case 'NS':
- return { address: 'dname' }
- case 'NSEC':
- return {
- address: 'next domain',
- description: 'type bit maps',
- }
- case 'NSEC3':
- return {
- address: ['hash algorithm', 'flags', 'iterations', 'salt', 'type bit maps', 'next hashed owner name'],
- }
- case 'NSEC3PARAM':
- return {
- address: ['hash algorithm', 'flags', 'iterations', 'salt'],
- }
- case 'NXT':
- return {
- address: 'next domain',
- description: 'type bit map',
- }
- case 'OPENPGPKEY':
- return { address: 'public key' }
- case 'PTR':
- return { address: 'dname' }
- case 'SMIMEA':
- return {
- address: 'certificate association data',
- weight: 'matching type',
- priority: 'selector',
- other: 'certificate usage',
- }
- case 'SOA':
- return {
- address: ['mname', 'rname', 'serial', 'refresh', 'retry', 'expire', 'minimum'],
- }
- case 'SPF':
- return { address: 'data' }
- case 'SSHFP':
- return {
- address: 'fingerprint',
- weight: 'algorithm',
- priority: 'fptype',
- }
- case 'SRV':
- return { address: 'target', other: 'port' }
- case 'SVCB':
- return {
- address: 'target name',
- other: 'params',
- }
- case 'TLSA':
- return {
- weight: 'certificate usage',
- priority: 'selector',
- address: 'certificate association data',
- other: 'matching type',
- }
- case 'TXT':
- return { address: 'data' }
- case 'URI':
- return { address: 'target' }
- }
-}
diff --git a/lib/zone_record/test/index.js b/lib/zone_record/test/index.js
index 50f5e6e..5e98f3b 100644
--- a/lib/zone_record/test/index.js
+++ b/lib/zone_record/test/index.js
@@ -15,7 +15,7 @@ describe('zone_record', function () {
it('CREATE accepts omitted ttl and stores 0', async () => {
const testCase = {
id: 60001,
- zid: 1,
+ zid: 4096, // the fixture zone, as the rrs/*.json cases use
owner: 'missing-ttl.example.com.',
type: 'A',
address: '203.0.113.45',
@@ -96,7 +96,9 @@ describe('zone_record', function () {
describe('pagination, search & sort', function () {
// Tests run against a shared DB; a unique owner token isolates these
// assertions from any other records that already exist in the zone.
- const pZid = 60200
+ //
+ // The zone has to be one the fixtures create
+ const pZid = 4096 // the fixture zone, as the rrs/*.json cases use
const token = 'zrpgtok'
const letters = ['ccc', 'aaa', 'eee', 'bbb', 'ddd'] // created out of sorted order
const idBase = 60210
diff --git a/package.json b/package.json
index b5bfa39..0406711 100644
--- a/package.json
+++ b/package.json
@@ -1,17 +1,17 @@
{
"name": "@nictool/api",
- "version": "3.0.0",
+ "version": "3.0.3",
"description": "NicTool API",
- "main": "index.js",
+ "main": "server.js",
"type": "module",
"files": [
"CHANGELOG.md",
- "conf.d",
"html",
"lib",
"routes",
"sql",
- "server.js"
+ "server.js",
+ "!**/*.test.js"
],
"scripts": {
"clean": "rm -rf coverage node_modules package-lock.json",
@@ -50,7 +50,7 @@
"@eslint/js": "^10.0.1",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
- "globals": "^17.7.0"
+ "globals": "^17.8.0"
},
"dependencies": {
"@hapi/cookie": "^12.0.1",
@@ -60,12 +60,12 @@
"@hapi/jwt": "^3.2.4",
"@hapi/vision": "^7.0.3",
"@msimerson/hapi-openapi": "^18.0.3",
- "@nictool/dns-resource-record": "^1.8.1",
+ "@nictool/dns-resource-record": "^1.8.2",
"@nictool/validate": "^0.9.1",
"joi": "^18.2.3",
- "mysql2": "^3.23.1",
+ "mysql2": "^3.23.2",
"qs": "^6.15.3",
- "smol-toml": "^1.7.0"
+ "smol-toml": "^1.7.1"
},
"prettier": {
"printWidth": 110,
diff --git a/routes/nameserver.test.js b/routes/nameserver.test.js
index 5103fbb..ef80f95 100644
--- a/routes/nameserver.test.js
+++ b/routes/nameserver.test.js
@@ -56,7 +56,6 @@ describe('nameserver routes', () => {
it(`POST /nameserver (${case2Id})`, async () => {
const testCase = JSON.parse(JSON.stringify(nsCase))
testCase.id = case2Id // make it unique
- testCase.gid = case2Id
testCase.name = 'c.ns.example.com.'
const res = await server.inject({
diff --git a/sql/01_nt_group.sql b/sql/01_nt_group.sql
index 79ccee1..4595ada 100644
--- a/sql/01_nt_group.sql
+++ b/sql/01_nt_group.sql
@@ -2,8 +2,7 @@
# Copyright 2001 Dajoba, LLC -
# Copyright 2004-2024 The Network People, Inc.
-DROP TABLE IF EXISTS nt_group;
-CREATE TABLE `nt_group` (
+CREATE TABLE IF NOT EXISTS `nt_group` (
nt_group_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
parent_group_id INT UNSIGNED NOT NULL DEFAULT 0,
name varchar(255) NOT NULL,
@@ -15,8 +14,7 @@ CREATE TABLE `nt_group` (
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-DROP TABLE IF EXISTS nt_group_log;
-CREATE TABLE nt_group_log(
+CREATE TABLE IF NOT EXISTS nt_group_log(
nt_group_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nt_group_id INT UNSIGNED NOT NULL,
nt_user_id INT UNSIGNED NOT NULL,
@@ -27,24 +25,23 @@ CREATE TABLE nt_group_log(
name VARCHAR(255),
PRIMARY KEY (`nt_group_log_id`),
KEY `nt_group_log_idx1` (`nt_group_id`),
- KEY `nt_group_log_idx2` (`timestamp`)
- /* CONSTRAINT `nt_group_log_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_group_log_idx2` (`timestamp`),
+ CONSTRAINT `nt_group_log_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-DROP TABLE IF EXISTS nt_group_subgroups;
-CREATE TABLE nt_group_subgroups(
+CREATE TABLE IF NOT EXISTS nt_group_subgroups(
nt_group_id INT UNSIGNED NOT NULL,
nt_subgroup_id INT UNSIGNED NOT NULL,
`rank` INT UNSIGNED NOT NULL,
KEY `nt_group_subgroups_idx1` (`nt_group_id`),
- KEY `nt_group_subgroups_idx2` (`nt_subgroup_id`)
- /* CONSTRAINT `nt_group_subgroups_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_group_subgroups_idx2` (`nt_subgroup_id`),
+ CONSTRAINT `nt_group_subgroups_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-INSERT INTO `nt_group` (`nt_group_id`, `parent_group_id`, `name`)
+INSERT IGNORE INTO `nt_group` (`nt_group_id`, `parent_group_id`, `name`)
VALUES
(1,0,'NicTool');
-INSERT INTO nt_group_log(nt_group_id, nt_user_id, action, timestamp, modified_group_id, parent_group_id)
+INSERT IGNORE INTO nt_group_log(nt_group_id, nt_user_id, action, timestamp, modified_group_id, parent_group_id)
VALUES
(1, 1, 'added', UNIX_TIMESTAMP(), 1, 0);
diff --git a/sql/02_nt_user.sql b/sql/02_nt_user.sql
index 7e79d1b..04b99d5 100644
--- a/sql/02_nt_user.sql
+++ b/sql/02_nt_user.sql
@@ -16,8 +16,7 @@
#
-DROP TABLE IF EXISTS nt_user;
-CREATE TABLE nt_user(
+CREATE TABLE IF NOT EXISTS nt_user(
nt_user_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
nt_group_id INT UNSIGNED NOT NULL,
first_name VARCHAR(120),
@@ -34,8 +33,7 @@ CREATE TABLE nt_user(
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-DROP TABLE IF EXISTS nt_user_log;
-CREATE TABLE nt_user_log(
+CREATE TABLE IF NOT EXISTS nt_user_log(
nt_user_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
nt_group_id INT UNSIGNED NOT NULL,
nt_user_id INT UNSIGNED NOT NULL,
@@ -51,19 +49,17 @@ CREATE TABLE nt_user_log(
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-DROP TABLE IF EXISTS nt_user_session;
-CREATE TABLE nt_user_session(
+CREATE TABLE IF NOT EXISTS nt_user_session(
nt_user_session_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nt_user_id INT UNSIGNED NOT NULL,
nt_user_session VARCHAR(100) NOT NULL,
last_access INT UNSIGNED NOT NULL,
PRIMARY KEY (`nt_user_session_id`),
- KEY `nt_user_session_idx1` (`nt_user_id`,`nt_user_session`)
- /* CONSTRAINT `nt_user_session_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_user_session_idx1` (`nt_user_id`,`nt_user_session`),
+ CONSTRAINT `nt_user_session_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-DROP TABLE IF EXISTS nt_user_session_log;
-CREATE TABLE nt_user_session_log(
+CREATE TABLE IF NOT EXISTS nt_user_session_log(
nt_user_session_log_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
nt_user_id INT UNSIGNED NOT NULL,
action ENUM('login','logout','timeout') NOT NULL,
@@ -71,12 +67,11 @@ CREATE TABLE nt_user_session_log(
nt_user_session_id INT UNSIGNED,
nt_user_session VARCHAR(100),
PRIMARY KEY (`nt_user_session_log_id`),
- KEY `nt_user_id` (`nt_user_id`)
- /* CONSTRAINT `nt_user_session_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_user_id` (`nt_user_id`),
+ CONSTRAINT `nt_user_session_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-DROP TABLE IF EXISTS nt_user_global_log;
-CREATE TABLE nt_user_global_log(
+CREATE TABLE IF NOT EXISTS nt_user_global_log(
nt_user_global_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nt_user_id INT UNSIGNED NOT NULL,
timestamp INT UNSIGNED NOT NULL,
@@ -90,6 +85,6 @@ CREATE TABLE nt_user_global_log(
title VARCHAR(255),
description VARCHAR(255),
PRIMARY KEY (`nt_user_global_log_id`),
- KEY `nt_user_global_log_idx1` (`nt_user_id`)
- /* CONSTRAINT `nt_user_global_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_user_global_log_idx1` (`nt_user_id`),
+ CONSTRAINT `nt_user_global_log_ibfk_1` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
diff --git a/sql/04_nt_nameserver.sql b/sql/04_nt_nameserver.sql
index afbe6d9..3e5a73a 100644
--- a/sql/04_nt_nameserver.sql
+++ b/sql/04_nt_nameserver.sql
@@ -2,8 +2,7 @@
# Copyright 2001 Dajoba, LLC -
# Copyright 2004-2024 The Network People, Inc.
-DROP TABLE IF EXISTS nt_nameserver;
-CREATE TABLE nt_nameserver(
+CREATE TABLE IF NOT EXISTS nt_nameserver(
nt_nameserver_id SMALLINT UNSIGNED AUTO_INCREMENT NOT NULL,
nt_group_id INT UNSIGNED NOT NULL,
name VARCHAR(127) NOT NULL,
@@ -18,17 +17,23 @@ CREATE TABLE nt_nameserver(
export_interval SMALLINT UNSIGNED,
export_serials tinyint(1) UNSIGNED NOT NULL DEFAULT '1',
export_status varchar(255) NULL DEFAULT NULL,
+ -- v3 runtime configuration, driving the nameserver supervisor. Stored as
+ -- JSON because the shape is engine-specific and evolves with the engines.
+ engine VARCHAR(32) NULL DEFAULT NULL,
+ listen JSON NULL DEFAULT NULL,
+ publisher JSON NULL DEFAULT NULL,
+ transport JSON NULL DEFAULT NULL,
+ dnssec JSON NULL DEFAULT NULL,
deleted TINYINT(1) UNSIGNED DEFAULT 0 NOT NULL,
PRIMARY KEY (`nt_nameserver_id`),
KEY `nt_nameserver_idx1` (`name`),
KEY `nt_nameserver_idx2` (`deleted`),
- KEY `nt_group_id` (`nt_group_id`)
- /* CONSTRAINT `nt_nameserver_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_group_id` (`nt_group_id`),
+ CONSTRAINT `nt_nameserver_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-DROP TABLE IF EXISTS nt_nameserver_log;
-CREATE TABLE nt_nameserver_log(
+CREATE TABLE IF NOT EXISTS nt_nameserver_log(
nt_nameserver_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nt_group_id INT UNSIGNED NOT NULL,
nt_user_id INT UNSIGNED NOT NULL,
@@ -51,8 +56,7 @@ CREATE TABLE nt_nameserver_log(
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-DROP TABLE IF EXISTS nt_nameserver_export_type;
-CREATE TABLE `nt_nameserver_export_type` (
+CREATE TABLE IF NOT EXISTS `nt_nameserver_export_type` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(16) NOT NULL DEFAULT '',
`descr` varchar(56) NOT NULL DEFAULT '',
@@ -60,7 +64,7 @@ CREATE TABLE `nt_nameserver_export_type` (
PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8mb4;
-INSERT INTO `nt_nameserver_export_type` (`id`, `name`, `descr`, `url`)
+INSERT IGNORE INTO `nt_nameserver_export_type` (`id`, `name`, `descr`, `url`)
VALUES (1,'djbdns','djbdns (tinydns & axfrdns)','cr.yp.to/djbdns.html'),
(2,'bind','BIND (zone files)', 'www.isc.org/downloads/bind/'),
(3,'maradns','MaraDNS', 'maradns.samiam.org'),
@@ -70,22 +74,21 @@ VALUES (1,'djbdns','djbdns (tinydns & axfrdns)','cr.yp.to/djbdns.html'),
(7,'dynect','DynECT Standard DNS','dyn.com/managed-dns/'),
(8,'knot','Knot DNS','www.knot-dns.cz');
-INSERT INTO nt_nameserver(nt_group_id, name, ttl, description, address,
+INSERT IGNORE INTO nt_nameserver(nt_group_id, name, ttl, description, address,
export_type_id, logdir, datadir, export_interval) values (1,'ns1.example.com.',86400,'ns east',
'198.93.97.188','1','/etc/tinydns-ns1/log/main/',
'/etc/tinydns-ns1/root/',120);
-INSERT INTO nt_nameserver(nt_group_id, name, ttl, description, address,
+INSERT IGNORE INTO nt_nameserver(nt_group_id, name, ttl, description, address,
export_type_id, logdir, datadir, export_interval) values (1,'ns2.example.com.',86400,'ns west',
'216.133.235.6','1','/etc/tinydns-ns2/log/main/','/etc/tinydns-ns2/root/',120);
-INSERT INTO nt_nameserver(nt_group_id, name, ttl, description, address,
+INSERT IGNORE INTO nt_nameserver(nt_group_id, name, ttl, description, address,
export_type_id, logdir, datadir, export_interval) values (1,'ns3.example.com.',86400,'ns test',
'127.0.0.1','2','/var/log', '/etc/namedb/master/',120);
-INSERT INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 1);
-INSERT INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 2);
-INSERT INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 3);
+INSERT IGNORE INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 1);
+INSERT IGNORE INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 2);
+INSERT IGNORE INTO nt_nameserver_log(nt_group_id,nt_user_id, action, timestamp, nt_nameserver_id) VALUES (1,1,'added',UNIX_TIMESTAMP(), 3);
-DROP TABLE IF EXISTS nt_nameserver_export_log;
-CREATE TABLE nt_nameserver_export_log(
+CREATE TABLE IF NOT EXISTS nt_nameserver_export_log(
nt_nameserver_export_log_id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
nt_nameserver_id SMALLINT UNSIGNED NOT NULL,
date_start timestamp NULL DEFAULT NULL,
@@ -96,6 +99,3 @@ CREATE TABLE nt_nameserver_export_log(
partial tinyint(1) UNSIGNED NOT NULL DEFAULT 0,
KEY `nt_nameserver_export_log_idx1` (`nt_nameserver_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-
-DROP TABLE IF EXISTS nt_nameserver_qlog;
-DROP TABLE IF EXISTS nt_nameserver_qlogfile;
\ No newline at end of file
diff --git a/sql/06_resource_records.sql b/sql/06_resource_records.sql
index e7ae115..60b93cc 100644
--- a/sql/06_resource_records.sql
+++ b/sql/06_resource_records.sql
@@ -1,7 +1,6 @@
# Copyright 2004-2024 The Network People, Inc.
-DROP TABLE IF EXISTS resource_record_type;
-CREATE TABLE resource_record_type (
+CREATE TABLE IF NOT EXISTS resource_record_type (
id smallint(2) unsigned NOT NULL,
name varchar(10) NOT NULL,
description varchar(55) NULL DEFAULT NULL,
@@ -12,7 +11,7 @@ CREATE TABLE resource_record_type (
UNIQUE `name` (`name`)
) DEFAULT CHARSET=utf8mb4;
-INSERT INTO `resource_record_type` (`id`, `name`, `description`, `reverse`, `forward`, `obsolete`)
+INSERT IGNORE INTO `resource_record_type` (`id`, `name`, `description`, `reverse`, `forward`, `obsolete`)
VALUES
(1,'A','Address',1,1,0),
(2,'NS','Name Server',1,1,0),
diff --git a/sql/08_nt_zone.sql b/sql/08_nt_zone.sql
index fe2ca3a..2d27431 100644
--- a/sql/08_nt_zone.sql
+++ b/sql/08_nt_zone.sql
@@ -16,8 +16,7 @@
#
-DROP TABLE IF EXISTS nt_zone;
-CREATE TABLE nt_zone(
+CREATE TABLE IF NOT EXISTS nt_zone(
nt_zone_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
nt_group_id INT UNSIGNED NOT NULL,
zone VARCHAR(255) NOT NULL,
@@ -40,8 +39,7 @@ CREATE TABLE nt_zone(
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-DROP TABLE IF EXISTS nt_zone_log;
-CREATE TABLE nt_zone_log(
+CREATE TABLE IF NOT EXISTS nt_zone_log(
nt_zone_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nt_group_id INT UNSIGNED NOT NULL,
nt_user_id INT UNSIGNED NOT NULL,
@@ -63,14 +61,14 @@ CREATE TABLE nt_zone_log(
KEY `nt_zone_log_idx2` (`nt_zone_id`),
KEY `nt_zone_log_idx3` (`action`),
KEY `nt_group_id` (`nt_group_id`),
- KEY `nt_user_id` (`nt_user_id`)
- /* CONSTRAINT `nt_zone_log_ibfk_3` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
- ** CONSTRAINT `nt_zone_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE,
- ** CONSTRAINT `nt_zone_log_ibfk_2` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_user_id` (`nt_user_id`),
+ CONSTRAINT `nt_zone_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `nt_zone_log_ibfk_2` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `nt_zone_log_ibfk_3` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-CREATE TABLE nt_zone_nameserver (
+CREATE TABLE IF NOT EXISTS nt_zone_nameserver (
nt_zone_id int(10) unsigned NOT NULL,
nt_nameserver_id smallint(5) unsigned NOT NULL,
UNIQUE KEY `zone_ns_id` (`nt_zone_id`,`nt_nameserver_id`)
diff --git a/sql/09_nt_zone_record.sql b/sql/09_nt_zone_record.sql
index 0214a21..554c483 100644
--- a/sql/09_nt_zone_record.sql
+++ b/sql/09_nt_zone_record.sql
@@ -15,8 +15,7 @@
# Suite 225, San Francisco, CA 94107, USA
#
-DROP TABLE IF EXISTS nt_zone_record;
-CREATE TABLE nt_zone_record(
+CREATE TABLE IF NOT EXISTS nt_zone_record(
nt_zone_record_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
nt_zone_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
@@ -34,13 +33,12 @@ CREATE TABLE nt_zone_record(
KEY `nt_zone_record_idx1` (`name`),
KEY `nt_zone_record_idx2` (address(191)),
KEY `nt_zone_record_idx3` (`nt_zone_id`),
- KEY `nt_zone_record_idx4` (`deleted`)
- /* CONSTRAINT `nt_zone_record_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_zone_record_idx4` (`deleted`),
+ CONSTRAINT `nt_zone_record_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
-DROP TABLE IF EXISTS nt_zone_record_log;
-CREATE TABLE nt_zone_record_log(
+CREATE TABLE IF NOT EXISTS nt_zone_record_log(
nt_zone_record_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nt_zone_id INT UNSIGNED NOT NULL,
nt_user_id INT UNSIGNED NOT NULL,
@@ -61,9 +59,9 @@ CREATE TABLE nt_zone_record_log(
KEY `nt_zone_record_log_idx2` (`nt_zone_record_id`),
KEY `nt_zone_record_log_idx3` (`nt_zone_id`),
KEY `nt_zone_record_log_idx4` (`action`),
- KEY `nt_user_id` (`nt_user_id`)
- /* CONSTRAINT `nt_zone_record_log_ibfk_3` FOREIGN KEY (`nt_zone_record_id`) REFERENCES `nt_zone_record` (`nt_zone_record_id`) ON DELETE CASCADE ON UPDATE CASCADE,
- ** CONSTRAINT `nt_zone_record_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE,
- ** CONSTRAINT `nt_zone_record_log_ibfk_2` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_user_id` (`nt_user_id`),
+ CONSTRAINT `nt_zone_record_log_ibfk_1` FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `nt_zone_record_log_ibfk_2` FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `nt_zone_record_log_ibfk_3` FOREIGN KEY (`nt_zone_record_id`) REFERENCES `nt_zone_record` (`nt_zone_record_id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=COMPRESSED;
diff --git a/sql/10_nt_perm.sql b/sql/10_nt_perm.sql
index 5ab958e..e94040f 100644
--- a/sql/10_nt_perm.sql
+++ b/sql/10_nt_perm.sql
@@ -15,8 +15,7 @@
# Suite 225, San Francisco, CA 94107, USA
#
-DROP TABLE IF EXISTS nt_perm;
-CREATE TABLE nt_perm(
+CREATE TABLE IF NOT EXISTS nt_perm(
nt_perm_id INT UNSIGNED AUTO_INCREMENT NOT NULL,
nt_group_id INT UNSIGNED DEFAULT NULL,
nt_user_id INT UNSIGNED DEFAULT NULL,
@@ -57,10 +56,9 @@ CREATE TABLE nt_perm(
KEY `nt_perm_idx2` (`nt_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-INSERT into nt_perm VALUES(1,1,0,NULL,NULL,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,0);
+INSERT IGNORE INTO nt_perm VALUES(1,1,0,NULL,NULL,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,0);
-DROP TABLE IF EXISTS nt_delegate;
-CREATE TABLE nt_delegate(
+CREATE TABLE IF NOT EXISTS nt_delegate(
#nt_delegate_id INT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
nt_group_id INT UNSIGNED NOT NULL,
nt_object_id INT UNSIGNED NOT NULL,
@@ -97,13 +95,12 @@ CREATE TABLE nt_delegate(
deleted TINYINT(1) UNSIGNED DEFAULT 0 NOT NULL,
KEY `nt_delegate_idx1` (`nt_group_id`,`nt_object_id`,`nt_object_type`),
- KEY `nt_delegate_idx2` (`nt_object_id`,`nt_object_type`)
- /* CONSTRAINT `nt_delegate_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE */
+ KEY `nt_delegate_idx2` (`nt_object_id`,`nt_object_type`),
+ CONSTRAINT `nt_delegate_ibfk_1` FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE
);
-DROP TABLE IF EXISTS nt_delegate_log;
-CREATE TABLE nt_delegate_log(
+CREATE TABLE IF NOT EXISTS nt_delegate_log(
nt_delegate_log_id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
nt_user_id INT UNSIGNED NOT NULL,
nt_user_name VARCHAR(50),
diff --git a/sql/12_nt_options.sql b/sql/12_nt_options.sql
index 107cf8c..1850801 100644
--- a/sql/12_nt_options.sql
+++ b/sql/12_nt_options.sql
@@ -1,7 +1,6 @@
# Copyright 2004-2024 The Network People, Inc.
-DROP TABLE IF EXISTS nt_options;
-CREATE TABLE nt_options (
+CREATE TABLE IF NOT EXISTS nt_options (
option_id int(11) unsigned NOT NULL auto_increment,
option_name varchar(64) NOT NULL default '',
option_value text NOT NULL,
@@ -9,8 +8,8 @@ CREATE TABLE nt_options (
UNIQUE KEY `option_name` (`option_name`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-INSERT INTO `nt_options`
-VALUES (1,'db_version','2.34'),
+INSERT IGNORE INTO `nt_options`
+VALUES (1,'db_version','2.41'),
(2,'session_timeout','45'),
(3,'default_group','NicTool')
;
diff --git a/sql/schema.test.js b/sql/schema.test.js
new file mode 100644
index 0000000..b054735
--- /dev/null
+++ b/sql/schema.test.js
@@ -0,0 +1,189 @@
+// Applies sql/*.sql the way the configurator's /nt/init-schema does.
+//
+// Regression guard for two things: the files hold many statements per file
+// (so the connection needs multipleStatements), and they must be idempotent
+//
+// Skips when MySQL is unreachable.
+import assert from 'node:assert/strict'
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, it, before, after } from 'node:test'
+
+import mysql from 'mysql2/promise'
+
+const sqlDir = fileURLToPath(new URL('./', import.meta.url))
+
+const DSN = process.env.NICTOOL_TEST_DSN ?? 'mysql://nictool:lootcin!mysql@127.0.0.1:3306/nictool'
+
+function connOpts() {
+ const u = new URL(DSN)
+ return {
+ host: u.hostname,
+ port: Number(u.port) || 3306,
+ user: decodeURIComponent(u.username),
+ password: decodeURIComponent(u.password),
+ database: u.pathname.replace(/^\//, ''),
+ connectTimeout: 4000,
+ multipleStatements: true,
+ }
+}
+
+let conn = null
+let skip = false
+
+before(async () => {
+ try {
+ conn = await mysql.createConnection(connOpts())
+ } catch (err) {
+ skip = `MySQL unreachable: ${err.code ?? err.message}`
+ }
+})
+
+after(async () => {
+ await conn?.end()
+})
+
+const schemaFiles = async () => (await fs.readdir(sqlDir)).filter((f) => f.endsWith('.sql')).sort()
+
+/**
+ * Parse the CREATE TABLE blocks into { table -> { columns, keys, fks } }.
+ * Enough structure to validate referential integrity statically, which matters
+ * because the constraints are only exercised against a freshly built database.
+ */
+async function parseSchema() {
+ const tables = {}
+ for (const f of await schemaFiles()) {
+ const sql = await fs.readFile(path.join(sqlDir, f), 'utf8')
+ const blocks = sql.matchAll(/CREATE TABLE IF NOT EXISTS\s+`?(\w+)`?\s*\(([\s\S]*?)\n\)\s*[^;]*;/g)
+ for (const [, name, body] of blocks) {
+ const columns = {}
+ const keys = new Set()
+ const fks = []
+
+ for (const raw of body.split('\n')) {
+ const line = raw.trim().replace(/,$/, '')
+ if (!line || line.startsWith('--') || line.startsWith('#')) continue
+
+ const fk = line.match(
+ /CONSTRAINT\s+`(\w+)`\s+FOREIGN KEY\s*\(`(\w+)`\)\s*REFERENCES\s+`(\w+)`\s*\(`(\w+)`\)/i,
+ )
+ if (fk) {
+ fks.push({ name: fk[1], column: fk[2], refTable: fk[3], refColumn: fk[4] })
+ continue
+ }
+
+ const key = line.match(/^(?:PRIMARY KEY|UNIQUE KEY|UNIQUE|KEY)\s*`?(\w+)?`?\s*\(([^)]*)\)/i)
+ if (key) {
+ const first = key[2].split(',')[0].trim().replace(/`/g, '')
+ keys.add(first)
+ continue
+ }
+
+ const col = line.match(/^`?(\w+)`?\s+([A-Za-z]+(?:\([^)]*\))?(?:\s+UNSIGNED)?)/i)
+ if (col) columns[col[1]] = col[2].toLowerCase().replace(/\s+/g, ' ')
+ }
+ tables[name] = { columns, keys, fks, file: f }
+ }
+ }
+ return tables
+}
+
+describe('foreign keys', () => {
+ it('reference a table and column that exist', async (t) => {
+ const tables = await parseSchema()
+ let count = 0
+
+ for (const [name, def] of Object.entries(tables)) {
+ for (const fk of def.fks) {
+ count++
+ assert.ok(def.columns[fk.column], `${name}.${fk.column} (${fk.name}) is not a column`)
+ assert.ok(tables[fk.refTable], `${fk.name} references unknown table ${fk.refTable}`)
+ assert.ok(
+ tables[fk.refTable].columns[fk.refColumn],
+ `${fk.name} references unknown column ${fk.refTable}.${fk.refColumn}`,
+ )
+ }
+ }
+
+ assert.ok(count >= 10, `expected the 2.41 constraint set, found ${count}`)
+ t.diagnostic(`${count} foreign keys validated`)
+ })
+
+ it('use matching column types on both sides', async () => {
+ const tables = await parseSchema()
+
+ for (const [name, def] of Object.entries(tables)) {
+ for (const fk of def.fks) {
+ const child = tables[name].columns[fk.column]
+ const parent = tables[fk.refTable].columns[fk.refColumn]
+ // MySQL rejects a FK whose types differ; auto_increment on the parent
+ // is not part of the type.
+ assert.equal(
+ child.replace(/ unsigned/, ''),
+ parent.replace(/ unsigned/, ''),
+ `${fk.name}: ${name}.${fk.column} is ${child} but ${fk.refTable}.${fk.refColumn} is ${parent}`,
+ )
+ }
+ }
+ })
+
+ it('are all covered by the upgrade migration, and vice versa', async () => {
+ const tables = await parseSchema()
+ const declared = new Set(Object.values(tables).flatMap((t) => t.fks.map((fk) => fk.name)))
+
+ const migration = await fs.readFile(path.join(sqlDir, 'upgrade', '05_enable_foreign_keys.sql'), 'utf8')
+ const migrated = new Set([...migration.matchAll(/CALL nt_add_fk\('\w+',\s*'(\w+)'/g)].map((m) => m[1]))
+
+ const missing = [...declared].filter((n) => !migrated.has(n))
+ const extra = [...migrated].filter((n) => !declared.has(n))
+
+ assert.deepEqual(missing, [], 'constraints an existing database would never get')
+ assert.deepEqual(extra, [], 'constraints the migration adds but the schema does not declare')
+ })
+
+ it('are indexed on the child side, as InnoDB requires', async () => {
+ const tables = await parseSchema()
+
+ for (const [name, def] of Object.entries(tables)) {
+ for (const fk of def.fks) {
+ assert.ok(
+ def.keys.has(fk.column),
+ `${fk.name}: ${name}.${fk.column} needs a leading index for the constraint`,
+ )
+ }
+ }
+ })
+})
+
+describe('sql schema', () => {
+ it('ships no destructive DDL in the install path', async (t) => {
+ for (const f of await schemaFiles()) {
+ const sql = await fs.readFile(path.join(sqlDir, f), 'utf8')
+ assert.ok(!/^DROP TABLE/m.test(sql), `${f} must not DROP TABLE; use sql/upgrade/`)
+ assert.ok(!/^CREATE TABLE(?! IF NOT EXISTS)/m.test(sql), `${f} must use CREATE TABLE IF NOT EXISTS`)
+ }
+ t.diagnostic('sql/upgrade/ holds the destructive cleanup and is not applied on install')
+ })
+
+ it('applies cleanly, twice, without duplicating seed rows', async (t) => {
+ if (skip) return t.skip(skip)
+
+ const files = await schemaFiles()
+ const seedCount = async () => (await conn.query('SELECT COUNT(*) n FROM resource_record_type'))[0][0].n
+
+ for (const pass of [1, 2]) {
+ for (const f of files) {
+ const sql = await fs.readFile(path.join(sqlDir, f), 'utf8')
+ await assert.doesNotReject(() => conn.query(sql), `${f} failed on pass ${pass}`)
+ }
+ }
+
+ assert.equal(await seedCount(), 29, 'INSERT IGNORE kept the seed rows unique')
+ })
+
+ it('declares the 2.x db_version the upgrade path detects', async () => {
+ const sql = await fs.readFile(path.join(sqlDir, '12_nt_options.sql'), 'utf8')
+ assert.match(sql, /'db_version','2\.41'/)
+ })
+})
diff --git a/sql/90_nt_summary.sql b/sql/upgrade/01_drop_obsolete_summary_tables.sql
similarity index 100%
rename from sql/90_nt_summary.sql
rename to sql/upgrade/01_drop_obsolete_summary_tables.sql
diff --git a/sql/upgrade/02_drop_obsolete_qlog_tables.sql b/sql/upgrade/02_drop_obsolete_qlog_tables.sql
new file mode 100644
index 0000000..5239d2d
--- /dev/null
+++ b/sql/upgrade/02_drop_obsolete_qlog_tables.sql
@@ -0,0 +1,6 @@
+# Copyright 2004-2024 The Network People, Inc.
+#
+# Query-logging tables from NicTool 2.x. v3 does not write them.
+
+DROP TABLE IF EXISTS nt_nameserver_qlog;
+DROP TABLE IF EXISTS nt_nameserver_qlogfile;
diff --git a/sql/upgrade/03_nameserver_runtime_columns.sql b/sql/upgrade/03_nameserver_runtime_columns.sql
new file mode 100644
index 0000000..7946060
--- /dev/null
+++ b/sql/upgrade/03_nameserver_runtime_columns.sql
@@ -0,0 +1,14 @@
+# Copyright 2004-2024 The Network People, Inc.
+#
+# Adds the v3 nameserver runtime configuration columns to a database created by
+# NicTool 2.x. New installs get these from sql/04_nt_nameserver.sql instead.
+#
+# MySQL has no ALTER TABLE ... ADD COLUMN IF NOT EXISTS, so re-running this
+# errors with ER_DUP_FIELDNAME. That is safe to ignore.
+
+ALTER TABLE nt_nameserver
+ ADD COLUMN engine VARCHAR(32) NULL DEFAULT NULL,
+ ADD COLUMN listen JSON NULL DEFAULT NULL,
+ ADD COLUMN publisher JSON NULL DEFAULT NULL,
+ ADD COLUMN transport JSON NULL DEFAULT NULL,
+ ADD COLUMN dnssec JSON NULL DEFAULT NULL;
diff --git a/sql/upgrade/04_clear_orphan_rows.sql b/sql/upgrade/04_clear_orphan_rows.sql
new file mode 100644
index 0000000..4ae2818
--- /dev/null
+++ b/sql/upgrade/04_clear_orphan_rows.sql
@@ -0,0 +1,105 @@
+# Copyright 2004-2024 The Network People, Inc.
+#
+# Prepares a NicTool 2.x database for 05_enable_foreign_keys.sql. Safe to
+# re-run. Two things block those constraints:
+#
+# 1. Zero datetimes. Adding a constraint rebuilds the table, and MySQL 8's
+# NO_ZERO_DATE re-validates every row, so '0000-00-00 00:00:00' left by
+# MySQL 5.x aborts the ALTER with errno 1292.
+# 2. Orphan rows — DESTRUCTIVE, see below.
+#
+
+# ---------------------------------------------------------------------------
+# Zero datetimes -> NULL
+#
+# Lossless: the API already reads these as absent (see the Date.parse guard in
+# lib/zone_record/store/mysql.js and the last_publish check in the zone store).
+# sql_mode is relaxed for the session because strict mode rejects the literal
+# in the comparison as well as in the write.
+# ---------------------------------------------------------------------------
+
+SET @nt_old_sql_mode = @@SESSION.sql_mode;
+SET SESSION sql_mode = '';
+
+UPDATE nt_zone_record SET timestamp = NULL WHERE timestamp = '0000-00-00 00:00:00';
+UPDATE nt_zone SET last_publish = NULL WHERE last_publish = '0000-00-00 00:00:00';
+
+SET SESSION sql_mode = @nt_old_sql_mode;
+
+# nt_zone.last_modified is NOT NULL, so a zero there has no lossless
+# replacement. It blocks nothing today; deal with it if nt_zone is ever
+# rebuilt:
+# UPDATE nt_zone SET last_modified = '1970-01-01 00:00:01'
+# WHERE last_modified = '0000-00-00 00:00:00';
+
+# ---------------------------------------------------------------------------
+# Orphan rows — DESTRUCTIVE
+#
+# A database that ran with the constraints disabled accumulates rows whose
+# parent has since been deleted. With ON DELETE CASCADE in force those rows
+# would have gone automatically, so removing them restores the state the schema
+# has always intended. They are almost entirely audit-log entries for objects
+# that no longer exist.
+#
+# Check what would be removed before running this:
+#
+# SELECT COUNT(*) FROM nt_zone_record c
+# LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id
+# WHERE p.nt_zone_id IS NULL;
+
+DELETE c FROM nt_group_log c
+ LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id
+ WHERE p.nt_group_id IS NULL;
+
+DELETE c FROM nt_group_subgroups c
+ LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id
+ WHERE p.nt_group_id IS NULL;
+
+DELETE c FROM nt_user_session c
+ LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id
+ WHERE p.nt_user_id IS NULL;
+
+DELETE c FROM nt_user_session_log c
+ LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id
+ WHERE p.nt_user_id IS NULL;
+
+DELETE c FROM nt_user_global_log c
+ LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id
+ WHERE p.nt_user_id IS NULL;
+
+DELETE c FROM nt_nameserver c
+ LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id
+ WHERE p.nt_group_id IS NULL;
+
+DELETE c FROM nt_delegate c
+ LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id
+ WHERE p.nt_group_id IS NULL;
+
+# zone_record before its log, so the log's own parent check sees the result.
+DELETE c FROM nt_zone_record c
+ LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id
+ WHERE p.nt_zone_id IS NULL;
+
+DELETE c FROM nt_zone_log c
+ LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id
+ WHERE p.nt_zone_id IS NULL;
+
+DELETE c FROM nt_zone_log c
+ LEFT JOIN nt_group p ON c.nt_group_id = p.nt_group_id
+ WHERE p.nt_group_id IS NULL;
+
+DELETE c FROM nt_zone_log c
+ LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id
+ WHERE p.nt_user_id IS NULL;
+
+DELETE c FROM nt_zone_record_log c
+ LEFT JOIN nt_zone p ON c.nt_zone_id = p.nt_zone_id
+ WHERE p.nt_zone_id IS NULL;
+
+DELETE c FROM nt_zone_record_log c
+ LEFT JOIN nt_user p ON c.nt_user_id = p.nt_user_id
+ WHERE p.nt_user_id IS NULL;
+
+DELETE c FROM nt_zone_record_log c
+ LEFT JOIN nt_zone_record p ON c.nt_zone_record_id = p.nt_zone_record_id
+ WHERE p.nt_zone_record_id IS NULL;
diff --git a/sql/upgrade/05_enable_foreign_keys.sql b/sql/upgrade/05_enable_foreign_keys.sql
new file mode 100644
index 0000000..a817531
--- /dev/null
+++ b/sql/upgrade/05_enable_foreign_keys.sql
@@ -0,0 +1,79 @@
+# Copyright 2004-2024 The Network People, Inc.
+#
+# Adds the referential constraints to a database created while they were
+# disabled. New installs get these from sql/*.sql instead.
+#
+# Run 04_clear_orphan_rows.sql first: adding a constraint rebuilds the table,
+# which re-validates every row. A failure with errno 150 means orphan rows
+# remain; errno 1292 means a zero datetime does.
+#
+# Idempotent — each constraint is added only if absent, so this can be re-run
+# after a partial failure without erroring on what already landed.
+#
+# mysql nictool < 05_enable_foreign_keys.sql
+
+DROP PROCEDURE IF EXISTS nt_add_fk;
+
+DELIMITER //
+
+CREATE PROCEDURE nt_add_fk(IN tbl VARCHAR(64), IN fk VARCHAR(64), IN clause TEXT)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.TABLE_CONSTRAINTS
+ WHERE CONSTRAINT_SCHEMA = DATABASE()
+ AND TABLE_NAME = tbl
+ AND CONSTRAINT_NAME = fk
+ AND CONSTRAINT_TYPE = 'FOREIGN KEY'
+ ) THEN
+ SET @nt_ddl = CONCAT('ALTER TABLE `', tbl, '` ADD CONSTRAINT `', fk, '` ', clause);
+ PREPARE nt_stmt FROM @nt_ddl;
+ EXECUTE nt_stmt;
+ DEALLOCATE PREPARE nt_stmt;
+ END IF;
+END //
+
+DELIMITER ;
+
+CALL nt_add_fk('nt_group_log', 'nt_group_log_ibfk_1',
+ 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_group_subgroups', 'nt_group_subgroups_ibfk_1',
+ 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_user_session', 'nt_user_session_ibfk_1',
+ 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_user_session_log', 'nt_user_session_log_ibfk_1',
+ 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_user_global_log', 'nt_user_global_log_ibfk_1',
+ 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_nameserver', 'nt_nameserver_ibfk_1',
+ 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_delegate', 'nt_delegate_ibfk_1',
+ 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_log', 'nt_zone_log_ibfk_1',
+ 'FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_log', 'nt_zone_log_ibfk_2',
+ 'FOREIGN KEY (`nt_group_id`) REFERENCES `nt_group` (`nt_group_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_log', 'nt_zone_log_ibfk_3',
+ 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_record', 'nt_zone_record_ibfk_1',
+ 'FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_record_log', 'nt_zone_record_log_ibfk_1',
+ 'FOREIGN KEY (`nt_zone_id`) REFERENCES `nt_zone` (`nt_zone_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_record_log', 'nt_zone_record_log_ibfk_2',
+ 'FOREIGN KEY (`nt_user_id`) REFERENCES `nt_user` (`nt_user_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+CALL nt_add_fk('nt_zone_record_log', 'nt_zone_record_log_ibfk_3',
+ 'FOREIGN KEY (`nt_zone_record_id`) REFERENCES `nt_zone_record` (`nt_zone_record_id`) ON DELETE CASCADE ON UPDATE CASCADE');
+
+DROP PROCEDURE nt_add_fk;
diff --git a/test/backends/json.sh b/test/backends/json.sh
new file mode 100755
index 0000000..ea6d980
--- /dev/null
+++ b/test/backends/json.sh
@@ -0,0 +1,37 @@
+#!/bin/sh
+# JSON backend lifecycle for test/run.sh
+
+setup() {
+ export NICTOOL_DATA_STORE_PATH="./test/conf.d"
+ mkdir -p test/conf.d
+ $NODE test/fixtures.js setup
+}
+
+cleanup() {
+ echo "cleaning JSON test store"
+ rm -f test/conf.d/*.json
+}
+
+run_tests() {
+ # Run serially: the file store uses shared files; parallel workers cause concurrent-write corruption
+ for f in \
+ lib/group/test/index.js \
+ lib/nameserver/test/index.js \
+ lib/nameserver/test/runtime.js \
+ lib/permission/test/index.js \
+ lib/session/test/index.js \
+ lib/user/test/index.js \
+ lib/zone/test/index.js \
+ lib/zone_record/test/index.js \
+ lib/config.test.js \
+ lib/util.test.js \
+ routes/group.test.js \
+ routes/nameserver.test.js \
+ routes/permission.test.js \
+ routes/session.test.js \
+ routes/user.test.js \
+ routes/zone.test.js \
+ routes/zone_record.test.js; do
+ $NODE --test --test-reporter=spec "$f" || exit 1
+ done
+}
diff --git a/test/backends/mysql.sh b/test/backends/mysql.sh
index 7958552..ad27184 100755
--- a/test/backends/mysql.sh
+++ b/test/backends/mysql.sh
@@ -21,6 +21,9 @@ run_tests() {
$NODE --test --test-reporter=spec \
lib/*/test/index.js \
lib/*/test/mysql.js \
+ lib/*/test/runtime.js \
lib/*.test.js \
+ lib/store/*.test.js \
+ sql/*.test.js \
routes/*.test.js
}
diff --git a/test/backends/toml.sh b/test/backends/toml.sh
index a045e69..03f1764 100755
--- a/test/backends/toml.sh
+++ b/test/backends/toml.sh
@@ -17,6 +17,7 @@ run_tests() {
for f in \
lib/group/test/index.js \
lib/nameserver/test/index.js \
+ lib/nameserver/test/runtime.js \
lib/permission/test/index.js \
lib/session/test/index.js \
lib/user/test/index.js \
diff --git a/test/env.js b/test/env.js
new file mode 100644
index 0000000..b786814
--- /dev/null
+++ b/test/env.js
@@ -0,0 +1,44 @@
+/**
+ * Isolate a test from the environment's config overrides.
+ *
+ * `applyEnvOverrides` in lib/config.js lets the environment win over api.json,
+ * which is what a container relies on — docker-compose.yml sets NICTOOL_DB_HOST
+ * and NICTOOL_HTTP_HOST. A test asserting what a config *file* holds therefore
+ * has to clear them, or it passes on a workstation and fails in Docker.
+ */
+const OVERRIDE_KEYS = [
+ 'NICTOOL_CONF_DIR',
+ 'NICTOOL_DATA_STORE',
+ 'NICTOOL_DATA_STORE_DSN',
+ 'NICTOOL_DATA_STORE_PATH',
+ 'NICTOOL_DB_HOST',
+ 'NICTOOL_DB_NAME',
+ 'NICTOOL_DB_PORT',
+ 'NICTOOL_DB_USER',
+ 'NICTOOL_DB_USER_PASSWORD',
+ 'NICTOOL_HTTP_HOST',
+ 'NICTOOL_HTTP_PORT',
+]
+
+export { OVERRIDE_KEYS }
+
+/**
+ * Clear every config override and return a function restoring what was there.
+ *
+ * const restoreEnv = clearConfigEnv()
+ * after(restoreEnv)
+ */
+export function clearConfigEnv(keys = OVERRIDE_KEYS) {
+ const saved = {}
+ for (const key of keys) {
+ saved[key] = process.env[key]
+ delete process.env[key]
+ }
+
+ return function restore() {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value === undefined) delete process.env[key]
+ else process.env[key] = value
+ }
+ }
+}
diff --git a/test/run.sh b/test/run.sh
index fcd4c7a..6de7990 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -6,7 +6,7 @@ NODE="node --no-warnings=ExperimentalWarning"
BACKEND="${NICTOOL_DATA_STORE:-mysql}"
case "$BACKEND" in
- toml|mysql) ;;
+ json|toml|mysql) ;;
*) echo "Unknown NICTOOL_DATA_STORE: $BACKEND" >&2; exit 1 ;;
esac