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/SECURITY.txt ADDED
@@ -0,0 +1,27 @@
1
+ -----BEGIN PGP SIGNED MESSAGE-----
2
+ Hash: SHA256
3
+
4
+ Contact: https://github.com/postalsys/pending-dns/security/advisories/new
5
+ Contact: mailto:andris@postalsys.com
6
+ Expires: 2027-06-01T00:00:00.000Z
7
+ Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/5D952A46E1D8C931F6364E01DC6C83F4D584D364
8
+ Preferred-Languages: en, et
9
+ Canonical: https://github.com/postalsys/pending-dns/blob/master/SECURITY.txt
10
+ Policy: https://github.com/postalsys/pending-dns/blob/master/SECURITY.md
11
+ -----BEGIN PGP SIGNATURE-----
12
+
13
+ iQJPBAEBCAA5FiEEXZUqRuHYyTH2Nk4B3GyD9NWE02QFAmozqqUbFIAAAAAABAAO
14
+ bWFudTIsMi41KzEuMTIsMCwzAAoJENxsg/TVhNNkUlUQAL8mZHOiH2ZVgsrJanW6
15
+ 58YQ5DTNfLL0CO5Qwo1j9XijmvF7dMwDsNTvQ2hDBV+Okz7mSfDBUaofgT6kIBQz
16
+ Qk0yMyOp1Um+l4rF7/J2d/9ABBnsu4Le59Mu87qIgziLCu3YarheThiPCQvFzRfH
17
+ A/vmGOu3/+gcHdF2GrlEk8xfFNdIqwdhuW8oTiR18WqUARe3S+wQdfumDX41gVRL
18
+ y0Q8eGXb3j8jLXp9E21ePlPdxtIQC4fZSexd64IEKv7kuVp9vDpai0jdi5SQSmCA
19
+ gOPp5f9jR0B6GCbRfCRYUHsrQlwOZPtCq46IGKCnrCd2wI7RHTeCUwtzOQsqod7n
20
+ u+GE/YAZZuck9OI6oDZ3klGXUNAi5RO16ix80rybFfVA2MmNdCDHDwTmlysHli8E
21
+ 9FjakLcnF/5eH5NlH579IhQIx1exmE9Q+ZyCwaNQ2uGIujxy6bay3cxvXXeecrJM
22
+ c0MMNgyh7CCfmHShoXfQ2JnFTlVycgqZetLySBxGzkgj5mczLKHviAjaoM3Hw2K6
23
+ znct7z4aE0yY+tCItdIHTdPV5NxR319HZRE980h2yxt92aWmhZn/dW/4fAEwqzFU
24
+ C6Ghu37qs9JwvyBZImH92fc9+27Xgx0Ahfb9RoXM/+TulXAdrQxcK+th9ye40GfJ
25
+ otvIQGxzLN1kyCWhQCqusEJ4
26
+ =QK6n
27
+ -----END PGP SIGNATURE-----
@@ -36,7 +36,7 @@ function run() {
36
36
 
37
37
  case 'version':
38
38
  // Show version
39
- console.log(`EmailEngine v${packageData.version} (${packageData.license})`);
39
+ console.log(`PendingDNS v${packageData.version} (${packageData.license})`);
40
40
  return process.exit();
41
41
 
42
42
  default:
@@ -2,6 +2,11 @@
2
2
  [log]
3
3
  level = "trace"
4
4
 
5
+ [sentry]
6
+ # Error reporting DSN for the self-hosted Sentry server. Leave empty to disable.
7
+ # The production value is set via the SENTRY_DSN environment variable.
8
+ dsn = ""
9
+
5
10
  [dbs]
6
11
 
7
12
  # By default all redis commands are sent against the same instance
@@ -138,6 +143,44 @@ ciphers = "ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES25
138
143
  A = ["127.0.0.1", "127.0.0.2"]
139
144
  AAAA = []
140
145
 
146
+ [dnssec]
147
+ # Master switch. Per-zone signing is additionally gated on a generated key:
148
+ # a zone is only signed once DNSSEC has been enabled for it over the API.
149
+ enabled = false
150
+
151
+ # Default algorithm for newly generated zone keys.
152
+ # 13 = ECDSA P-256 / SHA-256 (recommended)
153
+ # 15 = Ed25519
154
+ # 8 = RSASHA256
155
+ algorithm = 13
156
+
157
+ # RRSIG validity window (seconds) and how far inception is backdated to absorb
158
+ # validator clock skew.
159
+ signatureValidity = 604800 # 7 days
160
+ inceptionSkew = 3600 # backdate inception 1h
161
+
162
+ # TTL for the DNSKEY RRset. (Synthesized NSEC records use the SOA minimum so the
163
+ # denial proof and the negative answer expire together, per RFC 2308.)
164
+ dnskeyTtl = 3600
165
+
166
+ # How long (seconds) a per-zone signer (and the "unsigned" verdict for a zone) is
167
+ # cached in memory on the DNS workers, to avoid re-reading and re-parsing zone
168
+ # keys on every DO query. The API and DNS subsystems run as separate workers, so
169
+ # a key change made over the API takes effect on the DNS side after at most this
170
+ # long. Set to 0 to disable the cache. Keep small - DNSSEC key changes are rare.
171
+ signerCacheTtl = 5
172
+
173
+ # Denial of existence: nonexistent or empty names are answered NODATA (NOERROR)
174
+ # with a signed compact NSEC ("black lies"). True NXDOMAIN is not used because the
175
+ # server synthesizes CAA/NS/SOA for any name, so to a validator no name is truly
176
+ # absent.
177
+
178
+ # Advertised EDNS UDP payload size, and the cap on outgoing UDP responses: a
179
+ # response larger than the smaller of this and the requestor's advertised size is
180
+ # truncated with TC=1 so the client retries over TCP. 1232 avoids IP fragmentation
181
+ # on most paths.
182
+ udpPayloadSize = 1232
183
+
141
184
  [process]
142
185
  # Change user for child processes once privileged ports have been bound
143
186
  #user="www-data"
@@ -0,0 +1,35 @@
1
+ # Configuration used by the automated test suite (NODE_ENV=test).
2
+ # It is never loaded in production/development and keeps tests isolated
3
+ # from real data by pointing Redis at a dedicated database that the test
4
+ # setup is free to flush.
5
+
6
+ [log]
7
+ level = "silent"
8
+
9
+ [dbs]
10
+ # Dedicated Redis database for tests. The test bootstrap flushes this DB,
11
+ # so it must not be shared with development (db 2) or production data.
12
+ redis = "redis://127.0.0.1:6379/15"
13
+
14
+ [acme]
15
+ # A syntactically valid address is required for the server bootstrap check.
16
+ email = "test@example.com"
17
+
18
+ [dns]
19
+ # Avoid clashing with a locally running instance if the DNS server is started.
20
+ port = 15353
21
+ host = "127.0.0.1"
22
+
23
+ [api]
24
+ port = 15080
25
+ host = "127.0.0.1"
26
+
27
+ [dnssec]
28
+ # Enable the global DNSSEC master switch so the signing tests exercise the
29
+ # query-time signing path (per-zone signing is still gated on a generated key).
30
+ enabled = true
31
+
32
+ # Disable the in-memory signer cache in tests so each case reads fresh state from
33
+ # Redis (db 15 is flushed between cases); the cache itself is covered by a
34
+ # dedicated test that sets a positive TTL.
35
+ signerCacheTtl = 0
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { FlatCompat } = require('@eslint/eslintrc');
4
+ const js = require('@eslint/js');
5
+ const prettier = require('eslint-config-prettier');
6
+
7
+ const compat = new FlatCompat({
8
+ baseDirectory: __dirname,
9
+ recommendedConfig: js.configs.recommended,
10
+ allConfig: js.configs.all
11
+ });
12
+
13
+ module.exports = [
14
+ {
15
+ ignores: ['node_modules/', 'ee-dist/', 'coverage/', 'views/', '.prettierrc.js']
16
+ },
17
+
18
+ // Shared Nodemailer ESLint rules (eslintrc format, wrapped for flat config)
19
+ ...compat.extends('eslint-config-nodemailer'),
20
+
21
+ // Disable stylistic rules that conflict with Prettier
22
+ prettier,
23
+
24
+ {
25
+ languageOptions: {
26
+ ecmaVersion: 2023,
27
+ sourceType: 'commonjs'
28
+ },
29
+ rules: {
30
+ indent: 'off',
31
+ 'no-await-in-loop': 'off',
32
+ 'require-atomic-updates': 'off',
33
+ // Preserve the long-standing project convention of `catch (err) { /* ignore */ }`.
34
+ // ESLint 9 changed the no-unused-vars `caughtErrors` default to 'all'.
35
+ 'no-unused-vars': ['error', { caughtErrors: 'none' }]
36
+ }
37
+ }
38
+ ];
package/lib/api-server.js CHANGED
@@ -8,10 +8,15 @@ const Inert = require('@hapi/inert');
8
8
  const Vision = require('@hapi/vision');
9
9
  const HapiSwagger = require('hapi-swagger');
10
10
  const packageData = require('../package.json');
11
- const Joi = require('@hapi/joi');
11
+ const Joi = require('joi');
12
12
  const logger = require('./logger').child({ component: 'api-server' });
13
- const { zoneStore, allowedTypes, allowedTags } = require('./zone-store');
13
+ const { zoneStore, allowedTypes, allowedTags, tlsaToValue, isEvenHex } = require('./zone-store');
14
14
  const { getCertificate } = require('./certs');
15
+ const dnssec = require('./dnssec');
16
+
17
+ // Normalize a thrown error into a Boom response: pass Boom errors through,
18
+ // otherwise wrap while preserving any statusCode and decorating with err.code.
19
+ const toBoom = err => (Boom.isBoom(err) ? err : Boom.boomify(err, { statusCode: err.statusCode || 500, decorate: { code: err.code } }));
15
20
 
16
21
  const hostnameSchema = Joi.string().hostname({
17
22
  allowUnicode: true,
@@ -40,6 +45,20 @@ const subdomainValidator = opts => (value, helpers) => {
40
45
  return value;
41
46
  };
42
47
 
48
+ // All TLSA-specific fields share the same conditional shape: required when the
49
+ // record type is TLSA, forbidden otherwise. Keep the wrapper in one place so the
50
+ // four fields cannot drift apart.
51
+ const tlsaField = (inner, { example, description, label }) =>
52
+ Joi.any()
53
+ .example(example)
54
+ .when('type', {
55
+ is: 'TLSA',
56
+ then: inner.required(),
57
+ otherwise: Joi.any().forbidden()
58
+ })
59
+ .description(description)
60
+ .label(label);
61
+
43
62
  const recordScheme = Joi.object({
44
63
  subdomain: Joi.string()
45
64
  .replace(/[\s.@]+$/gi, '')
@@ -66,6 +85,16 @@ const recordScheme = Joi.object({
66
85
  }),
67
86
  'subdomain validation'
68
87
  )
88
+ },
89
+ {
90
+ is: 'TLSA',
91
+ then: Joi.custom(
92
+ subdomainValidator({
93
+ allowUnderscore: true,
94
+ allowWildcard: false
95
+ }),
96
+ 'subdomain validation'
97
+ )
69
98
  }
70
99
  ],
71
100
  otherwise: Joi.custom(
@@ -192,7 +221,7 @@ const recordScheme = Joi.object({
192
221
  .description('Certificate authority domain for CAA')
193
222
  .label('CADomain'),
194
223
 
195
- tags: Joi.any()
224
+ tag: Joi.any()
196
225
  .example('issue')
197
226
  .when('type', {
198
227
  is: 'CAA',
@@ -207,7 +236,7 @@ const recordScheme = Joi.object({
207
236
  .example(0)
208
237
  .when('type', {
209
238
  is: 'CAA',
210
- then: Joi.string().valid(0).default(0)
239
+ then: Joi.number().integer().min(0).max(255).default(0)
211
240
  })
212
241
  .description('Certificate authority flags for CAA')
213
242
  .label('Flags'),
@@ -230,6 +259,36 @@ const recordScheme = Joi.object({
230
259
  .description('Data for TXT record')
231
260
  .label('TXTData'),
232
261
 
262
+ usage: tlsaField(Joi.number().integer().min(0).max(3), {
263
+ example: 3,
264
+ description: 'Certificate usage for TLSA (0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE)',
265
+ label: 'TLSAUsage'
266
+ }),
267
+
268
+ selector: tlsaField(Joi.number().integer().min(0).max(1), {
269
+ example: 1,
270
+ description: 'Selector for TLSA (0 full certificate, 1 SubjectPublicKeyInfo)',
271
+ label: 'TLSASelector'
272
+ }),
273
+
274
+ matchingType: tlsaField(Joi.number().integer().min(0).max(2), {
275
+ example: 1,
276
+ description: 'Matching type for TLSA (0 full, 1 SHA-256, 2 SHA-512)',
277
+ label: 'TLSAMatchingType'
278
+ }),
279
+
280
+ certificate: tlsaField(
281
+ Joi.string()
282
+ .hex()
283
+ .lowercase()
284
+ .custom((value, helpers) => (isEvenHex(value) ? value : helpers.error('any.invalid')), 'even-length hex'),
285
+ {
286
+ example: '92003ba34942dc74152e2f2c408d29eca5a520e7f2e06bb944f4dca346baf63c',
287
+ description: 'Certificate association data for TLSA (hex)',
288
+ label: 'TLSACertificate'
289
+ }
290
+ ),
291
+
233
292
  url: Joi.any()
234
293
  .example('https://postalsys.com/')
235
294
  .when('type', {
@@ -273,7 +332,7 @@ const failAction = async (request, h, err) => {
273
332
  throw error;
274
333
  };
275
334
 
276
- const init = async () => {
335
+ const createServer = async () => {
277
336
  const server = Hapi.server({
278
337
  port: (process.env.API_PORT && Number(process.env.API_PORT)) || config.api.port,
279
338
  host: process.env.API_HOST || config.api.host
@@ -346,10 +405,7 @@ const init = async () => {
346
405
  }
347
406
  return { zone: request.params.zone, records };
348
407
  } catch (err) {
349
- if (Boom.isBoom(err)) {
350
- throw err;
351
- }
352
- throw Boom.boomify(err, { statusCode: err.statusCode || 500, decorate: { code: err.code } });
408
+ throw toBoom(err);
353
409
  }
354
410
  },
355
411
 
@@ -401,6 +457,9 @@ const init = async () => {
401
457
  case 'TXT':
402
458
  value = [request.payload.data];
403
459
  break;
460
+ case 'TLSA':
461
+ value = tlsaToValue(request.payload);
462
+ break;
404
463
  case 'URL':
405
464
  value = [request.payload.url, request.payload.code, request.payload.proxy];
406
465
  break;
@@ -414,10 +473,7 @@ const init = async () => {
414
473
  record
415
474
  };
416
475
  } catch (err) {
417
- if (Boom.isBoom(err)) {
418
- throw err;
419
- }
420
- throw Boom.boomify(err, { statusCode: err.statusCode || 500, decorate: { code: err.code } });
476
+ throw toBoom(err);
421
477
  }
422
478
  },
423
479
 
@@ -471,6 +527,9 @@ const init = async () => {
471
527
  case 'TXT':
472
528
  value = [request.payload.data];
473
529
  break;
530
+ case 'TLSA':
531
+ value = tlsaToValue(request.payload);
532
+ break;
474
533
  case 'URL':
475
534
  value = [request.payload.url, request.payload.code, request.payload.proxy];
476
535
  break;
@@ -478,13 +537,10 @@ const init = async () => {
478
537
  throw new Error('Unknown type');
479
538
  }
480
539
 
481
- let updated = await zoneStore.update(request.params.zone, request.params.record, request.payload.subdomain, request.payload.type, value);
482
- return { zone: request.params.zone, updated };
540
+ let record = await zoneStore.update(request.params.zone, request.params.record, request.payload.subdomain, request.payload.type, value);
541
+ return { zone: request.params.zone, record };
483
542
  } catch (err) {
484
- if (Boom.isBoom(err)) {
485
- throw err;
486
- }
487
- throw Boom.boomify(err, { statusCode: err.statusCode || 500, decorate: { code: err.code } });
543
+ throw toBoom(err);
488
544
  }
489
545
  },
490
546
 
@@ -525,10 +581,7 @@ const init = async () => {
525
581
  let deleted = await zoneStore.del(request.params.zone, request.params.record);
526
582
  return { zone: request.params.zone, record: request.params.record, deleted };
527
583
  } catch (err) {
528
- if (Boom.isBoom(err)) {
529
- throw err;
530
- }
531
- throw Boom.boomify(err, { statusCode: err.statusCode || 500, decorate: { code: err.code } });
584
+ throw toBoom(err);
532
585
  }
533
586
  },
534
587
  options: {
@@ -631,6 +684,121 @@ const init = async () => {
631
684
  }
632
685
  });
633
686
 
687
+ const zoneParam = Joi.object({
688
+ zone: Joi.string().hostname().required().example('example.com').description('Zone domain').label('ZoneDomain')
689
+ }).label('Zone');
690
+
691
+ server.route({
692
+ method: 'GET',
693
+ path: '/v1/zone/{zone}/dnssec',
694
+
695
+ async handler(request) {
696
+ try {
697
+ return await dnssec.getZoneStatus(request.params.zone);
698
+ } catch (err) {
699
+ throw toBoom(err);
700
+ }
701
+ },
702
+
703
+ options: {
704
+ description: 'Get DNSSEC status',
705
+ notes: 'Returns DNSSEC state, DS and DNSKEY records for a zone',
706
+ tags: ['api', 'dnssec'],
707
+ validate: {
708
+ options: { stripUnknown: true, abortEarly: false, convert: true },
709
+ failAction,
710
+ params: zoneParam
711
+ }
712
+ }
713
+ });
714
+
715
+ server.route({
716
+ method: 'POST',
717
+ path: '/v1/zone/{zone}/dnssec',
718
+
719
+ async handler(request) {
720
+ try {
721
+ return await dnssec.enableZone(request.params.zone, { algorithm: request.payload && request.payload.algorithm });
722
+ } catch (err) {
723
+ throw toBoom(err);
724
+ }
725
+ },
726
+
727
+ options: {
728
+ description: 'Enable DNSSEC',
729
+ notes: 'Enables DNSSEC for a zone, generating a signing key on first call. Returns DS records to set at the registrar.',
730
+ tags: ['api', 'dnssec'],
731
+ validate: {
732
+ options: { stripUnknown: true, abortEarly: false, convert: true },
733
+ failAction,
734
+ params: zoneParam,
735
+ payload: Joi.object({
736
+ algorithm: Joi.number()
737
+ .valid(8, 13, 15)
738
+ .example(13)
739
+ .description('DNSSEC algorithm (8 RSASHA256, 13 ECDSAP256SHA256, 15 ED25519)')
740
+ .label('DnssecAlgorithm')
741
+ })
742
+ .allow(null)
743
+ .label('DnssecOptions')
744
+ }
745
+ }
746
+ });
747
+
748
+ server.route({
749
+ method: 'DELETE',
750
+ path: '/v1/zone/{zone}/dnssec',
751
+
752
+ async handler(request) {
753
+ try {
754
+ let disabled = await dnssec.disableZone(request.params.zone);
755
+ return { zone: request.params.zone, disabled };
756
+ } catch (err) {
757
+ throw toBoom(err);
758
+ }
759
+ },
760
+
761
+ options: {
762
+ description: 'Disable DNSSEC',
763
+ notes: 'Disables DNSSEC signing for a zone',
764
+ tags: ['api', 'dnssec'],
765
+ validate: {
766
+ options: { stripUnknown: true, abortEarly: false, convert: true },
767
+ failAction,
768
+ params: zoneParam
769
+ }
770
+ }
771
+ });
772
+
773
+ server.route({
774
+ method: 'DELETE',
775
+ path: '/v1/zone/{zone}/dnssec/key/{keyTag}',
776
+
777
+ async handler(request) {
778
+ try {
779
+ let removed = await dnssec.removeKey(request.params.zone, request.params.keyTag);
780
+ return { zone: request.params.zone, keyTag: request.params.keyTag, removed };
781
+ } catch (err) {
782
+ throw toBoom(err);
783
+ }
784
+ },
785
+
786
+ options: {
787
+ description: 'Remove a DNSSEC key',
788
+ notes: 'Removes a non-active signing key from a zone, e.g. to finish an algorithm rollover after publishing the new DS. Refuses to remove the active or last key.',
789
+ tags: ['api', 'dnssec'],
790
+ validate: {
791
+ options: { stripUnknown: true, abortEarly: false, convert: true },
792
+ failAction,
793
+ params: zoneParam
794
+ .keys({
795
+ keyTag: Joi.number().integer().min(0).max(65535).required().example(48234).description('Key tag of the key to remove').label('KeyTag')
796
+ })
797
+ .label('ZoneKey')
798
+ }
799
+ }
800
+ });
801
+
634
802
  server.route({
635
803
  method: '*',
636
804
  path: '/{any*}',
@@ -639,7 +807,14 @@ const init = async () => {
639
807
  }
640
808
  });
641
809
 
810
+ return server;
811
+ };
812
+
813
+ const init = async () => {
814
+ const server = await createServer();
642
815
  await server.start();
816
+ return server;
643
817
  };
644
818
 
645
819
  module.exports = init;
820
+ module.exports.createServer = createServer;
@@ -32,12 +32,13 @@ function formatResult(record) {
32
32
  * @param {Object} [options]
33
33
  * @param {Number} [options.minTtl=10min] Cache timeout for successful resolving operations. If cached response is older than this value then resolving is retried. If resolving fails then cached value is used.
34
34
  * @param {Number} [options.maxTtl=8h] Time after cached data of a successful resolving is permanently deleted
35
- * @param {Number} [options.errorTtl=1min] Cache timeout for errored resolving operations. If cached response is older than this value then resolving is retried
35
+ * @param {Number} [options.errorMinTtl=1min] Cache timeout for errored resolving operations. If cached response is older than this value then resolving is retried
36
+ * @param {Number} [options.errorMaxTtl=1h] Time after cached error data is permanently deleted
36
37
  * @returns {Array|Boolean} Resolve response or `false` if query failed for whatever reason
37
38
  */
38
39
  const cachedResolver = async (target, type, options) => {
39
40
  try {
40
- target = punycode.toASCII(target.trim().toLowerCase().trim().toLowerCase());
41
+ target = punycode.toASCII(target.trim().toLowerCase());
41
42
  } catch (err) {
42
43
  return false;
43
44
  }
@@ -120,12 +121,13 @@ const cachedResolver = async (target, type, options) => {
120
121
  .set(
121
122
  cacheKey,
122
123
  JSON.stringify({
124
+ expires: Date.now() + options.errorMinTtl,
123
125
  data: false,
124
126
  error: err.message,
125
127
  code: err.code || err.errno
126
128
  })
127
129
  )
128
- .expire(cacheKey, Math.round(options.errorTtl / 1000))
130
+ .expire(cacheKey, Math.round(options.errorMaxTtl / 1000))
129
131
  .exec();
130
132
  throw err;
131
133
  }
package/lib/certs.js CHANGED
@@ -9,19 +9,14 @@ const crypto = require('crypto');
9
9
  const { checkNSStatus, normalizeDomain } = require('./tools');
10
10
  const ACME = require('@root/acme');
11
11
  const { pem2jwk } = require('pem-jwk');
12
- const NodeRSA = require('node-rsa');
13
12
  const logger = require('./logger').child({ component: 'certs' });
14
13
  const CSR = require('@root/csr');
15
14
  const { Certificate } = require('@fidm/x509');
16
15
  const { Resolver } = require('dns').promises;
17
- const Lock = require('ioredfour');
18
16
  const util = require('util');
17
+ const { waitAcquireLock, releaseLock } = require('./lock');
19
18
 
20
- const certLock = new Lock({
21
- redis: db.redisWrite,
22
- namespace: 'd:lock:'
23
- });
24
- const waitAcquireLock = util.promisify(certLock.waitAcquireLock.bind(certLock));
19
+ const generateKeyPairAsync = util.promisify(crypto.generateKeyPair);
25
20
 
26
21
  const localResolver = new Resolver();
27
22
  localResolver.setServers(config.ns.map(ns => ns.ip));
@@ -78,9 +73,12 @@ const ensureAcme = async () => {
78
73
  };
79
74
 
80
75
  const generateKey = async bits => {
81
- const key = new NodeRSA({ b: bits || 2048, e: 65537 });
82
- const pem = key.exportKey('pkcs1-private-pem');
83
- return pem;
76
+ const { privateKey } = await generateKeyPairAsync('rsa', {
77
+ modulusLength: bits || 2048,
78
+ publicExponent: 65537,
79
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' }
80
+ });
81
+ return privateKey;
84
82
  };
85
83
 
86
84
  class AcmeDNSPlugin {
@@ -437,16 +435,7 @@ const getCertificate = async (domains, force) => {
437
435
 
438
436
  throw err;
439
437
  } finally {
440
- if (lock.success) {
441
- await new Promise(resolve => {
442
- certLock.releaseLock(lock, err => {
443
- if (err) {
444
- logger.error({ msg: 'Failed releasing lock', lock: domainHash, domains, err });
445
- }
446
- resolve();
447
- });
448
- });
449
- }
438
+ await releaseLock(lock, { lock: domainHash, domains });
450
439
  }
451
440
  };
452
441
 
@@ -472,3 +461,6 @@ module.exports = {
472
461
  getCertificate,
473
462
  loadCertificate
474
463
  };
464
+
465
+ // Exposed for unit testing
466
+ module.exports.testables = { generateKey };