diff --git a/CHANGELOG.md b/CHANGELOG.md index 915db80..754d38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - fix(mysql): read v2 group permission rows, and save partial permission changes - fix: file store login checks the group in user@group - create: allocate ids internally and reject caller-supplied ids +- zone: `nameservers` holds the zone's nameserver ids on every store, and POST /zone defaults it to the group's usable nameservers ### [3.0.3] - 2026-07-27 diff --git a/lib/mysql.js b/lib/mysql.js index ee219ff..fd8cad2 100644 --- a/lib/mysql.js +++ b/lib/mysql.js @@ -4,6 +4,10 @@ import Config from './config.js' let _debug +function shapeRows(query, rows) { + return /^(REPLACE|INSERT) INTO/.test(query) ? rows.insertId : rows +} + class Mysql { constructor() { if (Mysql._instance) return Mysql._instance @@ -12,6 +16,12 @@ class Mysql { } async connect() { + this.dbh = await this.open() + if (_debug) console.log(`MySQL connection id ${this.dbh.connection.connectionId}`) + return this.dbh + } + + async open() { const cfg = await Config.get('mysql') const connCfg = { ...cfg } @@ -20,13 +30,34 @@ class Mysql { if (connCfg.socketPath === '') delete connCfg.socketPath // if (_debug) console.log(connCfg) try { - this.dbh = await mysql.createConnection(connCfg) + return await mysql.createConnection(connCfg) } catch (err) { if (err?.code !== 'MYSQL_CLEAR_PASSWORD_NOT_ENABLED') throw err - this.dbh = await mysql.createConnection({ ...connCfg, enableCleartextPlugin: true }) + return mysql.createConnection({ ...connCfg, enableCleartextPlugin: true }) + } + } + + // A transaction gets its own connection so concurrent requests stay isolated. + async transaction(fn) { + const conn = await this.open() + try { + await conn.beginTransaction() + const tx = { + execute: async (query, paramsArray) => { + const [rows] = await conn.execute(query, paramsArray ?? []) + return shapeRows(query, rows) + }, + } + const result = await fn(tx) + await conn.commit() + return result + } catch (err) { + await conn.rollback().catch(() => {}) + throw err + } finally { + // GET_LOCK lives as long as the session, so a failed end must not leave it open + await conn.end().catch(() => conn.destroy()) } - if (_debug) console.log(`MySQL connection id ${this.dbh.connection.connectionId}`) - return this.dbh } async execute(query, paramsArray) { @@ -43,9 +74,7 @@ class Mysql { console.log(rows) } - if (/^(REPLACE|INSERT) INTO/.test(query)) return rows.insertId - - return rows + return shapeRows(query, rows) } insert(table, params = {}) { diff --git a/lib/zone/store/base.js b/lib/zone/store/base.js index 20f2273..c406c00 100644 --- a/lib/zone/store/base.js +++ b/lib/zone/store/base.js @@ -9,6 +9,10 @@ * delete(args) → boolean * destroy(args) → boolean * nameserversFor(zid) → object[] ({zone, name, ttl} for the zone's NS) + * + * A zone's `nameservers` are the ids of the nameservers that serve it. As in + * NicTool 2, they are the zone's apex NS set, and the lowest id is the SOA + * MNAME, so every store keeps them ascending. */ class ZoneBase { constructor(args = {}) { @@ -49,3 +53,7 @@ class ZoneBase { } export default ZoneBase + +export function sortNameservers(ids) { + return [...new Set((ids ?? []).map(Number))].sort((a, b) => a - b) +} diff --git a/lib/zone/store/file.js b/lib/zone/store/file.js index 9f2b47b..9f46ca7 100644 --- a/lib/zone/store/file.js +++ b/lib/zone/store/file.js @@ -1,7 +1,7 @@ import FileStore, { claimId, removeRows, resolveCodec } from '../../store/file.js' import { idConflict } from '../../store/error.js' -import ZoneBase from './base.js' +import ZoneBase, { sortNameservers } from './base.js' const zoneDefaults = { minimum: 3600, ttl: 3600, refresh: 86400, retry: 7200, expire: 1209600 } @@ -23,6 +23,7 @@ class ZoneRepoFile extends ZoneBase { if ([null, undefined].includes(r[f])) r[f] = val } if ([null, undefined].includes(r.serial)) r.serial = 0 + r.nameservers = sortNameservers(r.nameservers) // 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 @@ -40,6 +41,7 @@ class ZoneRepoFile extends ZoneBase { } args.id = claimId(zones, data, args.id) + if (args.nameservers !== undefined) args.nameservers = sortNameservers(args.nameservers) zones.push(args) return args.id }) @@ -140,6 +142,7 @@ class ZoneRepoFile extends ZoneBase { const idx = zones.findIndex((z) => z.id === args.id) if (idx === -1) return false zones[idx] = { ...zones[idx], ...args } + if (args.nameservers !== undefined) zones[idx].nameservers = sortNameservers(args.nameservers) return true }) } @@ -160,18 +163,12 @@ class ZoneRepoFile extends ZoneBase { async nameserversFor(zid) { const zone = (await this._load()).find((z) => z.id === zid) if (!zone) return [] - const mappings = await new FileStore('zone_nameserver').load('zone_nameserver') const nameservers = await new FileStore('nameserver').load('nameserver') - return mappings - .filter((m) => m.zid === zid) - .map((m) => ({ - zone: zone.zone, - name: nameservers.find((n) => n.id === m.nid)?.name, - ttl: nameservers.find((n) => n.id === m.nid)?.ttl, - })) - .filter((row) => row.name !== undefined) - .sort((a, b) => (a.name < b.name ? -1 : 1)) + return sortNameservers(zone.nameservers) + .map((nid) => nameservers.find((n) => n.id === nid && !n.deleted)) + .filter((ns) => ns !== undefined) + .map((ns) => ({ zone: zone.zone, name: ns.name, ttl: ns.ttl })) } } diff --git a/lib/zone/store/file.test.js b/lib/zone/store/file.test.js index 4f3bcfc..917ac0b 100644 --- a/lib/zone/store/file.test.js +++ b/lib/zone/store/file.test.js @@ -20,11 +20,14 @@ describe('zone file store nameservers', () => { process.env.NICTOOL_CONF_DIR = path.join(tmp, 'no-such-conf') process.env.NICTOOL_DATA_STORE = 'json' process.env.NICTOOL_DATA_STORE_PATH = tmp - await seed(new FileStore('zone'), 'zone', [{ id: 1, zone: 'example.com' }]) - await seed(new FileStore('nameserver'), 'nameserver', [{ id: 5, name: 'ns1.example.net.', ttl: 3600 }]) - await seed(new FileStore('zone_nameserver'), 'zone_nameserver', [ - { zid: 1, nid: 5 }, - { zid: 2, nid: 5 }, + await seed(new FileStore('zone'), 'zone', [ + { id: 1, zone: 'example.com', nameservers: [7, 5, 6, 9] }, + { id: 2, zone: 'example.net' }, + ]) + await seed(new FileStore('nameserver'), 'nameserver', [ + { id: 5, name: 'ns1.example.net.', ttl: 3600 }, + { id: 6, name: 'ns0.example.net.', ttl: 3600, deleted: true }, + { id: 7, name: 'ns2.example.net.', ttl: 7200 }, ]) }) @@ -36,13 +39,18 @@ describe('zone file store nameservers', () => { fs.rmSync(tmp, { recursive: true, force: true }) }) - it('lists the nameservers assigned to a zone', async () => { + it('lists the active assigned nameservers, lowest id first', async () => { assert.deepEqual(await new ZoneRepoFile().nameserversFor(1), [ { zone: 'example.com', name: 'ns1.example.net.', ttl: 3600 }, + { zone: 'example.com', name: 'ns2.example.net.', ttl: 7200 }, ]) }) - it('ignores assignments left behind by a destroyed zone', async () => { + it('lists none for a zone without assignments', async () => { assert.deepEqual(await new ZoneRepoFile().nameserversFor(2), []) }) + + it('lists none for a missing zone', async () => { + assert.deepEqual(await new ZoneRepoFile().nameserversFor(3), []) + }) }) diff --git a/lib/zone/store/mysql.js b/lib/zone/store/mysql.js index 18712ca..25b3234 100644 --- a/lib/zone/store/mysql.js +++ b/lib/zone/store/mysql.js @@ -1,5 +1,5 @@ import Mysql from '../../mysql.js' -import ZoneBase from './base.js' +import ZoneBase, { sortNameservers } from './base.js' import { idConflict } from '../../store/error.js' import { mapToDbColumn } from '../../util.js' @@ -57,7 +57,37 @@ class ZoneRepoMySQL extends ZoneBase { if (g.length > 0) return idConflict('zone', args.id, options) } - return await Mysql.execute(...Mysql.insert(`nt_zone`, mapToDbColumn(args, zoneDbMap))) + const { nameservers, ...zone } = args + const insert = Mysql.insert(`nt_zone`, mapToDbColumn(zone, zoneDbMap)) + if (!nameservers?.length) return Mysql.execute(...insert) + return Mysql.transaction(async (tx) => { + const id = await tx.execute(...insert) + await this._setNameservers(tx, id, nameservers) + return id + }) + } + + async _nameserverIds(zids, db = Mysql) { + const byZone = new Map(zids.map((zid) => [zid, []])) + if (zids.length === 0) return byZone + const rows = await db.execute( + `SELECT nt_zone_id, nt_nameserver_id FROM nt_zone_nameserver + WHERE nt_zone_id IN (${zids.map(() => '?').join(',')}) + ORDER BY nt_nameserver_id`, + zids, + ) + for (const row of rows) byZone.get(row.nt_zone_id).push(row.nt_nameserver_id) + return byZone + } + + async _setNameservers(db, zid, nameservers) { + await db.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [zid]) + const ids = sortNameservers(nameservers) + if (ids.length === 0) return + await db.execute( + `INSERT INTO nt_zone_nameserver (nt_zone_id, nt_nameserver_id) VALUES ${ids.map(() => '(?,?)').join(',')}`, + ids.flatMap((nsid) => [zid, nsid]), + ) } async get(args) { @@ -118,7 +148,9 @@ class ZoneRepoMySQL extends ZoneBase { finalQuery += ` ORDER BY ${sortBy} ${sortDir}` const rows = await Mysql.execute(`${finalQuery}${sqlLimit}`, finalParams) + const nameservers = await this._nameserverIds(rows.map((row) => row.id)) for (const row of rows) { + row.nameservers = nameservers.get(row.id) for (const b of boolFields) { row[b] = row[b] === 1 } @@ -178,12 +210,34 @@ class ZoneRepoMySQL extends ZoneBase { async put(args) { if (!args.id) return false - const id = args.id - delete args.id - const r = await Mysql.execute( - ...Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(args, zoneDbMap)), - ) - return r.changedRows === 1 + const { id, nameservers, ...changes } = args + const update = Object.keys(changes).length + ? Mysql.update(`nt_zone`, `nt_zone_id=${id}`, mapToDbColumn(changes, zoneDbMap)) + : undefined + + if (nameservers === undefined) { + if (!update) return false + const r = await Mysql.execute(...update) + return r.changedRows === 1 + } + + // The row lock serialises replacements of one zone's set, which would + // otherwise interleave their DELETEs and INSERTs and leave the union. + // nt_zone_nameserver has no foreign key, so the lock's read is also what + // keeps a missing zone from gaining assignments. + return Mysql.transaction(async (tx) => { + const [zone] = await tx.execute('SELECT nt_zone_id FROM nt_zone WHERE nt_zone_id = ? FOR UPDATE', [id]) + if (!zone) return false + + let changed = false + if (update) changed = (await tx.execute(...update)).changedRows === 1 + const before = (await this._nameserverIds([id], tx)).get(id) + if (before.join() !== sortNameservers(nameservers).join()) { + await this._setNameservers(tx, id, nameservers) + changed = true + } + return changed + }) } async delete(args) { @@ -196,6 +250,8 @@ class ZoneRepoMySQL extends ZoneBase { } async destroy(args) { + // nt_zone_nameserver has no foreign key to cascade from nt_zone + await Mysql.execute('DELETE FROM nt_zone_nameserver WHERE nt_zone_id = ?', [args.id]) const r = await Mysql.execute(...Mysql.delete(`nt_zone`, { nt_zone_id: args.id })) return r.affectedRows === 1 } @@ -210,8 +266,8 @@ class ZoneRepoMySQL extends ZoneBase { FROM nt_zone_nameserver nzns JOIN nt_nameserver n ON n.nt_nameserver_id = nzns.nt_nameserver_id JOIN nt_zone z ON z.nt_zone_id = nzns.nt_zone_id - WHERE nzns.nt_zone_id = ? - ORDER BY n.name`, + WHERE nzns.nt_zone_id = ? AND n.deleted = 0 + ORDER BY n.nt_nameserver_id`, [zid], ) } diff --git a/lib/zone/test/index.js b/lib/zone/test/index.js index d579749..f36baa5 100644 --- a/lib/zone/test/index.js +++ b/lib/zone/test/index.js @@ -2,16 +2,28 @@ import assert from 'node:assert/strict' import { describe, it, after, before } from 'node:test' import Zone from '../index.js' +import Nameserver from '../../nameserver/index.js' import testCase from './zone.json' with { type: 'json' } +import nsCase from '../../nameserver/test/nameserver.json' with { type: 'json' } + +const nsA = { ...nsCase, id: 4082, name: 'a.zone-test.example.com.', ttl: 7200 } +const nsB = { ...nsCase, id: 4081, name: 'b.zone-test.example.com.' } +const nsGone = { ...nsCase, id: 4083, name: 'gone.zone-test.example.com.' } before(async () => { await Zone.destroy({ id: testCase.id }) await Zone.create(testCase) + for (const ns of [nsA, nsB, nsGone]) { + await Nameserver.destroy({ id: ns.id }) + await Nameserver.create(ns) + } + await Nameserver.delete({ id: nsGone.id }) }) after(async () => { // await Zone.destroy({ id: testCase.id }) + for (const ns of [nsA, nsB, nsGone]) await Nameserver.destroy({ id: ns.id }) await Zone.disconnect() }) @@ -19,17 +31,83 @@ describe('zone', function () { it('gets zone by id', async () => { const g = await Zone.get({ id: testCase.id }) delete g[0].last_modified - assert.deepEqual(g[0], testCase) + assert.deepEqual(g[0], { ...testCase, nameservers: [] }) }) - it('lists the nameservers of a zone', async () => { - assert.ok(Array.isArray(await Zone.nameserversFor(testCase.id))) + describe('nameservers', () => { + after(async () => { + await Zone.put({ id: testCase.id, nameservers: [] }) + }) + + it('stores assignments in ascending id order', async () => { + assert.ok(await Zone.put({ id: testCase.id, nameservers: [nsA.id, nsB.id, nsA.id] })) + const [z] = await Zone.get({ id: testCase.id }) + assert.deepEqual(z.nameservers, [nsB.id, nsA.id]) + }) + + it('leaves assignments alone when a put omits them', async () => { + assert.ok(await Zone.put({ id: testCase.id, description: 'kept ns' })) + const [z] = await Zone.get({ id: testCase.id }) + assert.deepEqual(z.nameservers, [nsB.id, nsA.id]) + await Zone.put({ id: testCase.id, description: testCase.description }) + }) + + it('lists the active assigned nameservers, lowest id first', async () => { + await Zone.put({ id: testCase.id, nameservers: [nsA.id, nsB.id, nsGone.id] }) + const zoneName = testCase.zone + assert.deepEqual(await Zone.nameserversFor(testCase.id), [ + { zone: zoneName, name: nsB.name, ttl: nsB.ttl }, + { zone: zoneName, name: nsA.name, ttl: nsA.ttl }, + ]) + }) + + it('clears assignments', async () => { + assert.ok(await Zone.put({ id: testCase.id, nameservers: [] })) + const [z] = await Zone.get({ id: testCase.id }) + assert.deepEqual(z.nameservers, []) + assert.deepEqual(await Zone.nameserversFor(testCase.id), []) + }) + + it('drops assignments with the zone', async () => { + const doomed = { ...testCase, id: 4089, zone: 'doomed.example.com', nameservers: [nsA.id] } + await Zone.destroy({ id: doomed.id }) + await Zone.create(doomed) + assert.deepEqual((await Zone.get({ id: doomed.id }))[0].nameservers, [nsA.id]) + + await Zone.destroy({ id: doomed.id }) + await Zone.create({ ...doomed, nameservers: undefined }) + assert.deepEqual((await Zone.get({ id: doomed.id }))[0].nameservers, []) + await Zone.destroy({ id: doomed.id }) + }) + + it('assigns nothing to a zone that does not exist', async () => { + const missing = { ...testCase, id: 4089, zone: 'missing.example.com' } + await Zone.destroy({ id: missing.id }) + assert.equal(await Zone.put({ id: missing.id, nameservers: [nsA.id] }), false) + + await Zone.create(missing) + assert.deepEqual((await Zone.get({ id: missing.id }))[0].nameservers, []) + await Zone.destroy({ id: missing.id }) + }) + + it('leaves one of two concurrent replacements, not both', async () => { + await Promise.all([ + Zone.put({ id: testCase.id, nameservers: [nsA.id] }), + Zone.put({ id: testCase.id, nameservers: [nsB.id] }), + ]) + const [z] = await Zone.get({ id: testCase.id }) + assert.ok( + [[nsA.id], [nsB.id]].some((set) => set.join() === z.nameservers.join()), + z.nameservers.join(), + ) + await Zone.put({ id: testCase.id, nameservers: [] }) + }) }) it('gets zone by name', async () => { const g = await Zone.get({ zone: testCase.zone }) delete g[0].last_modified - assert.deepEqual(g[0], testCase) + assert.deepEqual(g[0], { ...testCase, nameservers: [] }) }) it('changes a zone', async () => { diff --git a/routes/zone.js b/routes/zone.js index e19c5e8..5435145 100644 --- a/routes/zone.js +++ b/routes/zone.js @@ -4,6 +4,11 @@ import Zone from '../lib/zone/index.js' import Group from '../lib/group/index.js' import { meta } from '../lib/util.js' +async function usableNameservers(gid) { + const [group] = await Group.get({ id: gid }) + return (group?.permissions?.nameserver?.usable ?? []).map(Number).filter((nsid) => nsid > 0) +} + function ZoneRoutes(server) { server.route([ { @@ -87,7 +92,10 @@ function ZoneRoutes(server) { tags: ['api'], }, handler: async (request, h) => { - const id = await Zone.create(request.payload) + const zone = { ...request.payload } + // NicTool 2 offered the group's usable nameservers as a new zone's default + zone.nameservers ??= await usableNameservers(zone.gid) + const id = await Zone.create(zone) const zones = await Zone.get({ id }) diff --git a/routes/zone.test.js b/routes/zone.test.js index 1120ab3..e2f704f 100644 --- a/routes/zone.test.js +++ b/routes/zone.test.js @@ -5,16 +5,23 @@ import { init } from './index.js' import Group from '../lib/group/index.js' import User from '../lib/user/index.js' import Zone from '../lib/zone/index.js' +import Nameserver from '../lib/nameserver/index.js' import groupCase from './test/group.json' with { type: 'json' } import userCase from './test/user.json' with { type: 'json' } import nsCase from './test/zone.json' with { type: 'json' } +import nameserverCase from './test/nameserver.json' with { type: 'json' } let server let case2Id const subGroup = { id: 4090, parent_gid: groupCase.id, name: 'sub.route.example.com' } const subZone = { ...nsCase, id: 4091, gid: subGroup.id, zone: 'sub.route.example.com.' } +const nsLow = { ...nameserverCase, id: 4086, name: 'low.route.example.com.' } +const nsHigh = { ...nameserverCase, id: 4087, name: 'high.route.example.com.', ttl: 900 } +const assignedZone = { ...nsCase, gid: subGroup.id, zone: 'assigned.route.example.com.' } +delete assignedZone.id +let assignedId before(async () => { await Zone.destroy({ id: nsCase.id }) @@ -28,11 +35,17 @@ before(async () => { await Zone.create(nsCase, { ifExists: 'return' }) await Group.create(subGroup) await Zone.create(subZone) + for (const ns of [nsLow, nsHigh]) { + await Nameserver.destroy({ id: ns.id }) + await Nameserver.create(ns) + } server = await init() }) after(async () => { if (case2Id) await Zone.destroy({ id: case2Id }) + if (assignedId) await Zone.destroy({ id: assignedId }) + for (const ns of [nsLow, nsHigh]) await Nameserver.destroy({ id: ns.id }) await Zone.destroy({ id: subZone.id }) await Group.destroy({ id: subGroup.id }) await server.stop() @@ -194,6 +207,47 @@ describe('zone routes', () => { assert.ok(zones.includes(subZone.zone), 'subgroup zone present') }) + describe('nameservers', () => { + const post = (payload) => server.inject({ method: 'POST', url: '/zone', headers: auth.headers, payload }) + + it('POST /zone defaults to the group usable nameservers', async () => { + assert.ok(await Group.put({ id: subGroup.id, usable_ns: [nsHigh.id, nsLow.id] })) + const res = await post(assignedZone) + assert.equal(res.statusCode, 201) + assert.deepEqual(res.result.zone[0].nameservers, [nsLow.id, nsHigh.id]) + assignedId = res.result.zone[0].id + }) + + it(`GET /zone/{id}/ns lists the apex NS set, SOA primary first`, async () => { + const res = await server.inject({ method: 'GET', url: `/zone/${assignedId}/ns`, headers: auth.headers }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.ns, [ + { owner: assignedZone.zone, ttl: nsLow.ttl, dname: nsLow.name }, + { owner: assignedZone.zone, ttl: nsHigh.ttl, dname: nsHigh.name }, + ]) + }) + + it('PUT /zone/{id} replaces the assignments', async () => { + const res = await server.inject({ + method: 'PUT', + url: `/zone/${assignedId}`, + headers: auth.headers, + payload: { nameservers: [nsHigh.id] }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.result.zone[0].nameservers, [nsHigh.id]) + }) + + it('POST /zone keeps an explicit empty set', async () => { + await Zone.destroy({ id: assignedId }) + const res = await post({ ...assignedZone, nameservers: [] }) + assert.equal(res.statusCode, 201) + assert.deepEqual(res.result.zone[0].nameservers, []) + assignedId = res.result.zone[0].id + await Group.put({ id: subGroup.id, usable_ns: [] }) + }) + }) + it('DELETE /session', async () => { const res = await server.inject({ method: 'DELETE',