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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +15 -3
- package/README.md +83 -4
- package/config/default.toml +38 -0
- package/config/test.toml +10 -0
- package/lib/api-server.js +185 -17
- package/lib/certs.js +2 -17
- package/lib/dns-handler.js +350 -25
- package/lib/dns-server.js +90 -25
- package/lib/dnssec-wire.js +321 -0
- package/lib/dnssec.js +461 -0
- package/lib/lock.js +37 -0
- package/lib/zone-store.js +86 -3
- package/package.json +5 -2
- package/release-please-config.json +1 -0
- package/test/api.test.js +93 -1
- package/test/dns-handler.test.js +32 -1
- package/test/dns-server.test.js +93 -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 +3 -1
- package/test/zone-store.test.js +37 -1
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 }
|
|
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
|
-
-
|
|
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)
|
package/config/default.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|