Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 36 additions & 7 deletions lib/mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

let _debug

function shapeRows(query, rows) {
return /^(REPLACE|INSERT) INTO/.test(query) ? rows.insertId : rows
}

class Mysql {
constructor() {
if (Mysql._instance) return Mysql._instance
Expand All @@ -12,6 +16,12 @@
}

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 }

Expand All @@ -20,13 +30,34 @@
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 ?? [])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will handle in a follow up PR

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) {
Expand All @@ -43,9 +74,7 @@
console.log(rows)
}

if (/^(REPLACE|INSERT) INTO/.test(query)) return rows.insertId

return rows
return shapeRows(query, rows)
}

insert(table, params = {}) {
Expand Down
8 changes: 8 additions & 0 deletions lib/zone/store/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -49,3 +53,7 @@ class ZoneBase {
}

export default ZoneBase

export function sortNameservers(ids) {
return [...new Set((ids ?? []).map(Number))].sort((a, b) => a - b)
}
19 changes: 8 additions & 11 deletions lib/zone/store/file.js
Original file line number Diff line number Diff line change
@@ -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 }

Expand All @@ -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
Expand All @@ -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
})
Expand Down Expand Up @@ -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
})
}
Expand All @@ -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)
Comment thread
msimerson marked this conversation as resolved.
.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 }))
}
}

Expand Down
22 changes: 15 additions & 7 deletions lib/zone/store/file.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
])
})

Expand All @@ -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), [])
})
})
76 changes: 66 additions & 10 deletions lib/zone/store/mysql.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
Expand All @@ -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],
)
}
Expand Down
Loading
Loading