pending-dns 1.3.0 → 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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.3.0"
2
+ ".": "1.4.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0](https://github.com/postalsys/pending-dns/compare/v1.3.0...v1.4.0) (2026-06-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * add DNSSEC online signing, TLSA records, and EDNS UDP truncation ([414beb7](https://github.com/postalsys/pending-dns/commit/414beb7114d56f35e2d02e945abfb4125ee51404))
9
+ * replace Bugsnag with self-hosted Sentry error reporting ([3b374c7](https://github.com/postalsys/pending-dns/commit/3b374c737ff0a22509f79976bee18ab9d425f8bd))
10
+
3
11
  ## [1.3.0](https://github.com/postalsys/pending-dns/compare/pending-dns-v1.2.5...pending-dns-v1.3.0) (2026-06-18)
4
12
 
5
13
 
package/CLAUDE.md CHANGED
@@ -57,7 +57,7 @@ Config is loaded by `wild-config` from `config/default.toml` merged with `config
57
57
  This is the heart of the system and is non-obvious:
58
58
 
59
59
  - **Domain names are stored label-reversed**: `www.example.com` → `com.example.www` (`domainToName`/`nameToDomain`). This lets `resolveZone` walk *up* from a name to its registered zone by progressively dropping the most-specific label. A "zone" is any name with a `d:<name>:z` set; the shortest possible zone is the 2-label boundary (e.g. `example.com`).
60
- - Keys: `d:<name>:z` is a **set of record keys** belonging to that zone; `d:<name>:r:<TYPE>` is a **hash** of `hid → JSON.stringify(valueArray)`. Each record's value is a positional array whose meaning depends on type (e.g. A = `[address, healthCheckUri]`, MX = `[exchange, priority]`, CAA = `[value, tag, flags]`, URL = `[url, code, proxy]`).
60
+ - Keys: `d:<name>:z` is a **set of record keys** belonging to that zone; `d:<name>:r:<TYPE>` is a **hash** of `hid → JSON.stringify(valueArray)`. Each record's value is a positional array whose meaning depends on type (e.g. A = `[address, healthCheckUri]`, MX = `[exchange, priority]`, CAA = `[value, tag, flags]`, TLSA = `[usage, selector, matchingType, certificate]`, URL = `[url, code, proxy]`).
61
61
  - A record's public **ID is `base64url(name \x01 TYPE \x01 hid)`** (`getFullId`/`parseFullId`); `hid` is a `nanoid()`. IDs are opaque and stable only while domain+type are unchanged (an `update` that changes either deletes and re-adds, producing a new ID).
62
62
  - **Wildcards** are single-label: a record stored under subdomain `*.foo` matches `anything.foo.<zone>` only (`resolve` retries with the last label replaced by `*`).
63
63
  - **Read/write split**: reads go to `db.redisRead`, writes to `db.redisWrite` (configurable as separate master/replica URLs). The health-check Lua script `lib/lua/health.lua` is registered as a custom command `nextHealth` on the write client.
@@ -70,13 +70,25 @@ This is the heart of the system and is non-obvious:
70
70
 
71
71
  Two pseudo-record types: **ANAME** (apex alias) is resolved to real A/AAAA at query time via `lib/cached-resolver.js` (a Redis-cached wrapper over Node's `dns.Resolver`, with soft/hard TTLs for both hits and errors). **URL** records answer A/AAAA with the redirect server IPs from `config.public.hosts`; the actual redirect/proxy happens in `lib/public-server.js`.
72
72
 
73
+ ### DNSSEC online signing (`lib/dnssec.js`, `lib/dnssec-wire.js`)
74
+
75
+ DNSSEC is **online (query-time) signing**, gated on `[dnssec] enabled` AND a per-zone key AND the client's EDNS **DO** bit. `lib/dnssec-wire.js` is a pure, dependency-free (only `crypto` + `ipaddr.js`) wire/crypto layer: canonical RDATA/name encoding (RFC 4034 6.2), the `ALGS` table for algorithms 8/13/15, key-tag + DS-digest computation, NSEC bitmaps, and RRSIG encoding. **Invariant:** signatures are computed over the canonical uncompressed lowercased wire form produced here, never over dns2's serialization (validators decompress + downcase before verifying, so the two agree).
76
+
77
+ `lib/dnssec.js` owns per-zone keys and signing orchestration. Keys live in Redis: `d:dnssec:<revname>` is the state hash (`enabled`, `algorithm`, `activeKeyTag`) and `d:dnssec:<revname>:keys` is a hash of `hid → JSON` (private key PEM + public material). A zone has one **CSK** per algorithm; an algorithm rollover keeps both and signs with every algorithm (RFC 6840 5.11) until `removeKey`. `getSigner` assembles + caches the signer per process (short `signerCacheTtl`, since API and DNS run as separate workers); `enableZone`/`removeKey`/`disableZone` mutate under a shared Redis lock (`lib/lock.js`) and invalidate the cache.
78
+
79
+ The query-time path is `lib/dns-handler.js#signResponse` (DO queries only): it serves+self-signs DNSKEY at the apex, signs each in-zone RRset (`signSection`, but never a below-apex delegation NS — RFC 4035 2.2), and proves denial-of-existence as **NODATA (NOERROR) "black lies"** — a signed SOA + a compact NSEC at the queried name (`bitmapTypeNums`/`nsecRecord`), never NXDOMAIN or NSEC3, because the server synthesizes CAA/SOA for any name. The NSEC bitmap lists only **answerable** types and must exclude the queried-absent type (e.g. a `URL` record only advertises the `config.public.hosts` families) or a validator SERVFAILs. The API (`lib/api-server.js`) exposes `GET/POST/DELETE /v1/zone/{zone}/dnssec` and `DELETE .../dnssec/key/{keyTag}`.
80
+
81
+ **TLSA** records are stored as `[usage, selector, matchingType, certificate]` (even-length hex, guarded in the API Joi schema, the store's `add`/`update`, and `encodeTLSARdata`). dns2 has no TLSA/RRSIG/NSEC/DS encoder, so those are emitted as `{ type:<num>, data:<Buffer> }` and routed through dns2's raw-RDATA (RFC 3597) fallback; `dns-handler.js` merges `wire.EXTRA_TYPES` into its type maps to recognize them.
82
+
83
+ **EDNS / truncation** (`lib/dns-server.js`): `parseEdns` reads the OPT (DO bit + advertised payload size); `finalizeResponse` replaces the additional section with our own OPT and, for UDP, truncates with TC=1 above the smaller of the requestor's advertised size and our configured `udpPayloadSize` (anti-fragmentation), so the client retries over TCP.
84
+
73
85
  ### Certificates & the public server
74
86
 
75
- `lib/certs.js` issues Let's Encrypt certs via the **dns-01** challenge, using the zone store itself as the ACME DNS provider (it writes/reads the `_acme-challenge` TXT records). Concurrent issuance is guarded with an `ioredfour` Redis lock; results and a per-domain renewal lock are cached in Redis. `lib/public-server.js` uses an SNI callback to load the right cert per hostname (falling back to a bundled self-signed cert in `config/`), then serves URL-record redirects or reverse-proxies (`proxy=true`), with TLS session tickets stored in Redis.
87
+ `lib/certs.js` issues Let's Encrypt certs via the **dns-01** challenge, using the zone store itself as the ACME DNS provider (it writes/reads the `_acme-challenge` TXT records). Concurrent issuance is guarded with an `ioredfour` Redis lock (the shared `lib/lock.js`, also used by DNSSEC key management; callers namespace their own lock keys); results and a per-domain renewal lock are cached in Redis. `lib/public-server.js` uses an SNI callback to load the right cert per hostname (falling back to a bundled self-signed cert in `config/`), then serves URL-record redirects or reverse-proxies (`proxy=true`), with TLS session tickets stored in Redis.
76
88
 
77
89
  ### Testability seams
78
90
 
79
- Production code exposes hooks used only by tests: `lib/api-server.js` exports `createServer()` (build the Hapi server without `start()`, for `server.inject()`); `lib/dns-server.js`'s `init()` awaits binding and returns `{ udpServer, tcpServer }`; `lib/dns-handler.js` and `lib/certs.js` attach a `.testables` object to their exported function.
91
+ Production code exposes hooks used only by tests: `lib/api-server.js` exports `createServer()` (build the Hapi server without `start()`, for `server.inject()`); `lib/dns-server.js`'s `init()` awaits binding and returns `{ udpServer, tcpServer }`, and also attaches `.testables` (`parseEdns`, `finalizeResponse`); `lib/dns-handler.js` (`signResponse`, `bitmapTypeNums`, `processQuestion`, ...), `lib/certs.js`, and `lib/dnssec.js` attach a `.testables` object. `lib/dnssec-wire.js` is pure and is tested directly.
80
92
 
81
93
  ## CI / release
82
94
 
package/README.md CHANGED
@@ -6,11 +6,12 @@ Lightweight API driven Authoritative DNS server.
6
6
 
7
7
  - All records can be edited over **REST API**
8
8
  - All **changes are effective immediatelly** (or as long as it takes for Redis - eg. the backend for storing data - to distribute changes from master to replica instances)
9
- - **Basic record types** (A, AAAA, CNAME, TXT, MX, CAA)
9
+ - **Basic record types** (A, AAAA, CNAME, TXT, MX, CAA, NS, TLSA)
10
10
  - **ANAME pseudo-record** for apex domains
11
11
  - **URL pseudo-record** for HTTP and HTTPS redirects. Valid HTTPS certificates are generated automatically, HTTPS host gets A+ rating from SSLabs.
12
12
  - URL record can be turned into a **Cloudflare-like proxying** by using `proxy=true` flag. Though, while Cloudflare makes things faster then PendingDNS makes things slightly slower due to not caching anything.
13
13
  - Periodic **health checks** to filter out unhealthy A/AAAA records
14
+ - **DNSSEC** with online (live) signing, enabled per zone over the API
14
15
  - **Lightweight**
15
16
  - Can be **geographically distributed**. All writes go to central Redis master, all reads are done from local Redis replica
16
17
  - Request **certificates over API**
@@ -19,8 +20,9 @@ Lightweight API driven Authoritative DNS server.
19
20
 
20
21
  - No support for zone files, all records must be managed over API
21
22
  - Only the most basic and common record types
22
- - No support for DNSSEC
23
+ - DNSSEC uses online signing; denial of existence is NODATA (NOERROR) with compact NSEC "black lies" (no NSEC3, no true NXDOMAIN). Algorithm rollover is supported by re-enabling a zone with a new algorithm; there is no automated scheduled key rollover
23
24
  - Only plain old DNS over UDP and TCP, no DoH, no DoT
25
+ - UDP responses are capped at the smaller of the requestor's advertised EDNS payload size and the server's configured `[dnssec] udpPayloadSize` (1232 by default; 512 when the requestor advertises no EDNS), and truncated with TC=1 above that limit, per RFC 1035; clients then retry over TCP
24
26
  - Barely tested on [Project Pending](https://projectpending.com/). Do not use this for mission critical domains. PendingDNS is only good for leftover domains, ie. for development and testing.
25
27
 
26
28
  ## Requirements
@@ -151,7 +153,8 @@ $ curl -X GET "http://127.0.0.1:5080/v1/zone/mailtanker.com/records"
151
153
  {
152
154
  "id": "Y29tLm1haWx0YW5rZXIBQQEzc3lKWkkzbGo",
153
155
  "type": "A",
154
- "address": "18.203.150.145"
156
+ "address": "18.203.150.145",
157
+ "healthCheck": false
155
158
  },
156
159
  {
157
160
  "id": "Y29tLm1haWx0YW5rZXIud3d3AUNOQU1FAXhhV1lnbnFaMA",
@@ -187,7 +190,7 @@ $ curl -X POST "http://127.0.0.1:5080/v1/zone/mailtanker.com/records" -H "Conten
187
190
  All record types have the following properties
188
191
 
189
192
  - **subdomain** (optional) subdomain this record applies to. If blank, or "@" or missing then the record is created for zone domain.
190
- - **type** one of A, AAAA, CNAME, ANAME, URL, MX, TXT, CAA, NS
193
+ - **type** one of A, AAAA, CNAME, ANAME, URL, MX, TXT, CAA, NS, TLSA
191
194
 
192
195
  #### Type specific options
193
196
 
@@ -228,6 +231,14 @@ All record types have the following properties
228
231
  - **tag** is the CAA tag, one of `issue`, `issuewild` or `iodef`
229
232
  - **flags** (Number, default is `0`) is the CAA flags octet (0-255)
230
233
 
234
+ **TLSA**
235
+
236
+ - **subdomain** typically uses the DANE form `_port._proto`, eg. `_443._tcp.www`
237
+ - **usage** (Number, 0-3) certificate usage: 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE
238
+ - **selector** (Number, 0-1) 0 for the full certificate, 1 for the SubjectPublicKeyInfo
239
+ - **matchingType** (Number, 0-2) 0 full, 1 SHA-256, 2 SHA-512
240
+ - **certificate** (String) the certificate association data as a hex string
241
+
231
242
  **URL**
232
243
 
233
244
  - **url** (string) is the URL to redirect to. If it only has the root path set (eg. http://example.com/) then URLs are redirected with source paths (http://host/path -> http://example.com/path). Otherwise all source URLs are redirected exatly to destination URL (if url is http://example.com/some/path then http://host/path -> http://example.com/some/path)
@@ -299,6 +310,74 @@ $ curl -X POST "http://127.0.0.1:5080/v1/acme" -H "Content-Type: application/jso
299
310
  }
300
311
  ```
301
312
 
313
+ ### DNSSEC
314
+
315
+ PendingDNS signs answers **online** (at query time) for zones that have DNSSEC enabled. DNSSEC must be turned on globally (`[dnssec] enabled = true`) and then enabled per zone over the API; enabling a zone generates a signing key (a CSK) that is stored in Redis and used to sign every RRset on the fly. Signing only happens for clients that set the EDNS DO bit. Denial of existence is always **NODATA (NOERROR)** with a signed compact NSEC ("black lies"): because the server can synthesize CAA/NS/SOA for any name, no name is treated as truly nonexistent, so there is no NXDOMAIN and no NSEC3.
316
+
317
+ After enabling a zone you must copy the returned **DS** record to the parent zone at your registrar to complete the chain of trust.
318
+
319
+ **Enable DNSSEC for a zone**
320
+
321
+ **POST /v1/zone/{zone}/dnssec**
322
+
323
+ The optional `algorithm` selects the signing algorithm: `13` ECDSA P-256/SHA-256 (default), `15` Ed25519, or `8` RSASHA256.
324
+
325
+ ```
326
+ $ curl -X POST "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec" -H "Content-Type: application/json" -d '{
327
+ "algorithm": 13
328
+ }'
329
+ ```
330
+
331
+ ```json
332
+ {
333
+ "zone": "mailtanker.com",
334
+ "enabled": true,
335
+ "algorithm": 13,
336
+ "keyTag": 48234,
337
+ "ds": [{ "keyTag": 48234, "algorithm": 13, "digestType": 2, "digest": "a4f5...", "presentation": "48234 13 2 a4f5..." }],
338
+ "dnskey": [{ "flags": 257, "protocol": 3, "algorithm": 13, "publicKey": "GjL2...", "keyTag": 48234, "presentation": "257 3 13 GjL2..." }]
339
+ }
340
+ ```
341
+
342
+ **Get DNSSEC status (DS and DNSKEY records)**
343
+
344
+ **GET /v1/zone/{zone}/dnssec**
345
+
346
+ ```
347
+ $ curl -X GET "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec"
348
+ ```
349
+
350
+ Returns the same `enabled`, `algorithm`, `keyTag`, `ds` and `dnskey` shape as the enable response. For a zone that has never had DNSSEC enabled, `enabled` is `false` and the `ds`/`dnskey` arrays are empty.
351
+
352
+ **Disable DNSSEC for a zone**
353
+
354
+ **DELETE /v1/zone/{zone}/dnssec**
355
+
356
+ ```
357
+ $ curl -X DELETE "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec"
358
+ ```
359
+
360
+ ```json
361
+ {
362
+ "zone": "mailtanker.com",
363
+ "disabled": true
364
+ }
365
+ ```
366
+
367
+ **Roll the signing key to a new algorithm**
368
+
369
+ Re-POST to the enable endpoint with a different `algorithm`. A new key is generated and kept alongside the old one, and the zone is signed with **both** algorithms (every RRset gets an RRSIG per algorithm) so validation keeps working during the rollover. The response lists every key in `ds`/`dnskey`. Publish the new **DS** at the registrar, wait for the old DS TTL to expire, then remove the old key.
370
+
371
+ **Remove a signing key (finish a rollover)**
372
+
373
+ **DELETE /v1/zone/{zone}/dnssec/key/{keyTag}**
374
+
375
+ Removes a non-active key. Removing the active key or the last remaining key is refused.
376
+
377
+ ```
378
+ $ curl -X DELETE "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec/key/48234"
379
+ ```
380
+
302
381
  ## Acknowledgments
303
382
 
304
383
  - All DNS parsing / compiling is done using [dns2](https://www.npmjs.com/package/dns2) module by [Liu Song](https://github.com/song940)
@@ -143,6 +143,44 @@ ciphers = "ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES25
143
143
  A = ["127.0.0.1", "127.0.0.2"]
144
144
  AAAA = []
145
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
+
146
184
  [process]
147
185
  # Change user for child processes once privileged ports have been bound
148
186
  #user="www-data"
package/config/test.toml CHANGED
@@ -23,3 +23,13 @@ host = "127.0.0.1"
23
23
  [api]
24
24
  port = 15080
25
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/lib/api-server.js CHANGED
@@ -10,8 +10,13 @@ const HapiSwagger = require('hapi-swagger');
10
10
  const packageData = require('../package.json');
11
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(
@@ -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', {
@@ -346,10 +405,7 @@ const createServer = 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 createServer = 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 createServer = 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 createServer = 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;
@@ -481,10 +540,7 @@ const createServer = async () => {
481
540
  let record = await zoneStore.update(request.params.zone, request.params.record, request.payload.subdomain, request.payload.type, value);
482
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 createServer = 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 createServer = 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*}',
package/lib/certs.js CHANGED
@@ -13,17 +13,11 @@ const logger = require('./logger').child({ component: 'certs' });
13
13
  const CSR = require('@root/csr');
14
14
  const { Certificate } = require('@fidm/x509');
15
15
  const { Resolver } = require('dns').promises;
16
- const Lock = require('ioredfour');
17
16
  const util = require('util');
17
+ const { waitAcquireLock, releaseLock } = require('./lock');
18
18
 
19
19
  const generateKeyPairAsync = util.promisify(crypto.generateKeyPair);
20
20
 
21
- const certLock = new Lock({
22
- redis: db.redisWrite,
23
- namespace: 'd:lock:'
24
- });
25
- const waitAcquireLock = util.promisify(certLock.waitAcquireLock.bind(certLock));
26
-
27
21
  const localResolver = new Resolver();
28
22
  localResolver.setServers(config.ns.map(ns => ns.ip));
29
23
 
@@ -441,16 +435,7 @@ const getCertificate = async (domains, force) => {
441
435
 
442
436
  throw err;
443
437
  } finally {
444
- if (lock.success) {
445
- await new Promise(resolve => {
446
- certLock.releaseLock(lock, err => {
447
- if (err) {
448
- logger.error({ msg: 'Failed releasing lock', lock: domainHash, domains, err });
449
- }
450
- resolve();
451
- });
452
- });
453
- }
438
+ await releaseLock(lock, { lock: domainHash, domains });
454
439
  }
455
440
  };
456
441