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.
- package/.github/codeql/codeql-config.yml +11 -0
- package/.github/workflows/codeql.yml +52 -0
- package/.github/workflows/deploy.yml +16 -3
- package/.github/workflows/release.yaml +43 -0
- package/.github/workflows/test.yml +75 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +109 -0
- package/README.md +111 -9
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/bin/pending-dns.js +1 -1
- package/config/default.toml +43 -0
- package/config/test.toml +35 -0
- package/eslint.config.js +38 -0
- package/lib/api-server.js +198 -23
- package/lib/cached-resolver.js +5 -3
- package/lib/certs.js +12 -20
- package/lib/dns-handler.js +362 -32
- package/lib/dns-server.js +120 -43
- package/lib/dns-tcp-server.js +1 -1
- package/lib/dns-udp-server.js +1 -1
- package/lib/dnssec-wire.js +321 -0
- package/lib/dnssec.js +461 -0
- package/lib/lock.js +37 -0
- package/lib/logger.js +3 -0
- package/lib/public-server.js +20 -2
- package/lib/sentry.js +72 -0
- package/lib/tools.js +1 -1
- package/lib/zone-store.js +90 -7
- package/package.json +46 -33
- package/release-please-config.json +14 -0
- package/server.js +5 -24
- package/systemd/pending-dns.service +4 -4
- package/test/api.test.js +231 -0
- package/test/cached-resolver.test.js +57 -0
- package/test/certs.test.js +34 -0
- package/test/dns-handler.test.js +171 -0
- package/test/dns-server.test.js +162 -0
- package/test/dnssec-handler.test.js +550 -0
- package/test/dnssec-wire.test.js +163 -0
- package/test/dnssec.test.js +213 -0
- package/test/helpers.js +27 -0
- package/test/sentry.test.js +21 -0
- package/test/tools.test.js +48 -0
- package/test/zone-store.test.js +245 -0
- package/workers/api.js +3 -1
- package/workers/dns.js +2 -24
- package/workers/health.js +3 -26
- package/workers/public.js +3 -25
- package/.eslintrc +0 -14
- 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-----
|
package/bin/pending-dns.js
CHANGED
package/config/default.toml
CHANGED
|
@@ -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"
|
package/config/test.toml
ADDED
|
@@ -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
|
package/eslint.config.js
ADDED
|
@@ -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('
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
482
|
-
return { zone: request.params.zone,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/cached-resolver.js
CHANGED
|
@@ -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.
|
|
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()
|
|
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.
|
|
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
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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 };
|