@sdxc/doh 0.0.0-pre.1
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/LICENSE.md +21 -0
- package/README.md +212 -0
- package/dist/character-strings.d.ts +22 -0
- package/dist/character-strings.js +72 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +68 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/parse-record-data.d.ts +23 -0
- package/dist/parse-record-data.js +283 -0
- package/dist/record-types.d.ts +14 -0
- package/dist/record-types.js +54 -0
- package/dist/resolve.d.ts +25 -0
- package/dist/resolve.js +138 -0
- package/dist/resolvers.d.ts +11 -0
- package/dist/resolvers.js +7 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.js +8 -0
- package/dist/verify.d.ts +34 -0
- package/dist/verify.js +57 -0
- package/package.json +23 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @sdxc/doh
|
|
2
|
+
|
|
3
|
+
Typed DNS over HTTPS lookups.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm add @sdxc/doh
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Every lookup returns a [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) value, which
|
|
12
|
+
installs alongside this package.
|
|
13
|
+
|
|
14
|
+
A runtime with no DNS socket, such as a Cloudflare Worker, resolves names through DNS over HTTPS:
|
|
15
|
+
a `fetch` to a public resolver. This package speaks the DoH JSON API (`application/dns-json`)
|
|
16
|
+
that Cloudflare and Google answer, validates the envelope, and returns a `Result` whose records
|
|
17
|
+
are typed per query type: an `MX` query gives `preference` and `exchange`, a `TXT` query gives
|
|
18
|
+
the joined `text` with the resolver's quoting and escapes removed.
|
|
19
|
+
|
|
20
|
+
Failures are classes, so a caller tells "the name is gone" from "the resolver is down" with
|
|
21
|
+
`instanceof`: NXDOMAIN is `NameNotFoundError`, SERVFAIL is `ServerFailureError`, any other RCODE
|
|
22
|
+
is `ResponseCodeError`, and a request that produced no DNS answer is `TransportError`. A name
|
|
23
|
+
that exists with no records of the asked type (NODATA) is a success with no records.
|
|
24
|
+
|
|
25
|
+
Each call is one request through the global `fetch` and keeps no state: every answer carries its
|
|
26
|
+
TTL for a caller that caches, and retry policy stays with the caller.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Look Up Records
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { resolve } from "@sdxc/doh";
|
|
34
|
+
import { isSuccess } from "@sdxc/result";
|
|
35
|
+
|
|
36
|
+
let answer = await resolve("example.com", "MX");
|
|
37
|
+
if (isSuccess(answer)) {
|
|
38
|
+
for (let record of answer.data.records) console.log(record.preference, record.exchange);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Check Domain Ownership
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { checkCname, verifyTxtRecord } from "@sdxc/doh";
|
|
46
|
+
|
|
47
|
+
let verified = await verifyTxtRecord("_verify.example.com", "token_abc123"); // Result<boolean>
|
|
48
|
+
let pointed = await checkCname("shop.example.com", "custom.hosting.example"); // Result<boolean>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Read Record Data From Anywhere
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { parseRecordData } from "@sdxc/doh";
|
|
55
|
+
|
|
56
|
+
let data = parseRecordData("MX", "10 mail.example.com.");
|
|
57
|
+
// success({ type: "MX", preference: 10, exchange: "mail.example.com" })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
### `@sdxc/doh`
|
|
63
|
+
|
|
64
|
+
#### `resolve(name, type, options?): Promise<Result<DoH.Answer<Type>, DoHError>>`
|
|
65
|
+
|
|
66
|
+
Looks up `name`'s records of `type`. The name is sent as written, so an internationalized name must
|
|
67
|
+
already be in its ASCII (punycode) form. The answer holds:
|
|
68
|
+
|
|
69
|
+
- `records` - the records of the asked type, typed by `DoH.RecordFor<Type>`
|
|
70
|
+
- `unparsed` - records of the asked type whose data did not parse, as `{ name, ttl, type, data }`,
|
|
71
|
+
so one odd record never fails the whole answer
|
|
72
|
+
- `chain` - the CNAME records followed before reaching `records`
|
|
73
|
+
- `ttl` - the smallest TTL across `records` and `unparsed`, `null` when both are empty
|
|
74
|
+
- `authenticated` - the AD flag (the resolver validated DNSSEC); requiring it is the caller's policy
|
|
75
|
+
- `truncated` - the TC flag
|
|
76
|
+
- `name` (lowercased, no trailing dot), `type`, and `durationMs`
|
|
77
|
+
|
|
78
|
+
Records of other types (RRSIG with `dnssec: true`, say) are dropped. Owner names and targets come
|
|
79
|
+
back lowercased without the trailing dot; an AAAA address comes back in RFC 5952 form.
|
|
80
|
+
|
|
81
|
+
**Options:**
|
|
82
|
+
|
|
83
|
+
- `resolver`: `CLOUDFLARE` (default), `GOOGLE`, or `{ url, format: "json" }`
|
|
84
|
+
- `dnssec`: sets `do=1`
|
|
85
|
+
- `checkingDisabled`: sets `cd=1`
|
|
86
|
+
- `signal`: aborts the request
|
|
87
|
+
- `timeoutMs`: abandons the request as a `TransportError`, default `5000`
|
|
88
|
+
|
|
89
|
+
Typed record types: `A` (`address`), `AAAA` (`address`), `CNAME` (`target`), `NS` (`host`), `MX`
|
|
90
|
+
(`preference`, `exchange`), `TXT` (`text`, `strings`), `CAA` (`critical`, `tag`, `value`), `SRV`
|
|
91
|
+
(`priority`, `weight`, `port`, `target`), `SOA` (`primary`, `mailbox`, `serial`, `refresh`,
|
|
92
|
+
`retry`, `expire`, `minimum`). Any other type returns records with a raw `data` string.
|
|
93
|
+
|
|
94
|
+
#### `parseRecordData(type, data): Result<DoH.RecordData<Type>, RecordDataError>`
|
|
95
|
+
|
|
96
|
+
The presentation-format reader `resolve` uses, for RDATA from anywhere else, such as a zone file.
|
|
97
|
+
Reads RFC 3597 generic data (`\# 4 0A000001`) for the typed types too, which is how Cloudflare
|
|
98
|
+
answers CAA. TXT bare words are separate character-strings, per RFC 1035.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
parseRecordData("TXT", '"v=DKIM1; p=AAA" "BBB"');
|
|
102
|
+
// success({ type: "TXT", text: "v=DKIM1; p=AAABBB", strings: ["v=DKIM1; p=AAA", "BBB"] })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### `verifyTxtRecord(name, expected, options?): Promise<Result<boolean, DoHError>>`
|
|
106
|
+
|
|
107
|
+
Whether `name` publishes a TXT record whose joined text is exactly `expected`. A token split into
|
|
108
|
+
several character-strings or escaped still matches. NXDOMAIN is `success(false)`, since a record not
|
|
109
|
+
yet published is the ordinary state of a verification; every other failure stays a failure.
|
|
110
|
+
|
|
111
|
+
#### `checkCname(name, target, options?): Promise<Result<boolean, DoHError>>`
|
|
112
|
+
|
|
113
|
+
Whether `name` aliases `target`, directly or through further CNAMEs, compared case-insensitively and
|
|
114
|
+
ignoring the trailing dot. It follows the chain one `CNAME` query per hop (up to eight, stopping on
|
|
115
|
+
a loop), so it holds even when the target resolves to nothing. NXDOMAIN is `success(false)`.
|
|
116
|
+
|
|
117
|
+
#### `CLOUDFLARE` / `GOOGLE`
|
|
118
|
+
|
|
119
|
+
`{ url: "https://cloudflare-dns.com/dns-query", format: "json" }` and
|
|
120
|
+
`{ url: "https://dns.google/resolve", format: "json" }`.
|
|
121
|
+
|
|
122
|
+
#### Errors
|
|
123
|
+
|
|
124
|
+
| Class | When | Extra field |
|
|
125
|
+
| -------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
126
|
+
| `DoHError` | Base of every `resolve` failure | |
|
|
127
|
+
| `NameNotFoundError` | RCODE 3, NXDOMAIN | `ttl`: the SOA's negative-caching TTL, or `null` |
|
|
128
|
+
| `ServerFailureError` | RCODE 2, SERVFAIL, DNSSEC validation failures included | |
|
|
129
|
+
| `ResponseCodeError` | Any other non-zero RCODE (FORMERR, NOTIMP, REFUSED) | `rcode` |
|
|
130
|
+
| `TransportError` | Network error, timeout, abort, non-2xx, or a body that is not the envelope | `status`: the HTTP status, `null` when none arrived |
|
|
131
|
+
| `RecordDataError` | `parseRecordData` on data that does not fit the type | |
|
|
132
|
+
|
|
133
|
+
#### `DoH` namespace
|
|
134
|
+
|
|
135
|
+
Types only: `RecordType`, `Resolver`, `ResolveOptions`, `Answer<Type>`, `RecordFor<Type>`,
|
|
136
|
+
`RecordData<Type>`, `RecordBase`, the nine record interfaces (`ARecord` through `SOARecord`), and
|
|
137
|
+
`UnknownRecord`.
|
|
138
|
+
|
|
139
|
+
## Pattern: Tell A Vanished Name From A Failing Resolver
|
|
140
|
+
|
|
141
|
+
Keep NXDOMAIN and NODATA apart, and treat `TransportError` and `ServerFailureError` as unknown
|
|
142
|
+
rather than as "no records".
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { NameNotFoundError, resolve } from "@sdxc/doh";
|
|
146
|
+
import { isFailure } from "@sdxc/result";
|
|
147
|
+
|
|
148
|
+
let answer = await resolve("app.example.com", "A");
|
|
149
|
+
if (isFailure(answer)) {
|
|
150
|
+
if (answer.error instanceof NameNotFoundError) return { records: [] };
|
|
151
|
+
return { error: answer.error.message }; // the resolver is having a bad minute
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let viaAlias = answer.data.chain.length > 0;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Pattern: Cache Through The Answer's TTL
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { resolve } from "@sdxc/doh";
|
|
161
|
+
import { isSuccess } from "@sdxc/result";
|
|
162
|
+
|
|
163
|
+
let answer = await resolve(hostname, "A");
|
|
164
|
+
if (isSuccess(answer)) {
|
|
165
|
+
let seconds = Math.max(answer.data.ttl ?? 60, 60);
|
|
166
|
+
await cache.put(`doh:A:${hostname}`, JSON.stringify(answer.data.records), {
|
|
167
|
+
expirationTtl: seconds,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Pattern: Confirm A SERVFAIL With A Second Resolver
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
import { GOOGLE, resolve, ServerFailureError } from "@sdxc/doh";
|
|
176
|
+
import { isFailure } from "@sdxc/result";
|
|
177
|
+
|
|
178
|
+
let answer = await resolve(name, "TXT");
|
|
179
|
+
if (isFailure(answer) && answer.error instanceof ServerFailureError) {
|
|
180
|
+
answer = await resolve(name, "TXT", { resolver: GOOGLE });
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Versioning
|
|
185
|
+
|
|
186
|
+
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
187
|
+
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
|
|
188
|
+
release goes out per day.
|
|
189
|
+
|
|
190
|
+
Those numbers say when, not what: a later date means a later release and carries no
|
|
191
|
+
compatibility promise. Any release may change or remove an export.
|
|
192
|
+
|
|
193
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"dependencies": {
|
|
198
|
+
"@sdxc/doh": "2026.9.4"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A caret or tilde range reads the date as major, minor and patch, so it accepts every
|
|
204
|
+
later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
|
209
|
+
|
|
210
|
+
## Author
|
|
211
|
+
|
|
212
|
+
[Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the character-strings of TXT RDATA in presentation format (RFC 1035 section 5.1):
|
|
3
|
+
* quoted strings keep their spaces, bare words split on whitespace, and `\"`, `\\` and
|
|
4
|
+
* `\DDD` escapes are decoded, so a resolver's quoting never leaks into the text.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import { RecordDataError } from "./errors.js";
|
|
11
|
+
/**
|
|
12
|
+
* Splits TXT presentation data into its character-strings. Escapes are decoded to octets
|
|
13
|
+
* and each string is read as UTF-8, so text a resolver writes as `\195\169` comes back as
|
|
14
|
+
* `é`. Fails on an unterminated quote or a decimal escape above 255.
|
|
15
|
+
*
|
|
16
|
+
* @param data - One or more quoted or bare character-strings.
|
|
17
|
+
* @returns The strings in order, empty strings included.
|
|
18
|
+
* @example readCharacterStrings('"v=DKIM1; p=AAA" "BBB"') // success(["v=DKIM1; p=AAA", "BBB"])
|
|
19
|
+
*/
|
|
20
|
+
export declare function readCharacterStrings(data: string): Result<string[], RecordDataError>;
|
|
21
|
+
/** Decodes octets as UTF-8, the reading applied to every character-string. */
|
|
22
|
+
export declare function decodeOctets(octets: Uint8Array): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { RecordDataError } from "./errors.js";
|
|
3
|
+
/** Decodes a character-string's octets as UTF-8, replacing invalid sequences with U+FFFD. */
|
|
4
|
+
const DECODER = new TextDecoder();
|
|
5
|
+
/** Encodes literal characters into the octets they stand for. */
|
|
6
|
+
const ENCODER = new TextEncoder();
|
|
7
|
+
/** Whether a character separates character-strings. */
|
|
8
|
+
function isSpace(char) {
|
|
9
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Splits TXT presentation data into its character-strings. Escapes are decoded to octets
|
|
13
|
+
* and each string is read as UTF-8, so text a resolver writes as `\195\169` comes back as
|
|
14
|
+
* `é`. Fails on an unterminated quote or a decimal escape above 255.
|
|
15
|
+
*
|
|
16
|
+
* @param data - One or more quoted or bare character-strings.
|
|
17
|
+
* @returns The strings in order, empty strings included.
|
|
18
|
+
* @example readCharacterStrings('"v=DKIM1; p=AAA" "BBB"') // success(["v=DKIM1; p=AAA", "BBB"])
|
|
19
|
+
*/
|
|
20
|
+
export function readCharacterStrings(data) {
|
|
21
|
+
let chars = Array.from(data);
|
|
22
|
+
let strings = [];
|
|
23
|
+
let index = 0;
|
|
24
|
+
while (index < chars.length) {
|
|
25
|
+
if (isSpace(chars[index])) {
|
|
26
|
+
index += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let quoted = chars[index] === '"';
|
|
30
|
+
if (quoted)
|
|
31
|
+
index += 1;
|
|
32
|
+
let octets = [];
|
|
33
|
+
let terminated = !quoted;
|
|
34
|
+
while (index < chars.length) {
|
|
35
|
+
let char = chars[index] ?? "";
|
|
36
|
+
if (quoted && char === '"') {
|
|
37
|
+
terminated = true;
|
|
38
|
+
index += 1;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (!quoted && isSpace(char))
|
|
42
|
+
break;
|
|
43
|
+
if (char === "\\") {
|
|
44
|
+
let digits = chars.slice(index + 1, index + 4).join("");
|
|
45
|
+
if (/^\d{3}$/.test(digits)) {
|
|
46
|
+
let octet = Number(digits);
|
|
47
|
+
if (octet > 255)
|
|
48
|
+
return failure(new RecordDataError(`Invalid escape \\${digits} in TXT data`));
|
|
49
|
+
octets.push(octet);
|
|
50
|
+
index += 4;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let next = chars[index + 1];
|
|
54
|
+
if (next === undefined)
|
|
55
|
+
return failure(new RecordDataError("TXT data ends in a lone backslash"));
|
|
56
|
+
octets.push(...ENCODER.encode(next));
|
|
57
|
+
index += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
octets.push(...ENCODER.encode(char));
|
|
61
|
+
index += 1;
|
|
62
|
+
}
|
|
63
|
+
if (!terminated)
|
|
64
|
+
return failure(new RecordDataError("TXT data has an unterminated quoted string"));
|
|
65
|
+
strings.push(DECODER.decode(new Uint8Array(octets)));
|
|
66
|
+
}
|
|
67
|
+
return success(strings);
|
|
68
|
+
}
|
|
69
|
+
/** Decodes octets as UTF-8, the reading applied to every character-string. */
|
|
70
|
+
export function decodeOctets(octets) {
|
|
71
|
+
return DECODER.decode(octets);
|
|
72
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failures a lookup returns, one class per meaning, so a caller tells "the name is
|
|
3
|
+
* gone" from "the resolver is down" with `instanceof` instead of matching messages.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** Every failure `resolve` returns; `instanceof` on it narrows to a lookup failure. */
|
|
9
|
+
export declare class DoHError extends Error {
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
/** RCODE 3 (NXDOMAIN): the name does not exist. */
|
|
13
|
+
export declare class NameNotFoundError extends DoHError {
|
|
14
|
+
name: string;
|
|
15
|
+
/**
|
|
16
|
+
* Seconds the absence may be cached: the smaller of the SOA's TTL and its minimum
|
|
17
|
+
* (RFC 2308), or `null` when the answer carried no SOA.
|
|
18
|
+
*/
|
|
19
|
+
readonly ttl: number | null;
|
|
20
|
+
/**
|
|
21
|
+
* @param queried - The name that was looked up.
|
|
22
|
+
* @param ttl - The negative-caching TTL, when known.
|
|
23
|
+
*/
|
|
24
|
+
constructor(queried: string, ttl: number | null);
|
|
25
|
+
}
|
|
26
|
+
/** RCODE 2 (SERVFAIL): the resolver could not answer, a DNSSEC validation failure included. */
|
|
27
|
+
export declare class ServerFailureError extends DoHError {
|
|
28
|
+
name: string;
|
|
29
|
+
/** @param queried - The name that was looked up. */
|
|
30
|
+
constructor(queried: string);
|
|
31
|
+
}
|
|
32
|
+
/** Any other non-zero RCODE: FORMERR, NOTIMP, REFUSED and the rest. */
|
|
33
|
+
export declare class ResponseCodeError extends DoHError {
|
|
34
|
+
name: string;
|
|
35
|
+
readonly rcode: number;
|
|
36
|
+
/**
|
|
37
|
+
* @param queried - The name that was looked up.
|
|
38
|
+
* @param rcode - The DNS response code the resolver returned.
|
|
39
|
+
*/
|
|
40
|
+
constructor(queried: string, rcode: number);
|
|
41
|
+
}
|
|
42
|
+
/** The request produced no DNS answer: a network error, a timeout, a non-2xx, or a body that is not the envelope. */
|
|
43
|
+
export declare class TransportError extends DoHError {
|
|
44
|
+
name: string;
|
|
45
|
+
/** The HTTP status when a response arrived, `null` when none did. */
|
|
46
|
+
readonly status: number | null;
|
|
47
|
+
/**
|
|
48
|
+
* @param message - What went wrong.
|
|
49
|
+
* @param status - The HTTP status, when a response arrived.
|
|
50
|
+
* @param options - The underlying error.
|
|
51
|
+
*/
|
|
52
|
+
constructor(message: string, status: number | null, options?: ErrorOptions);
|
|
53
|
+
}
|
|
54
|
+
/** RDATA that does not parse for its type; `resolve` keeps such a record in `unparsed`. */
|
|
55
|
+
export declare class RecordDataError extends Error {
|
|
56
|
+
name: string;
|
|
57
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failures a lookup returns, one class per meaning, so a caller tells "the name is
|
|
3
|
+
* gone" from "the resolver is down" with `instanceof` instead of matching messages.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** Every failure `resolve` returns; `instanceof` on it narrows to a lookup failure. */
|
|
9
|
+
export class DoHError extends Error {
|
|
10
|
+
name = "DoHError";
|
|
11
|
+
}
|
|
12
|
+
/** RCODE 3 (NXDOMAIN): the name does not exist. */
|
|
13
|
+
export class NameNotFoundError extends DoHError {
|
|
14
|
+
name = "NameNotFoundError";
|
|
15
|
+
/**
|
|
16
|
+
* Seconds the absence may be cached: the smaller of the SOA's TTL and its minimum
|
|
17
|
+
* (RFC 2308), or `null` when the answer carried no SOA.
|
|
18
|
+
*/
|
|
19
|
+
ttl;
|
|
20
|
+
/**
|
|
21
|
+
* @param queried - The name that was looked up.
|
|
22
|
+
* @param ttl - The negative-caching TTL, when known.
|
|
23
|
+
*/
|
|
24
|
+
constructor(queried, ttl) {
|
|
25
|
+
super(`${queried} does not exist (NXDOMAIN)`);
|
|
26
|
+
this.ttl = ttl;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** RCODE 2 (SERVFAIL): the resolver could not answer, a DNSSEC validation failure included. */
|
|
30
|
+
export class ServerFailureError extends DoHError {
|
|
31
|
+
name = "ServerFailureError";
|
|
32
|
+
/** @param queried - The name that was looked up. */
|
|
33
|
+
constructor(queried) {
|
|
34
|
+
super(`The resolver failed to answer for ${queried} (SERVFAIL)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Any other non-zero RCODE: FORMERR, NOTIMP, REFUSED and the rest. */
|
|
38
|
+
export class ResponseCodeError extends DoHError {
|
|
39
|
+
name = "ResponseCodeError";
|
|
40
|
+
rcode;
|
|
41
|
+
/**
|
|
42
|
+
* @param queried - The name that was looked up.
|
|
43
|
+
* @param rcode - The DNS response code the resolver returned.
|
|
44
|
+
*/
|
|
45
|
+
constructor(queried, rcode) {
|
|
46
|
+
super(`The resolver answered ${queried} with response code ${rcode}`);
|
|
47
|
+
this.rcode = rcode;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The request produced no DNS answer: a network error, a timeout, a non-2xx, or a body that is not the envelope. */
|
|
51
|
+
export class TransportError extends DoHError {
|
|
52
|
+
name = "TransportError";
|
|
53
|
+
/** The HTTP status when a response arrived, `null` when none did. */
|
|
54
|
+
status;
|
|
55
|
+
/**
|
|
56
|
+
* @param message - What went wrong.
|
|
57
|
+
* @param status - The HTTP status, when a response arrived.
|
|
58
|
+
* @param options - The underlying error.
|
|
59
|
+
*/
|
|
60
|
+
constructor(message, status, options) {
|
|
61
|
+
super(message, options);
|
|
62
|
+
this.status = status;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** RDATA that does not parse for its type; `resolve` keeps such a record in `unparsed`. */
|
|
66
|
+
export class RecordDataError extends Error {
|
|
67
|
+
name = "RecordDataError";
|
|
68
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed DNS-over-HTTPS lookups for runtimes with no DNS socket, such as Cloudflare Workers:
|
|
3
|
+
* `resolve` returns records typed per query type and distinct failures for NXDOMAIN,
|
|
4
|
+
* SERVFAIL and transport errors, beside the TXT and CNAME checks domain verification needs.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export type { DoH } from "./types.js";
|
|
10
|
+
export { DoHError, NameNotFoundError, RecordDataError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
|
|
11
|
+
export { parseRecordData } from "./parse-record-data.js";
|
|
12
|
+
export { resolve } from "./resolve.js";
|
|
13
|
+
export { CLOUDFLARE, GOOGLE } from "./resolvers.js";
|
|
14
|
+
export { checkCname, verifyTxtRecord } from "./verify.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed DNS-over-HTTPS lookups for runtimes with no DNS socket, such as Cloudflare Workers:
|
|
3
|
+
* `resolve` returns records typed per query type and distinct failures for NXDOMAIN,
|
|
4
|
+
* SERVFAIL and transport errors, beside the TXT and CNAME checks domain verification needs.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export { DoHError, NameNotFoundError, RecordDataError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
|
|
10
|
+
export { parseRecordData } from "./parse-record-data.js";
|
|
11
|
+
export { resolve } from "./resolve.js";
|
|
12
|
+
export { CLOUDFLARE, GOOGLE } from "./resolvers.js";
|
|
13
|
+
export { checkCname, verifyTxtRecord } from "./verify.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads record data in presentation format, as a DoH JSON answer or a zone file writes it,
|
|
3
|
+
* into typed fields per record type. RFC 3597 generic data (`\# len hex`) is decoded from
|
|
4
|
+
* its wire form, which is how some resolvers answer types such as CAA.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { DoH } from "./types.js";
|
|
11
|
+
import { RecordDataError } from "./errors.js";
|
|
12
|
+
/**
|
|
13
|
+
* Reads a record's data into its typed fields. Types without a typed reading come back
|
|
14
|
+
* as `{ type, data }` with the data unchanged. Names are lowercased without the trailing
|
|
15
|
+
* dot, except the root, which stays `"."`.
|
|
16
|
+
*
|
|
17
|
+
* @param type - The record type, as a mnemonic or RFC 3597 `TYPEnnn`.
|
|
18
|
+
* @param data - The RDATA in presentation format.
|
|
19
|
+
* @returns The record's fields, or why the data does not fit the type.
|
|
20
|
+
* @template Type - The record type, which selects the fields returned.
|
|
21
|
+
* @example parseRecordData("MX", "10 mx.example.com.") // success({ type: "MX", preference: 10, exchange: "mx.example.com" })
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseRecordData<Type extends DoH.RecordType>(type: Type, data: string): Result<DoH.RecordData<Type>, RecordDataError>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { decodeOctets, readCharacterStrings } from "./character-strings.js";
|
|
3
|
+
import { RecordDataError } from "./errors.js";
|
|
4
|
+
import { canonicalType } from "./record-types.js";
|
|
5
|
+
/**
|
|
6
|
+
* A dotted-quad IPv4 address without leading zeros, which `inet_aton` would read as octal
|
|
7
|
+
* and browsers as decimal, so an address carrying them has no single meaning.
|
|
8
|
+
*/
|
|
9
|
+
const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
10
|
+
/**
|
|
11
|
+
* Reads a record's data into its typed fields. Types without a typed reading come back
|
|
12
|
+
* as `{ type, data }` with the data unchanged. Names are lowercased without the trailing
|
|
13
|
+
* dot, except the root, which stays `"."`.
|
|
14
|
+
*
|
|
15
|
+
* @param type - The record type, as a mnemonic or RFC 3597 `TYPEnnn`.
|
|
16
|
+
* @param data - The RDATA in presentation format.
|
|
17
|
+
* @returns The record's fields, or why the data does not fit the type.
|
|
18
|
+
* @template Type - The record type, which selects the fields returned.
|
|
19
|
+
* @example parseRecordData("MX", "10 mx.example.com.") // success({ type: "MX", preference: 10, exchange: "mx.example.com" })
|
|
20
|
+
*/
|
|
21
|
+
export function parseRecordData(type, data) {
|
|
22
|
+
let canonical = canonicalType(type);
|
|
23
|
+
let value = data.trim();
|
|
24
|
+
let parsed = value.startsWith("\\#")
|
|
25
|
+
? parseGeneric(canonical, value)
|
|
26
|
+
: parsePresentation(canonical, value);
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
/** Fails with the reason a record's data does not fit its type. */
|
|
30
|
+
function invalid(type, data) {
|
|
31
|
+
return failure(new RecordDataError(`Invalid ${type} record data: ${JSON.stringify(data)}`));
|
|
32
|
+
}
|
|
33
|
+
/** Folds a domain name: lowercased, trailing dot dropped, the root kept as `"."`. */
|
|
34
|
+
function normalizeName(name) {
|
|
35
|
+
let lower = name.toLowerCase();
|
|
36
|
+
return lower.length > 1 && lower.endsWith(".") ? lower.slice(0, -1) : lower;
|
|
37
|
+
}
|
|
38
|
+
/** Reads an unsigned decimal field no larger than `max`, or `null`. */
|
|
39
|
+
function readNumber(field, max) {
|
|
40
|
+
if (field === undefined || !/^\d+$/.test(field))
|
|
41
|
+
return null;
|
|
42
|
+
let number = Number(field);
|
|
43
|
+
return number <= max ? number : null;
|
|
44
|
+
}
|
|
45
|
+
/** An IPv6 literal in RFC 5952 canonical form, or `null` when the text is not one. */
|
|
46
|
+
function canonicalIpv6(value) {
|
|
47
|
+
if (!value.includes(":"))
|
|
48
|
+
return null;
|
|
49
|
+
let url = URL.parse(`http://[${value}]/`);
|
|
50
|
+
return url ? url.hostname.slice(1, -1) : null;
|
|
51
|
+
}
|
|
52
|
+
/** Reads presentation-format data for the typed record types. */
|
|
53
|
+
function parsePresentation(type, value) {
|
|
54
|
+
let fields = value.split(/\s+/).filter((field) => field.length > 0);
|
|
55
|
+
switch (type) {
|
|
56
|
+
case "A":
|
|
57
|
+
return IPV4_PATTERN.test(value) ? success({ type, address: value }) : invalid(type, value);
|
|
58
|
+
case "AAAA": {
|
|
59
|
+
let address = canonicalIpv6(value);
|
|
60
|
+
return address ? success({ type, address }) : invalid(type, value);
|
|
61
|
+
}
|
|
62
|
+
case "CNAME":
|
|
63
|
+
case "NS": {
|
|
64
|
+
if (fields.length !== 1 || fields[0] === undefined)
|
|
65
|
+
return invalid(type, value);
|
|
66
|
+
let name = normalizeName(fields[0]);
|
|
67
|
+
return success(type === "CNAME" ? { type, target: name } : { type, host: name });
|
|
68
|
+
}
|
|
69
|
+
case "MX": {
|
|
70
|
+
let preference = readNumber(fields[0], 0xffff);
|
|
71
|
+
if (fields.length !== 2 || preference === null || fields[1] === undefined)
|
|
72
|
+
return invalid(type, value);
|
|
73
|
+
return success({ type, preference, exchange: normalizeName(fields[1]) });
|
|
74
|
+
}
|
|
75
|
+
case "TXT": {
|
|
76
|
+
let strings = readCharacterStrings(value);
|
|
77
|
+
if (isFailure(strings))
|
|
78
|
+
return strings;
|
|
79
|
+
return success({ type, text: strings.data.join(""), strings: strings.data });
|
|
80
|
+
}
|
|
81
|
+
case "CAA": {
|
|
82
|
+
let match = /^(\d+)\s+([A-Za-z0-9]+)\s+(.+)$/.exec(value);
|
|
83
|
+
let flags = readNumber(match?.[1], 0xff);
|
|
84
|
+
if (!match || flags === null || match[2] === undefined || match[3] === undefined)
|
|
85
|
+
return invalid(type, value);
|
|
86
|
+
let raw = match[3];
|
|
87
|
+
let text = raw;
|
|
88
|
+
if (raw.startsWith('"')) {
|
|
89
|
+
let strings = readCharacterStrings(raw);
|
|
90
|
+
if (isFailure(strings))
|
|
91
|
+
return strings;
|
|
92
|
+
text = strings.data.join("");
|
|
93
|
+
}
|
|
94
|
+
return success({
|
|
95
|
+
type,
|
|
96
|
+
critical: (flags & 0x80) !== 0,
|
|
97
|
+
tag: match[2].toLowerCase(),
|
|
98
|
+
value: text,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
case "SRV": {
|
|
102
|
+
let [priority, weight, port] = fields.slice(0, 3).map((field) => readNumber(field, 0xffff));
|
|
103
|
+
let target = fields[3];
|
|
104
|
+
if (fields.length !== 4 ||
|
|
105
|
+
priority == null ||
|
|
106
|
+
weight == null ||
|
|
107
|
+
port == null ||
|
|
108
|
+
target === undefined) {
|
|
109
|
+
return invalid(type, value);
|
|
110
|
+
}
|
|
111
|
+
return success({ type, priority, weight, port, target: normalizeName(target) });
|
|
112
|
+
}
|
|
113
|
+
case "SOA": {
|
|
114
|
+
let [primary, mailbox] = fields;
|
|
115
|
+
let numbers = fields.slice(2).map((field) => readNumber(field, 0xffffffff));
|
|
116
|
+
if (fields.length !== 7 ||
|
|
117
|
+
primary === undefined ||
|
|
118
|
+
mailbox === undefined ||
|
|
119
|
+
numbers.some((n) => n === null)) {
|
|
120
|
+
return invalid(type, value);
|
|
121
|
+
}
|
|
122
|
+
let [serial, refresh, retry, expire, minimum] = numbers;
|
|
123
|
+
return success({
|
|
124
|
+
type,
|
|
125
|
+
primary: normalizeName(primary),
|
|
126
|
+
mailbox: normalizeName(mailbox),
|
|
127
|
+
serial,
|
|
128
|
+
refresh,
|
|
129
|
+
retry,
|
|
130
|
+
expire,
|
|
131
|
+
minimum,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
return success({ type, data: value });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Decodes RFC 3597 generic data (`\# <length> <hex>`) and reads the octets for the type. */
|
|
139
|
+
function parseGeneric(type, value) {
|
|
140
|
+
let match = /^\\#\s+(\d+)((?:\s*[0-9a-fA-F]+)*)\s*$/.exec(value);
|
|
141
|
+
if (!match)
|
|
142
|
+
return invalid(type, value);
|
|
143
|
+
let hex = (match[2] ?? "").replace(/\s+/g, "");
|
|
144
|
+
let length = Number(match[1]);
|
|
145
|
+
if (hex.length !== length * 2)
|
|
146
|
+
return invalid(type, value);
|
|
147
|
+
let octets = new Uint8Array(length);
|
|
148
|
+
for (let index = 0; index < length; index++)
|
|
149
|
+
octets[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
|
150
|
+
let fields = new WireReader(octets).record(type);
|
|
151
|
+
if (fields === "unknown")
|
|
152
|
+
return success({ type, data: value });
|
|
153
|
+
return fields ? success({ type, ...fields }) : invalid(type, value);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Reads the wire form of RDATA front to back. Every read returns `null` past the end, and a
|
|
157
|
+
* record parses only when it consumes every octet, since trailing bytes mean a wrong type.
|
|
158
|
+
*/
|
|
159
|
+
class WireReader {
|
|
160
|
+
#octets;
|
|
161
|
+
#offset = 0;
|
|
162
|
+
/** @param octets - The RDATA octets. */
|
|
163
|
+
constructor(octets) {
|
|
164
|
+
this.#octets = octets;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The typed fields for `type`, `null` when the octets do not fit it, or `"unknown"` for a
|
|
168
|
+
* type without a typed reading.
|
|
169
|
+
*/
|
|
170
|
+
record(type) {
|
|
171
|
+
let fields = this.#fields(type);
|
|
172
|
+
if (fields === null || fields === "unknown")
|
|
173
|
+
return fields;
|
|
174
|
+
return this.#offset === this.#octets.length ? fields : null;
|
|
175
|
+
}
|
|
176
|
+
/** Reads the type's fields, leaving the offset after the last octet read. */
|
|
177
|
+
#fields(type) {
|
|
178
|
+
switch (type) {
|
|
179
|
+
case "A": {
|
|
180
|
+
let bytes = this.#bytes(4);
|
|
181
|
+
return bytes ? { address: Array.from(bytes).join(".") } : null;
|
|
182
|
+
}
|
|
183
|
+
case "AAAA": {
|
|
184
|
+
let bytes = this.#bytes(16);
|
|
185
|
+
if (!bytes)
|
|
186
|
+
return null;
|
|
187
|
+
let groups = Array.from({ length: 8 }, (_, index) => (((bytes[index * 2] ?? 0) << 8) | (bytes[index * 2 + 1] ?? 0)).toString(16));
|
|
188
|
+
return { address: canonicalIpv6(groups.join(":")) ?? groups.join(":") };
|
|
189
|
+
}
|
|
190
|
+
case "CNAME": {
|
|
191
|
+
let target = this.#name();
|
|
192
|
+
return target === null ? null : { target };
|
|
193
|
+
}
|
|
194
|
+
case "NS": {
|
|
195
|
+
let host = this.#name();
|
|
196
|
+
return host === null ? null : { host };
|
|
197
|
+
}
|
|
198
|
+
case "MX": {
|
|
199
|
+
let preference = this.#uint(2);
|
|
200
|
+
let exchange = this.#name();
|
|
201
|
+
return preference === null || exchange === null ? null : { preference, exchange };
|
|
202
|
+
}
|
|
203
|
+
case "TXT": {
|
|
204
|
+
let strings = [];
|
|
205
|
+
while (this.#offset < this.#octets.length) {
|
|
206
|
+
let length = this.#uint(1);
|
|
207
|
+
let bytes = length === null ? null : this.#bytes(length);
|
|
208
|
+
if (!bytes)
|
|
209
|
+
return null;
|
|
210
|
+
strings.push(decodeOctets(bytes));
|
|
211
|
+
}
|
|
212
|
+
return { text: strings.join(""), strings };
|
|
213
|
+
}
|
|
214
|
+
case "CAA": {
|
|
215
|
+
let flags = this.#uint(1);
|
|
216
|
+
let tagLength = this.#uint(1);
|
|
217
|
+
let tag = tagLength === null ? null : this.#bytes(tagLength);
|
|
218
|
+
if (flags === null || !tag)
|
|
219
|
+
return null;
|
|
220
|
+
let rest = this.#bytes(this.#octets.length - this.#offset) ?? new Uint8Array();
|
|
221
|
+
return {
|
|
222
|
+
critical: (flags & 0x80) !== 0,
|
|
223
|
+
tag: decodeOctets(tag).toLowerCase(),
|
|
224
|
+
value: decodeOctets(rest),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
case "SRV": {
|
|
228
|
+
let priority = this.#uint(2);
|
|
229
|
+
let weight = this.#uint(2);
|
|
230
|
+
let port = this.#uint(2);
|
|
231
|
+
let target = this.#name();
|
|
232
|
+
if (priority === null || weight === null || port === null || target === null)
|
|
233
|
+
return null;
|
|
234
|
+
return { priority, weight, port, target };
|
|
235
|
+
}
|
|
236
|
+
case "SOA": {
|
|
237
|
+
let primary = this.#name();
|
|
238
|
+
let mailbox = this.#name();
|
|
239
|
+
let numbers = Array.from({ length: 5 }, () => this.#uint(4));
|
|
240
|
+
if (primary === null || mailbox === null || numbers.some((n) => n === null))
|
|
241
|
+
return null;
|
|
242
|
+
let [serial, refresh, retry, expire, minimum] = numbers;
|
|
243
|
+
return { primary, mailbox, serial, refresh, retry, expire, minimum };
|
|
244
|
+
}
|
|
245
|
+
default:
|
|
246
|
+
return "unknown";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** The next `length` octets, or `null` past the end. */
|
|
250
|
+
#bytes(length) {
|
|
251
|
+
if (this.#offset + length > this.#octets.length)
|
|
252
|
+
return null;
|
|
253
|
+
let bytes = this.#octets.subarray(this.#offset, this.#offset + length);
|
|
254
|
+
this.#offset += length;
|
|
255
|
+
return bytes;
|
|
256
|
+
}
|
|
257
|
+
/** A big-endian unsigned integer of `size` octets. */
|
|
258
|
+
#uint(size) {
|
|
259
|
+
let bytes = this.#bytes(size);
|
|
260
|
+
if (!bytes)
|
|
261
|
+
return null;
|
|
262
|
+
return bytes.reduce((total, byte) => total * 256 + byte, 0);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* An uncompressed domain name, folded like presentation names. RFC 3597 forbids
|
|
266
|
+
* compression in generic data, so a compression pointer fails the read.
|
|
267
|
+
*/
|
|
268
|
+
#name() {
|
|
269
|
+
let labels = [];
|
|
270
|
+
while (true) {
|
|
271
|
+
let length = this.#uint(1);
|
|
272
|
+
if (length === null || length > 63)
|
|
273
|
+
return null;
|
|
274
|
+
if (length === 0)
|
|
275
|
+
break;
|
|
276
|
+
let label = this.#bytes(length);
|
|
277
|
+
if (!label)
|
|
278
|
+
return null;
|
|
279
|
+
labels.push(String.fromCharCode(...label));
|
|
280
|
+
}
|
|
281
|
+
return labels.length === 0 ? "." : normalizeName(labels.join("."));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps record type mnemonics to the IANA codes the JSON API writes in `type`, and back,
|
|
3
|
+
* with RFC 3597's `TYPEnnn` spelling for any code without a mnemonic here.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** The mnemonic for a type code, or `TYPEnnn` when it has none here. */
|
|
9
|
+
export declare function typeName(code: number): string;
|
|
10
|
+
/**
|
|
11
|
+
* The spelling of a type as the query's `type` parameter and as a record's `type`: the
|
|
12
|
+
* uppercase mnemonic, and a known mnemonic for `TYPEnnn` when one exists.
|
|
13
|
+
*/
|
|
14
|
+
export declare function canonicalType(type: string): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps record type mnemonics to the IANA codes the JSON API writes in `type`, and back,
|
|
3
|
+
* with RFC 3597's `TYPEnnn` spelling for any code without a mnemonic here.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/** The IANA codes of the record types a caller is likely to ask for or see in an answer. */
|
|
9
|
+
const TYPE_CODES = {
|
|
10
|
+
A: 1,
|
|
11
|
+
NS: 2,
|
|
12
|
+
CNAME: 5,
|
|
13
|
+
SOA: 6,
|
|
14
|
+
PTR: 12,
|
|
15
|
+
HINFO: 13,
|
|
16
|
+
MX: 15,
|
|
17
|
+
TXT: 16,
|
|
18
|
+
AAAA: 28,
|
|
19
|
+
LOC: 29,
|
|
20
|
+
SRV: 33,
|
|
21
|
+
NAPTR: 35,
|
|
22
|
+
DNAME: 39,
|
|
23
|
+
DS: 43,
|
|
24
|
+
SSHFP: 44,
|
|
25
|
+
RRSIG: 46,
|
|
26
|
+
NSEC: 47,
|
|
27
|
+
DNSKEY: 48,
|
|
28
|
+
NSEC3: 50,
|
|
29
|
+
NSEC3PARAM: 51,
|
|
30
|
+
TLSA: 52,
|
|
31
|
+
SMIMEA: 53,
|
|
32
|
+
CDS: 59,
|
|
33
|
+
CDNSKEY: 60,
|
|
34
|
+
OPENPGPKEY: 61,
|
|
35
|
+
SVCB: 64,
|
|
36
|
+
HTTPS: 65,
|
|
37
|
+
URI: 256,
|
|
38
|
+
CAA: 257,
|
|
39
|
+
};
|
|
40
|
+
/** The mnemonic for each code in {@link TYPE_CODES}. */
|
|
41
|
+
const TYPE_NAMES = new Map(Object.entries(TYPE_CODES).map(([name, code]) => [code, name]));
|
|
42
|
+
/** The mnemonic for a type code, or `TYPEnnn` when it has none here. */
|
|
43
|
+
export function typeName(code) {
|
|
44
|
+
return TYPE_NAMES.get(code) ?? `TYPE${code}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The spelling of a type as the query's `type` parameter and as a record's `type`: the
|
|
48
|
+
* uppercase mnemonic, and a known mnemonic for `TYPEnnn` when one exists.
|
|
49
|
+
*/
|
|
50
|
+
export function canonicalType(type) {
|
|
51
|
+
let upper = type.trim().toUpperCase();
|
|
52
|
+
let generic = /^TYPE(\d+)$/.exec(upper);
|
|
53
|
+
return generic ? typeName(Number(generic[1])) : upper;
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Looks a name up over the DoH JSON API: one `fetch` to the resolver, the envelope
|
|
3
|
+
* validated, the RCODE classified into a typed failure, and the answer split into the
|
|
4
|
+
* records of the asked type and the CNAME chain that led to them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { DoHError } from "./errors.js";
|
|
11
|
+
import type { DoH } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Resolves `name`'s records of `type`. NXDOMAIN, SERVFAIL, any other RCODE and a request
|
|
14
|
+
* that never produced an answer are distinct `DoHError` subclasses; a name that exists
|
|
15
|
+
* with no records of the type succeeds with none. The name is sent as written, so an
|
|
16
|
+
* internationalized name must already be in its ASCII form.
|
|
17
|
+
*
|
|
18
|
+
* @param name - The domain name to look up.
|
|
19
|
+
* @param type - The record type, as a mnemonic or RFC 3597 `TYPEnnn`.
|
|
20
|
+
* @param options - Resolver, DNSSEC flags, abort signal and timeout.
|
|
21
|
+
* @returns The answer, or the typed reason there is none.
|
|
22
|
+
* @template Type - The record type, which types the records returned.
|
|
23
|
+
* @example let answer = await resolve("example.com", "MX");
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolve<Type extends DoH.RecordType>(name: string, type: Type, options?: DoH.ResolveOptions): Promise<Result<DoH.Answer<Type>, DoHError>>;
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { failure, isSuccess, success } from "@sdxc/result";
|
|
2
|
+
import * as s from "remix/data-schema";
|
|
3
|
+
import { NameNotFoundError, ResponseCodeError, ServerFailureError, TransportError, } from "./errors.js";
|
|
4
|
+
import { parseRecordData } from "./parse-record-data.js";
|
|
5
|
+
import { canonicalType, typeName } from "./record-types.js";
|
|
6
|
+
import { CLOUDFLARE } from "./resolvers.js";
|
|
7
|
+
/** One resource record as the JSON API writes it. */
|
|
8
|
+
const WIRE_RECORD_SCHEMA = s.object({
|
|
9
|
+
name: s.string(),
|
|
10
|
+
type: s.number(),
|
|
11
|
+
TTL: s.number(),
|
|
12
|
+
data: s.string(),
|
|
13
|
+
});
|
|
14
|
+
/** The members of the JSON envelope this package reads; Cloudflare and Google both send them. */
|
|
15
|
+
const ENVELOPE_SCHEMA = s.object({
|
|
16
|
+
Status: s.number(),
|
|
17
|
+
TC: s.optional(s.boolean()),
|
|
18
|
+
AD: s.optional(s.boolean()),
|
|
19
|
+
Answer: s.optional(s.array(WIRE_RECORD_SCHEMA)),
|
|
20
|
+
Authority: s.optional(s.array(WIRE_RECORD_SCHEMA)),
|
|
21
|
+
});
|
|
22
|
+
/** Folds a name the way every owner name in an answer is reported. */
|
|
23
|
+
function normalizeName(name) {
|
|
24
|
+
let lower = name.toLowerCase();
|
|
25
|
+
return lower.length > 1 && lower.endsWith(".") ? lower.slice(0, -1) : lower;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolves `name`'s records of `type`. NXDOMAIN, SERVFAIL, any other RCODE and a request
|
|
29
|
+
* that never produced an answer are distinct `DoHError` subclasses; a name that exists
|
|
30
|
+
* with no records of the type succeeds with none. The name is sent as written, so an
|
|
31
|
+
* internationalized name must already be in its ASCII form.
|
|
32
|
+
*
|
|
33
|
+
* @param name - The domain name to look up.
|
|
34
|
+
* @param type - The record type, as a mnemonic or RFC 3597 `TYPEnnn`.
|
|
35
|
+
* @param options - Resolver, DNSSEC flags, abort signal and timeout.
|
|
36
|
+
* @returns The answer, or the typed reason there is none.
|
|
37
|
+
* @template Type - The record type, which types the records returned.
|
|
38
|
+
* @example let answer = await resolve("example.com", "MX");
|
|
39
|
+
*/
|
|
40
|
+
export async function resolve(name, type, options = {}) {
|
|
41
|
+
let resolver = options.resolver ?? CLOUDFLARE;
|
|
42
|
+
let timeoutMs = options.timeoutMs ?? 5000;
|
|
43
|
+
let asked = canonicalType(type);
|
|
44
|
+
let url = new URL(resolver.url);
|
|
45
|
+
url.searchParams.set("name", name);
|
|
46
|
+
url.searchParams.set("type", /^TYPE\d+$/.test(asked) ? asked.slice(4) : asked);
|
|
47
|
+
if (options.dnssec)
|
|
48
|
+
url.searchParams.set("do", "1");
|
|
49
|
+
if (options.checkingDisabled)
|
|
50
|
+
url.searchParams.set("cd", "1");
|
|
51
|
+
let timeout = AbortSignal.timeout(timeoutMs);
|
|
52
|
+
let signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
53
|
+
let started = performance.now();
|
|
54
|
+
let envelope = await fetchEnvelope(url, signal, timeout, timeoutMs);
|
|
55
|
+
if (!isSuccess(envelope))
|
|
56
|
+
return envelope;
|
|
57
|
+
let durationMs = Math.round(performance.now() - started);
|
|
58
|
+
let { Status: rcode } = envelope.data;
|
|
59
|
+
if (rcode === 3)
|
|
60
|
+
return failure(new NameNotFoundError(name, negativeTtl(envelope.data)));
|
|
61
|
+
if (rcode === 2)
|
|
62
|
+
return failure(new ServerFailureError(name));
|
|
63
|
+
if (rcode !== 0)
|
|
64
|
+
return failure(new ResponseCodeError(name, rcode));
|
|
65
|
+
let records = [];
|
|
66
|
+
let unparsed = [];
|
|
67
|
+
let chain = [];
|
|
68
|
+
for (let record of envelope.data.Answer ?? []) {
|
|
69
|
+
let recordType = typeName(record.type);
|
|
70
|
+
if (recordType !== asked && recordType !== "CNAME")
|
|
71
|
+
continue;
|
|
72
|
+
let base = { name: normalizeName(record.name), ttl: record.TTL };
|
|
73
|
+
let data = parseRecordData(recordType, record.data);
|
|
74
|
+
if (recordType === asked) {
|
|
75
|
+
if (isSuccess(data))
|
|
76
|
+
records.push({ ...base, ...data.data });
|
|
77
|
+
else
|
|
78
|
+
unparsed.push({ ...base, type: recordType, data: record.data });
|
|
79
|
+
}
|
|
80
|
+
else if (isSuccess(data)) {
|
|
81
|
+
chain.push({ ...base, ...data.data });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let ttls = [...records, ...unparsed].map((record) => record.ttl);
|
|
85
|
+
return success({
|
|
86
|
+
name: normalizeName(name),
|
|
87
|
+
type,
|
|
88
|
+
records,
|
|
89
|
+
unparsed,
|
|
90
|
+
chain,
|
|
91
|
+
ttl: ttls.length > 0 ? Math.min(...ttls) : null,
|
|
92
|
+
authenticated: envelope.data.AD ?? false,
|
|
93
|
+
truncated: envelope.data.TC ?? false,
|
|
94
|
+
durationMs,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Sends the query and validates the envelope, mapping every way the exchange can fail
|
|
99
|
+
* without a DNS answer to a `TransportError`.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchEnvelope(url, signal, timeout, timeoutMs) {
|
|
102
|
+
let response;
|
|
103
|
+
try {
|
|
104
|
+
response = await fetch(url, { headers: { Accept: "application/dns-json" }, signal });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
let message = timeout.aborted
|
|
108
|
+
? `The DNS query timed out after ${timeoutMs}ms`
|
|
109
|
+
: `The DNS query failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
110
|
+
return failure(new TransportError(message, null, { cause: error }));
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
return failure(new TransportError(`The resolver answered HTTP ${response.status}`, response.status));
|
|
114
|
+
}
|
|
115
|
+
let body;
|
|
116
|
+
try {
|
|
117
|
+
body = await response.json();
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return failure(new TransportError("The resolver's response is not JSON", response.status, { cause: error }));
|
|
121
|
+
}
|
|
122
|
+
let parsed = s.parseSafe(ENVELOPE_SCHEMA, body);
|
|
123
|
+
if (!parsed.success) {
|
|
124
|
+
return failure(new TransportError("The resolver's response is not a DNS JSON answer", response.status));
|
|
125
|
+
}
|
|
126
|
+
return success(parsed.value);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The negative-caching TTL of an NXDOMAIN (RFC 2308 section 5): the smaller of the SOA
|
|
130
|
+
* record's own TTL and its `minimum` field, or `null` without a readable SOA.
|
|
131
|
+
*/
|
|
132
|
+
function negativeTtl(envelope) {
|
|
133
|
+
let soa = (envelope.Authority ?? []).find((record) => typeName(record.type) === "SOA");
|
|
134
|
+
if (!soa)
|
|
135
|
+
return null;
|
|
136
|
+
let data = parseRecordData("SOA", soa.data);
|
|
137
|
+
return isSuccess(data) ? Math.min(soa.TTL, data.data.minimum) : null;
|
|
138
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public resolvers that answer the DoH JSON API, ready to pass as `resolver`.
|
|
3
|
+
*
|
|
4
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
5
|
+
* @copyright Sergio Xalambrí 2026
|
|
6
|
+
*/
|
|
7
|
+
import type { DoH } from "./types.js";
|
|
8
|
+
/** Cloudflare's 1.1.1.1, the default resolver; it answers JSON when asked with `Accept: application/dns-json`. */
|
|
9
|
+
export declare const CLOUDFLARE: DoH.Resolver;
|
|
10
|
+
/** Google Public DNS, which answers JSON at `/resolve` regardless of `Accept`. */
|
|
11
|
+
export declare const GOOGLE: DoH.Resolver;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Cloudflare's 1.1.1.1, the default resolver; it answers JSON when asked with `Accept: application/dns-json`. */
|
|
2
|
+
export const CLOUDFLARE = {
|
|
3
|
+
url: "https://cloudflare-dns.com/dns-query",
|
|
4
|
+
format: "json",
|
|
5
|
+
};
|
|
6
|
+
/** Google Public DNS, which answers JSON at `/resolve` regardless of `Accept`. */
|
|
7
|
+
export const GOOGLE = { url: "https://dns.google/resolve", format: "json" };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The types a DNS-over-HTTPS lookup reads and returns: the resolver it talks to, one
|
|
3
|
+
* interface per typed record, and the answer that splits records of the asked type from
|
|
4
|
+
* the CNAME chain that led to them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Groups the DNS-over-HTTPS types under a single import surface.
|
|
11
|
+
*/
|
|
12
|
+
export declare namespace DoH {
|
|
13
|
+
/** A record type by mnemonic; the nine typed ones autocomplete, any other name is accepted. */
|
|
14
|
+
type RecordType = "A" | "AAAA" | "CNAME" | "TXT" | "MX" | "NS" | "CAA" | "SOA" | "SRV" | (string & {});
|
|
15
|
+
/**
|
|
16
|
+
* A DoH endpoint and the encoding it answers in. `format` leaves room for an RFC 8484
|
|
17
|
+
* `application/dns-message` resolver as a new value with no change to `resolve`.
|
|
18
|
+
*/
|
|
19
|
+
interface Resolver {
|
|
20
|
+
url: string;
|
|
21
|
+
format: "json";
|
|
22
|
+
}
|
|
23
|
+
/** What every record carries. */
|
|
24
|
+
interface RecordBase {
|
|
25
|
+
/** The owner name, lowercased and without the trailing dot. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Seconds the resolver allows the record to be cached. */
|
|
28
|
+
ttl: number;
|
|
29
|
+
}
|
|
30
|
+
/** An IPv4 address, in dotted-quad form. */
|
|
31
|
+
interface ARecord extends RecordBase {
|
|
32
|
+
type: "A";
|
|
33
|
+
address: string;
|
|
34
|
+
}
|
|
35
|
+
/** An IPv6 address. */
|
|
36
|
+
interface AAAARecord extends RecordBase {
|
|
37
|
+
type: "AAAA";
|
|
38
|
+
/** In RFC 5952 canonical form: lowercase, zero runs compressed. */
|
|
39
|
+
address: string;
|
|
40
|
+
}
|
|
41
|
+
/** An alias: `name` resolves as `target` does. */
|
|
42
|
+
interface CNAMERecord extends RecordBase {
|
|
43
|
+
type: "CNAME";
|
|
44
|
+
target: string;
|
|
45
|
+
}
|
|
46
|
+
/** A name server authoritative for the zone at `name`. */
|
|
47
|
+
interface NSRecord extends RecordBase {
|
|
48
|
+
type: "NS";
|
|
49
|
+
host: string;
|
|
50
|
+
}
|
|
51
|
+
/** A mail exchanger; lower `preference` values are tried first. */
|
|
52
|
+
interface MXRecord extends RecordBase {
|
|
53
|
+
type: "MX";
|
|
54
|
+
preference: number;
|
|
55
|
+
/** The mail host; `"."` is an RFC 7505 null MX, a domain that accepts no mail. */
|
|
56
|
+
exchange: string;
|
|
57
|
+
}
|
|
58
|
+
/** Free text, such as SPF policies, DKIM keys and verification tokens. */
|
|
59
|
+
interface TXTRecord extends RecordBase {
|
|
60
|
+
type: "TXT";
|
|
61
|
+
/** The character-strings concatenated with nothing between them, as SPF and DKIM read it. */
|
|
62
|
+
text: string;
|
|
63
|
+
/** The character-strings as published, for the rare record whose boundaries matter. */
|
|
64
|
+
strings: string[];
|
|
65
|
+
}
|
|
66
|
+
/** Which certificate authorities may issue for the name (RFC 8659). */
|
|
67
|
+
interface CAARecord extends RecordBase {
|
|
68
|
+
type: "CAA";
|
|
69
|
+
/** The issuer-critical flag: a CA that does not understand `tag` must refuse to issue. */
|
|
70
|
+
critical: boolean;
|
|
71
|
+
tag: string;
|
|
72
|
+
value: string;
|
|
73
|
+
}
|
|
74
|
+
/** Where a service runs; lower `priority` first, `weight` spreads load within one priority. */
|
|
75
|
+
interface SRVRecord extends RecordBase {
|
|
76
|
+
type: "SRV";
|
|
77
|
+
priority: number;
|
|
78
|
+
weight: number;
|
|
79
|
+
port: number;
|
|
80
|
+
target: string;
|
|
81
|
+
}
|
|
82
|
+
/** The zone's start of authority, found at its apex and in negative answers. */
|
|
83
|
+
interface SOARecord extends RecordBase {
|
|
84
|
+
type: "SOA";
|
|
85
|
+
primary: string;
|
|
86
|
+
/** The responsible mailbox in DNS form: its first label is the local part. */
|
|
87
|
+
mailbox: string;
|
|
88
|
+
serial: number;
|
|
89
|
+
refresh: number;
|
|
90
|
+
retry: number;
|
|
91
|
+
expire: number;
|
|
92
|
+
/** Bounds how long a negative answer may be cached (RFC 2308). */
|
|
93
|
+
minimum: number;
|
|
94
|
+
}
|
|
95
|
+
/** A record of a type without a typed reading, or one whose data failed to parse, with its raw RDATA. */
|
|
96
|
+
interface UnknownRecord extends RecordBase {
|
|
97
|
+
type: string;
|
|
98
|
+
data: string;
|
|
99
|
+
}
|
|
100
|
+
/** The record a query for `Type` returns. */
|
|
101
|
+
type RecordFor<Type extends RecordType> = Type extends "A" ? ARecord : Type extends "AAAA" ? AAAARecord : Type extends "CNAME" ? CNAMERecord : Type extends "TXT" ? TXTRecord : Type extends "MX" ? MXRecord : Type extends "NS" ? NSRecord : Type extends "CAA" ? CAARecord : Type extends "SOA" ? SOARecord : Type extends "SRV" ? SRVRecord : UnknownRecord;
|
|
102
|
+
/** A record's type-specific fields, what `parseRecordData` reads out of RDATA. */
|
|
103
|
+
type RecordData<Type extends RecordType> = Omit<RecordFor<Type>, "name" | "ttl">;
|
|
104
|
+
/** A successful (NOERROR) answer to one query. */
|
|
105
|
+
interface Answer<Type extends RecordType> {
|
|
106
|
+
/** The queried name, lowercased and without the trailing dot. */
|
|
107
|
+
name: string;
|
|
108
|
+
type: Type;
|
|
109
|
+
/** The records of the asked type; empty with an empty `unparsed` is NODATA. */
|
|
110
|
+
records: RecordFor<Type>[];
|
|
111
|
+
/** Records of the asked type whose data did not parse, kept with their raw RDATA. */
|
|
112
|
+
unparsed: UnknownRecord[];
|
|
113
|
+
/** The aliases followed before reaching `records`, in order. */
|
|
114
|
+
chain: CNAMERecord[];
|
|
115
|
+
/** The smallest TTL across `records` and `unparsed`, `null` when both are empty. */
|
|
116
|
+
ttl: number | null;
|
|
117
|
+
/** The AD flag: the resolver validated the answer with DNSSEC. */
|
|
118
|
+
authenticated: boolean;
|
|
119
|
+
/** The TC flag: the resolver's upstream answer was truncated. */
|
|
120
|
+
truncated: boolean;
|
|
121
|
+
durationMs: number;
|
|
122
|
+
}
|
|
123
|
+
/** Options for one lookup. */
|
|
124
|
+
interface ResolveOptions {
|
|
125
|
+
/**
|
|
126
|
+
* @default CLOUDFLARE
|
|
127
|
+
*/
|
|
128
|
+
resolver?: Resolver;
|
|
129
|
+
/** Sets `do=1`, asking for DNSSEC records alongside the answer. */
|
|
130
|
+
dnssec?: boolean;
|
|
131
|
+
/** Sets `cd=1`, asking the resolver to skip DNSSEC validation. */
|
|
132
|
+
checkingDisabled?: boolean;
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
/**
|
|
135
|
+
* Milliseconds before the request is abandoned as a `TransportError`.
|
|
136
|
+
*
|
|
137
|
+
* @default 5000
|
|
138
|
+
*/
|
|
139
|
+
timeoutMs?: number;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The types a DNS-over-HTTPS lookup reads and returns: the resolver it talks to, one
|
|
3
|
+
* interface per typed record, and the answer that splits records of the asked type from
|
|
4
|
+
* the CNAME chain that led to them.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two DNS checks a domain-ownership flow performs: a TXT token published at a name,
|
|
3
|
+
* and a CNAME pointing a name at a target. A name that does not exist yet is the ordinary
|
|
4
|
+
* state of a verification in progress, so NXDOMAIN reads as "not verified".
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { DoHError } from "./errors.js";
|
|
11
|
+
import type { DoH } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Whether `name` publishes a TXT record whose joined text is exactly `expected`, so a
|
|
14
|
+
* token a resolver splits into several character-strings, or escapes, still matches.
|
|
15
|
+
* NXDOMAIN is `success(false)`; every other lookup failure stays a failure.
|
|
16
|
+
*
|
|
17
|
+
* @param name - The name holding the token, such as `_verify.example.com`.
|
|
18
|
+
* @param expected - The token, compared exactly.
|
|
19
|
+
* @param options - Passed to `resolve`.
|
|
20
|
+
* @example await verifyTxtRecord("_verify.example.com", "token_abc123") // success(true)
|
|
21
|
+
*/
|
|
22
|
+
export declare function verifyTxtRecord(name: string, expected: string, options?: DoH.ResolveOptions): Promise<Result<boolean, DoHError>>;
|
|
23
|
+
/**
|
|
24
|
+
* Whether `name` is a CNAME to `target`, directly or through further aliases, compared
|
|
25
|
+
* case-insensitively without the trailing dot. The chain is followed one CNAME query per
|
|
26
|
+
* hop, up to eight, so the check holds even when the target itself resolves to nothing.
|
|
27
|
+
* NXDOMAIN at any hop is `success(false)`; every other lookup failure stays a failure.
|
|
28
|
+
*
|
|
29
|
+
* @param name - The name the customer points, such as `shop.example.com`.
|
|
30
|
+
* @param target - The host it must alias.
|
|
31
|
+
* @param options - Passed to `resolve`.
|
|
32
|
+
* @example await checkCname("shop.example.com", "custom.hosting.example") // success(true)
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkCname(name: string, target: string, options?: DoH.ResolveOptions): Promise<Result<boolean, DoHError>>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import { NameNotFoundError } from "./errors.js";
|
|
3
|
+
import { resolve } from "./resolve.js";
|
|
4
|
+
/** How many aliases `checkCname` follows before giving up, bounding a long or looping chain. */
|
|
5
|
+
const MAX_CNAME_HOPS = 8;
|
|
6
|
+
/** Folds a name for comparison: lowercased, trailing dot dropped. */
|
|
7
|
+
function normalizeName(name) {
|
|
8
|
+
let lower = name.trim().toLowerCase();
|
|
9
|
+
return lower.length > 1 && lower.endsWith(".") ? lower.slice(0, -1) : lower;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether `name` publishes a TXT record whose joined text is exactly `expected`, so a
|
|
13
|
+
* token a resolver splits into several character-strings, or escapes, still matches.
|
|
14
|
+
* NXDOMAIN is `success(false)`; every other lookup failure stays a failure.
|
|
15
|
+
*
|
|
16
|
+
* @param name - The name holding the token, such as `_verify.example.com`.
|
|
17
|
+
* @param expected - The token, compared exactly.
|
|
18
|
+
* @param options - Passed to `resolve`.
|
|
19
|
+
* @example await verifyTxtRecord("_verify.example.com", "token_abc123") // success(true)
|
|
20
|
+
*/
|
|
21
|
+
export async function verifyTxtRecord(name, expected, options) {
|
|
22
|
+
let answer = await resolve(name, "TXT", options);
|
|
23
|
+
if (isFailure(answer))
|
|
24
|
+
return answer.error instanceof NameNotFoundError ? success(false) : answer;
|
|
25
|
+
return success(answer.data.records.some((record) => record.text === expected));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether `name` is a CNAME to `target`, directly or through further aliases, compared
|
|
29
|
+
* case-insensitively without the trailing dot. The chain is followed one CNAME query per
|
|
30
|
+
* hop, up to eight, so the check holds even when the target itself resolves to nothing.
|
|
31
|
+
* NXDOMAIN at any hop is `success(false)`; every other lookup failure stays a failure.
|
|
32
|
+
*
|
|
33
|
+
* @param name - The name the customer points, such as `shop.example.com`.
|
|
34
|
+
* @param target - The host it must alias.
|
|
35
|
+
* @param options - Passed to `resolve`.
|
|
36
|
+
* @example await checkCname("shop.example.com", "custom.hosting.example") // success(true)
|
|
37
|
+
*/
|
|
38
|
+
export async function checkCname(name, target, options) {
|
|
39
|
+
let wanted = normalizeName(target);
|
|
40
|
+
let current = normalizeName(name);
|
|
41
|
+
let seen = new Set();
|
|
42
|
+
for (let hop = 0; hop < MAX_CNAME_HOPS && !seen.has(current); hop++) {
|
|
43
|
+
seen.add(current);
|
|
44
|
+
let answer = await resolve(current, "CNAME", options);
|
|
45
|
+
if (isFailure(answer)) {
|
|
46
|
+
return answer.error instanceof NameNotFoundError ? success(false) : failure(answer.error);
|
|
47
|
+
}
|
|
48
|
+
let targets = answer.data.records.map((record) => record.target);
|
|
49
|
+
if (targets.includes(wanted))
|
|
50
|
+
return success(true);
|
|
51
|
+
let next = targets[0];
|
|
52
|
+
if (next === undefined)
|
|
53
|
+
return success(false);
|
|
54
|
+
current = next;
|
|
55
|
+
}
|
|
56
|
+
return success(false);
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdxc/doh",
|
|
3
|
+
"version": "0.0.0-pre.1",
|
|
4
|
+
"description": "Typed DNS over HTTPS lookups",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sdxc/result": "2026.9.15",
|
|
12
|
+
"remix": "3.0.0-rc.2"
|
|
13
|
+
},
|
|
14
|
+
"gitHead": "f0a77f407e4c30389df65cd8138b150e367da94f",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
21
|
+
"directory": "packages/doh"
|
|
22
|
+
}
|
|
23
|
+
}
|