@gusnips/one-click-dns 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +151 -2
- package/dist/bounded-fetch-nozW2egX.d.cts +5 -0
- package/dist/bounded-fetch-nozW2egX.d.ts +5 -0
- package/dist/chunk-2T2PEF4H.js +521 -0
- package/dist/chunk-56ZUD4EQ.js +525 -0
- package/dist/chunk-P2O7INDB.js +8 -0
- package/dist/hosts-B0_V4Ned.d.cts +106 -0
- package/dist/hosts-B0_V4Ned.d.ts +106 -0
- package/dist/index.cjs +745 -0
- package/dist/index.d.cts +119 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.js +198 -0
- package/dist/server/index.cjs +1320 -0
- package/dist/server/index.d.cts +145 -0
- package/dist/server/index.d.ts +145 -0
- package/dist/server/index.js +274 -0
- package/dist/writers/index.cjs +555 -0
- package/dist/writers/index.d.cts +64 -0
- package/dist/writers/index.d.ts +64 -0
- package/dist/writers/index.js +17 -0
- package/package.json +93 -4
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeDnsName
|
|
3
|
+
} from "./chunk-P2O7INDB.js";
|
|
4
|
+
|
|
5
|
+
// src/bounded-fetch.ts
|
|
6
|
+
import { readBounded } from "@gusnips/server";
|
|
7
|
+
async function fetchBounded(doFetch, url, init, limits) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await doFetch(url, {
|
|
10
|
+
...init,
|
|
11
|
+
redirect: "manual",
|
|
12
|
+
signal: AbortSignal.timeout(limits.timeoutMs)
|
|
13
|
+
});
|
|
14
|
+
if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
|
|
15
|
+
await res.body?.cancel().catch(() => void 0);
|
|
16
|
+
return { status: res.status || 302, text: "" };
|
|
17
|
+
}
|
|
18
|
+
const { bytes, truncated } = await readBounded(res.body, limits.maxBytes);
|
|
19
|
+
if (truncated) return null;
|
|
20
|
+
return { status: res.status, text: new TextDecoder().decode(bytes) };
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/guards.ts
|
|
27
|
+
function isObject(value) {
|
|
28
|
+
return typeof value === "object" && value !== null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/writers/types.ts
|
|
32
|
+
var DnsWriteError = class extends Error {
|
|
33
|
+
constructor(code, params = {}) {
|
|
34
|
+
super(MESSAGES[code](params));
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.params = params;
|
|
37
|
+
this.name = "DnsWriteError";
|
|
38
|
+
}
|
|
39
|
+
code;
|
|
40
|
+
params;
|
|
41
|
+
};
|
|
42
|
+
var MESSAGES = {
|
|
43
|
+
token_invalid: () => "The DNS host didn't accept that token",
|
|
44
|
+
zone_read_missing: () => "That token can't read your domains",
|
|
45
|
+
dns_edit_missing: () => "That token can't change DNS records",
|
|
46
|
+
zone_not_found: (p) => `No zone holds ${p.hostname ?? "that name"}`,
|
|
47
|
+
conflict: (p) => `${p.hostname ?? "That name"} already has a record${p.type ? ` (type ${p.type})` : ""}`,
|
|
48
|
+
unavailable: () => "Couldn't reach the DNS host"
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/writers/policy.ts
|
|
52
|
+
function zoneCandidates(hostname) {
|
|
53
|
+
const labels = hostname.split(".");
|
|
54
|
+
const names = [];
|
|
55
|
+
for (let start = 0; start < labels.length - 1; start++) names.push(labels.slice(start).join("."));
|
|
56
|
+
return names;
|
|
57
|
+
}
|
|
58
|
+
var unquoted = (content) => content.replace(/"\s+"/g, "").replace(/^"|"$/g, "");
|
|
59
|
+
var sameValue = (record, existing) => record.type === "CNAME" ? normalizeDnsName(existing.content) === normalizeDnsName(record.value) : unquoted(existing.content) === record.value;
|
|
60
|
+
var isSpf = (text) => /^v=spf1(?:\s|$)/i.test(unquoted(text).trim());
|
|
61
|
+
function blocks(record, existing) {
|
|
62
|
+
if (record.type === "CNAME" || existing.type === "CNAME") return true;
|
|
63
|
+
return existing.type === "TXT" && isSpf(record.value) && isSpf(existing.content);
|
|
64
|
+
}
|
|
65
|
+
function decide(record, existing, hostname) {
|
|
66
|
+
const ours = existing.find((r) => r.type === record.type && sameValue(record, r));
|
|
67
|
+
if (ours) {
|
|
68
|
+
if (record.type === "CNAME" && ours.proxied === true) return { kind: "unproxy", id: ours.id };
|
|
69
|
+
return { kind: "unchanged" };
|
|
70
|
+
}
|
|
71
|
+
const blocker = existing.find((r) => blocks(record, r));
|
|
72
|
+
if (blocker) throw new DnsWriteError("conflict", { hostname, type: blocker.type });
|
|
73
|
+
return { kind: "create" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/writers/cloudflare.ts
|
|
77
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
78
|
+
var TIMEOUT_MS = 1e4;
|
|
79
|
+
var MAX_BODY_BYTES = 256 * 1024;
|
|
80
|
+
var TOKEN_CODES = /* @__PURE__ */ new Set([1e3, 6003, 6111, 1e4]);
|
|
81
|
+
var CONFLICT_CODES = /* @__PURE__ */ new Set([81053, 81054, 81057]);
|
|
82
|
+
var ID = /^[A-Za-z0-9]{1,64}$/;
|
|
83
|
+
var RECORD_TYPE = /^[A-Z0-9]{1,12}$/;
|
|
84
|
+
function readEnvelope(json) {
|
|
85
|
+
if (!isObject(json) || typeof json.success !== "boolean") return null;
|
|
86
|
+
const codes = [];
|
|
87
|
+
if (Array.isArray(json.errors)) {
|
|
88
|
+
for (const error of json.errors) {
|
|
89
|
+
if (!isObject(error)) return null;
|
|
90
|
+
if (typeof error.code === "number") codes.push(error.code);
|
|
91
|
+
if (Array.isArray(error.error_chain)) {
|
|
92
|
+
for (const link of error.error_chain)
|
|
93
|
+
if (isObject(link) && typeof link.code === "number") codes.push(link.code);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { success: json.success, codes, result: json.result };
|
|
98
|
+
}
|
|
99
|
+
async function call(doFetch, token, path, send) {
|
|
100
|
+
const res = await fetchBounded(
|
|
101
|
+
doFetch,
|
|
102
|
+
`${API}${path}`,
|
|
103
|
+
{
|
|
104
|
+
method: send?.method ?? "GET",
|
|
105
|
+
headers: {
|
|
106
|
+
Authorization: `Bearer ${token}`,
|
|
107
|
+
Accept: "application/json",
|
|
108
|
+
...send ? { "Content-Type": "application/json" } : {}
|
|
109
|
+
},
|
|
110
|
+
body: send ? JSON.stringify(send.body) : void 0
|
|
111
|
+
},
|
|
112
|
+
{ timeoutMs: TIMEOUT_MS, maxBytes: MAX_BODY_BYTES }
|
|
113
|
+
);
|
|
114
|
+
if (!res) return { status: 0, body: null };
|
|
115
|
+
let json = null;
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(res.text);
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
return { status: res.status, body: readEnvelope(json) };
|
|
121
|
+
}
|
|
122
|
+
function refusal(answer, stage, subject) {
|
|
123
|
+
const codes = answer.body?.codes ?? [];
|
|
124
|
+
const refusedToken = answer.status === 401 || codes.some((c) => TOKEN_CODES.has(c));
|
|
125
|
+
if (stage === "zones" && refusedToken) return new DnsWriteError("token_invalid");
|
|
126
|
+
if (codes.some((c) => CONFLICT_CODES.has(c)))
|
|
127
|
+
return new DnsWriteError("conflict", { ...subject });
|
|
128
|
+
if (answer.status === 403 || refusedToken) {
|
|
129
|
+
if (stage === "zones") return new DnsWriteError("zone_read_missing");
|
|
130
|
+
return new DnsWriteError("dns_edit_missing");
|
|
131
|
+
}
|
|
132
|
+
return new DnsWriteError("unavailable");
|
|
133
|
+
}
|
|
134
|
+
function resultOf(answer, stage, subject) {
|
|
135
|
+
const ok = answer.status >= 200 && answer.status < 300 && answer.body?.success === true;
|
|
136
|
+
if (!ok) throw refusal(answer, stage, subject);
|
|
137
|
+
return answer.body?.result;
|
|
138
|
+
}
|
|
139
|
+
function zonesOf(result) {
|
|
140
|
+
if (!Array.isArray(result)) throw new DnsWriteError("unavailable");
|
|
141
|
+
return result.map((row) => {
|
|
142
|
+
if (!isObject(row) || typeof row.id !== "string" || typeof row.name !== "string")
|
|
143
|
+
throw new DnsWriteError("unavailable");
|
|
144
|
+
if (!ID.test(row.id)) throw new DnsWriteError("unavailable");
|
|
145
|
+
return { id: row.id, name: row.name };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function recordsOf(result) {
|
|
149
|
+
if (!Array.isArray(result)) throw new DnsWriteError("unavailable");
|
|
150
|
+
return result.map((row) => {
|
|
151
|
+
if (!isObject(row) || typeof row.id !== "string" || typeof row.type !== "string" || typeof row.content !== "string" || !ID.test(row.id) || !RECORD_TYPE.test(row.type))
|
|
152
|
+
throw new DnsWriteError("unavailable");
|
|
153
|
+
return {
|
|
154
|
+
id: row.id,
|
|
155
|
+
type: row.type,
|
|
156
|
+
content: row.content,
|
|
157
|
+
proxied: typeof row.proxied === "boolean" ? row.proxied : void 0
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
var cloudflare = {
|
|
162
|
+
host: "cloudflare",
|
|
163
|
+
/** Add the record (DNS only) to the Cloudflare zone that holds its name. An identical record
|
|
164
|
+
* is left as it is; the same CNAME behind the proxy gets the proxy turned off. */
|
|
165
|
+
async addRecord({ token, record, comment, fetch: doFetch = fetch }) {
|
|
166
|
+
const hostname = normalizeDnsName(record.name);
|
|
167
|
+
const subject = { hostname, type: record.type };
|
|
168
|
+
let zoneId;
|
|
169
|
+
for (const candidate of zoneCandidates(hostname)) {
|
|
170
|
+
const query = new URLSearchParams({ name: candidate });
|
|
171
|
+
const zones = zonesOf(
|
|
172
|
+
resultOf(await call(doFetch, token, `/zones?${query}`), "zones", subject)
|
|
173
|
+
);
|
|
174
|
+
zoneId = zones.find((z) => normalizeDnsName(z.name) === candidate)?.id;
|
|
175
|
+
if (zoneId) break;
|
|
176
|
+
}
|
|
177
|
+
if (!zoneId) throw new DnsWriteError("zone_not_found", { hostname });
|
|
178
|
+
const existing = recordsOf(
|
|
179
|
+
resultOf(
|
|
180
|
+
await call(
|
|
181
|
+
doFetch,
|
|
182
|
+
token,
|
|
183
|
+
`/zones/${zoneId}/dns_records?${new URLSearchParams({ name: hostname })}`
|
|
184
|
+
),
|
|
185
|
+
"records",
|
|
186
|
+
subject
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
const decision = decide(record, existing, hostname);
|
|
190
|
+
if (decision.kind === "unchanged") return "unchanged";
|
|
191
|
+
if (decision.kind === "unproxy") {
|
|
192
|
+
resultOf(
|
|
193
|
+
await call(doFetch, token, `/zones/${zoneId}/dns_records/${decision.id}`, {
|
|
194
|
+
method: "PATCH",
|
|
195
|
+
body: { proxied: false }
|
|
196
|
+
}),
|
|
197
|
+
"records",
|
|
198
|
+
subject
|
|
199
|
+
);
|
|
200
|
+
return "unproxied";
|
|
201
|
+
}
|
|
202
|
+
resultOf(
|
|
203
|
+
await call(doFetch, token, `/zones/${zoneId}/dns_records`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: {
|
|
206
|
+
type: record.type,
|
|
207
|
+
name: hostname,
|
|
208
|
+
content: record.value,
|
|
209
|
+
// A proxied record hides the CNAME our checker reads.
|
|
210
|
+
...record.type === "CNAME" ? { proxied: false } : {},
|
|
211
|
+
// 1 is Cloudflare's "automatic".
|
|
212
|
+
ttl: 1,
|
|
213
|
+
...comment ? { comment } : {}
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
"records",
|
|
217
|
+
subject
|
|
218
|
+
);
|
|
219
|
+
return "created";
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// src/writers/digitalocean.ts
|
|
224
|
+
var API2 = "https://api.digitalocean.com/v2";
|
|
225
|
+
var TIMEOUT_MS2 = 1e4;
|
|
226
|
+
var MAX_BODY_BYTES2 = 512 * 1024;
|
|
227
|
+
var RECORD_TYPE2 = /^[A-Z0-9]{1,12}$/;
|
|
228
|
+
async function call2(doFetch, token, path, send) {
|
|
229
|
+
const res = await fetchBounded(
|
|
230
|
+
doFetch,
|
|
231
|
+
`${API2}${path}`,
|
|
232
|
+
{
|
|
233
|
+
method: send ? "POST" : "GET",
|
|
234
|
+
headers: {
|
|
235
|
+
Authorization: `Bearer ${token}`,
|
|
236
|
+
Accept: "application/json",
|
|
237
|
+
...send ? { "Content-Type": "application/json" } : {}
|
|
238
|
+
},
|
|
239
|
+
body: send ? JSON.stringify(send.body) : void 0
|
|
240
|
+
},
|
|
241
|
+
{ timeoutMs: TIMEOUT_MS2, maxBytes: MAX_BODY_BYTES2 }
|
|
242
|
+
);
|
|
243
|
+
if (!res) return { status: 0, body: null };
|
|
244
|
+
let json = null;
|
|
245
|
+
try {
|
|
246
|
+
json = JSON.parse(res.text);
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
return { status: res.status, body: isObject(json) ? json : null };
|
|
250
|
+
}
|
|
251
|
+
function refusal2(answer, stage, hostname) {
|
|
252
|
+
if (answer.status === 401) return new DnsWriteError("token_invalid");
|
|
253
|
+
if (answer.status === 403)
|
|
254
|
+
return new DnsWriteError(stage === "reading" ? "zone_read_missing" : "dns_edit_missing");
|
|
255
|
+
if (answer.status === 422 && stage === "writing")
|
|
256
|
+
return new DnsWriteError("conflict", { hostname });
|
|
257
|
+
return new DnsWriteError("unavailable");
|
|
258
|
+
}
|
|
259
|
+
function recordsOf2(body) {
|
|
260
|
+
const rows = body?.domain_records;
|
|
261
|
+
if (!Array.isArray(rows)) throw new DnsWriteError("unavailable");
|
|
262
|
+
return rows.map((row) => {
|
|
263
|
+
if (!isObject(row) || typeof row.id !== "number" || typeof row.type !== "string" || typeof row.data !== "string" || !RECORD_TYPE2.test(row.type))
|
|
264
|
+
throw new DnsWriteError("unavailable");
|
|
265
|
+
return { id: String(row.id), type: row.type, content: row.data };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
var digitalocean = {
|
|
269
|
+
host: "digitalocean",
|
|
270
|
+
/** Add the record to the DigitalOcean domain that holds its name. An identical record is left
|
|
271
|
+
* as it is. DigitalOcean has no proxy, so the outcome is `created` or `unchanged`. */
|
|
272
|
+
async addRecord({ token, record, fetch: doFetch = fetch }) {
|
|
273
|
+
const hostname = normalizeDnsName(record.name);
|
|
274
|
+
let zone;
|
|
275
|
+
let existing = [];
|
|
276
|
+
for (const candidate of zoneCandidates(hostname)) {
|
|
277
|
+
const query = new URLSearchParams({ name: hostname, per_page: "200" });
|
|
278
|
+
const answer = await call2(
|
|
279
|
+
doFetch,
|
|
280
|
+
token,
|
|
281
|
+
`/domains/${encodeURIComponent(candidate)}/records?${query}`
|
|
282
|
+
);
|
|
283
|
+
if (answer.status === 200) {
|
|
284
|
+
zone = candidate;
|
|
285
|
+
existing = recordsOf2(answer.body);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (answer.status !== 404) throw refusal2(answer, "reading", hostname);
|
|
289
|
+
}
|
|
290
|
+
if (!zone) throw new DnsWriteError("zone_not_found", { hostname });
|
|
291
|
+
const decision = decide(record, existing, hostname);
|
|
292
|
+
if (decision.kind === "unchanged") return "unchanged";
|
|
293
|
+
const name = hostname === zone ? "@" : hostname.slice(0, -(zone.length + 1));
|
|
294
|
+
const created = await call2(doFetch, token, `/domains/${encodeURIComponent(zone)}/records`, {
|
|
295
|
+
body: {
|
|
296
|
+
type: record.type,
|
|
297
|
+
name,
|
|
298
|
+
data: record.type === "CNAME" ? `${normalizeDnsName(record.value)}.` : record.value,
|
|
299
|
+
// The API lists these as required for a TXT; they mean nothing for one.
|
|
300
|
+
...record.type === "TXT" ? { flags: null, tag: null } : {}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
if (created.status !== 201) throw refusal2(created, "writing", hostname);
|
|
304
|
+
return "created";
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// src/writers/namecom.ts
|
|
309
|
+
var API3 = "https://api.name.com/core/v1";
|
|
310
|
+
var TIMEOUT_MS3 = 1e4;
|
|
311
|
+
var MAX_BODY_BYTES3 = 512 * 1024;
|
|
312
|
+
var PER_PAGE = 500;
|
|
313
|
+
var MAX_PAGES = 10;
|
|
314
|
+
var TTL = 300;
|
|
315
|
+
var RECORD_TYPE3 = /^[A-Z0-9]{1,12}$/;
|
|
316
|
+
function basicOf(token) {
|
|
317
|
+
const at = token.indexOf(":");
|
|
318
|
+
return at > 0 && at < token.length - 1 && !/\s/.test(token) ? btoa(token) : null;
|
|
319
|
+
}
|
|
320
|
+
async function call3(doFetch, basic, path, send) {
|
|
321
|
+
const res = await fetchBounded(
|
|
322
|
+
doFetch,
|
|
323
|
+
`${API3}${path}`,
|
|
324
|
+
{
|
|
325
|
+
method: send ? "POST" : "GET",
|
|
326
|
+
headers: {
|
|
327
|
+
Authorization: `Basic ${basic}`,
|
|
328
|
+
Accept: "application/json",
|
|
329
|
+
...send ? { "Content-Type": "application/json" } : {}
|
|
330
|
+
},
|
|
331
|
+
body: send ? JSON.stringify(send.body) : void 0
|
|
332
|
+
},
|
|
333
|
+
{ timeoutMs: TIMEOUT_MS3, maxBytes: MAX_BODY_BYTES3 }
|
|
334
|
+
);
|
|
335
|
+
if (!res) return { status: 0, body: null };
|
|
336
|
+
let json = null;
|
|
337
|
+
try {
|
|
338
|
+
json = JSON.parse(res.text);
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
return { status: res.status, body: isObject(json) ? json : null };
|
|
342
|
+
}
|
|
343
|
+
function refusal3(answer, stage, hostname) {
|
|
344
|
+
if (answer.status === 401) return new DnsWriteError("token_invalid");
|
|
345
|
+
if (answer.status === 403)
|
|
346
|
+
return new DnsWriteError(stage === "reading" ? "zone_read_missing" : "dns_edit_missing");
|
|
347
|
+
if (answer.status === 409) return new DnsWriteError("conflict", { hostname });
|
|
348
|
+
return new DnsWriteError("unavailable");
|
|
349
|
+
}
|
|
350
|
+
function pageOf(body) {
|
|
351
|
+
const rows = body?.records;
|
|
352
|
+
if (!Array.isArray(rows)) throw new DnsWriteError("unavailable");
|
|
353
|
+
const records = rows.map((row) => {
|
|
354
|
+
if (!isObject(row) || typeof row.id !== "number" || typeof row.type !== "string" || typeof row.answer !== "string" || typeof row.fqdn !== "string" || !RECORD_TYPE3.test(row.type))
|
|
355
|
+
throw new DnsWriteError("unavailable");
|
|
356
|
+
return { id: String(row.id), type: row.type, content: row.answer, name: row.fqdn };
|
|
357
|
+
});
|
|
358
|
+
return { records, more: typeof body?.nextPage === "number" };
|
|
359
|
+
}
|
|
360
|
+
var namecom = {
|
|
361
|
+
host: "name-com",
|
|
362
|
+
/** Add the record to the Name.com domain that holds its name. An identical record is left as
|
|
363
|
+
* it is. Name.com has no proxy, so the outcome is `created` or `unchanged`. */
|
|
364
|
+
async addRecord({ token, record, fetch: doFetch = fetch }) {
|
|
365
|
+
const hostname = normalizeDnsName(record.name);
|
|
366
|
+
const basic = basicOf(token);
|
|
367
|
+
if (!basic) throw new DnsWriteError("token_invalid");
|
|
368
|
+
let zone;
|
|
369
|
+
let existing = [];
|
|
370
|
+
for (const candidate of zoneCandidates(hostname)) {
|
|
371
|
+
const path = `/domains/${encodeURIComponent(candidate)}/records`;
|
|
372
|
+
const first = await call3(doFetch, basic, `${path}?perPage=${PER_PAGE}`);
|
|
373
|
+
if (first.status === 404) continue;
|
|
374
|
+
if (first.status !== 200) throw refusal3(first, "reading", hostname);
|
|
375
|
+
zone = candidate;
|
|
376
|
+
let page = pageOf(first.body);
|
|
377
|
+
existing = page.records;
|
|
378
|
+
for (let next = 2; page.more && next <= MAX_PAGES; next++) {
|
|
379
|
+
const answer = await call3(doFetch, basic, `${path}?perPage=${PER_PAGE}&page=${next}`);
|
|
380
|
+
if (answer.status !== 200) throw refusal3(answer, "reading", hostname);
|
|
381
|
+
page = pageOf(answer.body);
|
|
382
|
+
existing = existing.concat(page.records);
|
|
383
|
+
}
|
|
384
|
+
if (page.more) throw new DnsWriteError("unavailable");
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (!zone) throw new DnsWriteError("zone_not_found", { hostname });
|
|
388
|
+
const here = existing.filter(
|
|
389
|
+
(r) => r.name !== void 0 && normalizeDnsName(r.name) === hostname
|
|
390
|
+
);
|
|
391
|
+
const decision = decide(record, here, hostname);
|
|
392
|
+
if (decision.kind === "unchanged") return "unchanged";
|
|
393
|
+
const host = hostname === zone ? "@" : hostname.slice(0, -(zone.length + 1));
|
|
394
|
+
const created = await call3(doFetch, basic, `/domains/${encodeURIComponent(zone)}/records`, {
|
|
395
|
+
body: { host, type: record.type, answer: record.value, ttl: TTL }
|
|
396
|
+
});
|
|
397
|
+
if (created.status !== 200) throw refusal3(created, "writing", hostname);
|
|
398
|
+
const stored = created.body?.answer;
|
|
399
|
+
if (typeof stored !== "string" || !sameValue(record, { content: stored }))
|
|
400
|
+
throw new DnsWriteError("unavailable");
|
|
401
|
+
return "created";
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// src/writers/porkbun.ts
|
|
406
|
+
var API4 = "https://api.porkbun.com/api/json/v3";
|
|
407
|
+
var TIMEOUT_MS4 = 1e4;
|
|
408
|
+
var MAX_BODY_BYTES4 = 256 * 1024;
|
|
409
|
+
var KEY_CODES = /* @__PURE__ */ new Set([
|
|
410
|
+
"API_KEY_REQUIRED",
|
|
411
|
+
"INVALID_API_KEYS_001",
|
|
412
|
+
"INVALID_TOKEN",
|
|
413
|
+
"INVALID_USER"
|
|
414
|
+
]);
|
|
415
|
+
var ACCESS_CODES = /* @__PURE__ */ new Set(["API_ACCESS_DISABLED", "IP_NOT_ALLOWED", "DOMAIN_NOT_ALLOWED"]);
|
|
416
|
+
var NOT_A_ZONE_CODES = /* @__PURE__ */ new Set(["INVALID_DOMAIN", "DOMAIN_NOT_FOUND"]);
|
|
417
|
+
var ID2 = /^[0-9]{1,20}$/;
|
|
418
|
+
var RECORD_TYPE4 = /^[A-Z0-9]{1,12}$/;
|
|
419
|
+
function keysOf(token) {
|
|
420
|
+
const at = token.indexOf(":");
|
|
421
|
+
const key = token.slice(0, at);
|
|
422
|
+
const secret = token.slice(at + 1);
|
|
423
|
+
return at > 0 && secret && !/\s/.test(token) ? { key, secret } : null;
|
|
424
|
+
}
|
|
425
|
+
async function call4(doFetch, keys, path, send) {
|
|
426
|
+
const res = await fetchBounded(
|
|
427
|
+
doFetch,
|
|
428
|
+
`${API4}${path}`,
|
|
429
|
+
{
|
|
430
|
+
method: send ? "POST" : "GET",
|
|
431
|
+
headers: {
|
|
432
|
+
"X-API-Key": keys.key,
|
|
433
|
+
"X-Secret-API-Key": keys.secret,
|
|
434
|
+
Accept: "application/json",
|
|
435
|
+
...send ? { "Content-Type": "application/json" } : {}
|
|
436
|
+
},
|
|
437
|
+
body: send ? JSON.stringify(send.body) : void 0
|
|
438
|
+
},
|
|
439
|
+
{ timeoutMs: TIMEOUT_MS4, maxBytes: MAX_BODY_BYTES4 }
|
|
440
|
+
);
|
|
441
|
+
if (!res) return { code: "", ok: false, body: null };
|
|
442
|
+
let json = null;
|
|
443
|
+
try {
|
|
444
|
+
json = JSON.parse(res.text);
|
|
445
|
+
} catch {
|
|
446
|
+
}
|
|
447
|
+
const body = isObject(json) ? json : null;
|
|
448
|
+
return {
|
|
449
|
+
code: typeof body?.code === "string" ? body.code : "",
|
|
450
|
+
ok: res.status === 200 && body?.status === "SUCCESS",
|
|
451
|
+
body
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function refusal4(answer, hostname) {
|
|
455
|
+
if (KEY_CODES.has(answer.code)) return new DnsWriteError("token_invalid");
|
|
456
|
+
if (ACCESS_CODES.has(answer.code)) return new DnsWriteError("dns_edit_missing");
|
|
457
|
+
if (answer.code === "RECORD_CONFLICT") return new DnsWriteError("conflict", { hostname });
|
|
458
|
+
return new DnsWriteError("unavailable");
|
|
459
|
+
}
|
|
460
|
+
function recordsOf3(body) {
|
|
461
|
+
const rows = body?.records;
|
|
462
|
+
if (!Array.isArray(rows)) throw new DnsWriteError("unavailable");
|
|
463
|
+
return rows.map((row) => {
|
|
464
|
+
if (!isObject(row) || typeof row.id !== "string" || typeof row.name !== "string" || typeof row.type !== "string" || typeof row.content !== "string" || !ID2.test(row.id) || !RECORD_TYPE4.test(row.type))
|
|
465
|
+
throw new DnsWriteError("unavailable");
|
|
466
|
+
return { id: row.id, type: row.type, content: row.content, name: row.name };
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
var porkbun = {
|
|
470
|
+
host: "porkbun",
|
|
471
|
+
/** Add the record to the Porkbun domain that holds its name. An identical record is left as
|
|
472
|
+
* it is. Porkbun has no proxy, so the outcome is `created` or `unchanged`. */
|
|
473
|
+
async addRecord({ token, record, comment, fetch: doFetch = fetch }) {
|
|
474
|
+
const hostname = normalizeDnsName(record.name);
|
|
475
|
+
const keys = keysOf(token);
|
|
476
|
+
if (!keys) throw new DnsWriteError("token_invalid");
|
|
477
|
+
let zone;
|
|
478
|
+
let existing = [];
|
|
479
|
+
for (const candidate of zoneCandidates(hostname)) {
|
|
480
|
+
const answer = await call4(doFetch, keys, `/dns/retrieve/${encodeURIComponent(candidate)}`);
|
|
481
|
+
if (answer.ok) {
|
|
482
|
+
zone = candidate;
|
|
483
|
+
existing = recordsOf3(answer.body).filter(
|
|
484
|
+
(r) => r.name !== void 0 && normalizeDnsName(r.name) === hostname
|
|
485
|
+
);
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
if (!NOT_A_ZONE_CODES.has(answer.code)) throw refusal4(answer, hostname);
|
|
489
|
+
}
|
|
490
|
+
if (!zone) throw new DnsWriteError("zone_not_found", { hostname });
|
|
491
|
+
const decision = decide(record, existing, hostname);
|
|
492
|
+
if (decision.kind === "unchanged") return "unchanged";
|
|
493
|
+
const subdomain = hostname === zone ? "" : hostname.slice(0, -(zone.length + 1));
|
|
494
|
+
const created = await call4(doFetch, keys, `/dns/create/${encodeURIComponent(zone)}`, {
|
|
495
|
+
body: {
|
|
496
|
+
name: subdomain,
|
|
497
|
+
type: record.type,
|
|
498
|
+
content: record.value,
|
|
499
|
+
...comment ? { notes: comment } : {}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
if (created.code === "DUPLICATE_RECORD") return "unchanged";
|
|
503
|
+
if (!created.ok) throw refusal4(created, hostname);
|
|
504
|
+
return "created";
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// src/writers/index.ts
|
|
509
|
+
var writers = {
|
|
510
|
+
cloudflare,
|
|
511
|
+
digitalocean,
|
|
512
|
+
"name-com": namecom,
|
|
513
|
+
porkbun
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export {
|
|
517
|
+
fetchBounded,
|
|
518
|
+
isObject,
|
|
519
|
+
DnsWriteError,
|
|
520
|
+
cloudflare,
|
|
521
|
+
digitalocean,
|
|
522
|
+
namecom,
|
|
523
|
+
porkbun,
|
|
524
|
+
writers
|
|
525
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DNS hosts we know steps for. A closed list. Order is the order a picker shows them and a
|
|
3
|
+
* detection lists them in.
|
|
4
|
+
*
|
|
5
|
+
* `cpanel` and `namecheap-hosting` both hand the person cPanel's Zone Editor. `hostinger` and
|
|
6
|
+
* `cpanel` have no name server pattern, so DNS alone never names them: a person picks them by
|
|
7
|
+
* hand.
|
|
8
|
+
*/
|
|
9
|
+
declare const DNS_HOST_IDS: readonly ["cloudflare", "godaddy", "namecheap", "namecheap-hosting", "squarespace", "route53", "google-cloud-dns", "azure-dns", "ionos", "hostinger", "porkbun", "name-com", "dynadot", "namesilo", "gandi", "ovhcloud", "digitalocean", "hetzner", "vercel", "netlify", "wix", "wordpress-com", "cpanel", "bluehost", "hostgator", "hostgator-br", "siteground", "registro-br", "locaweb", "kinghost", "uol-host", "umbler", "dnsimple"];
|
|
10
|
+
type DnsHostId = (typeof DNS_HOST_IDS)[number];
|
|
11
|
+
/** The labels on the form of one record type. */
|
|
12
|
+
interface DnsFields {
|
|
13
|
+
/** The box that takes the record's name. */
|
|
14
|
+
name: string | null;
|
|
15
|
+
/** The box that takes its value: a CNAME's target, or a TXT's text. */
|
|
16
|
+
value: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What goes wrong at a host, each with a sentence of its own in the app.
|
|
20
|
+
*
|
|
21
|
+
* - `proxyOff`: a record is proxied by default and the proxy hides it (Cloudflare);
|
|
22
|
+
* - `trailingDot`: without a dot at the end, the host adds the zone to a CNAME's target
|
|
23
|
+
* (OVHcloud, Hetzner, DigitalOcean);
|
|
24
|
+
* - `quotedTxt`: the host wants straight quotes around a TXT value (Route 53, Hetzner);
|
|
25
|
+
* - `secondSave`: adding the record is not saving it (KingHost);
|
|
26
|
+
* - `advancedMode`: the basic mode can't add the record (Registro.br);
|
|
27
|
+
* - `oneSpf`: the host may already hold an SPF record, and a domain may have only one;
|
|
28
|
+
* - `shortName`: the name box takes only the part before the zone, and the host adds the rest.
|
|
29
|
+
*
|
|
30
|
+
* `trailingDot` and `quotedTxt` also change the value we hand over (`records.ts`).
|
|
31
|
+
*/
|
|
32
|
+
type DnsTrap = {
|
|
33
|
+
kind: "proxyOff";
|
|
34
|
+
field: string;
|
|
35
|
+
value: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "trailingDot";
|
|
38
|
+
} | {
|
|
39
|
+
kind: "quotedTxt";
|
|
40
|
+
} | {
|
|
41
|
+
kind: "secondSave";
|
|
42
|
+
button: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "advancedMode";
|
|
45
|
+
mode: string;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "oneSpf";
|
|
48
|
+
} | {
|
|
49
|
+
kind: "shortName";
|
|
50
|
+
};
|
|
51
|
+
type DnsTrapKind = DnsTrap["kind"];
|
|
52
|
+
interface DnsHost {
|
|
53
|
+
/** The brand, as the company writes it. */
|
|
54
|
+
name: string;
|
|
55
|
+
/** The host's name servers, as patterns over a lowercase name with no trailing dot. A set
|
|
56
|
+
* of names belongs to this host only when every name matches one of them. Empty: no
|
|
57
|
+
* pattern we may trust, so the host is reached by the picker alone. */
|
|
58
|
+
ns: readonly RegExp[];
|
|
59
|
+
/** The host's page for a zone's records, only where its own docs link one. */
|
|
60
|
+
link: string | null;
|
|
61
|
+
/** The menu, tab or button that opens the zone's records. */
|
|
62
|
+
open: string | null;
|
|
63
|
+
/** The button that starts a new record. */
|
|
64
|
+
add: string | null;
|
|
65
|
+
/** The button that saves it. */
|
|
66
|
+
save: string | null;
|
|
67
|
+
cname: DnsFields;
|
|
68
|
+
txt: DnsFields;
|
|
69
|
+
/** How the host writes the zone's own name in the name box: `"@"`, empty, or `"."`. `null`
|
|
70
|
+
* when no page of the host says. */
|
|
71
|
+
root: "@" | "" | "." | null;
|
|
72
|
+
traps: readonly DnsTrap[];
|
|
73
|
+
}
|
|
74
|
+
declare const DNS_HOSTS: Record<DnsHostId, DnsHost>;
|
|
75
|
+
/** The first trap of a kind a host has. */
|
|
76
|
+
declare function trapOf<K extends DnsTrapKind>(host: DnsHost, kind: K): Extract<DnsTrap, {
|
|
77
|
+
kind: K;
|
|
78
|
+
}> | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* The hosts a zone's name servers point at. A host fits when every name matches one of its
|
|
81
|
+
* patterns, so a set that mixes two hosts, or holds one name nobody claims, fits none. Names that
|
|
82
|
+
* several hosts share answer with all of them, in `DNS_HOST_IDS` order: the app shows the
|
|
83
|
+
* choice and never guesses. An empty list is a host we don't know.
|
|
84
|
+
*/
|
|
85
|
+
declare function detectDnsHosts(nameservers: readonly string[]): DnsHostId[];
|
|
86
|
+
/** The zone whose name servers answered: the nearest name at or above the hostname that has NS
|
|
87
|
+
* records of its own. Records are added in this zone, named relative to it. */
|
|
88
|
+
interface DnsZone {
|
|
89
|
+
zone: string;
|
|
90
|
+
/** The zone's name servers, lowercase, without the trailing dot, sorted. */
|
|
91
|
+
nameservers: string[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* What we read from DNS about where a domain's records are edited. `hosts` says what the names
|
|
95
|
+
* mean:
|
|
96
|
+
* - one id: a host we know;
|
|
97
|
+
* - several: the names fit more than one (Netlify and older Squarespace zones share NS1's
|
|
98
|
+
* names, Google Cloud DNS and Squarespace domains moved from Google Domains share Google's),
|
|
99
|
+
* so we do not guess;
|
|
100
|
+
* - none: names no host we know uses, or a mix of hosts. Show the names and ask.
|
|
101
|
+
*/
|
|
102
|
+
interface DnsHostReading extends DnsZone {
|
|
103
|
+
hosts: DnsHostId[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { type DnsHostId as D, DNS_HOSTS as a, DNS_HOST_IDS as b, type DnsFields as c, type DnsHost as d, type DnsHostReading as e, type DnsTrap as f, type DnsTrapKind as g, type DnsZone as h, detectDnsHosts as i, trapOf as t };
|