pending-dns 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.github/codeql/codeql-config.yml +11 -0
  2. package/.github/workflows/codeql.yml +52 -0
  3. package/.github/workflows/deploy.yml +16 -3
  4. package/.github/workflows/release.yaml +43 -0
  5. package/.github/workflows/test.yml +75 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/CHANGELOG.md +8 -0
  8. package/CLAUDE.md +97 -0
  9. package/README.md +28 -5
  10. package/SECURITY.md +88 -0
  11. package/SECURITY.txt +27 -0
  12. package/bin/pending-dns.js +1 -1
  13. package/config/default.toml +13 -0
  14. package/config/test.toml +25 -0
  15. package/eslint.config.js +38 -0
  16. package/lib/api-server.js +13 -6
  17. package/lib/cached-resolver.js +5 -3
  18. package/lib/certs.js +11 -4
  19. package/lib/dns-handler.js +46 -14
  20. package/lib/dns-server.js +30 -18
  21. package/lib/dns-tcp-server.js +1 -1
  22. package/lib/dns-udp-server.js +1 -1
  23. package/lib/logger.js +3 -0
  24. package/lib/public-server.js +20 -2
  25. package/lib/sentry.js +72 -0
  26. package/lib/tools.js +1 -1
  27. package/lib/zone-store.js +4 -4
  28. package/package.json +43 -33
  29. package/release-please-config.json +13 -0
  30. package/server.js +5 -24
  31. package/systemd/pending-dns.service +4 -4
  32. package/test/api.test.js +139 -0
  33. package/test/cached-resolver.test.js +57 -0
  34. package/test/certs.test.js +34 -0
  35. package/test/dns-handler.test.js +140 -0
  36. package/test/dns-server.test.js +69 -0
  37. package/test/helpers.js +25 -0
  38. package/test/sentry.test.js +21 -0
  39. package/test/tools.test.js +48 -0
  40. package/test/zone-store.test.js +209 -0
  41. package/workers/api.js +3 -1
  42. package/workers/dns.js +2 -24
  43. package/workers/health.js +3 -26
  44. package/workers/public.js +3 -25
  45. package/.eslintrc +0 -14
  46. package/Gruntfile.js +0 -16
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const { zoneStore, ZoneStore, allowedTypes, allowedTags } = require('../lib/zone-store');
7
+ const { flushTestDb, closeDb } = require('./helpers');
8
+
9
+ test.after(async () => {
10
+ await closeDb();
11
+ });
12
+
13
+ test.beforeEach(async () => {
14
+ await flushTestDb();
15
+ });
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Pure helpers (no Redis)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ test('module exports the allowed record types and CAA tags', () => {
22
+ assert.deepEqual(allowedTypes, ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'URL', 'NS']);
23
+ assert.deepEqual(allowedTags, ['issue', 'issuewild', 'iodef']);
24
+ });
25
+
26
+ test('getFullId / parseFullId round-trip', () => {
27
+ const id = zoneStore.getFullId('com.example.www', 'CNAME', 'abc123');
28
+ const parsed = zoneStore.parseFullId(id);
29
+ assert.deepEqual(parsed, { name: 'com.example.www', type: 'CNAME', hid: 'abc123' });
30
+ });
31
+
32
+ test('getFullId produces URL-safe ids (no +, /, =)', () => {
33
+ // Use values likely to produce base64 padding / special chars
34
+ const id = zoneStore.getFullId('com.example.subsubsub', 'AAAA', 'zzzzz');
35
+ assert.match(id, /^[A-Za-z0-9_-]+$/);
36
+ });
37
+
38
+ test('parseFullId returns empty object for garbage input', () => {
39
+ const parsed = zoneStore.parseFullId('!!!not base64!!!');
40
+ // name/type/hid will be undefined for malformed ids
41
+ assert.equal(parsed.hid, undefined);
42
+ });
43
+
44
+ test('domainToName reverses labels and nameToDomain restores them', () => {
45
+ assert.equal(zoneStore.domainToName('www.example.com'), 'com.example.www');
46
+ assert.equal(zoneStore.nameToDomain('com.example.www'), 'www.example.com');
47
+ // round trip
48
+ assert.equal(zoneStore.nameToDomain(zoneStore.domainToName('a.b.c.example.com')), 'a.b.c.example.com');
49
+ });
50
+
51
+ test('getsubdomain extracts the subdomain relative to a zone', () => {
52
+ assert.equal(zoneStore.getsubdomain('example.com', 'www.example.com'), 'www');
53
+ assert.equal(zoneStore.getsubdomain('example.com', 'a.b.example.com'), 'a.b');
54
+ assert.equal(zoneStore.getsubdomain('example.com', 'example.com'), '');
55
+ // unrelated domain returned as-is
56
+ assert.equal(zoneStore.getsubdomain('example.com', 'other.org'), 'other.org');
57
+ });
58
+
59
+ test('formatValue shapes each record type for API output', () => {
60
+ const store = new ZoneStore();
61
+
62
+ assert.deepEqual(store.formatValue({ id: '1', type: 'A', value: ['1.2.3.4', false] }), {
63
+ id: '1',
64
+ type: 'A',
65
+ address: '1.2.3.4',
66
+ healthCheck: false
67
+ });
68
+
69
+ assert.deepEqual(store.formatValue({ id: '2', type: 'CNAME', zone: 'example.com', value: ['@'] }), {
70
+ id: '2',
71
+ type: 'CNAME',
72
+ target: 'example.com'
73
+ });
74
+
75
+ assert.deepEqual(store.formatValue({ id: '3', type: 'MX', value: ['mx.example.com', 10] }), {
76
+ id: '3',
77
+ type: 'MX',
78
+ exchange: 'mx.example.com',
79
+ priority: 10
80
+ });
81
+
82
+ assert.deepEqual(store.formatValue({ id: '4', type: 'CAA', value: ['letsencrypt.org', 'issue', 0] }), {
83
+ id: '4',
84
+ type: 'CAA',
85
+ value: 'letsencrypt.org',
86
+ tag: 'issue',
87
+ flags: 0
88
+ });
89
+
90
+ assert.deepEqual(store.formatValue({ id: '5', type: 'TXT', value: ['hello world'] }), {
91
+ id: '5',
92
+ type: 'TXT',
93
+ data: 'hello world'
94
+ });
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Redis-backed behaviour
99
+ // ---------------------------------------------------------------------------
100
+
101
+ test('add stores a record and list returns it', async () => {
102
+ const id = await zoneStore.add('example.com', '', 'A', ['1.2.3.4']);
103
+ assert.ok(id, 'add should return a record id');
104
+
105
+ const list = await zoneStore.list('example.com');
106
+ assert.equal(list.length, 1);
107
+ assert.equal(list[0].type, 'A');
108
+ assert.deepEqual(list[0].value, ['1.2.3.4']);
109
+ assert.equal(list[0].id, id);
110
+ });
111
+
112
+ test('add rejects unknown types and empty values', async () => {
113
+ assert.equal(await zoneStore.add('example.com', '', 'BOGUS', ['x']), false);
114
+ assert.equal(await zoneStore.add('example.com', '', 'A', []), false);
115
+ });
116
+
117
+ test('resolveZone / resolveDomainZone find the closest zone', async () => {
118
+ await zoneStore.add('example.com', '', 'A', ['1.2.3.4']);
119
+
120
+ assert.equal(await zoneStore.resolveDomainZone('example.com'), 'example.com');
121
+ assert.equal(await zoneStore.resolveDomainZone('www.example.com'), 'example.com');
122
+ assert.equal(await zoneStore.resolveDomainZone('deep.sub.example.com'), 'example.com');
123
+ assert.equal(await zoneStore.resolveDomainZone('nonexistent-zone.org'), false);
124
+ });
125
+
126
+ test('resolve returns exact records', async () => {
127
+ await zoneStore.add('example.com', 'www', 'CNAME', ['example.com']);
128
+ const res = await zoneStore.resolve('www.example.com', 'CNAME', false);
129
+ assert.ok(Array.isArray(res));
130
+ assert.equal(res.length, 1);
131
+ assert.deepEqual(res[0].value, ['example.com']);
132
+ });
133
+
134
+ test('resolve falls back to a one-level wildcard record', async () => {
135
+ await zoneStore.add('example.com', '*.test', 'CNAME', ['example.com']);
136
+ const res = await zoneStore.resolve('anything.test.example.com', 'CNAME', false);
137
+ assert.ok(Array.isArray(res));
138
+ assert.equal(res.length, 1);
139
+ assert.equal(res[0].wildcard, '*.test.example.com');
140
+ });
141
+
142
+ test('update keeps the id when only the value changes', async () => {
143
+ const id = await zoneStore.add('example.com', '', 'A', ['1.2.3.4']);
144
+ const newId = await zoneStore.update('example.com', id, '', 'A', ['5.6.7.8']);
145
+ assert.equal(newId, id, 'id is stable when domain and type are unchanged');
146
+
147
+ const res = await zoneStore.resolve('example.com', 'A', false);
148
+ assert.deepEqual(res[0].value, ['5.6.7.8']);
149
+ });
150
+
151
+ test('update changes the id when the type changes', async () => {
152
+ const id = await zoneStore.add('example.com', 'host', 'A', ['1.2.3.4']);
153
+ const newId = await zoneStore.update('example.com', id, 'host', 'AAAA', ['::1']);
154
+ assert.ok(newId);
155
+ assert.notEqual(newId, id);
156
+
157
+ // old A record is gone, new AAAA record exists
158
+ assert.equal(await zoneStore.resolve('host.example.com', 'A', false), false);
159
+ const res = await zoneStore.resolve('host.example.com', 'AAAA', false);
160
+ assert.deepEqual(res[0].value, ['::1']);
161
+ });
162
+
163
+ test('del removes a single record by id', async () => {
164
+ const id = await zoneStore.add('example.com', '', 'A', ['1.2.3.4']);
165
+ assert.equal(await zoneStore.del('example.com', id), true);
166
+ assert.equal((await zoneStore.list('example.com')).length, 0);
167
+ });
168
+
169
+ test('deleting one of several same-type records keeps the others listed', async () => {
170
+ // Regression test: deleting one entry from a record hash that still has
171
+ // other entries must NOT drop the whole record key from the zone listing.
172
+ const id1 = await zoneStore.add('example.com', '', 'A', ['1.1.1.1']);
173
+ const id2 = await zoneStore.add('example.com', '', 'A', ['2.2.2.2']);
174
+ assert.ok(id1 && id2);
175
+
176
+ assert.equal((await zoneStore.list('example.com')).length, 2);
177
+
178
+ assert.equal(await zoneStore.del('example.com', id1), true);
179
+
180
+ const list = await zoneStore.list('example.com');
181
+ assert.equal(list.length, 1, 'the remaining A record must still be listed');
182
+ assert.deepEqual(list[0].value, ['2.2.2.2']);
183
+
184
+ // resolve must also still find it
185
+ const res = await zoneStore.resolve('example.com', 'A', false);
186
+ assert.ok(res && res.length === 1);
187
+ assert.deepEqual(res[0].value, ['2.2.2.2']);
188
+ });
189
+
190
+ test('deleteDomain removes matching records and reports the count', async () => {
191
+ await zoneStore.add('example.com', 'multi', 'A', ['1.1.1.1']);
192
+ await zoneStore.add('example.com', 'multi', 'A', ['2.2.2.2']);
193
+
194
+ const deleted = await zoneStore.deleteDomain('multi.example.com', 'A');
195
+ assert.equal(deleted, 2);
196
+ assert.equal(await zoneStore.resolve('multi.example.com', 'A', false), false);
197
+ });
198
+
199
+ test('deleteDomain can match a specific value', async () => {
200
+ await zoneStore.add('example.com', 'pick', 'A', ['1.1.1.1']);
201
+ await zoneStore.add('example.com', 'pick', 'A', ['2.2.2.2']);
202
+
203
+ const deleted = await zoneStore.deleteDomain('pick.example.com', 'A', ['1.1.1.1']);
204
+ assert.equal(deleted, 1);
205
+
206
+ const res = await zoneStore.resolve('pick.example.com', 'A', false);
207
+ assert.equal(res.length, 1);
208
+ assert.deepEqual(res[0].value, ['2.2.2.2']);
209
+ });
package/workers/api.js CHANGED
@@ -27,7 +27,7 @@ const closeProcess = (code, errType, err) => {
27
27
  err
28
28
  });
29
29
 
30
- if (!logger.notifyError) {
30
+ if (!logger.errorReportingEnabled) {
31
31
  setTimeout(() => process.exit(code), 10);
32
32
  }
33
33
  };
@@ -37,6 +37,8 @@ process.on('unhandledRejection', err => closeProcess(2, 'unhandledRejection', er
37
37
  process.on('SIGTERM', () => closeProcess(0));
38
38
  process.on('SIGINT', () => closeProcess(0));
39
39
 
40
+ require('../lib/sentry').initSentry(workerName);
41
+
40
42
  const run = () => {
41
43
  require(`../lib/${workerName}-server.js`)()
42
44
  .then(() => {
package/workers/dns.js CHANGED
@@ -27,7 +27,7 @@ const closeProcess = (code, errType, err) => {
27
27
  err
28
28
  });
29
29
 
30
- if (!logger.notifyError) {
30
+ if (!logger.errorReportingEnabled) {
31
31
  setTimeout(() => process.exit(code), 10);
32
32
  }
33
33
  };
@@ -37,29 +37,7 @@ process.on('unhandledRejection', err => closeProcess(2, 'unhandledRejection', er
37
37
  process.on('SIGTERM', () => closeProcess(0));
38
38
  process.on('SIGINT', () => closeProcess(0));
39
39
 
40
- const packageData = require('../package.json');
41
- const Bugsnag = require('@bugsnag/js');
42
- if (process.env.BUGSNAG_API_KEY) {
43
- Bugsnag.start({
44
- apiKey: process.env.BUGSNAG_API_KEY,
45
- appVersion: packageData.version,
46
- logger: {
47
- debug(...args) {
48
- logger.debug({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
49
- },
50
- info(...args) {
51
- logger.debug({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
52
- },
53
- warn(...args) {
54
- logger.warn({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
55
- },
56
- error(...args) {
57
- logger.error({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
58
- }
59
- }
60
- });
61
- logger.notifyError = Bugsnag.notify.bind(Bugsnag);
62
- }
40
+ require('../lib/sentry').initSentry(workerName);
63
41
 
64
42
  const run = () => {
65
43
  require(`../lib/${workerName}-server.js`)()
package/workers/health.js CHANGED
@@ -27,7 +27,7 @@ const closeProcess = (code, errType, err) => {
27
27
  err
28
28
  });
29
29
 
30
- if (!logger.notifyError) {
30
+ if (!logger.errorReportingEnabled) {
31
31
  setTimeout(() => process.exit(code), 10);
32
32
  }
33
33
  };
@@ -37,30 +37,7 @@ process.on('unhandledRejection', err => closeProcess(2, 'unhandledRejection', er
37
37
  process.on('SIGTERM', () => closeProcess(0));
38
38
  process.on('SIGINT', () => closeProcess(0));
39
39
 
40
- const packageData = require('../package.json');
41
- const Bugsnag = require('@bugsnag/js');
42
-
43
- if (process.env.BUGSNAG_API_KEY) {
44
- Bugsnag.start({
45
- apiKey: process.env.BUGSNAG_API_KEY,
46
- appVersion: packageData.version,
47
- logger: {
48
- debug(...args) {
49
- logger.debug({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
50
- },
51
- info(...args) {
52
- logger.debug({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
53
- },
54
- warn(...args) {
55
- logger.warn({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
56
- },
57
- error(...args) {
58
- logger.error({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
59
- }
60
- }
61
- });
62
- logger.notifyError = Bugsnag.notify.bind(Bugsnag);
63
- }
40
+ require('../lib/sentry').initSentry(workerName);
64
41
 
65
42
  const run = () => {
66
43
  require(`../lib/${workerName}-worker.js`)()
@@ -111,6 +88,6 @@ if (cluster.isMaster) {
111
88
  });
112
89
  }
113
90
  } else {
114
- process.title = `postal-public:${workerName}`;
91
+ process.title = `pending-dns:${workerName}`;
115
92
  run();
116
93
  }
package/workers/public.js CHANGED
@@ -27,7 +27,7 @@ const closeProcess = (code, errType, err) => {
27
27
  err
28
28
  });
29
29
 
30
- if (!logger.notifyError) {
30
+ if (!logger.errorReportingEnabled) {
31
31
  setTimeout(() => process.exit(code), 10);
32
32
  }
33
33
  };
@@ -37,29 +37,7 @@ process.on('unhandledRejection', err => closeProcess(2, 'unhandledRejection', er
37
37
  process.on('SIGTERM', () => closeProcess(0));
38
38
  process.on('SIGINT', () => closeProcess(0));
39
39
 
40
- const packageData = require('../package.json');
41
- const Bugsnag = require('@bugsnag/js');
42
- if (process.env.BUGSNAG_API_KEY) {
43
- Bugsnag.start({
44
- apiKey: process.env.BUGSNAG_API_KEY,
45
- appVersion: packageData.version,
46
- logger: {
47
- debug(...args) {
48
- logger.debug({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
49
- },
50
- info(...args) {
51
- logger.debug({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
52
- },
53
- warn(...args) {
54
- logger.warn({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
55
- },
56
- error(...args) {
57
- logger.error({ msg: args.shift(), worker: workerName, source: 'bugsnag', args: args.length ? args : undefined });
58
- }
59
- }
60
- });
61
- logger.notifyError = Bugsnag.notify.bind(Bugsnag);
62
- }
40
+ require('../lib/sentry').initSentry(workerName);
63
41
 
64
42
  const run = () => {
65
43
  require(`../lib/${workerName}-server.js`)()
@@ -110,6 +88,6 @@ if (cluster.isMaster) {
110
88
  });
111
89
  }
112
90
  } else {
113
- process.title = `postal-public:${workerName}`;
91
+ process.title = `pending-dns:${workerName}`;
114
92
  run();
115
93
  }
package/.eslintrc DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "rules": {
3
- "indent": 0,
4
- "no-await-in-loop": 0,
5
- "require-atomic-updates": 0
6
- },
7
- "globals": {
8
- "BigInt": true
9
- },
10
- "extends": ["nodemailer", "prettier"],
11
- "parserOptions": {
12
- "ecmaVersion": 2018
13
- }
14
- }
package/Gruntfile.js DELETED
@@ -1,16 +0,0 @@
1
- 'use strict';
2
-
3
- module.exports = function (grunt) {
4
- // Project configuration.
5
- grunt.initConfig({
6
- eslint: {
7
- all: ['lib/**/*.js', 'server.js', 'routes/**/*.js', 'workers/**/*.js', 'Gruntfile.js']
8
- }
9
- });
10
-
11
- // Load the plugin(s)
12
- grunt.loadNpmTasks('grunt-eslint');
13
-
14
- // Tasks
15
- grunt.registerTask('default', ['eslint']);
16
- };