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/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',
|
package/lib/public-server.js
CHANGED
|
@@ -254,7 +254,16 @@ const setupHttps = () =>
|
|
|
254
254
|
},
|
|
255
255
|
(req, res) => {
|
|
256
256
|
req.proto = 'https';
|
|
257
|
-
|
|
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
|
-
|
|
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 };
|