From 30b3e7ea3de7d9b05743ff55844324c0f06aefc9 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 19:44:56 -0700 Subject: [PATCH 1/5] sql: lowercase existing zone names once Zone names are matched exactly in their canonical form, lowercase without the trailing dot. 2.x keeps the case it is given, so a zone it stored in capitals couldn't be found by its name and didn't block a second zone of that name. sql/upgrade/07 rewrites those names, keeping last_modified. A live zone whose canonical name another live zone already has is left alone and listed for an admin to resolve. --- CHANGELOG.md | 1 + sql/schema.test.js | 46 +++++++++++++++++++++++++ sql/upgrade/07_lowercase_zone_names.sql | 35 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 sql/upgrade/07_lowercase_zone_names.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index b96fc9a..605e49d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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 - fix: one live zone per name, ignoring case and a trailing dot +- fix(sql): `sql/upgrade/07_lowercase_zone_names.sql` stores existing zone names lowercase without the trailing dot ### [3.0.3] - 2026-07-27 diff --git a/sql/schema.test.js b/sql/schema.test.js index d3066d5..89c677a 100644 --- a/sql/schema.test.js +++ b/sql/schema.test.js @@ -199,6 +199,52 @@ describe('sql schema', () => { ) }) + it('lowercases zone names, leaving live zones that would share a name', async () => { + const migration = await fs.readFile(path.join(sqlDir, 'upgrade', '07_lowercase_zone_names.sql'), 'utf8') + const suffix = `lowercase-${Date.now()}.test` + const rows = { + upper: [`Upper.${suffix}`, 0], + dotted: [`dotted.${suffix}.`, 0], + taken: [`Taken.${suffix}`, 0], + takenBy: [`taken.${suffix}`, 0], + deleted: [`Gone.${suffix}`, 1], + liveBeside: [`gone.${suffix}`, 0], + } + const ids = {} + for (const [key, [zone, deleted]] of Object.entries(rows)) { + const [r] = await conn.query( + "INSERT INTO nt_zone (nt_group_id, zone, deleted, last_modified) VALUES (1, ?, ?, '2020-01-01')", + [zone, deleted], + ) + ids[key] = r.insertId + } + const read = async () => { + const [found] = await conn.query( + 'SELECT nt_zone_id, zone, last_modified FROM nt_zone WHERE nt_zone_id IN (?)', + [Object.values(ids)], + ) + return Object.fromEntries( + Object.entries(ids).map(([k, id]) => [k, found.find((r) => r.nt_zone_id === id)]), + ) + } + + try { + const before = await read() + await conn.query(migration) + const after = await read() + assert.equal(after.upper.zone, `upper.${suffix}`) + assert.equal(after.dotted.zone, `dotted.${suffix}`) + assert.equal(after.taken.zone, `Taken.${suffix}`, 'a live zone keeps a name another live zone has') + assert.equal(after.deleted.zone, `gone.${suffix}`, 'a deleted zone may share a name') + assert.deepEqual(after.upper.last_modified, before.upper.last_modified) + + await assert.doesNotReject(() => conn.query(migration), 'the migration is not re-runnable') + assert.deepEqual(await read(), after) + } finally { + await conn.query('DELETE FROM nt_zone WHERE nt_zone_id IN (?)', [Object.values(ids)]) + } + }) + it('adds the v3 export types to an existing database, and again on a re-run', async () => { const migration = await fs.readFile( path.join(sqlDir, 'upgrade', '06_add_nameserver_export_types.sql'), diff --git a/sql/upgrade/07_lowercase_zone_names.sql b/sql/upgrade/07_lowercase_zone_names.sql new file mode 100644 index 0000000..8faa577 --- /dev/null +++ b/sql/upgrade/07_lowercase_zone_names.sql @@ -0,0 +1,35 @@ +# Copyright 2004-2026 The Network People, Inc. +# +# Stores every zone name in the form NicTool 3 matches: lowercase, without +# the trailing dot. 2.x keeps the case it was given, so such a zone can't be +# found by its name and doesn't block a second zone of the same name. +# +# A live zone is left alone when another live zone has the same canonical +# name; the final SELECT lists those for an admin to rename or delete. DNS +# names are case-insensitive, so the zone itself is unchanged, and +# last_modified is kept so it doesn't read as edited. +# +# Idempotent, so it is safe to re-run: +# +# mysql nictool < 07_lowercase_zone_names.sql + +CREATE TEMPORARY TABLE nt_zone_name_taken AS +SELECT LOWER(TRIM(TRAILING '.' FROM zone)) AS canonical + FROM nt_zone + WHERE deleted = 0 + GROUP BY canonical +HAVING COUNT(*) > 1; + +UPDATE nt_zone + SET zone = LOWER(TRIM(TRAILING '.' FROM zone)), + last_modified = last_modified + WHERE zone <> LOWER(TRIM(TRAILING '.' FROM zone)) + AND (deleted = 1 + OR LOWER(TRIM(TRAILING '.' FROM zone)) NOT IN (SELECT canonical FROM nt_zone_name_taken)); + +DROP TEMPORARY TABLE nt_zone_name_taken; + +SELECT nt_zone_id, zone AS `live zone sharing its name with another` + FROM nt_zone + WHERE deleted = 0 + AND zone <> LOWER(TRIM(TRAILING '.' FROM zone)); From 90844aa674be88a0227e816a15cdc3a053fc0020 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 20:07:20 -0700 Subject: [PATCH 2/5] sql: say which 2.x zones keep their case, and that NicTool 3 misses them --- sql/upgrade/07_lowercase_zone_names.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sql/upgrade/07_lowercase_zone_names.sql b/sql/upgrade/07_lowercase_zone_names.sql index 8faa577..92e222a 100644 --- a/sql/upgrade/07_lowercase_zone_names.sql +++ b/sql/upgrade/07_lowercase_zone_names.sql @@ -1,8 +1,10 @@ # Copyright 2004-2026 The Network People, Inc. # # Stores every zone name in the form NicTool 3 matches: lowercase, without -# the trailing dot. 2.x keeps the case it was given, so such a zone can't be -# found by its name and doesn't block a second zone of the same name. +# the trailing dot. A zone created through the 2.x API or web UI keeps the +# case it was given (2.x imports lowercase it). NicTool 3 matches names +# exactly, so it can't find such a zone by name, and the zone doesn't block +# a second one of the same name. # # A live zone is left alone when another live zone has the same canonical # name; the final SELECT lists those for an admin to rename or delete. DNS From 99b8758e68d491350bb3219a971141310eb434fa Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 20:16:57 -0700 Subject: [PATCH 3/5] prose --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1552b15..2f38458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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 - fix: one live zone per name, ignoring case and a trailing dot -- fix(sql): `sql/upgrade/07_lowercase_zone_names.sql` stores existing zone names lowercase without the trailing dot +- fix(sql): store existing zone names lowercase without the trailing dot ### [3.0.3] - 2026-07-27 From 2582320b2dcde2e5b037f81885a7315d3e6e1bf1 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 20:21:07 -0700 Subject: [PATCH 4/5] sql: db_version 2.42, matching NicTool 2.42's zone name rewrite NicTool/NicTool#373 makes 2.x store zone names lowercase and adds the same rewrite as upgrade 2.42. api/sql tracks the 2.x schema, so a new install declares 2.42. --- sql/12_nt_options.sql | 2 +- sql/schema.test.js | 2 +- sql/upgrade/07_lowercase_zone_names.sql | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sql/12_nt_options.sql b/sql/12_nt_options.sql index 1850801..6997884 100644 --- a/sql/12_nt_options.sql +++ b/sql/12_nt_options.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS nt_options ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; INSERT IGNORE INTO `nt_options` -VALUES (1,'db_version','2.41'), +VALUES (1,'db_version','2.42'), (2,'session_timeout','45'), (3,'default_group','NicTool') ; diff --git a/sql/schema.test.js b/sql/schema.test.js index 89c677a..f0772f9 100644 --- a/sql/schema.test.js +++ b/sql/schema.test.js @@ -263,6 +263,6 @@ describe('sql schema', () => { 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'/) + assert.match(sql, /'db_version','2\.42'/) }) }) diff --git a/sql/upgrade/07_lowercase_zone_names.sql b/sql/upgrade/07_lowercase_zone_names.sql index 92e222a..cc0f2a8 100644 --- a/sql/upgrade/07_lowercase_zone_names.sql +++ b/sql/upgrade/07_lowercase_zone_names.sql @@ -4,7 +4,8 @@ # the trailing dot. A zone created through the 2.x API or web UI keeps the # case it was given (2.x imports lowercase it). NicTool 3 matches names # exactly, so it can't find such a zone by name, and the zone doesn't block -# a second one of the same name. +# a second one of the same name. NicTool 2.42's sql/upgrade.pl does the +# same rewrite. # # A live zone is left alone when another live zone has the same canonical # name; the final SELECT lists those for an admin to rename or delete. DNS From ee00609ae926ff3404087292da879a8ceee55c79 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 20:27:58 -0700 Subject: [PATCH 5/5] sql: list every live zone sharing a name, not only the skipped spelling The report selected live names still not canonical, so it showed the spelling the rewrite skipped but not the zone it collides with, and nothing for two live zones already spelled the same. It now lists every live member of a name more than one live zone shares, before the temporary table is dropped. --- sql/schema.test.js | 12 +++++++++++- sql/upgrade/07_lowercase_zone_names.sql | 10 ++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/sql/schema.test.js b/sql/schema.test.js index f0772f9..aef47b1 100644 --- a/sql/schema.test.js +++ b/sql/schema.test.js @@ -209,6 +209,8 @@ describe('sql schema', () => { takenBy: [`taken.${suffix}`, 0], deleted: [`Gone.${suffix}`, 1], liveBeside: [`gone.${suffix}`, 0], + same: [`same.${suffix}`, 0], + sameAgain: [`same.${suffix}`, 0], } const ids = {} for (const [key, [zone, deleted]] of Object.entries(rows)) { @@ -230,7 +232,7 @@ describe('sql schema', () => { try { const before = await read() - await conn.query(migration) + const [results] = await conn.query(migration) const after = await read() assert.equal(after.upper.zone, `upper.${suffix}`) assert.equal(after.dotted.zone, `dotted.${suffix}`) @@ -238,6 +240,14 @@ describe('sql schema', () => { assert.equal(after.deleted.zone, `gone.${suffix}`, 'a deleted zone may share a name') assert.deepEqual(after.upper.last_modified, before.upper.last_modified) + const reported = results.find(Array.isArray).map((r) => r.nt_zone_id) + const ours = Object.entries(ids).filter(([, id]) => reported.includes(id)) + assert.deepEqual( + ours.map(([k]) => k), + ['taken', 'takenBy', 'same', 'sameAgain'], + 'every live zone sharing a name, whatever its spelling', + ) + await assert.doesNotReject(() => conn.query(migration), 'the migration is not re-runnable') assert.deepEqual(await read(), after) } finally { diff --git a/sql/upgrade/07_lowercase_zone_names.sql b/sql/upgrade/07_lowercase_zone_names.sql index cc0f2a8..b876297 100644 --- a/sql/upgrade/07_lowercase_zone_names.sql +++ b/sql/upgrade/07_lowercase_zone_names.sql @@ -8,7 +8,8 @@ # same rewrite. # # A live zone is left alone when another live zone has the same canonical -# name; the final SELECT lists those for an admin to rename or delete. DNS +# name. The SELECT at the end lists every live zone that shares its name with +# another, whatever its spelling, for an admin to rename or delete. DNS # names are case-insensitive, so the zone itself is unchanged, and # last_modified is kept so it doesn't read as edited. # @@ -30,9 +31,10 @@ UPDATE nt_zone AND (deleted = 1 OR LOWER(TRIM(TRAILING '.' FROM zone)) NOT IN (SELECT canonical FROM nt_zone_name_taken)); -DROP TEMPORARY TABLE nt_zone_name_taken; - SELECT nt_zone_id, zone AS `live zone sharing its name with another` FROM nt_zone WHERE deleted = 0 - AND zone <> LOWER(TRIM(TRAILING '.' FROM zone)); + AND LOWER(TRIM(TRAILING '.' FROM zone)) IN (SELECT canonical FROM nt_zone_name_taken) + ORDER BY LOWER(TRIM(TRAILING '.' FROM zone)), nt_zone_id; + +DROP TEMPORARY TABLE nt_zone_name_taken;