@aoxborrow/registrar-client 0.8.0 → 0.9.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/README.md +78 -43
- package/dist/index.cjs +903 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -1
- package/dist/index.d.ts +62 -1
- package/dist/index.js +891 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/dns-records.ts +544 -0
- package/src/index.ts +4 -0
- package/src/registrars/domain101.ts +727 -0
- package/src/registrars/index.ts +12 -10
- package/src/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aoxborrow/registrar-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "One consistent, fully-typed TypeScript interface to many domain registrar APIs (GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more) behind a single pluggable multi-provider client",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Aaron Oxborrow",
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax and presentation for DNS resource records.
|
|
3
|
+
*
|
|
4
|
+
* No network, no zone origin, no provider. Names are not joined to a parent
|
|
5
|
+
* and a suffix is never stripped: `www` and `www.example.com` are different
|
|
6
|
+
* owners. Case is folded and one trailing dot is removed, because neither
|
|
7
|
+
* changes which name you wrote (`WWW.Example.COM.` and `www.example.com` are
|
|
8
|
+
* the same name). A trailing dot is not used to decide that a name is
|
|
9
|
+
* relative to something else.
|
|
10
|
+
*
|
|
11
|
+
* Records are a flat list. Each entry is one owner, one type, one TTL, and
|
|
12
|
+
* one data string. This module does not turn a list into an update plan.
|
|
13
|
+
* Some providers want other types removed before a CNAME is written, and
|
|
14
|
+
* some accept the whole set in one call. That sequence belongs to the
|
|
15
|
+
* provider. `findCnameConflicts` only answers whether the set you already
|
|
16
|
+
* have breaks the alias rule.
|
|
17
|
+
*
|
|
18
|
+
* Prepared names have no trailing dot. Add one yourself when an API asks for
|
|
19
|
+
* an absolute name. `@` is left as `@`. It is not expanded to an origin,
|
|
20
|
+
* and it is not treated as equal to any other owner.
|
|
21
|
+
*
|
|
22
|
+
* Label check is ASCII by default (letters, digits, hyphen, underscore).
|
|
23
|
+
* Underscores stay, because real zones use them (`_dmarc`, DKIM, SRV).
|
|
24
|
+
* Install `useLabelMapper` for Unicode. A strict hostname mapper (the IDNA
|
|
25
|
+
* STD3 profile) will start rejecting those underscores; that is the mapper's
|
|
26
|
+
* choice.
|
|
27
|
+
*
|
|
28
|
+
* useLabelMapper((label) => idna.toAscii(label)) // null when the label is illegal
|
|
29
|
+
*
|
|
30
|
+
* Known data syntax: A, AAAA, CNAME, NS, MX, TXT, SRV, CAA, DS. Any other type is
|
|
31
|
+
* trimmed and otherwise left alone, so a provider-specific type still round-trips.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { DnsRecord } from './types';
|
|
35
|
+
|
|
36
|
+
export class DnsSyntaxError extends Error {
|
|
37
|
+
constructor(message: string) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'DnsSyntaxError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One resource record. `rdata` is a single value, not a list of values. */
|
|
44
|
+
export interface DnsEntry {
|
|
45
|
+
owner: string;
|
|
46
|
+
kind: string;
|
|
47
|
+
ttl: number;
|
|
48
|
+
rdata: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Something wrong with a set of records. `detail` is safe to show a person. */
|
|
52
|
+
export interface RecordClash {
|
|
53
|
+
owner: string;
|
|
54
|
+
kind: string;
|
|
55
|
+
detail: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `owner` — a name that owns records. A leftmost `*` label is allowed.
|
|
60
|
+
* `target` — a name used inside record data (CNAME, NS, MX). No wildcard.
|
|
61
|
+
*/
|
|
62
|
+
export type NameRole = 'owner' | 'target';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Turn one label into the ASCII form to store.
|
|
66
|
+
* Return null to reject the label. The default mapper does not punycode.
|
|
67
|
+
*/
|
|
68
|
+
export type LabelMapper = (label: string) => string | null;
|
|
69
|
+
|
|
70
|
+
export interface DelegationSigner {
|
|
71
|
+
keyTag: number;
|
|
72
|
+
algorithm: number;
|
|
73
|
+
digestType: number;
|
|
74
|
+
digest: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NAME_LIMIT = 253;
|
|
78
|
+
const LABEL_LIMIT = 63;
|
|
79
|
+
const TTL_LIMIT = 2147483647;
|
|
80
|
+
const TEXT_OCTETS = 255;
|
|
81
|
+
// RDATA is at most 65535 octets, and each character-string costs one length
|
|
82
|
+
// octet: 257 strings of 255 octets plus their lengths.
|
|
83
|
+
const TEXT_TOTAL_OCTETS = 65535 - Math.ceil(65535 / (TEXT_OCTETS + 1));
|
|
84
|
+
const UINT16_MAX = 65535;
|
|
85
|
+
|
|
86
|
+
/** Digest type → hex length, for the sizes registered with IANA. Other type numbers are not rejected. */
|
|
87
|
+
const DIGEST_HEX_LENGTH: Record<number, number> = {
|
|
88
|
+
1: 40,
|
|
89
|
+
2: 64,
|
|
90
|
+
3: 64,
|
|
91
|
+
4: 96,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Types that may sit at the same owner as a CNAME.
|
|
96
|
+
* RFC 2181 says an alias owns the name. DNSSEC later allowed the signature
|
|
97
|
+
* and denial records that describe that alias (RRSIG, NSEC, NSEC3).
|
|
98
|
+
* Everything else — A, MX, TXT, NS, and so on — still conflicts.
|
|
99
|
+
*/
|
|
100
|
+
const KINDS_ALLOWED_BESIDE_CNAME = new Set(['CNAME', 'RRSIG', 'NSEC', 'NSEC3']);
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Labels
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function mapAsciiLabel(label: string): string | null {
|
|
107
|
+
const folded = label.toLowerCase();
|
|
108
|
+
if (folded.length < 1 || folded.length > LABEL_LIMIT) return null;
|
|
109
|
+
// One character, or an inner hyphen run that does not start or end the label.
|
|
110
|
+
// Underscore is included on purpose. See the file header.
|
|
111
|
+
if (!/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(folded)) return null;
|
|
112
|
+
return folded;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let labelMapper: LabelMapper = mapAsciiLabel;
|
|
116
|
+
|
|
117
|
+
export function useLabelMapper(mapper: LabelMapper): void {
|
|
118
|
+
labelMapper = mapper;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resetLabelMapper(): void {
|
|
122
|
+
labelMapper = mapAsciiLabel;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Canonical owner or target.
|
|
127
|
+
* Lowercase, one trailing dot removed, labels passed through the mapper.
|
|
128
|
+
* Does not append, remove, or replace any other suffix.
|
|
129
|
+
*/
|
|
130
|
+
export function prepareName(input: string, role: NameRole = 'owner'): string {
|
|
131
|
+
const trimmed = input.trim();
|
|
132
|
+
if (!trimmed) throw new DnsSyntaxError('Name is empty');
|
|
133
|
+
|
|
134
|
+
const bare = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
|
|
135
|
+
if (!bare) throw new DnsSyntaxError('Name is empty');
|
|
136
|
+
if (bare === '@') {
|
|
137
|
+
if (role === 'target') throw new DnsSyntaxError('Origin token @ cannot be a record target');
|
|
138
|
+
return '@';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const labels = bare.split('.');
|
|
142
|
+
if (labels.some(label => label.length === 0)) {
|
|
143
|
+
throw new DnsSyntaxError('Name contains an empty label');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const starLabels = labels.filter(label => label.includes('*'));
|
|
147
|
+
if (starLabels.length > 0) {
|
|
148
|
+
if (role !== 'owner') {
|
|
149
|
+
throw new DnsSyntaxError('A wildcard is only valid as an owner name');
|
|
150
|
+
}
|
|
151
|
+
const starCount = starLabels.reduce(
|
|
152
|
+
(count, label) => count + (label.match(/\*/g)?.length ?? 0),
|
|
153
|
+
0
|
|
154
|
+
);
|
|
155
|
+
if (starCount > 1) throw new DnsSyntaxError('A name can contain only one wildcard');
|
|
156
|
+
// RFC 4592: the wildcard is the entire leftmost label, not a prefix of one.
|
|
157
|
+
if (labels[0] !== '*') throw new DnsSyntaxError('A wildcard must be its own leftmost label');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const mapped = labels.map(label => {
|
|
161
|
+
if (label === '*') return '*';
|
|
162
|
+
const ascii = labelMapper(label);
|
|
163
|
+
if (!ascii) throw new DnsSyntaxError(`Label is not a valid DNS label: ${label}`);
|
|
164
|
+
return ascii.toLowerCase();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const name = mapped.join('.');
|
|
168
|
+
if (name.length > NAME_LIMIT) throw new DnsSyntaxError('Name is longer than 253 characters');
|
|
169
|
+
return name;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Addresses
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/** Dotted IPv4. Leading zeros are rejected (`01.2.3.4` is not `1.2.3.4`). */
|
|
177
|
+
export function isIpv4(text: string): boolean {
|
|
178
|
+
const octets = text.split('.');
|
|
179
|
+
if (octets.length !== 4) return false;
|
|
180
|
+
return octets.every(octet => {
|
|
181
|
+
if (!/^\d{1,3}$/.test(octet)) return false;
|
|
182
|
+
const value = Number(octet);
|
|
183
|
+
return value >= 0 && value <= 255 && String(value) === octet;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* IPv6 text form: eight groups, a single `::`, or an IPv4 tail
|
|
189
|
+
* (`::ffff:192.0.2.1`). No zone index (`fe80::1%eth0`).
|
|
190
|
+
*/
|
|
191
|
+
export function isIpv6(text: string): boolean {
|
|
192
|
+
let body = text;
|
|
193
|
+
|
|
194
|
+
const embedded = text.match(/^(.+):(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
195
|
+
if (embedded) {
|
|
196
|
+
if (!isIpv4(embedded[2])) return false;
|
|
197
|
+
body = `${embedded[1]}:0:0`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const compressedAt = body.indexOf('::');
|
|
201
|
+
if (compressedAt !== -1) {
|
|
202
|
+
if (body.indexOf('::', compressedAt + 1) !== -1) return false;
|
|
203
|
+
// Each side of `::` is empty or a run of groups. Splitting an empty side
|
|
204
|
+
// gives no groups; any other empty group is a stray colon (`::1:`, `:1::`).
|
|
205
|
+
const groupsOf = (side: string) => (side === '' ? [] : side.split(':'));
|
|
206
|
+
const head = groupsOf(body.slice(0, compressedAt));
|
|
207
|
+
const tail = groupsOf(body.slice(compressedAt + 2));
|
|
208
|
+
// `::` replaces at least one group, so at most 7 groups are written out.
|
|
209
|
+
if (head.length + tail.length > 7) return false;
|
|
210
|
+
return [...head, ...tail].every(group => /^[0-9a-fA-F]{1,4}$/.test(group));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const groups = body.split(':');
|
|
214
|
+
return groups.length === 8 && groups.every(group => /^[0-9a-fA-F]{1,4}$/.test(group));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Record data
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
function utf8OctetCount(text: string): number {
|
|
222
|
+
let count = 0;
|
|
223
|
+
for (const char of text) {
|
|
224
|
+
const code = char.codePointAt(0) ?? 0;
|
|
225
|
+
if (code <= 0x7f) count += 1;
|
|
226
|
+
else if (code <= 0x7ff) count += 2;
|
|
227
|
+
else if (code <= 0xffff) count += 3;
|
|
228
|
+
else count += 4;
|
|
229
|
+
}
|
|
230
|
+
return count;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function decimalInRange(token: string, limit: number): number | null {
|
|
234
|
+
if (!/^(0|[1-9][0-9]*)$/.test(token)) return null;
|
|
235
|
+
const value = Number(token);
|
|
236
|
+
if (value > limit) return null;
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function prepareIpv4(text: string): string {
|
|
241
|
+
if (!isIpv4(text)) throw new DnsSyntaxError('IPv4 address is invalid');
|
|
242
|
+
return text;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function prepareIpv6(text: string): string {
|
|
246
|
+
if (!isIpv6(text)) throw new DnsSyntaxError('IPv6 address is invalid');
|
|
247
|
+
return text.toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* CNAME or NS target.
|
|
252
|
+
* An address is rejected for both: a CNAME is an alias to a name, and an NS
|
|
253
|
+
* target is a host name. Numeric labels would otherwise look like a legal name.
|
|
254
|
+
* Self-alias is checked only for CNAME, and only against the owner string as
|
|
255
|
+
* written. `www` aliasing to `www.example.com` is not treated as a loop.
|
|
256
|
+
*/
|
|
257
|
+
function prepareHostTarget(text: string, owner: string | undefined, rejectSelf: boolean): string {
|
|
258
|
+
const withoutDot = text.endsWith('.') ? text.slice(0, -1) : text;
|
|
259
|
+
if (isIpv4(withoutDot) || isIpv6(withoutDot)) {
|
|
260
|
+
throw new DnsSyntaxError('Target must be a domain name, not an address');
|
|
261
|
+
}
|
|
262
|
+
const target = prepareName(text, 'target');
|
|
263
|
+
if (rejectSelf && owner !== undefined && prepareName(owner, 'owner') === target) {
|
|
264
|
+
throw new DnsSyntaxError('CNAME target is the same name as its owner');
|
|
265
|
+
}
|
|
266
|
+
return target;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** `priority host`. Priority is a 16-bit unsigned integer. The host is a target name. */
|
|
270
|
+
function prepareMailExchange(text: string): string {
|
|
271
|
+
const parts = text.split(/\s+/);
|
|
272
|
+
if (parts.length !== 2) {
|
|
273
|
+
throw new DnsSyntaxError('MX data must be "<priority> <mail host>"');
|
|
274
|
+
}
|
|
275
|
+
const priority = decimalInRange(parts[0], UINT16_MAX);
|
|
276
|
+
if (priority === null) throw new DnsSyntaxError('MX priority must be an integer from 0 to 65535');
|
|
277
|
+
return `${priority} ${prepareServiceHost(parts[1])}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* MX or SRV host: a host name, never an address (RFC 2181 §10.3). A lone `.`
|
|
282
|
+
* is the root: "no service here" (null MX, RFC 7505; SRV, RFC 2782). It stays
|
|
283
|
+
* `.` because there is no name left to prepare.
|
|
284
|
+
*/
|
|
285
|
+
function prepareServiceHost(text: string): string {
|
|
286
|
+
return text === '.' ? '.' : prepareHostTarget(text, undefined, false);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `priority weight port target`, each number a 16-bit unsigned integer. */
|
|
290
|
+
function prepareService(text: string): string {
|
|
291
|
+
const parts = text.split(/\s+/);
|
|
292
|
+
if (parts.length !== 4) {
|
|
293
|
+
throw new DnsSyntaxError('SRV data must be "<priority> <weight> <port> <target>"');
|
|
294
|
+
}
|
|
295
|
+
const [priority, weight, port] = parts.slice(0, 3).map(part => decimalInRange(part, UINT16_MAX));
|
|
296
|
+
if (priority === null)
|
|
297
|
+
throw new DnsSyntaxError('SRV priority must be an integer from 0 to 65535');
|
|
298
|
+
if (weight === null) throw new DnsSyntaxError('SRV weight must be an integer from 0 to 65535');
|
|
299
|
+
if (port === null) throw new DnsSyntaxError('SRV port must be an integer from 0 to 65535');
|
|
300
|
+
return `${priority} ${weight} ${port} ${prepareServiceHost(parts[3])}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* `flags tag value` (RFC 8659). Flags fit in one octet; the tag is 1–15
|
|
305
|
+
* letters or digits, folded to lowercase. The value may be quoted or bare and
|
|
306
|
+
* comes back quoted.
|
|
307
|
+
*/
|
|
308
|
+
function prepareAuthorization(text: string): string {
|
|
309
|
+
const match = /^(\S+)\s+(\S+)\s+([\s\S]+)$/.exec(text);
|
|
310
|
+
if (!match) throw new DnsSyntaxError('CAA data must be "<flags> <tag> <value>"');
|
|
311
|
+
const flags = decimalInRange(match[1], 255);
|
|
312
|
+
if (flags === null) throw new DnsSyntaxError('CAA flags must be an integer from 0 to 255');
|
|
313
|
+
if (!/^[a-z0-9]{1,15}$/i.test(match[2])) {
|
|
314
|
+
throw new DnsSyntaxError('CAA tag must be 1 to 15 letters or digits');
|
|
315
|
+
}
|
|
316
|
+
const quoted = /^"((?:[^"\\]|\\.)*)"$/.exec(match[3]);
|
|
317
|
+
const value = quoted ? quoted[1] : match[3];
|
|
318
|
+
if (!quoted && value.includes('"')) {
|
|
319
|
+
throw new DnsSyntaxError('CAA value must be one quoted or unquoted string');
|
|
320
|
+
}
|
|
321
|
+
return `${flags} ${match[2].toLowerCase()} "${value}"`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* TXT data, as one string of text.
|
|
326
|
+
* On the wire a TXT record is a run of character-strings of at most 255
|
|
327
|
+
* octets each. Text longer than that (a DKIM key, a long SPF policy) is split
|
|
328
|
+
* into several strings when written, so the limit here is the whole record:
|
|
329
|
+
* 65535 octets of RDATA, less a length octet per string. This function does
|
|
330
|
+
* not add or remove quotes; quotes in the input stay part of the text.
|
|
331
|
+
*/
|
|
332
|
+
function prepareText(text: string): string {
|
|
333
|
+
if (utf8OctetCount(text) > TEXT_TOTAL_OCTETS) {
|
|
334
|
+
throw new DnsSyntaxError(`TXT data is longer than ${TEXT_TOTAL_OCTETS} octets`);
|
|
335
|
+
}
|
|
336
|
+
return text;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readSignerFields(text: string): DelegationSigner {
|
|
340
|
+
const parts = text.trim().split(/\s+/);
|
|
341
|
+
if (parts.length !== 4)
|
|
342
|
+
throw new DnsSyntaxError('DS data must be "<key tag> <algorithm> <digest type> <digest>"');
|
|
343
|
+
|
|
344
|
+
const keyTag = decimalInRange(parts[0], UINT16_MAX);
|
|
345
|
+
const algorithm = decimalInRange(parts[1], 255);
|
|
346
|
+
const digestType = decimalInRange(parts[2], 255);
|
|
347
|
+
if (keyTag === null || algorithm === null || digestType === null || !parts[3]) {
|
|
348
|
+
throw new DnsSyntaxError('DS data must be "<key tag> <algorithm> <digest type> <digest>"');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const digest = parts[3].toLowerCase();
|
|
352
|
+
if (!/^[0-9a-f]+$/.test(digest)) throw new DnsSyntaxError('DS digest must be hexadecimal');
|
|
353
|
+
|
|
354
|
+
const expectedLength = DIGEST_HEX_LENGTH[digestType];
|
|
355
|
+
if (expectedLength !== undefined && digest.length !== expectedLength) {
|
|
356
|
+
throw new DnsSyntaxError(
|
|
357
|
+
`DS digest must be ${expectedLength} hex characters for digest type ${digestType}`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { keyTag, algorithm, digestType, digest };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function readDelegationSigner(rdata: string): DelegationSigner {
|
|
365
|
+
return readSignerFields(rdata);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function prepareSignerText(text: string): string {
|
|
369
|
+
const signer = readSignerFields(text);
|
|
370
|
+
return `${signer.keyTag} ${signer.algorithm} ${signer.digestType} ${signer.digest}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Canonical data for one record.
|
|
375
|
+
* Pass `owner` when checking a CNAME so a target equal to that owner is rejected.
|
|
376
|
+
* Unknown types are trimmed only.
|
|
377
|
+
*/
|
|
378
|
+
export function prepareRdata(kind: string, rdata: string, owner?: string): string {
|
|
379
|
+
const token = kind.trim().toUpperCase();
|
|
380
|
+
const text = rdata.trim();
|
|
381
|
+
if (!text) throw new DnsSyntaxError('Record data is empty');
|
|
382
|
+
|
|
383
|
+
switch (token) {
|
|
384
|
+
case 'A':
|
|
385
|
+
return prepareIpv4(text);
|
|
386
|
+
case 'AAAA':
|
|
387
|
+
return prepareIpv6(text);
|
|
388
|
+
case 'CNAME':
|
|
389
|
+
return prepareHostTarget(text, owner, true);
|
|
390
|
+
case 'NS':
|
|
391
|
+
return prepareHostTarget(text, undefined, false);
|
|
392
|
+
case 'MX':
|
|
393
|
+
return prepareMailExchange(text);
|
|
394
|
+
case 'TXT':
|
|
395
|
+
return prepareText(text);
|
|
396
|
+
case 'SRV':
|
|
397
|
+
return prepareService(text);
|
|
398
|
+
case 'CAA':
|
|
399
|
+
return prepareAuthorization(text);
|
|
400
|
+
case 'DS':
|
|
401
|
+
return prepareSignerText(text);
|
|
402
|
+
default:
|
|
403
|
+
return text;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Canonical entry. TTL is the DNS unsigned range used on the wire in practice: 0 through 2^31−1. */
|
|
408
|
+
export function prepareEntry(entry: DnsEntry): DnsEntry {
|
|
409
|
+
if (!Number.isInteger(entry.ttl) || entry.ttl < 0 || entry.ttl > TTL_LIMIT) {
|
|
410
|
+
throw new DnsSyntaxError('TTL must be an integer from 0 to 2147483647');
|
|
411
|
+
}
|
|
412
|
+
const kind = entry.kind.trim().toUpperCase();
|
|
413
|
+
if (!kind) throw new DnsSyntaxError('Record type is empty');
|
|
414
|
+
return {
|
|
415
|
+
owner: prepareName(entry.owner, 'owner'),
|
|
416
|
+
kind,
|
|
417
|
+
ttl: entry.ttl,
|
|
418
|
+
rdata: prepareRdata(kind, entry.rdata, entry.owner),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Sets of records
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
function syntaxClash(entry: DnsEntry, error: unknown): RecordClash {
|
|
427
|
+
const detail = error instanceof Error ? error.message : 'Invalid record';
|
|
428
|
+
return { owner: entry.owner, kind: entry.kind, detail };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Alias rule for a set of records that will exist at the same time.
|
|
433
|
+
*
|
|
434
|
+
* Owners match only after `prepareName` (case and a trailing dot).
|
|
435
|
+
* `www` does not match `www.example.com`.
|
|
436
|
+
*
|
|
437
|
+
* Reports one clash when a name has more than one CNAME, and one clash when
|
|
438
|
+
* a CNAME shares a name with a type other than RRSIG, NSEC, or NSEC3.
|
|
439
|
+
* Does not say which write to send first.
|
|
440
|
+
*/
|
|
441
|
+
export function findCnameConflicts(entries: readonly DnsEntry[]): RecordClash[] {
|
|
442
|
+
const clashes: RecordClash[] = [];
|
|
443
|
+
const byOwner = new Map<string, DnsEntry[]>();
|
|
444
|
+
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
let owner: string;
|
|
447
|
+
let kind: string;
|
|
448
|
+
try {
|
|
449
|
+
owner = prepareName(entry.owner, 'owner');
|
|
450
|
+
kind = entry.kind.trim().toUpperCase();
|
|
451
|
+
if (!kind) throw new DnsSyntaxError('Record type is empty');
|
|
452
|
+
} catch (error) {
|
|
453
|
+
clashes.push(syntaxClash(entry, error));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const group = byOwner.get(owner) ?? [];
|
|
457
|
+
group.push({ ...entry, owner, kind });
|
|
458
|
+
byOwner.set(owner, group);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const [owner, group] of byOwner) {
|
|
462
|
+
const aliasCount = group.filter(entry => entry.kind === 'CNAME').length;
|
|
463
|
+
if (aliasCount === 0) continue;
|
|
464
|
+
|
|
465
|
+
if (aliasCount > 1) {
|
|
466
|
+
clashes.push({
|
|
467
|
+
owner,
|
|
468
|
+
kind: 'CNAME',
|
|
469
|
+
detail: 'A name can have only one CNAME target',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const others = [...new Set(group.map(entry => entry.kind))].filter(
|
|
474
|
+
kind => !KINDS_ALLOWED_BESIDE_CNAME.has(kind)
|
|
475
|
+
);
|
|
476
|
+
if (others.length > 0) {
|
|
477
|
+
clashes.push({
|
|
478
|
+
owner,
|
|
479
|
+
kind: 'CNAME',
|
|
480
|
+
detail: `A CNAME cannot share a name with ${others.join(', ')}`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return clashes;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Same owner, same type, same prepared data, more than once.
|
|
490
|
+
* CNAME is omitted here; more than one alias at a name is reported by `findCnameConflicts`.
|
|
491
|
+
* Entries that fail `prepareEntry` are returned as clashes instead of throwing.
|
|
492
|
+
*/
|
|
493
|
+
export function findRepeatedRecords(entries: readonly DnsEntry[]): RecordClash[] {
|
|
494
|
+
const clashes: RecordClash[] = [];
|
|
495
|
+
const seen = new Set<string>();
|
|
496
|
+
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
let prepared: DnsEntry;
|
|
499
|
+
try {
|
|
500
|
+
prepared = prepareEntry(entry);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
clashes.push(syntaxClash(entry, error));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (prepared.kind === 'CNAME') continue;
|
|
506
|
+
|
|
507
|
+
const key = `${prepared.owner}\0${prepared.kind}\0${prepared.rdata}`;
|
|
508
|
+
if (seen.has(key)) {
|
|
509
|
+
clashes.push({
|
|
510
|
+
owner: prepared.owner,
|
|
511
|
+
kind: prepared.kind,
|
|
512
|
+
detail: `Repeated ${prepared.kind} data at this name`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
seen.add(key);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return clashes;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// registrar-client records
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A registrar-client `DnsRecord` as an entry for the functions above. MX and
|
|
527
|
+
* SRV carry their numbers as separate fields there, so they're folded into the
|
|
528
|
+
* data string here. `name` stays relative to the zone (`@`, `www`), as above.
|
|
529
|
+
*/
|
|
530
|
+
export function toDnsEntry(record: DnsRecord, defaultTtl = 3600): DnsEntry {
|
|
531
|
+
const kind = record.type.trim().toUpperCase();
|
|
532
|
+
const numbers =
|
|
533
|
+
kind === 'MX'
|
|
534
|
+
? [record.priority]
|
|
535
|
+
: kind === 'SRV'
|
|
536
|
+
? [record.priority, record.weight, record.port]
|
|
537
|
+
: [];
|
|
538
|
+
return {
|
|
539
|
+
owner: record.name,
|
|
540
|
+
kind,
|
|
541
|
+
ttl: record.ttl ?? defaultTtl,
|
|
542
|
+
rdata: [...numbers.map(n => (n === undefined ? '' : String(n))), record.value.trim()].join(' '),
|
|
543
|
+
};
|
|
544
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,8 +25,12 @@ export {
|
|
|
25
25
|
} from './features';
|
|
26
26
|
export type { RegistrarFeature } from './features';
|
|
27
27
|
|
|
28
|
+
// DNS record syntax and canonical form
|
|
29
|
+
export * from './dns-records';
|
|
30
|
+
|
|
28
31
|
// bundled providers
|
|
29
32
|
export { CloudflareRegistrar } from './registrars/cloudflare';
|
|
33
|
+
export { Domain101Registrar } from './registrars/domain101';
|
|
30
34
|
export { DynadotRegistrar } from './registrars/dynadot';
|
|
31
35
|
export { GandiRegistrar } from './registrars/gandi';
|
|
32
36
|
export { GoDaddyRegistrar } from './registrars/godaddy';
|