@sdxc/doh 2026.10.10 → 2026.10.16
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/README.md +216 -175
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -2,33 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Typed DNS over HTTPS lookups.
|
|
4
4
|
|
|
5
|
+
A runtime with no DNS socket, such as a Cloudflare Worker, resolves names with a `fetch` to a
|
|
6
|
+
public resolver. This package speaks the DoH JSON API (`application/dns-json`) that Cloudflare
|
|
7
|
+
and Google answer, returns records typed per query type, and gives each kind of failure its own
|
|
8
|
+
error class. The `./caa` entry point reads CAA records the way a certificate authority does.
|
|
9
|
+
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
7
|
-
```
|
|
12
|
+
```sh
|
|
8
13
|
npm add @sdxc/doh
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
Every lookup returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
A runtime with no DNS socket, such as a Cloudflare Worker, resolves names through DNS over HTTPS:
|
|
16
|
-
a `fetch` to a public resolver. This package speaks the DoH JSON API (`application/dns-json`)
|
|
17
|
-
that Cloudflare and Google answer, validates the envelope, and returns a `Result` whose records
|
|
18
|
-
are typed per query type: an `MX` query gives `preference` and `exchange`, a `TXT` query gives
|
|
19
|
-
the joined `text` with the resolver's quoting and escapes removed.
|
|
20
|
-
|
|
21
|
-
Failures are classes, so a caller tells "the name is gone" from "the resolver is down" with
|
|
22
|
-
`instanceof`: NXDOMAIN is `NameNotFoundError`, SERVFAIL is `ServerFailureError`, any other RCODE
|
|
23
|
-
is `ResponseCodeError`, and a request that produced no DNS answer is `TransportError`. A name
|
|
24
|
-
that exists with no records of the asked type (NODATA) is a success with no records.
|
|
25
|
-
|
|
26
|
-
Each call is one request through the global `fetch` and keeps no state: every answer carries its
|
|
27
|
-
TTL for a caller that caches, and retry policy stays with the caller.
|
|
16
|
+
Every lookup returns a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result),
|
|
17
|
+
and record data is read by [`@sdxc/zone-file`](https://www.npmjs.com/package/@sdxc/zone-file); both
|
|
18
|
+
install alongside this package.
|
|
28
19
|
|
|
29
20
|
## Usage
|
|
30
21
|
|
|
31
|
-
### Look
|
|
22
|
+
### Look up records
|
|
32
23
|
|
|
33
24
|
```typescript
|
|
34
25
|
import { resolve } from "@sdxc/doh";
|
|
@@ -36,202 +27,241 @@ import { isSuccess } from "@sdxc/result";
|
|
|
36
27
|
|
|
37
28
|
let answer = await resolve("example.com", "MX");
|
|
38
29
|
if (isSuccess(answer)) {
|
|
39
|
-
for (let record of answer.data.records)
|
|
30
|
+
for (let record of answer.data.records) {
|
|
31
|
+
record.preference; // 10
|
|
32
|
+
record.exchange; // "mail.example.com"
|
|
33
|
+
}
|
|
34
|
+
answer.data.ttl; // the smallest TTL across the records
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each record carries the fields of its type plus `name` and `ttl`. A name that exists with no
|
|
39
|
+
records of the type (NODATA) succeeds with an empty `records`, and `chain` lists any CNAMEs
|
|
40
|
+
followed to reach them.
|
|
41
|
+
|
|
42
|
+
### Tell failures apart
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { NameNotFoundError, resolve, ServerFailureError, TransportError } from "@sdxc/doh";
|
|
46
|
+
import { isFailure } from "@sdxc/result";
|
|
47
|
+
|
|
48
|
+
let answer = await resolve("app.example.com", "A", { timeoutMs: 2000 });
|
|
49
|
+
if (isFailure(answer)) {
|
|
50
|
+
if (answer.error instanceof NameNotFoundError) return "the name does not exist";
|
|
51
|
+
if (answer.error instanceof ServerFailureError) return "the resolver failed (SERVFAIL)";
|
|
52
|
+
if (answer.error instanceof TransportError) return "no DNS answer";
|
|
53
|
+
return answer.error.message;
|
|
40
54
|
}
|
|
41
55
|
```
|
|
42
56
|
|
|
43
|
-
###
|
|
57
|
+
### Verify domain ownership
|
|
44
58
|
|
|
45
59
|
```typescript
|
|
46
60
|
import { checkCname, verifyTxtRecord } from "@sdxc/doh";
|
|
47
61
|
|
|
48
62
|
let verified = await verifyTxtRecord("_verify.example.com", "token_abc123"); // Result<boolean>
|
|
49
|
-
let pointed = await checkCname("shop.example.com", "custom.hosting.
|
|
63
|
+
let pointed = await checkCname("shop.example.com", "custom.hosting.com"); // Result<boolean>
|
|
50
64
|
```
|
|
51
65
|
|
|
52
|
-
|
|
66
|
+
Both read NXDOMAIN as `success(false)`, since an unpublished record is the ordinary state of a
|
|
67
|
+
verification in progress.
|
|
53
68
|
|
|
54
|
-
###
|
|
69
|
+
### Check whether a CA may issue
|
|
55
70
|
|
|
56
|
-
|
|
71
|
+
```typescript
|
|
72
|
+
import { mayIssue } from "@sdxc/doh/caa";
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
let verdict = await mayIssue({ domain: "*.example.com", issuer: "letsencrypt.org" });
|
|
75
|
+
if (!verdict.allowed) verdict.reason; // "not-authorized", "forbidden", "lookup-failed", …
|
|
76
|
+
```
|
|
60
77
|
|
|
61
|
-
|
|
62
|
-
`ZoneFile.RecordData<Type>` fields plus `name` and `ttl`, so a resolved record and one read from a
|
|
63
|
-
zone file share every type-specific field
|
|
64
|
-
- `unparsed` - records of the asked type whose data did not parse, as `{ name, ttl, type, data }`,
|
|
65
|
-
so one odd record never fails the whole answer
|
|
66
|
-
- `chain` - the CNAME records followed before reaching `records`
|
|
67
|
-
- `ttl` - the smallest TTL across `records` and `unparsed`, `null` when both are empty
|
|
68
|
-
- `authenticated` - the AD flag (the resolver validated DNSSEC); requiring it is the caller's policy
|
|
69
|
-
- `truncated` - the TC flag
|
|
70
|
-
- `name` (lowercased, no trailing dot), `type`, and `durationMs`
|
|
78
|
+
## API
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
back lowercased without the trailing dot; an AAAA address comes back in RFC 5952 form.
|
|
80
|
+
### `resolve(name, type, options?): Promise<Result<DoH.Answer<Type>, DoHError>>`
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
Looks up `name`'s records of `type` with one `fetch` and no state. Send an internationalized name
|
|
83
|
+
in its ASCII (punycode) form. Options:
|
|
76
84
|
|
|
77
|
-
- `resolver`: `CLOUDFLARE` (default), `GOOGLE`, or `{ url, format: "json" }`
|
|
85
|
+
- `resolver`: `CLOUDFLARE` (default), `GOOGLE`, or any `{ url, format: "json" }`
|
|
78
86
|
- `dnssec`: sets `do=1`
|
|
79
87
|
- `checkingDisabled`: sets `cd=1`
|
|
80
88
|
- `signal`: aborts the request
|
|
81
89
|
- `timeoutMs`: abandons the request as a `TransportError`, default `5000`
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
The answer holds `records`, `unparsed` (records of the type whose data did not parse, with their
|
|
92
|
+
raw `data`), `chain` (the CNAMEs followed to reach `records`), `ttl` (smallest across `records`
|
|
93
|
+
and `unparsed`, `null` when both are empty), `authenticated` (the AD flag), `truncated` (the TC
|
|
94
|
+
flag), `name`, `type` and `durationMs`. Names come back lowercased without the trailing dot, and
|
|
95
|
+
AAAA addresses in RFC 5952 form.
|
|
96
|
+
|
|
97
|
+
Typed record fields: `A` and `AAAA` (`address`), `CNAME`, `PTR` and `DNAME` (`target`), `NS`
|
|
84
98
|
(`host`), `MX` (`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`flags`, `critical`,
|
|
85
99
|
`tag`, `value`), `SRV` (`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`,
|
|
86
|
-
`serial`, `refresh`, `retry`, `expire`, `minimum`). Any other type
|
|
87
|
-
string. To read or print RDATA outside a lookup, use `parseRecordData` and `formatRecordData` from
|
|
88
|
-
`@sdxc/zone-file`.
|
|
89
|
-
|
|
90
|
-
#### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
|
|
91
|
-
|
|
92
|
-
Whether `name` publishes a TXT record whose joined text is exactly `expected`. A token split into
|
|
93
|
-
several character-strings or escaped still matches. NXDOMAIN is `success(false)`, since a record not
|
|
94
|
-
yet published is the ordinary state of a verification; every other failure stays a failure.
|
|
95
|
-
|
|
96
|
-
#### `checkCname(name, target, options?): Promise<Result<boolean, DoHError>>`
|
|
100
|
+
`serial`, `refresh`, `retry`, `expire`, `minimum`). Any other type gives a raw `data` string.
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
ignoring the trailing dot. It follows the chain one `CNAME` query per hop (up to eight, stopping on
|
|
100
|
-
a loop), so it holds even when the target resolves to nothing. NXDOMAIN is `success(false)`.
|
|
102
|
+
### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
Whether `name` publishes a TXT record whose joined text equals `expected`, so a token split into
|
|
105
|
+
several character-strings still matches. NXDOMAIN is `success(false)`.
|
|
103
106
|
|
|
104
|
-
`
|
|
105
|
-
`{ url: "https://dns.google/resolve", format: "json" }`.
|
|
107
|
+
### `checkCname(name, target, options?): Promise<Result<boolean, DoHError>>`
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
Whether `name` aliases `target`, directly or through up to eight CNAME hops, compared
|
|
110
|
+
case-insensitively without the trailing dot. It holds even when the target resolves to nothing.
|
|
111
|
+
NXDOMAIN is `success(false)`.
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
| -------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
111
|
-
| `DoHError` | Base of every `resolve` failure | |
|
|
112
|
-
| `NameNotFoundError` | RCODE 3, NXDOMAIN | `ttl`: the SOA's negative-caching TTL, or `null`; `authenticated`: the AD flag, a DNSSEC-validated denial |
|
|
113
|
-
| `ServerFailureError` | RCODE 2, SERVFAIL, DNSSEC validation failures included | |
|
|
114
|
-
| `ResponseCodeError` | Any other non-zero RCODE (FORMERR, NOTIMP, REFUSED) | `rcode` |
|
|
115
|
-
| `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not the envelope | `status`: the HTTP status, `null` when none arrived |
|
|
113
|
+
### `CLOUDFLARE` / `GOOGLE`
|
|
116
114
|
|
|
117
|
-
|
|
115
|
+
The resolvers `https://cloudflare-dns.com/dns-query` and `https://dns.google/resolve`, ready to
|
|
116
|
+
pass as `resolver`.
|
|
118
117
|
|
|
119
|
-
|
|
120
|
-
`RecordBase`, one alias per typed record (`ARecord` through `SOARecord`, `PTRRecord` and
|
|
121
|
-
`DNAMERecord` included), and `UnknownRecord`.
|
|
118
|
+
### Errors
|
|
122
119
|
|
|
123
|
-
|
|
120
|
+
| Class | When | Extra fields |
|
|
121
|
+
| -------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
122
|
+
| `DoHError` | Base of every failure | |
|
|
123
|
+
| `NameNotFoundError` | NXDOMAIN (RCODE 3) | `ttl`: the SOA's negative-caching TTL or `null`; `authenticated` |
|
|
124
|
+
| `ServerFailureError` | SERVFAIL (RCODE 2), DNSSEC validation failures included | |
|
|
125
|
+
| `ResponseCodeError` | Any other non-zero RCODE | `rcode` |
|
|
126
|
+
| `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not a DNS answer | `status`: the HTTP status, `null` when no response arrived |
|
|
124
127
|
|
|
125
|
-
|
|
126
|
-
authority, which RRset applies to a name, and whether a CA may issue for it.
|
|
128
|
+
### `parseCaaProperty(record): CAA.Property` from `./caa`
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
- `issue` / `issuewild` - `{ kind, critical, issuer, malformed, parameters }`. The value follows RFC
|
|
133
|
-
8659's grammar, whitespace around `;` and `=` included. `issuer` is lowercased without a trailing
|
|
134
|
-
dot, and `null` when the value names none (`";"`), which forbids issuance. A value that fails the
|
|
135
|
-
grammar reads as `issuer: null, malformed: true`, forbidding issuance the same way. Parameters keep
|
|
136
|
-
their published order and spelling.
|
|
137
|
-
- `iodef` - `{ kind, critical, url }`, `url` being `null` unless the value is a `mailto:`, `http:` or
|
|
138
|
-
`https:` URL.
|
|
139
|
-
- Any other tag - `{ kind: "unknown", critical, tag, value }`; critical, it forbids issuance.
|
|
130
|
+
Reads what one CAA record asks of a CA. `issue` and `issuewild` give `{ issuer, malformed,
|
|
131
|
+
parameters }`, with `issuer: null` when the value names none or fails RFC 8659's grammar (both
|
|
132
|
+
forbid issuance). `iodef` gives `{ url }`. Any other tag gives `{ kind: "unknown", tag, value }`.
|
|
140
133
|
|
|
141
134
|
```typescript
|
|
142
|
-
import { parseCaaProperty } from "@sdxc/doh/caa";
|
|
143
|
-
|
|
144
135
|
parseCaaProperty({
|
|
145
136
|
type: "CAA",
|
|
146
137
|
flags: 0,
|
|
147
138
|
critical: false,
|
|
148
139
|
tag: "issue",
|
|
149
|
-
value: "
|
|
140
|
+
value: "ca.com; accounturi=x",
|
|
150
141
|
});
|
|
151
|
-
// { kind: "issue", critical: false, issuer: "
|
|
152
|
-
// parameters: [{ key: "
|
|
142
|
+
// { kind: "issue", critical: false, issuer: "ca.com", malformed: false,
|
|
143
|
+
// parameters: [{ key: "accounturi", value: "x" }] }
|
|
153
144
|
```
|
|
154
145
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
RFC 8659's climb: one `CAA` query for the domain, then for each ancestor up to the TLD, stopping at
|
|
158
|
-
the first answer with records. A leading `*.` and a trailing dot are dropped and the name is
|
|
159
|
-
lowercased. NODATA and NXDOMAIN move the climb on; any other failure ends it as a failure. Records
|
|
160
|
-
reached through a CNAME or DNAME are the target's, with the aliases in `chain`, and when the target
|
|
161
|
-
publishes none the climb continues at the alias's parent.
|
|
146
|
+
### `findRelevantCaa(domain, options?): Promise<Result<CAA.RelevantSet, DoHError>>` from `./caa`
|
|
162
147
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
148
|
+
Finds the CAA RRset RFC 8659 applies to `domain`: its own, or the nearest ancestor's, one query
|
|
149
|
+
per label. A leading `*.` is dropped. The set holds `name` (where the records live, `null` when
|
|
150
|
+
nowhere), `records`, `unparsed`, `chain`, `queried` and `authenticated` (every answer in the climb
|
|
151
|
+
carried the AD flag).
|
|
166
152
|
|
|
167
|
-
|
|
153
|
+
### `evaluateCaa(records, request): CAA.Decision` from `./caa`
|
|
168
154
|
|
|
169
|
-
Decides a request
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
In order:
|
|
155
|
+
Decides a request `{ domain, issuer, accountUri?, validationMethod? }` against one RRset with no
|
|
156
|
+
I/O. `issuer` is one CAA identifier or a list; a `*.` domain reads `issuewild` when the set has
|
|
157
|
+
any. Checked in this order:
|
|
173
158
|
|
|
174
|
-
|
|
|
175
|
-
| ------------------------------------------------- | --------- |
|
|
176
|
-
| `no-policy` | `true` | No records
|
|
177
|
-
| `critical-tag` | `false` | A critical property
|
|
178
|
-
| `unrestricted` | `true` | No property applies
|
|
179
|
-
| `authorized` | `true` |
|
|
180
|
-
| `account-mismatch` / `validation-method-mismatch` | `false` |
|
|
181
|
-
| `not-authorized` | `false` |
|
|
182
|
-
| `forbidden` | `false` | Every applicable property names no issuer
|
|
159
|
+
| `reason` | `allowed` | When |
|
|
160
|
+
| ------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
|
161
|
+
| `no-policy` | `true` | No records |
|
|
162
|
+
| `critical-tag` | `false` | A critical property with a tag this package does not interpret |
|
|
163
|
+
| `unrestricted` | `true` | No property applies, such as only `iodef` |
|
|
164
|
+
| `authorized` | `true` | A property names the issuer and its parameters are satisfied |
|
|
165
|
+
| `account-mismatch` / `validation-method-mismatch` | `false` | RFC 8657 `accounturi` or `validationmethods` exclude the request |
|
|
166
|
+
| `not-authorized` | `false` | Properties name other issuers, listed in `issuers` |
|
|
167
|
+
| `forbidden` | `false` | Every applicable property names no issuer |
|
|
183
168
|
|
|
184
|
-
|
|
185
|
-
`issue ";"` beside `issue "ca.example"` still authorizes `ca.example`. Issuers match exactly and
|
|
186
|
-
case-insensitively. Two `accounturi` parameters, or a repeated, empty or malformed
|
|
187
|
-
`validationmethods`, are never satisfied; an omitted `accountUri` or `validationMethod` passes that
|
|
188
|
-
check, and the `authorized` decision carries the property whose parameters show what went unchecked.
|
|
169
|
+
An omitted `accountUri` or `validationMethod` passes that check.
|
|
189
170
|
|
|
190
|
-
|
|
171
|
+
### `mayIssue(request, options?): Promise<CAA.Verdict>` from `./caa`
|
|
191
172
|
|
|
192
|
-
`findRelevantCaa
|
|
193
|
-
`unreadable` when the set holds a record that does not parse, and `lookup-failed`
|
|
194
|
-
|
|
195
|
-
query, so `resolver: GOOGLE` confirms a verdict through a second resolver.
|
|
173
|
+
`findRelevantCaa` then `evaluateCaa`, with the RRset in `relevant`. It adds two refusals, as a CA
|
|
174
|
+
does: `unreadable` when the set holds a record that does not parse, and `lookup-failed` (with
|
|
175
|
+
`name`, `queried` and `error`) when a query in the climb fails.
|
|
196
176
|
|
|
197
|
-
|
|
177
|
+
### Types
|
|
198
178
|
|
|
199
|
-
|
|
179
|
+
- `DoH`: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
|
|
180
|
+
`RecordBase`, `UnknownRecord`, and one alias per typed record (`ARecord` through `SOARecord`).
|
|
181
|
+
- `CAA` (from `./caa`): `Record`, `Parameter`, `Property` and its three variants, `RelevantSet`,
|
|
182
|
+
`Request`, `Decision`, `Verdict`, and one interface per reason. Every one but `LookupFailed` is
|
|
183
|
+
plain data, ready to log or store.
|
|
200
184
|
|
|
201
|
-
|
|
202
|
-
`RelevantSet`, `Request`, `Decision`, `Verdict` and one interface per reason. Every one but
|
|
203
|
-
`LookupFailed` is plain data, ready for a log line or storage.
|
|
185
|
+
## Pattern: Cache Answers And Absences By TTL
|
|
204
186
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
Keep NXDOMAIN and NODATA apart, and treat `TransportError` and `ServerFailureError` as unknown
|
|
208
|
-
rather than as "no records".
|
|
187
|
+
Each answer carries the TTL to cache it for, and an NXDOMAIN carries the negative-caching TTL from
|
|
188
|
+
the zone's SOA.
|
|
209
189
|
|
|
210
190
|
```typescript
|
|
191
|
+
import { env } from "cloudflare:workers";
|
|
211
192
|
import { NameNotFoundError, resolve } from "@sdxc/doh";
|
|
212
193
|
import { isFailure } from "@sdxc/result";
|
|
213
194
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
195
|
+
async function cachedAddresses(hostname: string): Promise<string[] | null> {
|
|
196
|
+
let key = `doh:A:${hostname}`;
|
|
197
|
+
let cached = await env.DNS_CACHE.get<string[]>(key, "json");
|
|
198
|
+
if (cached) return cached;
|
|
199
|
+
|
|
200
|
+
let answer = await resolve(hostname, "A");
|
|
201
|
+
if (isFailure(answer)) {
|
|
202
|
+
if (!(answer.error instanceof NameNotFoundError)) return null;
|
|
203
|
+
let expirationTtl = Math.max(answer.error.ttl ?? 60, 60);
|
|
204
|
+
await env.DNS_CACHE.put(key, "[]", { expirationTtl });
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let addresses = answer.data.records.map((record) => record.address);
|
|
209
|
+
let expirationTtl = Math.max(answer.data.ttl ?? 60, 60);
|
|
210
|
+
await env.DNS_CACHE.put(key, JSON.stringify(addresses), { expirationTtl });
|
|
211
|
+
return addresses;
|
|
218
212
|
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Pattern: Confirm A Failure With A Second Resolver
|
|
219
216
|
|
|
220
|
-
|
|
217
|
+
A SERVFAIL or timeout from one resolver may be that resolver's problem. Ask another before
|
|
218
|
+
reporting it, and require DNSSEC validation when the answer must be trusted.
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { GOOGLE, resolve, ServerFailureError, TransportError } from "@sdxc/doh";
|
|
222
|
+
import { isFailure, isSuccess } from "@sdxc/result";
|
|
223
|
+
|
|
224
|
+
let options = { dnssec: true, timeoutMs: 3000 };
|
|
225
|
+
let answer = await resolve("example.com", "TXT", options);
|
|
226
|
+
if (
|
|
227
|
+
isFailure(answer) &&
|
|
228
|
+
(answer.error instanceof ServerFailureError || answer.error instanceof TransportError)
|
|
229
|
+
) {
|
|
230
|
+
answer = await resolve("example.com", "TXT", { ...options, resolver: GOOGLE });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let trusted = isSuccess(answer) && answer.data.authenticated;
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Pattern: A Custom Domain Onboarding Check
|
|
237
|
+
|
|
238
|
+
A customer proves ownership with a TXT token and points their host at yours with a CNAME. Report
|
|
239
|
+
each step separately, and keep a lookup failure apart from "not set up yet".
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
import { checkCname, verifyTxtRecord } from "@sdxc/doh";
|
|
243
|
+
import { isFailure } from "@sdxc/result";
|
|
244
|
+
|
|
245
|
+
async function checkDomain(hostname: string, token: string) {
|
|
246
|
+
let owned = await verifyTxtRecord(`_verify.${hostname}`, token);
|
|
247
|
+
let pointed = await checkCname(hostname, "custom.hosting.com");
|
|
248
|
+
if (isFailure(owned) || isFailure(pointed)) return { status: "retry-later" as const };
|
|
249
|
+
return { status: "checked" as const, owned: owned.data, pointed: pointed.data };
|
|
250
|
+
}
|
|
221
251
|
```
|
|
222
252
|
|
|
223
253
|
## Pattern: Compare A Zone File With DNS
|
|
224
254
|
|
|
225
|
-
A zone file and a resolver can spell one record differently
|
|
226
|
-
RFC 3597 generic data
|
|
227
|
-
equal
|
|
255
|
+
A zone file and a resolver can spell one record differently, such as `0 issue "ca.com"` against
|
|
256
|
+
RFC 3597 generic data. Parse both with `@sdxc/zone-file` and print them, and equal records give
|
|
257
|
+
equal strings.
|
|
228
258
|
|
|
229
259
|
```typescript
|
|
230
260
|
import { resolve } from "@sdxc/doh";
|
|
231
261
|
import { isSuccess } from "@sdxc/result";
|
|
232
262
|
import { formatRecordData, parseRecordData } from "@sdxc/zone-file";
|
|
233
263
|
|
|
234
|
-
let published = parseRecordData("CAA",
|
|
264
|
+
let published = parseRecordData("CAA", '0 issue "letsencrypt.org"');
|
|
235
265
|
let answer = await resolve("example.com", "CAA");
|
|
236
266
|
if (isSuccess(published) && isSuccess(answer)) {
|
|
237
267
|
let live = new Set(answer.data.records.map(formatRecordData));
|
|
@@ -239,49 +269,60 @@ if (isSuccess(published) && isSuccess(answer)) {
|
|
|
239
269
|
}
|
|
240
270
|
```
|
|
241
271
|
|
|
242
|
-
## Pattern: A
|
|
272
|
+
## Pattern: Decide A CAA Policy Before Publishing It
|
|
243
273
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
`reason` to retry first.
|
|
274
|
+
`evaluateCaa` runs on records from anywhere, so a policy can be checked against a request before
|
|
275
|
+
it reaches DNS, and `parseCaaProperty` shows what each record asks.
|
|
247
276
|
|
|
248
277
|
```typescript
|
|
249
|
-
import {
|
|
250
|
-
import {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
278
|
+
import { isSuccess } from "@sdxc/result";
|
|
279
|
+
import { evaluateCaa, parseCaaProperty } from "@sdxc/doh/caa";
|
|
280
|
+
import type { CAA } from "@sdxc/doh/caa";
|
|
281
|
+
import { parseRecordData } from "@sdxc/zone-file";
|
|
282
|
+
|
|
283
|
+
let lines = ['0 issue "letsencrypt.org; validationmethods=dns-01"', '0 issuewild ";"'];
|
|
284
|
+
let records: CAA.Record[] = [];
|
|
285
|
+
for (let line of lines) {
|
|
286
|
+
let parsed = parseRecordData("CAA", line);
|
|
287
|
+
if (isSuccess(parsed)) records.push(parsed.data);
|
|
258
288
|
}
|
|
259
|
-
```
|
|
260
289
|
|
|
261
|
-
|
|
290
|
+
records.map(parseCaaProperty); // [{ kind: "issue", issuer: "letsencrypt.org", … }, { kind: "issuewild", issuer: null, … }]
|
|
262
291
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
292
|
+
evaluateCaa(records, {
|
|
293
|
+
domain: "example.com",
|
|
294
|
+
issuer: "letsencrypt.org",
|
|
295
|
+
validationMethod: "dns-01",
|
|
296
|
+
});
|
|
297
|
+
// { allowed: true, reason: "authorized", property: … }
|
|
298
|
+
evaluateCaa(records, {
|
|
299
|
+
domain: "example.com",
|
|
300
|
+
issuer: "letsencrypt.org",
|
|
301
|
+
validationMethod: "http-01",
|
|
302
|
+
});
|
|
303
|
+
// { allowed: false, reason: "validation-method-mismatch", property: … }
|
|
304
|
+
evaluateCaa(records, { domain: "*.example.com", issuer: "letsencrypt.org" });
|
|
305
|
+
// { allowed: false, reason: "forbidden" }
|
|
274
306
|
```
|
|
275
307
|
|
|
276
|
-
## Pattern:
|
|
308
|
+
## Pattern: Explain A CAA Refusal
|
|
309
|
+
|
|
310
|
+
A CA refuses when its CAA lookup fails, so `mayIssue` answers `allowed: false` for a failing
|
|
311
|
+
lookup too. Narrow on `reason` to retry the lookup, or to tell the domain owner where the
|
|
312
|
+
blocking policy lives.
|
|
277
313
|
|
|
278
314
|
```typescript
|
|
279
|
-
import { GOOGLE
|
|
280
|
-
import {
|
|
315
|
+
import { GOOGLE } from "@sdxc/doh";
|
|
316
|
+
import { mayIssue } from "@sdxc/doh/caa";
|
|
317
|
+
|
|
318
|
+
let request = { domain: "shop.example.com", issuer: ["letsencrypt.org", "pki.goog"] };
|
|
319
|
+
let verdict = await mayIssue(request);
|
|
320
|
+
if (verdict.reason === "lookup-failed") verdict = await mayIssue(request, { resolver: GOOGLE });
|
|
281
321
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
322
|
+
if (verdict.reason === "not-authorized") {
|
|
323
|
+
let where = verdict.relevant.name; // "example.com"
|
|
324
|
+
let allowed = verdict.issuers; // ["digicert.com"]
|
|
325
|
+
let validated = verdict.relevant.authenticated;
|
|
285
326
|
}
|
|
286
327
|
```
|
|
287
328
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/doh",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.16",
|
|
4
4
|
"description": "Typed DNS over HTTPS lookups",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
"./caa": "./dist/caa.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@sdxc/result": "2026.10.
|
|
13
|
-
"@sdxc/zone-file": "2026.10.
|
|
12
|
+
"@sdxc/result": "2026.10.13",
|
|
13
|
+
"@sdxc/zone-file": "2026.10.13"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
14
16
|
"remix": "3.0.0"
|
|
15
17
|
},
|
|
16
|
-
"gitHead": "
|
|
18
|
+
"gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
|
|
17
19
|
"publishConfig": {
|
|
18
20
|
"access": "public"
|
|
19
21
|
},
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
23
25
|
"directory": "packages/doh"
|
|
24
26
|
},
|
|
25
|
-
"homepage": "https://sdxc.
|
|
27
|
+
"homepage": "https://sdxc.dev/api/doh",
|
|
26
28
|
"author": {
|
|
27
29
|
"name": "Sergio Xalambrí",
|
|
28
30
|
"url": "https://sergiodxa.com"
|