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 @@ -16,6 +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

### [3.0.3] - 2026-07-27

Expand Down
2 changes: 1 addition & 1 deletion sql/12_nt_options.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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')
;
58 changes: 57 additions & 1 deletion sql/schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,62 @@ 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],
same: [`same.${suffix}`, 0],
sameAgain: [`same.${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()
const [results] = 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)

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 {
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'),
Expand All @@ -217,6 +273,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'/)
})
})
40 changes: 40 additions & 0 deletions sql/upgrade/07_lowercase_zone_names.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2004-2026 The Network People, Inc.
#
# Stores every zone name in the form NicTool 3 matches: lowercase, without
# 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 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
# 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.
#
# 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));

SELECT nt_zone_id, zone AS `live zone sharing its name with another`
FROM nt_zone
WHERE deleted = 0
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;
Loading