diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c1fbee..a0f5f83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,20 +7,13 @@ on: env: CI: true +permissions: + contents: read + jobs: lint: uses: NicTool/.github/.github/workflows/lint.yml@main - permissions: - contents: read - - coverage: - uses: NicTool/.github/.github/workflows/coverage.yml@main - secrets: inherit - permissions: - contents: read test: uses: NicTool/.github/.github/workflows/test.yml@main secrets: inherit - permissions: - contents: read diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..348329d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,15 @@ +name: Coverage + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +permissions: + contents: read + +jobs: + coverage: + uses: NicTool/.github/.github/workflows/coverage.yml@main + secrets: inherit diff --git a/.gitmodules b/.gitmodules index a8e94cb..1647327 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule ".release"] path = .release - url = git@github.com:msimerson/.release.git + url = https://github.com/msimerson/.release.git diff --git a/.release b/.release index a6911a9..9069128 160000 --- a/.release +++ b/.release @@ -1 +1 @@ -Subproject commit a6911a90f1b15486fb319d844341421c78035b2a +Subproject commit 9069128a8cbbab42c2b50989c630700397c4aba1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e65643f..6e1fda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### [0.8.10] - 2026-04-13 - delegation: add validation schemas for GET, POST, PUT, DELETE (#22) +- feat: add nameserver type native +- zone_rec: add pagination options - deps: bump versions ### [0.8.9] - 2026-03-29 @@ -118,7 +120,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [0.5.0]: https://github.com/NicTool/validate/releases/tag/0.5.0 [0.6.0]: https://github.com/NicTool/validate/releases/tag/0.6.0 [0.6.1]: https://github.com/NicTool/validate/releases/tag/0.6.1 -[0.6.3]: https://github.com/NicTool/validate/releases/tag/0.6.3 +[0.6.3]: https://github.com/NicTool/validate/releases/tag/v0.6.3 [0.7.0]: https://github.com/NicTool/validate/releases/tag/0.7.0 [0.7.1]: https://github.com/NicTool/validate/releases/tag/v0.7.1 [0.7.2]: https://github.com/NicTool/validate/releases/tag/v0.7.2 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 549c44c..4d55feb 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -2,8 +2,8 @@ This handcrafted artisanal software is brought to you by: -|
msimerson (29)| -| :---: | +|
msimerson (30) |
burning-bush-dev (1) | +| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is generated by [.release](https://github.com/msimerson/.release). Contribute to this project to get your GitHub profile included here. diff --git a/lib/nameserver.js b/lib/nameserver.js index dce8a4d..9230fcb 100644 --- a/lib/nameserver.js +++ b/lib/nameserver.js @@ -18,10 +18,51 @@ export const type = Joi.string().valid( 'maradns', 'powerdns', 'dynect', + 'native', ) export const remote_login = Joi.string().empty('').max(127) +// --- Runtime configuration: listen sockets, publisher, transport, dnssec --- + +export const listen = Joi.array().items( + Joi.object({ + address: Joi.alternatives(shared.ipv4, shared.ipv6).required(), + port: shared.uint16.min(1).required(), + proto: Joi.string().valid('udp', 'tcp').default('udp'), + }), +) + +export const publisher = Joi.object({ + type: Joi.string().valid('memory', 'rfc1035', 'tinydns-cdb', 'powerdns-db').required(), + path: Joi.string().empty('').max(1024), + database: Joi.string().empty('').max(255), +}).unknown(true) + +export const transport = Joi.object({ + type: Joi.string().valid('noop', 'axfr', 'rsync', 'db-replication').required(), + interval: shared.uint32.default(300), + cooldown: shared.uint16.default(5), + master: Joi.string().empty('').max(255), + tsig_key: Joi.string().empty('').max(255), + remote: Joi.string().empty('').max(255), + ssh_key: Joi.string().empty('').max(1024), +}).unknown(true) + +export const dnssec = Joi.object({ + enabled: Joi.boolean().default(false), + algorithm: Joi.string().valid( + 'RSASHA256', + 'RSASHA512', + 'ECDSAP256SHA256', + 'ECDSAP384SHA384', + 'ED25519', + 'ED448', + ), + keyset: Joi.string().empty('').max(1024), + nsec3: Joi.boolean().default(false), +}) + export const v3 = Joi.object({ id: id, gid: shared.uint32.required(), @@ -33,6 +74,11 @@ export const v3 = Joi.object({ remote_login: remote_login, logdir: Joi.string().empty('').max(255), datadir: Joi.string().empty('').min(2).max(255), + engine: type, + listen: listen, + publisher: publisher, + transport: transport, + dnssec: dnssec, export: Joi.object({ interval: shared.uint16, serials: Joi.boolean(), @@ -58,6 +104,11 @@ export const PUT = Joi.object({ address: shared.ipv4, address6: shared.ipv6.empty(''), remote_login: remote_login, + engine: type, + listen: listen, + publisher: publisher, + transport: transport, + dnssec: dnssec, export: Joi.object({ interval: shared.uint16, serials: Joi.boolean(), diff --git a/lib/nameserver.test.js b/lib/nameserver.test.js index e84ac15..0e56376 100644 --- a/lib/nameserver.test.js +++ b/lib/nameserver.test.js @@ -78,7 +78,7 @@ describe('nameserver', function () { assert.deepEqual(value, testCase) }) - for (const n of ['bind', 'djbdns', 'knot', 'nsd', 'maradns', 'powerdns', 'dynect']) { + for (const n of ['bind', 'djbdns', 'knot', 'nsd', 'maradns', 'powerdns', 'dynect', 'native']) { it(`accepts valid: ${n}`, () => { const testCase = JSON.parse(JSON.stringify(testNS)) testCase.export.type = n @@ -99,13 +99,96 @@ describe('nameserver', function () { assert.strictEqual( error.message, - '"export.type" must be one of [bind, djbdns, knot, nsd, maradns, powerdns, dynect]', + '"export.type" must be one of [bind, djbdns, knot, nsd, maradns, powerdns, dynect, native]', ) assert.deepEqual(value, testCase) }) } }) + describe('listen', function () { + it('accepts an array of {address, port, proto}', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.listen = [ + { address: '127.0.0.1', port: 53, proto: 'udp' }, + { address: '127.0.0.1', port: 53, proto: 'tcp' }, + ] + const { error } = schema.validate(testCase) + assert.ifError(error) + }) + + it('rejects an invalid proto', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.listen = [{ address: '127.0.0.1', port: 53, proto: 'http' }] + const { error } = schema.validate(testCase) + assert.match(error.message, /"listen\[0\]\.proto" must be one of/) + }) + + it('rejects out-of-range port', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.listen = [{ address: '127.0.0.1', port: 70000 }] + const { error } = schema.validate(testCase) + assert.match(error.message, /"listen\[0\]\.port"/) + }) + }) + + describe('publisher', function () { + for (const t of ['memory', 'rfc1035', 'tinydns-cdb', 'powerdns-db']) { + it(`accepts type=${t}`, () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.publisher = { type: t } + const { error } = schema.validate(testCase) + assert.ifError(error) + }) + } + + it('rejects unknown publisher type', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.publisher = { type: 'floppy' } + const { error } = schema.validate(testCase) + assert.match(error.message, /"publisher\.type" must be one of/) + }) + }) + + describe('transport', function () { + for (const t of ['noop', 'axfr', 'rsync', 'db-replication']) { + it(`accepts type=${t}`, () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.transport = { type: t, interval: 0, cooldown: 2 } + const { error } = schema.validate(testCase) + assert.ifError(error) + }) + } + + it('rejects unknown transport type', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.transport = { type: 'sneakernet' } + const { error } = schema.validate(testCase) + assert.match(error.message, /"transport\.type" must be one of/) + }) + }) + + describe('dnssec', function () { + it('accepts a valid dnssec block', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.dnssec = { + enabled: true, + algorithm: 'ECDSAP256SHA256', + keyset: './keys/ns1', + nsec3: true, + } + const { error } = schema.validate(testCase) + assert.ifError(error) + }) + + it('rejects an unknown algorithm', () => { + const testCase = JSON.parse(JSON.stringify(testNS)) + testCase.dnssec = { enabled: true, algorithm: 'ROT13' } + const { error } = schema.validate(testCase) + assert.match(error.message, /"dnssec\.algorithm" must be one of/) + }) + }) + describe('gid', function () { it(`accepts valid`, () => { const testCase = JSON.parse(JSON.stringify(testNS)) diff --git a/lib/zone_record.js b/lib/zone_record.js index d5be903..5fb0699 100644 --- a/lib/zone_record.js +++ b/lib/zone_record.js @@ -206,7 +206,7 @@ export const v3 = Joi.object({ 'time signed': [Joi.number(), Joi.string().empty('')], - timestamp: Joi.date(), + timestamp: Joi.date().allow(null), txt: Joi.string().empty(''), @@ -226,6 +226,11 @@ export const v3 = Joi.object({ export const GET_req = Joi.object({ id: shared.uint32, zid: shared.uint32, + search: Joi.string().max(255).allow(''), + limit: shared.uint32, + offset: shared.uint32, + sort_by: Joi.string().valid('id', 'owner', 'type', 'ttl'), + sort_dir: Joi.string().lowercase().valid('asc', 'desc'), deleted: Joi.boolean(), }).options({ allowUnknown: true }) diff --git a/lib/zone_record.test.js b/lib/zone_record.test.js index 858e8da..bd050d8 100644 --- a/lib/zone_record.test.js +++ b/lib/zone_record.test.js @@ -181,4 +181,57 @@ describe('zone_record', function () { assert.deepEqual(missing, []) }) }) + + describe('timestamp', function () { + const base = { + id: 1, + zid: 1, + owner: 'www.example.com.', + ttl: 300, + type: 'A', + address: '1.2.3.4', + } + + it('accepts a null or omitted timestamp (the store drops invalid ones)', () => { + assert.equal(schema.validate({ ...base, timestamp: null }).error, undefined) + assert.equal(schema.validate(base).error, undefined) + }) + + it('accepts a real date', () => { + const { error } = schema.validate({ ...base, timestamp: '2026-01-19T00:00:00Z' }) + assert.equal(error, undefined) + }) + }) + + describe('GET_req', function () { + const GET_req = zoneRecordSchema.GET_req + + it('accepts pagination, search and sort params', () => { + const { error } = GET_req.validate({ + zid: 5, + search: 'www', + limit: 50, + offset: 100, + sort_by: 'owner', + sort_dir: 'desc', + }) + assert.equal(error, undefined) + }) + + it('lowercases sort_dir and accepts an empty search', () => { + const { value, error } = GET_req.validate({ zid: 5, sort_dir: 'DESC', search: '' }) + assert.equal(error, undefined) + assert.equal(value.sort_dir, 'desc') + }) + + it('rejects an unknown sort_by column', () => { + const { error } = GET_req.validate({ zid: 5, sort_by: 'address' }) + assert.ok(error) + }) + + it('rejects a negative offset', () => { + const { error } = GET_req.validate({ zid: 5, offset: -1 }) + assert.ok(error) + }) + }) }) diff --git a/package.json b/package.json index c6f0584..527a649 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "versions": "npx npm-dep-mgr check", "versions:fix": "npx npm-dep-mgr update", "watch": "node --test --watch", - "test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test" + "test:coverage": "node --test --experimental-test-coverage", + "test:coverage:lcov": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info" }, "repository": { "type": "git", @@ -48,13 +49,13 @@ }, "homepage": "https://github.com/NicTool/validate#readme", "devDependencies": { - "eslint": "^10.2.0", + "eslint": "^10.7.0", "@eslint/js": "^10.0.1", "eslint-config-prettier": "^10.1.8", - "globals": "^17.5.0" + "globals": "^17.7.0" }, "dependencies": { - "joi": "^18.1.2", + "joi": "^18.2.3", "joi-password": "^4.3.0" }, "prettier": { @@ -63,4 +64,4 @@ "trailingComma": "all", "printWidth": 100 } -} \ No newline at end of file +}