From f083ce70f5f34d02ca2502cf6ec50eaa7fb80c51 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 21:47:20 -0700 Subject: [PATCH 1/3] sql: give record names that spell their zone its lowercase spelling 07 lowercased zone names, but 2.x stored '@' as "." in the zone's own case, so records still spelled the old name: BandL-horizon.com. in zone bandl-horizon.com. powerdns-pipe matches record names exactly, so it never found them, and the web UI showed the wrong owner. 08 rewrites the zone part of absolute names, keeping the host's case, and a bare name equal to the zone. NicTool 2.42 does the same rewrite. --- CHANGELOG.md | 1 + sql/schema.test.js | 46 +++++++++++++++++++ .../08_lowercase_record_zone_suffix.sql | 29 ++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 sql/upgrade/08_lowercase_record_zone_suffix.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcb612..5378a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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): store existing zone names lowercase without the trailing dot +- fix(sql): `sql/upgrade/08_lowercase_record_zone_suffix.sql` gives record names that spell their zone its lowercase spelling - fix: one random, self describing session row per login, expired ones swept at login, and mysql throttles the activity touch - fix: a token is valid only while its session row exists, so logout revokes it, and any authenticated request counts as activity diff --git a/sql/schema.test.js b/sql/schema.test.js index aef47b1..eda1557 100644 --- a/sql/schema.test.js +++ b/sql/schema.test.js @@ -255,6 +255,52 @@ describe('sql schema', () => { } }) + it('gives record names that spell their zone its canonical spelling', async () => { + const migration = await fs.readFile( + path.join(sqlDir, 'upgrade', '08_lowercase_record_zone_suffix.sql'), + 'utf8', + ) + const zone = `records-${Date.now()}.test` + const Zone = zone.replace('records', 'Records').replace('.test', '.TEST') + const [z] = await conn.query('INSERT INTO nt_zone (nt_group_id, zone) VALUES (1, ?)', [zone]) + const names = { + apex: [`${Zone}.`, `${zone}.`], + host: [`WWW.${Zone}.`, `WWW.${zone}.`], + bare: [Zone, zone], + relative: ['www', 'www'], + outside: [`not${Zone}.`, `not${Zone}.`], + canonical: [`mail.${zone}.`, `mail.${zone}.`], + } + const ids = {} + for (const [key, [name]] of Object.entries(names)) { + const [r] = await conn.query( + "INSERT INTO nt_zone_record (nt_zone_id, name, ttl, type_id, address) VALUES (?, ?, 60, 1, '192.0.2.1')", + [z.insertId, name], + ) + ids[key] = r.insertId + } + const read = async () => { + const [rows] = await conn.query( + 'SELECT nt_zone_record_id, name FROM nt_zone_record WHERE nt_zone_id = ?', + [z.insertId], + ) + return Object.fromEntries( + Object.entries(ids).map(([k, id]) => [k, rows.find((r) => r.nt_zone_record_id === id).name]), + ) + } + + try { + await conn.query(migration) + const after = await read() + assert.deepEqual(after, Object.fromEntries(Object.entries(names).map(([k, [, want]]) => [k, want]))) + + 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 = ?', [z.insertId]) + } + }) + 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/08_lowercase_record_zone_suffix.sql b/sql/upgrade/08_lowercase_record_zone_suffix.sql new file mode 100644 index 0000000..1f48a95 --- /dev/null +++ b/sql/upgrade/08_lowercase_record_zone_suffix.sql @@ -0,0 +1,29 @@ +# Copyright 2004-2026 The Network People, Inc. +# +# Gives record names that spell their zone the zone's canonical spelling, +# after 07 lowercased the zones. 2.x stored '@' as "." in the zone's +# own case, and powerdns-pipe and the web UI compare record names to the zone +# exactly. Absolute names keep their host's case: www.Example.com. becomes +# www.example.com. A bare name equal to the zone (Example.com) becomes the +# zone. NicTool 2.42's sql/upgrade.pl does the same rewrite. +# +# Idempotent, so it is safe to re-run: +# +# mysql nictool < 08_lowercase_record_zone_suffix.sql + +UPDATE nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + SET r.name = CONCAT( + LEFT(r.name, CHAR_LENGTH(r.name) - CHAR_LENGTH(LOWER(TRIM(TRAILING '.' FROM z.zone))) - 1), + LOWER(TRIM(TRAILING '.' FROM z.zone)), '.') + WHERE (LOWER(r.name) = CONCAT(LOWER(TRIM(TRAILING '.' FROM z.zone)), '.') + OR RIGHT(LOWER(r.name), CHAR_LENGTH(LOWER(TRIM(TRAILING '.' FROM z.zone))) + 2) + = CONCAT('.', LOWER(TRIM(TRAILING '.' FROM z.zone)), '.')) + AND RIGHT(r.name, CHAR_LENGTH(LOWER(TRIM(TRAILING '.' FROM z.zone))) + 1) + <> CONCAT(LOWER(TRIM(TRAILING '.' FROM z.zone)), '.'); + +UPDATE nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + SET r.name = LOWER(TRIM(TRAILING '.' FROM z.zone)) + WHERE LOWER(r.name) = LOWER(TRIM(TRAILING '.' FROM z.zone)) + AND r.name <> LOWER(TRIM(TRAILING '.' FROM z.zone)); From c35a21fe109e0e252bf8e77ed6528be0081eacb3 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 21:47:48 -0700 Subject: [PATCH 2/3] changelog: shorten the record name entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5378a05..1148bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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): store existing zone names lowercase without the trailing dot -- fix(sql): `sql/upgrade/08_lowercase_record_zone_suffix.sql` gives record names that spell their zone its lowercase spelling +- fix(sql): lowercase the zone in record names - fix: one random, self describing session row per login, expired ones swept at login, and mysql throttles the activity touch - fix: a token is valid only while its session row exists, so logout revokes it, and any authenticated request counts as activity From 3f70f737dce8da51a95f8d795b52c31975b5d37d Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 21:54:05 -0700 Subject: [PATCH 3/3] sql: fold the record name rewrite into 07 3.x has no releases, so 07 can still change. In one upgrade the record rewrite follows the zone rewrite and compares with the zone as stored, so a live zone 07 leaves in capitals keeps records that match it; a separate 08 lowercased those records regardless. --- CHANGELOG.md | 3 +- sql/schema.test.js | 44 +++++++++++-------- sql/upgrade/07_lowercase_zone_names.sql | 24 +++++++++- .../08_lowercase_record_zone_suffix.sql | 29 ------------ 4 files changed, 49 insertions(+), 51 deletions(-) delete mode 100644 sql/upgrade/08_lowercase_record_zone_suffix.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 1148bbe..41f0360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +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): store existing zone names lowercase without the trailing dot -- fix(sql): lowercase the zone in record names +- fix(sql): lowercase existing zone names, and the zone in record names - fix: one random, self describing session row per login, expired ones swept at login, and mysql throttles the activity touch - fix: a token is valid only while its session row exists, so logout revokes it, and any authenticated request counts as activity diff --git a/sql/schema.test.js b/sql/schema.test.js index eda1557..df36928 100644 --- a/sql/schema.test.js +++ b/sql/schema.test.js @@ -255,34 +255,42 @@ describe('sql schema', () => { } }) - it('gives record names that spell their zone its canonical spelling', async () => { - const migration = await fs.readFile( - path.join(sqlDir, 'upgrade', '08_lowercase_record_zone_suffix.sql'), - 'utf8', - ) + it('gives record names that spell their zone its new spelling', async () => { + const migration = await fs.readFile(path.join(sqlDir, 'upgrade', '07_lowercase_zone_names.sql'), 'utf8') const zone = `records-${Date.now()}.test` const Zone = zone.replace('records', 'Records').replace('.test', '.TEST') - const [z] = await conn.query('INSERT INTO nt_zone (nt_group_id, zone) VALUES (1, ?)', [zone]) + const zoneIds = [] + const addZone = async (name) => { + const [z] = await conn.query('INSERT INTO nt_zone (nt_group_id, zone) VALUES (1, ?)', [name]) + zoneIds.push(z.insertId) + return z.insertId + } + const zid = await addZone(Zone) + // a live pair 07 leaves alone, so its records must keep its spelling + const skipped = await addZone(`Pair-${zone}`) + await addZone(`pair-${zone}`) + const names = { - apex: [`${Zone}.`, `${zone}.`], - host: [`WWW.${Zone}.`, `WWW.${zone}.`], - bare: [Zone, zone], - relative: ['www', 'www'], - outside: [`not${Zone}.`, `not${Zone}.`], - canonical: [`mail.${zone}.`, `mail.${zone}.`], + apex: [zid, `${Zone}.`, `${zone}.`], + host: [zid, `WWW.${Zone}.`, `WWW.${zone}.`], + bare: [zid, Zone, zone], + relative: [zid, 'www', 'www'], + outside: [zid, `not${Zone}.`, `not${Zone}.`], + canonical: [zid, `mail.${zone}.`, `mail.${zone}.`], + inSkippedZone: [skipped, `Pair-${zone}.`, `Pair-${zone}.`], } const ids = {} - for (const [key, [name]] of Object.entries(names)) { + for (const [key, [inZone, name]] of Object.entries(names)) { const [r] = await conn.query( "INSERT INTO nt_zone_record (nt_zone_id, name, ttl, type_id, address) VALUES (?, ?, 60, 1, '192.0.2.1')", - [z.insertId, name], + [inZone, name], ) ids[key] = r.insertId } const read = async () => { const [rows] = await conn.query( - 'SELECT nt_zone_record_id, name FROM nt_zone_record WHERE nt_zone_id = ?', - [z.insertId], + 'SELECT nt_zone_record_id, name FROM nt_zone_record WHERE nt_zone_id IN (?)', + [zoneIds], ) return Object.fromEntries( Object.entries(ids).map(([k, id]) => [k, rows.find((r) => r.nt_zone_record_id === id).name]), @@ -292,12 +300,12 @@ describe('sql schema', () => { try { await conn.query(migration) const after = await read() - assert.deepEqual(after, Object.fromEntries(Object.entries(names).map(([k, [, want]]) => [k, want]))) + assert.deepEqual(after, Object.fromEntries(Object.entries(names).map(([k, [, , want]]) => [k, want]))) 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 = ?', [z.insertId]) + await conn.query('DELETE FROM nt_zone WHERE nt_zone_id IN (?)', [zoneIds]) } }) diff --git a/sql/upgrade/07_lowercase_zone_names.sql b/sql/upgrade/07_lowercase_zone_names.sql index b876297..a8620e7 100644 --- a/sql/upgrade/07_lowercase_zone_names.sql +++ b/sql/upgrade/07_lowercase_zone_names.sql @@ -4,8 +4,13 @@ # 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. NicTool 2.42's sql/upgrade.pl does the -# same rewrite. +# a second one of the same name. +# +# Record names that spell their zone then take its new spelling. 2.x stored +# '@' as "." in the zone's own case, and powerdns-pipe and the web UI +# compare record names to the zone exactly. Absolute names keep their host's +# case (www.Example.com. becomes www.example.com.), and a bare name equal to +# the zone becomes the zone. NicTool 2.42's sql/upgrade.pl does the same. # # A live zone is left alone when another live zone has the same canonical # name. The SELECT at the end lists every live zone that shares its name with @@ -31,6 +36,21 @@ UPDATE nt_zone AND (deleted = 1 OR LOWER(TRIM(TRAILING '.' FROM zone)) NOT IN (SELECT canonical FROM nt_zone_name_taken)); +# compared with z.zone as stored, so a zone left alone above, still in +# capitals, matches no lowercased name and its records keep its spelling +UPDATE nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + SET r.name = CONCAT(LEFT(r.name, CHAR_LENGTH(r.name) - CHAR_LENGTH(z.zone) - 1), z.zone, '.') + WHERE (LOWER(r.name) = CONCAT(z.zone, '.') + OR RIGHT(LOWER(r.name), CHAR_LENGTH(z.zone) + 2) = CONCAT('.', z.zone, '.')) + AND RIGHT(r.name, CHAR_LENGTH(z.zone) + 1) <> CONCAT(z.zone, '.'); + +UPDATE nt_zone_record r + JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id + SET r.name = z.zone + WHERE LOWER(r.name) = z.zone + AND r.name <> z.zone; + SELECT nt_zone_id, zone AS `live zone sharing its name with another` FROM nt_zone WHERE deleted = 0 diff --git a/sql/upgrade/08_lowercase_record_zone_suffix.sql b/sql/upgrade/08_lowercase_record_zone_suffix.sql deleted file mode 100644 index 1f48a95..0000000 --- a/sql/upgrade/08_lowercase_record_zone_suffix.sql +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright 2004-2026 The Network People, Inc. -# -# Gives record names that spell their zone the zone's canonical spelling, -# after 07 lowercased the zones. 2.x stored '@' as "." in the zone's -# own case, and powerdns-pipe and the web UI compare record names to the zone -# exactly. Absolute names keep their host's case: www.Example.com. becomes -# www.example.com. A bare name equal to the zone (Example.com) becomes the -# zone. NicTool 2.42's sql/upgrade.pl does the same rewrite. -# -# Idempotent, so it is safe to re-run: -# -# mysql nictool < 08_lowercase_record_zone_suffix.sql - -UPDATE nt_zone_record r - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - SET r.name = CONCAT( - LEFT(r.name, CHAR_LENGTH(r.name) - CHAR_LENGTH(LOWER(TRIM(TRAILING '.' FROM z.zone))) - 1), - LOWER(TRIM(TRAILING '.' FROM z.zone)), '.') - WHERE (LOWER(r.name) = CONCAT(LOWER(TRIM(TRAILING '.' FROM z.zone)), '.') - OR RIGHT(LOWER(r.name), CHAR_LENGTH(LOWER(TRIM(TRAILING '.' FROM z.zone))) + 2) - = CONCAT('.', LOWER(TRIM(TRAILING '.' FROM z.zone)), '.')) - AND RIGHT(r.name, CHAR_LENGTH(LOWER(TRIM(TRAILING '.' FROM z.zone))) + 1) - <> CONCAT(LOWER(TRIM(TRAILING '.' FROM z.zone)), '.'); - -UPDATE nt_zone_record r - JOIN nt_zone z ON z.nt_zone_id = r.nt_zone_id - SET r.name = LOWER(TRIM(TRAILING '.' FROM z.zone)) - WHERE LOWER(r.name) = LOWER(TRIM(TRAILING '.' FROM z.zone)) - AND r.name <> LOWER(TRIM(TRAILING '.' FROM z.zone));