pending-dns 1.2.5 → 1.4.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 (52) 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 +16 -0
  8. package/CLAUDE.md +109 -0
  9. package/README.md +111 -9
  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 +43 -0
  14. package/config/test.toml +35 -0
  15. package/eslint.config.js +38 -0
  16. package/lib/api-server.js +198 -23
  17. package/lib/cached-resolver.js +5 -3
  18. package/lib/certs.js +12 -20
  19. package/lib/dns-handler.js +362 -32
  20. package/lib/dns-server.js +120 -43
  21. package/lib/dns-tcp-server.js +1 -1
  22. package/lib/dns-udp-server.js +1 -1
  23. package/lib/dnssec-wire.js +321 -0
  24. package/lib/dnssec.js +461 -0
  25. package/lib/lock.js +37 -0
  26. package/lib/logger.js +3 -0
  27. package/lib/public-server.js +20 -2
  28. package/lib/sentry.js +72 -0
  29. package/lib/tools.js +1 -1
  30. package/lib/zone-store.js +90 -7
  31. package/package.json +46 -33
  32. package/release-please-config.json +14 -0
  33. package/server.js +5 -24
  34. package/systemd/pending-dns.service +4 -4
  35. package/test/api.test.js +231 -0
  36. package/test/cached-resolver.test.js +57 -0
  37. package/test/certs.test.js +34 -0
  38. package/test/dns-handler.test.js +171 -0
  39. package/test/dns-server.test.js +162 -0
  40. package/test/dnssec-handler.test.js +550 -0
  41. package/test/dnssec-wire.test.js +163 -0
  42. package/test/dnssec.test.js +213 -0
  43. package/test/helpers.js +27 -0
  44. package/test/sentry.test.js +21 -0
  45. package/test/tools.test.js +48 -0
  46. package/test/zone-store.test.js +245 -0
  47. package/workers/api.js +3 -1
  48. package/workers/dns.js +2 -24
  49. package/workers/health.js +3 -26
  50. package/workers/public.js +3 -25
  51. package/.eslintrc +0 -14
  52. package/Gruntfile.js +0 -16
package/lib/dnssec.js ADDED
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ // Online (live) DNSSEC signing. Per-zone keys live in Redis; RRsets are signed
4
+ // at query time and denial-of-existence is synthesized with compact NSEC
5
+ // "black lies" - no pre-signed zone, no stored RRSIG/NSEC chains.
6
+ //
7
+ // Signatures are always computed over the canonical wire form from
8
+ // dnssec-wire.js (uncompressed, lowercased), never over dns2's serialization.
9
+
10
+ const config = require('wild-config');
11
+ const crypto = require('crypto');
12
+ const util = require('util');
13
+ const punycode = require('punycode/');
14
+ const db = require('./db');
15
+ const { waitAcquireLock, releaseLock } = require('./lock');
16
+ const { zoneStore } = require('./zone-store');
17
+ const logger = require('./logger').child({ component: 'dnssec' });
18
+ const wire = require('./dnssec-wire');
19
+
20
+ const generateKeyPairAsync = util.promisify(crypto.generateKeyPair);
21
+
22
+ // Parsing a PEM into a KeyObject is relatively expensive; cache by PEM string
23
+ // (a PEM uniquely identifies its key, so this is always safe and never stale).
24
+ // Bounded LRU so the cache cannot grow without limit as keys roll over: on a
25
+ // hit we refresh recency, and once over the cap we drop the oldest entry.
26
+ // Bounded-LRU insert: (re)insert the key as most-recent and evict the oldest
27
+ // entry once over the cap. Re-inserting an existing key refreshes its recency.
28
+ const lruSet = (map, key, value, max) => {
29
+ map.delete(key);
30
+ map.set(key, value);
31
+ if (map.size > max) {
32
+ map.delete(map.keys().next().value);
33
+ }
34
+ };
35
+
36
+ const PRIVATE_KEY_CACHE_MAX = 1000;
37
+ const privateKeyCache = new Map();
38
+ const cachedPrivateKey = pem => {
39
+ let key = privateKeyCache.get(pem) || crypto.createPrivateKey(pem);
40
+ lruSet(privateKeyCache, pem, key, PRIVATE_KEY_CACHE_MAX);
41
+ return key;
42
+ };
43
+
44
+ // `<name>` is the label-reversed zone name, matching the d:<name>:z keyspace.
45
+ const zoneName = zone => zoneStore.domainToName(zone);
46
+ const stateKey = name => `d:dnssec:${name}`;
47
+ const keysKey = name => `d:dnssec:${name}:keys`;
48
+
49
+ const dnssecConfig = () => config.dnssec || {};
50
+
51
+ // keyTag is a number when freshly generated but a string after a JSON/Redis round
52
+ // trip or as a route param, so compare key tags through one string coercion.
53
+ const sameTag = (a, b) => String(a) === String(b);
54
+
55
+ // A key is "preferred" within its algorithm when it is the recorded active key
56
+ // (by tag) or, lacking that, one explicitly marked active. Shared by getSigner
57
+ // so the per-algorithm selection rule lives in one place.
58
+ const isPreferredKey = (key, activeKeyTag) => sameTag(key.keyTag, activeKeyTag) || key.status === 'active';
59
+
60
+ // Assembled-signer cache (zone reversed-name -> { signer, expires }). getSigner
61
+ // is on the DNS hot path and otherwise re-reads + re-parses the zone keys on
62
+ // every DO query (including null for unsigned zones). The API and DNS subsystems
63
+ // run as separate workers, so a mutation in the API worker cannot invalidate
64
+ // this cache directly - a short TTL ([dnssec] signerCacheTtl) bounds staleness
65
+ // and DNSSEC key changes are rare. Within one process the mutating calls below
66
+ // invalidate the entry explicitly.
67
+ const SIGNER_CACHE_MAX = 1000;
68
+ const signerCache = new Map();
69
+ const invalidateSigner = name => signerCache.delete(name);
70
+
71
+ const getKeysRaw = async (name, client) => {
72
+ const stored = await (client || db.redisRead).hgetall(keysKey(name));
73
+ const keys = [];
74
+ for (const hid of Object.keys(stored || {})) {
75
+ try {
76
+ keys.push(Object.assign({ hid }, JSON.parse(stored[hid])));
77
+ } catch (err) {
78
+ logger.error({ msg: 'Failed to parse DNSSEC key', name, hid, err });
79
+ }
80
+ }
81
+ return keys;
82
+ };
83
+
84
+ // Generate a single Combined Signing Key (KSK+ZSK in one) for a zone.
85
+ const generateCsk = async (zone, algorithm) => {
86
+ const alg = wire.ALGS[algorithm];
87
+ if (!alg) {
88
+ throw new Error(`Unsupported DNSSEC algorithm ${algorithm}`);
89
+ }
90
+
91
+ const { publicKey, privateKey } = await generateKeyPairAsync(
92
+ alg.generate.type,
93
+ Object.assign({}, alg.generate.options, {
94
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
95
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
96
+ })
97
+ );
98
+
99
+ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
100
+ const pubkey = alg.pubkeyFromJwk(jwk);
101
+
102
+ const flags = wire.DNSKEY_FLAGS_CSK;
103
+ const protocol = wire.DNSKEY_PROTOCOL;
104
+ const dnskeyRdata = wire.encodeDNSKEYRdata({ flags, protocol, algorithm, pubkey });
105
+
106
+ return {
107
+ hid: crypto.randomUUID(),
108
+ keyTag: wire.dnskeyKeyTag(dnskeyRdata),
109
+ algorithm,
110
+ flags,
111
+ protocol,
112
+ privateKey,
113
+ publicKeyDnssec: pubkey.toString('base64'),
114
+ status: 'active',
115
+ created: new Date().toISOString()
116
+ };
117
+ };
118
+
119
+ // DNSKEY RDATA Buffer for a stored key.
120
+ const keyDnskeyRdata = key =>
121
+ wire.encodeDNSKEYRdata({
122
+ flags: key.flags,
123
+ protocol: key.protocol,
124
+ algorithm: key.algorithm,
125
+ pubkey: Buffer.from(key.publicKeyDnssec, 'base64')
126
+ });
127
+
128
+ // Presentation + component form of a key's DS record (digest computed by us;
129
+ // the operator pastes this at the registrar).
130
+ const keyDsRecord = (zone, key) => {
131
+ const digestType = wire.ALGS[key.algorithm].dsDigestType;
132
+ // The DS digest covers the canonical (punycode A-label) DNSKEY owner name,
133
+ // regardless of whether the operator passed an IDN zone in Unicode form.
134
+ const digest = wire.dsDigest(punycode.toASCII(zone), keyDnskeyRdata(key), digestType).toString('hex');
135
+ return {
136
+ keyTag: key.keyTag,
137
+ algorithm: key.algorithm,
138
+ digestType,
139
+ digest,
140
+ presentation: `${key.keyTag} ${key.algorithm} ${digestType} ${digest}`
141
+ };
142
+ };
143
+
144
+ // DNSKEY presentation form of a key.
145
+ const keyDnskeyRecord = key => ({
146
+ flags: key.flags,
147
+ protocol: key.protocol,
148
+ algorithm: key.algorithm,
149
+ publicKey: key.publicKeyDnssec,
150
+ keyTag: key.keyTag,
151
+ presentation: `${key.flags} ${key.protocol} ${key.algorithm} ${key.publicKeyDnssec}`
152
+ });
153
+
154
+ // Pick the active key for a zone from its stored key set + state hash.
155
+ const activeKey = (state, keys) => {
156
+ const activeKeyTag = state && state.activeKeyTag;
157
+ return keys.find(key => sameTag(key.keyTag, activeKeyTag)) || keys.find(key => key.status === 'active') || keys[0] || null;
158
+ };
159
+
160
+ const summarize = (zone, state, keys) => {
161
+ const active = activeKey(state, keys);
162
+ return {
163
+ zone,
164
+ enabled: !!(state && state.enabled === '1'),
165
+ // Report the active key's algorithm/keyTag (during an algorithm rollover
166
+ // several keys of different algorithms coexist); ds/dnskey list them all.
167
+ algorithm: active ? active.algorithm : Number((state && state.algorithm) || dnssecConfig().algorithm || 13),
168
+ keyTag: active ? active.keyTag : null,
169
+ ds: keys.map(key => keyDsRecord(zone, key)),
170
+ dnskey: keys.map(keyDnskeyRecord)
171
+ };
172
+ };
173
+
174
+ // Enable DNSSEC for a zone, generating a CSK on first call. Idempotent for the
175
+ // same algorithm. Requesting a different algorithm performs an algorithm
176
+ // rollover: a new CSK is generated and made active while the old key is kept, so
177
+ // the zone keeps being signed with every algorithm in the DNSKEY RRset
178
+ // (RFC 6840 5.11) until the operator removes the old key (removeKey) after
179
+ // updating the parent DS.
180
+ const enableZone = async (zone, opts = {}) => {
181
+ // The query-time signing path is additionally gated on config.dnssec.enabled
182
+ // (see dns-handler.js). Refuse here when the global switch is off so the API
183
+ // never hands back a DS the server will not honour - publishing that DS would
184
+ // SERVFAIL the whole zone.
185
+ if (!dnssecConfig().enabled) {
186
+ throw Object.assign(new Error('DNSSEC is disabled globally; set [dnssec] enabled = true to enable signing'), {
187
+ statusCode: 400,
188
+ code: 'DnssecGloballyDisabled'
189
+ });
190
+ }
191
+
192
+ const name = zoneName(zone);
193
+ const requestedAlgorithm = opts.algorithm || dnssecConfig().algorithm || 13;
194
+
195
+ const lock = await waitAcquireLock(`dnssec:${name}`, 60 * 1000, 60 * 1000);
196
+ if (!lock || !lock.success) {
197
+ throw new Error('Failed to acquire DNSSEC lock');
198
+ }
199
+ try {
200
+ // Read from the write client: this is a mutating, lock-held section and
201
+ // must not act on a possibly-stale replica view.
202
+ let keys = await getKeysRaw(name, db.redisWrite);
203
+ // state is only needed to preserve the active key when no algorithm is
204
+ // requested; skip the read on the explicit-algorithm (rollover) path.
205
+ const state = opts.algorithm ? null : await db.redisWrite.hgetall(stateKey(name));
206
+
207
+ const haveRequestedAlg = keys.some(key => Number(key.algorithm) === Number(requestedAlgorithm));
208
+ // Generate a key when none exist, or when the caller explicitly asked for
209
+ // an algorithm we do not yet have (rollover - keep the existing keys).
210
+ if (!keys.length || (opts.algorithm && !haveRequestedAlg)) {
211
+ const key = await generateCsk(zone, requestedAlgorithm);
212
+ await db.redisWrite.hset(keysKey(name), key.hid, JSON.stringify(key));
213
+ keys.push(key);
214
+ logger.info({ msg: 'Generated DNSSEC key', zone, algorithm: requestedAlgorithm, keyTag: key.keyTag, rollover: keys.length > 1 });
215
+ }
216
+
217
+ // When an algorithm was explicitly requested, that key becomes active (the
218
+ // freshly generated one on a rollover). With no algorithm in the request,
219
+ // preserve the current active key rather than reverting to the config
220
+ // default - re-enabling a zone mid-rollover must not flip the active key
221
+ // away from the one the operator rolled to.
222
+ const active = opts.algorithm ? keys.find(key => Number(key.algorithm) === Number(requestedAlgorithm)) || keys[0] : activeKey(state, keys);
223
+ await db.redisWrite.hmset(stateKey(name), {
224
+ enabled: '1',
225
+ algorithm: String(active.algorithm),
226
+ activeKeyTag: String(active.keyTag),
227
+ created: new Date().toISOString()
228
+ });
229
+ invalidateSigner(name);
230
+
231
+ return summarize(zone, { enabled: '1', activeKeyTag: String(active.keyTag) }, keys);
232
+ } finally {
233
+ await releaseLock(lock);
234
+ }
235
+ };
236
+
237
+ // Remove a (non-active) signing key from a zone, e.g. to finish an algorithm
238
+ // rollover once the new DS is published at the parent. Refuses to remove the
239
+ // active key or the last remaining key so a signed zone never loses its signer.
240
+ const removeKey = async (zone, keyTag) => {
241
+ const name = zoneName(zone);
242
+
243
+ const lock = await waitAcquireLock(`dnssec:${name}`, 60 * 1000, 60 * 1000);
244
+ if (!lock || !lock.success) {
245
+ throw new Error('Failed to acquire DNSSEC lock');
246
+ }
247
+ try {
248
+ // Read from the write client inside the lock: the active-key guard below
249
+ // must not be decided on a stale replica view, or it could permit
250
+ // removing the key that is actually active.
251
+ const keys = await getKeysRaw(name, db.redisWrite);
252
+ const target = keys.find(key => sameTag(key.keyTag, keyTag));
253
+ if (!target) {
254
+ return false;
255
+ }
256
+ if (keys.length <= 1) {
257
+ throw Object.assign(new Error('Cannot remove the last remaining key; disable the zone instead'), {
258
+ statusCode: 400,
259
+ code: 'CannotRemoveLastKey'
260
+ });
261
+ }
262
+ const state = await db.redisWrite.hgetall(stateKey(name));
263
+ if (state && sameTag(state.activeKeyTag, keyTag)) {
264
+ throw Object.assign(new Error('Cannot remove the active key; roll to a new key first'), {
265
+ statusCode: 400,
266
+ code: 'CannotRemoveActiveKey'
267
+ });
268
+ }
269
+ await db.redisWrite.hdel(keysKey(name), target.hid);
270
+ invalidateSigner(name);
271
+ logger.info({ msg: 'Removed DNSSEC key', zone, keyTag });
272
+ return true;
273
+ } finally {
274
+ await releaseLock(lock);
275
+ }
276
+ };
277
+
278
+ const disableZone = async zone => {
279
+ const name = zoneName(zone);
280
+
281
+ // Take the same per-zone lock enableZone/removeKey hold so a disable cannot race
282
+ // a concurrent enable into a lost update - which could leave the zone unsigned
283
+ // after the operator already published the DS returned by enable, SERVFAILing
284
+ // the zone at validators.
285
+ const lock = await waitAcquireLock(`dnssec:${name}`, 60 * 1000, 60 * 1000);
286
+ if (!lock || !lock.success) {
287
+ throw new Error('Failed to acquire DNSSEC lock');
288
+ }
289
+ try {
290
+ await db.redisWrite.hset(stateKey(name), 'enabled', '0');
291
+ invalidateSigner(name);
292
+ return true;
293
+ } finally {
294
+ await releaseLock(lock);
295
+ }
296
+ };
297
+
298
+ const getZoneStatus = async zone => {
299
+ const name = zoneName(zone);
300
+ // Read from the master, not a replica: a status check right after enable or a
301
+ // rollover must not return a stale/empty DS due to replication lag, since the
302
+ // operator copies this DS to the registrar.
303
+ const state = await db.redisWrite.hgetall(stateKey(name));
304
+ const keys = await getKeysRaw(name, db.redisWrite);
305
+ return summarize(zone, state, keys);
306
+ };
307
+
308
+ const isZoneSigned = async zone => {
309
+ const name = zoneName(zone);
310
+ // Master read, consistent with getZoneStatus and the lock-held mutators.
311
+ const enabled = await db.redisWrite.hget(stateKey(name), 'enabled');
312
+ return enabled === '1';
313
+ };
314
+
315
+ // Resolve the signing context for a zone (or null when not signed). Returns one
316
+ // signing key per distinct algorithm present (the active key within each
317
+ // algorithm) so every RRset gets an RRSIG from every algorithm in the DNSKEY
318
+ // RRset (RFC 6840 5.11). `zone` is the punycode A-label form - it is the RRSIG
319
+ // signer name and the DNSKEY owner, so it must be canonical even for IDN zones.
320
+ const buildSigner = async (zone, name) => {
321
+ // state and keys are independent reads; fetch them together to halve the
322
+ // signer-cache-miss latency on the DO query hot path.
323
+ const [state, keys] = await Promise.all([db.redisRead.hgetall(stateKey(name)), getKeysRaw(name)]);
324
+ if (!state || state.enabled !== '1') {
325
+ return null;
326
+ }
327
+ if (!keys.length) {
328
+ return null;
329
+ }
330
+
331
+ // One key per algorithm; prefer the active key (by tag, then status) within
332
+ // an algorithm so a same-algorithm key set still picks deterministically.
333
+ const byAlgorithm = new Map();
334
+ for (const key of keys) {
335
+ const alg = Number(key.algorithm);
336
+ const current = byAlgorithm.get(alg);
337
+ if (!current || (isPreferredKey(key, state.activeKeyTag) && !isPreferredKey(current, state.activeKeyTag))) {
338
+ byAlgorithm.set(alg, key);
339
+ }
340
+ }
341
+
342
+ const signingKeys = [...byAlgorithm.values()].map(key => ({
343
+ algorithm: Number(key.algorithm),
344
+ keyTag: key.keyTag,
345
+ flags: key.flags,
346
+ protocol: key.protocol,
347
+ publicKeyDnssec: key.publicKeyDnssec,
348
+ privateKeyObj: cachedPrivateKey(key.privateKey)
349
+ }));
350
+ if (!signingKeys.length) {
351
+ return null;
352
+ }
353
+
354
+ return {
355
+ zone: punycode.toASCII(zone), // also the RRSIG signer name (canonical A-label form)
356
+ activeKeyTag: state.activeKeyTag,
357
+ keys: signingKeys
358
+ };
359
+ };
360
+
361
+ const getSigner = async zone => {
362
+ const name = zoneName(zone);
363
+
364
+ const cached = signerCache.get(name);
365
+ if (cached && cached.expires > Date.now()) {
366
+ lruSet(signerCache, name, cached, SIGNER_CACHE_MAX); // refresh recency
367
+ return cached.signer;
368
+ }
369
+
370
+ const signer = await buildSigner(zone, name);
371
+
372
+ const ttlSeconds = dnssecConfig().signerCacheTtl;
373
+ const ttl = (typeof ttlSeconds === 'number' ? ttlSeconds : 5) * 1000;
374
+ if (ttl > 0) {
375
+ lruSet(signerCache, name, { signer, expires: Date.now() + ttl }, SIGNER_CACHE_MAX);
376
+ }
377
+ return signer;
378
+ };
379
+
380
+ // Sign one RRset, returning one RRSIG resource (raw type 46) per signing key -
381
+ // i.e. one per algorithm in the zone (RFC 6840 5.11). `rrs` are normalized
382
+ // answer objects sharing the owner name + numeric type.
383
+ //
384
+ // `wireOwner` is the on-the-wire owner name the RRSIG is attached to (for a
385
+ // wildcard expansion this is the expanded query name). `signingOwner` is the
386
+ // name the signature is computed over: the wildcard owner (`*.zone`) for an
387
+ // expansion, otherwise the same as `wireOwner`. The RRSIG Labels field is the
388
+ // label count of `signingOwner`, so a validator can reconstruct the wildcard
389
+ // owner and verify the signature (RFC 4035 5.3.2).
390
+ const signRRset = (signer, wireOwner, signingOwner, typeNum, ttl, rrs) => {
391
+ const now = Math.floor(Date.now() / 1000);
392
+ const cfg = dnssecConfig();
393
+ const labels = wire.nameLabelCount(signingOwner);
394
+ // typeof guards so a configured 0 (e.g. no inception backdating) is honored
395
+ // rather than silently reverting to the default. These do not vary per signing
396
+ // key, so compute them once instead of inside the per-key loop below.
397
+ const expiration = now + (typeof cfg.signatureValidity === 'number' ? cfg.signatureValidity : 604800);
398
+ const inception = now - (typeof cfg.inceptionSkew === 'number' ? cfg.inceptionSkew : 3600);
399
+
400
+ const sortedRdata = rrs.map(rr => wire.canonicalRdata(typeNum, rr)).sort(wire.compareCanonicalRdata);
401
+ // Remove duplicate RRs from the RRset before signing (RFC 4034 6.3). A
402
+ // validator de-duplicates before verifying, so signing over duplicates - e.g.
403
+ // the same A value stored twice, or one SOA per question in a multi-question
404
+ // NODATA packet - would produce an RRSIG that fails validation.
405
+ const canonical = sortedRdata
406
+ .filter((rdata, i) => i === 0 || !rdata.equals(sortedRdata[i - 1]))
407
+ .map(rdata => wire.canonicalRR(signingOwner, typeNum, 1, ttl, rdata));
408
+ // The canonical RRset bytes are identical for every signing key; concatenate
409
+ // them once and only prepend the per-key RRSIG preimage inside the loop.
410
+ const canonicalRRs = Buffer.concat(canonical);
411
+
412
+ return signer.keys.map(key => {
413
+ const fields = {
414
+ typeCovered: typeNum,
415
+ algorithm: key.algorithm,
416
+ labels,
417
+ originalTtl: ttl,
418
+ expiration,
419
+ inception,
420
+ keyTag: key.keyTag,
421
+ signerName: signer.zone
422
+ };
423
+ const signature = wire.ALGS[key.algorithm].sign(Buffer.concat([wire.encodeRRSIGSigningPreimage(fields), canonicalRRs]), key.privateKeyObj);
424
+ return {
425
+ name: wireOwner,
426
+ type: wire.TYPE.RRSIG,
427
+ class: 1,
428
+ ttl,
429
+ data: wire.encodeRRSIGRdata(fields, signature)
430
+ };
431
+ });
432
+ };
433
+
434
+ // DNSKEY RRset for the apex (structured for dns2's native encoder).
435
+ const buildDnskeyRecords = signer => {
436
+ const dnskeyTtl = dnssecConfig().dnskeyTtl;
437
+ const ttl = typeof dnskeyTtl === 'number' ? dnskeyTtl : 3600;
438
+ return signer.keys.map(key => ({
439
+ name: signer.zone,
440
+ type: 'DNSKEY',
441
+ class: 1,
442
+ ttl,
443
+ flags: key.flags,
444
+ protocol: key.protocol,
445
+ algorithm: key.algorithm,
446
+ key: key.publicKeyDnssec
447
+ }));
448
+ };
449
+
450
+ module.exports = {
451
+ enableZone,
452
+ disableZone,
453
+ removeKey,
454
+ getZoneStatus,
455
+ isZoneSigned,
456
+ getSigner,
457
+ signRRset,
458
+ buildDnskeyRecords,
459
+ // testing seam
460
+ testables: { zoneName }
461
+ };
package/lib/lock.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // Shared ioredfour distributed lock (namespace 'd:lock:' on the Redis write
4
+ // client). Used by both ACME certificate issuance (lib/certs.js) and DNSSEC key
5
+ // management (lib/dnssec.js); callers namespace their own lock keys so the two
6
+ // never collide.
7
+
8
+ const Lock = require('ioredfour');
9
+ const util = require('util');
10
+ const db = require('./db');
11
+ const logger = require('./logger').child({ component: 'lock' });
12
+
13
+ const lock = new Lock({
14
+ redis: db.redisWrite,
15
+ namespace: 'd:lock:'
16
+ });
17
+
18
+ const waitAcquireLock = util.promisify(lock.waitAcquireLock.bind(lock));
19
+
20
+ // Release an acquired lock. Safe to call with a missing or unsuccessful lock
21
+ // (no-op), so callers can release unconditionally in a finally block. `context`
22
+ // is merged into the release-failure log so the caller can identify which lock
23
+ // (e.g. which domains) is stuck.
24
+ const releaseLock = (acquired, context) =>
25
+ new Promise(resolve => {
26
+ if (!acquired || !acquired.success) {
27
+ return resolve();
28
+ }
29
+ lock.releaseLock(acquired, err => {
30
+ if (err) {
31
+ logger.error(Object.assign({ msg: 'Failed releasing lock' }, context || {}, { err }));
32
+ }
33
+ resolve();
34
+ });
35
+ });
36
+
37
+ module.exports = { waitAcquireLock, releaseLock };
package/lib/logger.js CHANGED
@@ -11,6 +11,9 @@ if (threadId) {
11
11
  logger = logger.child({ tid: threadId });
12
12
  }
13
13
 
14
+ // No-op by default; lib/sentry.js overrides this with a real reporter when a DSN is configured
15
+ logger.notifyError = () => false;
16
+
14
17
  process.on('uncaughtException', err => {
15
18
  logger.fatal({
16
19
  msg: 'uncaughtException',
@@ -254,7 +254,16 @@ const setupHttps = () =>
254
254
  },
255
255
  (req, res) => {
256
256
  req.proto = 'https';
257
- middleware(req, res);
257
+
258
+ try {
259
+ middleware(req, res);
260
+ } catch (err) {
261
+ logger.error({ msg: 'Failed to process request', err });
262
+ res.statusCode = 500;
263
+ res.setHeader('Content-Type', 'text/html');
264
+ return res.end(errors.error500({}));
265
+ }
266
+
258
267
  handler(req, res).catch(err => {
259
268
  res.statusCode = 500;
260
269
  res.setHeader('Content-Type', 'text/html');
@@ -334,7 +343,16 @@ const setupHttp = () =>
334
343
  new Promise((resolve, reject) => {
335
344
  const server = http.createServer((req, res) => {
336
345
  req.proto = 'http';
337
- middleware(req, res);
346
+
347
+ try {
348
+ middleware(req, res);
349
+ } catch (err) {
350
+ logger.error({ msg: 'Failed to process request', err });
351
+ res.statusCode = 500;
352
+ res.setHeader('Content-Type', 'text/html');
353
+ return res.end(errors.error500({}));
354
+ }
355
+
338
356
  handler(req, res).catch(err => {
339
357
  res.statusCode = 500;
340
358
  res.setHeader('Content-Type', 'text/html');
package/lib/sentry.js ADDED
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /* eslint global-require: 0 */
4
+
5
+ const config = require('wild-config');
6
+ const packageData = require('../package.json');
7
+ const logger = require('./logger');
8
+
9
+ // Initialize Sentry error tracking. With no DSN configured, error reporting stays
10
+ // disabled and logger.notifyError keeps its no-op default from lib/logger.js.
11
+ function initSentry(worker) {
12
+ // The SENTRY_DSN environment variable overrides the configured value, otherwise
13
+ // fall back to the wild-config value. An empty DSN disables error reporting.
14
+ const dsn = (process.env.SENTRY_DSN || (config.sentry && config.sentry.dsn) || '').trim();
15
+ if (!dsn) {
16
+ return;
17
+ }
18
+
19
+ // require lazily, the SDK loads several hundred modules in every worker,
20
+ // so only pay that cost when error tracking is actually enabled
21
+ const Sentry = require('@sentry/node');
22
+
23
+ Sentry.init({
24
+ dsn,
25
+ release: packageData.version,
26
+ environment: process.env.NODE_ENV || 'development',
27
+ // Error capture only: skip the OpenTelemetry setup and the default
28
+ // integrations that patch http/fetch/console on hot paths. The uncaught
29
+ // exception / unhandled rejection integrations are added back explicitly
30
+ // so crashes are still reported (Bugsnag's autoDetectErrors did this).
31
+ skipOpenTelemetrySetup: true,
32
+ defaultIntegrations: false,
33
+ integrations: [
34
+ Sentry.eventFiltersIntegration(),
35
+ Sentry.functionToStringIntegration(),
36
+ Sentry.linkedErrorsIntegration(),
37
+ Sentry.contextLinesIntegration(),
38
+ Sentry.nodeContextIntegration(),
39
+ Sentry.modulesIntegration(),
40
+ // captures, flushes and exits the worker so the supervisor restarts it
41
+ Sentry.onUncaughtExceptionIntegration(),
42
+ // captures and warns, but does not exit (matches the previous behaviour)
43
+ Sentry.onUnhandledRejectionIntegration({ mode: 'warn' })
44
+ ],
45
+ initialScope: {
46
+ tags: { worker, app: packageData.name }
47
+ }
48
+ });
49
+
50
+ // Signals to the worker bootstraps that an error reporter with its own
51
+ // crash handler is active, so closeProcess() should let Sentry flush and
52
+ // exit instead of exiting immediately (which would drop the in-flight event).
53
+ logger.errorReportingEnabled = true;
54
+
55
+ logger.notifyError = (err, opts) => {
56
+ let captureContext = {};
57
+ if (opts && opts.level) {
58
+ captureContext.level = opts.level;
59
+ }
60
+ if (opts && opts.context) {
61
+ captureContext.tags = { context: opts.context };
62
+ }
63
+ if (opts && opts.meta && Object.keys(opts.meta).length) {
64
+ captureContext.contexts = { error: opts.meta };
65
+ }
66
+ Sentry.captureException(err, captureContext);
67
+ };
68
+
69
+ logger.info({ msg: 'Enabled Sentry error reporting', worker });
70
+ }
71
+
72
+ module.exports = { initSentry };
package/lib/tools.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const cachedResolver = require('./cached-resolver');
4
4
  const punycode = require('punycode/');
5
- const Joi = require('@hapi/joi');
5
+ const Joi = require('joi');
6
6
 
7
7
  const emailSchema = Joi.string().email({}).required();
8
8