@aigne/afs-dns 1.11.0-beta.10
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 +26 -0
- package/README.md +233 -0
- package/dist/index.d.mts +859 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2013 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +52 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2013 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { minimatch } from "minimatch";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ChangeResourceRecordSetsCommand, GetHostedZoneCommand, ListHostedZonesCommand, ListResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53";
|
|
5
|
+
|
|
6
|
+
//#region rolldown:runtime
|
|
7
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/clouddns/adapter.ts
|
|
11
|
+
var CloudDNSAdapter = class {
|
|
12
|
+
client;
|
|
13
|
+
zoneCache = /* @__PURE__ */ new Map();
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
if (options.client) this.client = options.client;
|
|
16
|
+
else {
|
|
17
|
+
const { DNS } = __require("@google-cloud/dns");
|
|
18
|
+
this.client = new DNS({
|
|
19
|
+
projectId: options.projectId,
|
|
20
|
+
keyFilename: options.keyFilename
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* List all managed zones
|
|
26
|
+
*/
|
|
27
|
+
async listZones() {
|
|
28
|
+
const [zones] = await this.client.getZones();
|
|
29
|
+
return zones.map((zone) => ({
|
|
30
|
+
domain: zone.meta.dnsName.replace(/\.$/, ""),
|
|
31
|
+
id: zone.meta.id,
|
|
32
|
+
provider: "clouddns",
|
|
33
|
+
nameservers: zone.meta.nameServers ?? [],
|
|
34
|
+
recordCount: void 0
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* List all record names in a zone
|
|
39
|
+
*/
|
|
40
|
+
async listRecords(zoneDomain) {
|
|
41
|
+
const [records] = await (await this.getZone(zoneDomain)).getRecords();
|
|
42
|
+
const normalizedDomain = zoneDomain.endsWith(".") ? zoneDomain : `${zoneDomain}.`;
|
|
43
|
+
const names = /* @__PURE__ */ new Set();
|
|
44
|
+
for (const record of records) {
|
|
45
|
+
const name = this.extractRecordName(record.name, normalizedDomain);
|
|
46
|
+
names.add(name);
|
|
47
|
+
}
|
|
48
|
+
names.add("_zone");
|
|
49
|
+
return Array.from(names);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get all records for a specific name
|
|
53
|
+
*/
|
|
54
|
+
async getRecord(zoneDomain, name) {
|
|
55
|
+
const [records] = await (await this.getZone(zoneDomain)).getRecords();
|
|
56
|
+
const normalizedDomain = zoneDomain.endsWith(".") ? zoneDomain : `${zoneDomain}.`;
|
|
57
|
+
const fqdn = name === "@" ? zoneDomain : `${name}.${zoneDomain}`;
|
|
58
|
+
const targetName = name === "@" ? normalizedDomain : `${name}.${normalizedDomain}`;
|
|
59
|
+
return {
|
|
60
|
+
fqdn,
|
|
61
|
+
records: records.filter((r) => r.name === targetName).map((r) => this.parseRecord(r))
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get zone metadata
|
|
66
|
+
*/
|
|
67
|
+
async getZoneMetadata(zoneDomain) {
|
|
68
|
+
const zone = await this.getZone(zoneDomain);
|
|
69
|
+
return {
|
|
70
|
+
domain: zone.meta.dnsName.replace(/\.$/, ""),
|
|
71
|
+
id: zone.meta.id,
|
|
72
|
+
provider: "clouddns",
|
|
73
|
+
nameservers: zone.meta.nameServers ?? [],
|
|
74
|
+
recordCount: void 0
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Set records for a name (create or update)
|
|
79
|
+
*/
|
|
80
|
+
async setRecord(zoneDomain, name, records, _options) {
|
|
81
|
+
const zone = await this.getZone(zoneDomain);
|
|
82
|
+
const normalizedDomain = zoneDomain.endsWith(".") ? zoneDomain : `${zoneDomain}.`;
|
|
83
|
+
const fqdn = name === "@" ? normalizedDomain : `${name}.${normalizedDomain}`;
|
|
84
|
+
const [existingRecords] = await zone.getRecords();
|
|
85
|
+
const toDelete = existingRecords.filter((r) => {
|
|
86
|
+
if (r.name !== fqdn) return false;
|
|
87
|
+
return records.some((newRec) => newRec.type === r.type);
|
|
88
|
+
});
|
|
89
|
+
for (const record of records) {
|
|
90
|
+
const changeConfig = { add: this.buildCloudRecord(zone, fqdn, record) };
|
|
91
|
+
const matchingDeletes = toDelete.filter((r) => r.type === record.type);
|
|
92
|
+
if (matchingDeletes.length > 0) changeConfig.delete = matchingDeletes;
|
|
93
|
+
await zone.createChange(changeConfig);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Delete records for a name
|
|
98
|
+
*/
|
|
99
|
+
async deleteRecord(zoneDomain, name, type, _options) {
|
|
100
|
+
const zone = await this.getZone(zoneDomain);
|
|
101
|
+
const normalizedDomain = zoneDomain.endsWith(".") ? zoneDomain : `${zoneDomain}.`;
|
|
102
|
+
const fqdn = name === "@" ? normalizedDomain : `${name}.${normalizedDomain}`;
|
|
103
|
+
const [existingRecords] = await zone.getRecords();
|
|
104
|
+
const toDelete = existingRecords.filter((r) => {
|
|
105
|
+
if (r.name !== fqdn) return false;
|
|
106
|
+
if (type && r.type !== type) return false;
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
if (toDelete.length === 0) return;
|
|
110
|
+
await zone.createChange({ delete: toDelete });
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get zone by domain name
|
|
114
|
+
*/
|
|
115
|
+
async getZone(zoneDomain) {
|
|
116
|
+
if (this.zoneCache.has(zoneDomain)) return this.zoneCache.get(zoneDomain);
|
|
117
|
+
const [zones] = await this.client.getZones();
|
|
118
|
+
const normalizedDomain = zoneDomain.endsWith(".") ? zoneDomain : `${zoneDomain}.`;
|
|
119
|
+
const zone = zones.find((z$1) => z$1.meta.dnsName === normalizedDomain);
|
|
120
|
+
if (!zone) {
|
|
121
|
+
const error = /* @__PURE__ */ new Error(`Zone not found: ${zoneDomain}`);
|
|
122
|
+
error.name = "ZoneNotFound";
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
this.zoneCache.set(zoneDomain, zone);
|
|
126
|
+
return zone;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Extract record name from FQDN
|
|
130
|
+
*/
|
|
131
|
+
extractRecordName(fqdn, zoneDomain) {
|
|
132
|
+
const normalizedFqdn = fqdn.endsWith(".") ? fqdn : `${fqdn}.`;
|
|
133
|
+
const normalizedZone = zoneDomain.endsWith(".") ? zoneDomain : `${zoneDomain}.`;
|
|
134
|
+
if (normalizedFqdn === normalizedZone) return "@";
|
|
135
|
+
return normalizedFqdn.replace(/* @__PURE__ */ new RegExp(`\\.?${normalizedZone.replace(/\./g, "\\.")}$`), "") || "@";
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Parse Cloud DNS record to DNSRecord
|
|
139
|
+
*/
|
|
140
|
+
parseRecord(record) {
|
|
141
|
+
const { type, ttl, data } = record;
|
|
142
|
+
switch (type) {
|
|
143
|
+
case "A":
|
|
144
|
+
case "AAAA":
|
|
145
|
+
case "NS":
|
|
146
|
+
case "TXT":
|
|
147
|
+
case "PTR": return {
|
|
148
|
+
type,
|
|
149
|
+
ttl,
|
|
150
|
+
values: data
|
|
151
|
+
};
|
|
152
|
+
case "CNAME": return {
|
|
153
|
+
type: "CNAME",
|
|
154
|
+
ttl,
|
|
155
|
+
values: data[0] ?? ""
|
|
156
|
+
};
|
|
157
|
+
case "MX": return {
|
|
158
|
+
type: "MX",
|
|
159
|
+
ttl,
|
|
160
|
+
values: data.map((v) => {
|
|
161
|
+
const [priority, value] = v.split(" ", 2);
|
|
162
|
+
return {
|
|
163
|
+
priority: Number.parseInt(priority ?? "0", 10),
|
|
164
|
+
value: value ?? ""
|
|
165
|
+
};
|
|
166
|
+
})
|
|
167
|
+
};
|
|
168
|
+
case "SRV": return {
|
|
169
|
+
type: "SRV",
|
|
170
|
+
ttl,
|
|
171
|
+
values: data.map((v) => {
|
|
172
|
+
const [priority, weight, port, target] = v.split(" ", 4);
|
|
173
|
+
return {
|
|
174
|
+
priority: Number.parseInt(priority ?? "0", 10),
|
|
175
|
+
weight: Number.parseInt(weight ?? "0", 10),
|
|
176
|
+
port: Number.parseInt(port ?? "0", 10),
|
|
177
|
+
target: target ?? ""
|
|
178
|
+
};
|
|
179
|
+
})
|
|
180
|
+
};
|
|
181
|
+
case "CAA": return {
|
|
182
|
+
type: "CAA",
|
|
183
|
+
ttl,
|
|
184
|
+
values: data.map((v) => {
|
|
185
|
+
const match = v.match(/^(\d+)\s+(\w+)\s+"?(.+?)"?$/);
|
|
186
|
+
if (match) return {
|
|
187
|
+
flags: Number.parseInt(match[1] ?? "0", 10),
|
|
188
|
+
tag: match[2] ?? "",
|
|
189
|
+
value: match[3] ?? ""
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
flags: 0,
|
|
193
|
+
tag: "",
|
|
194
|
+
value: v
|
|
195
|
+
};
|
|
196
|
+
})
|
|
197
|
+
};
|
|
198
|
+
case "SOA": {
|
|
199
|
+
const soaParts = data[0]?.split(" ") ?? [];
|
|
200
|
+
return {
|
|
201
|
+
type: "SOA",
|
|
202
|
+
ttl,
|
|
203
|
+
values: {
|
|
204
|
+
mname: soaParts[0] ?? "",
|
|
205
|
+
rname: soaParts[1] ?? "",
|
|
206
|
+
serial: Number.parseInt(soaParts[2] ?? "0", 10),
|
|
207
|
+
refresh: Number.parseInt(soaParts[3] ?? "0", 10),
|
|
208
|
+
retry: Number.parseInt(soaParts[4] ?? "0", 10),
|
|
209
|
+
expire: Number.parseInt(soaParts[5] ?? "0", 10),
|
|
210
|
+
minimum: Number.parseInt(soaParts[6] ?? "0", 10)
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
default: return {
|
|
215
|
+
type,
|
|
216
|
+
ttl,
|
|
217
|
+
values: data
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Build Cloud DNS record from DNSRecord
|
|
223
|
+
*/
|
|
224
|
+
buildCloudRecord(zone, fqdn, record) {
|
|
225
|
+
const { type, ttl, values } = record;
|
|
226
|
+
let data;
|
|
227
|
+
switch (type) {
|
|
228
|
+
case "A":
|
|
229
|
+
case "AAAA":
|
|
230
|
+
case "NS":
|
|
231
|
+
case "TXT":
|
|
232
|
+
case "PTR":
|
|
233
|
+
data = Array.isArray(values) ? values : [values];
|
|
234
|
+
break;
|
|
235
|
+
case "CNAME":
|
|
236
|
+
data = [typeof values === "string" ? values : values[0] ?? ""];
|
|
237
|
+
break;
|
|
238
|
+
case "MX":
|
|
239
|
+
data = values.map((mx) => `${mx.priority} ${mx.value}`);
|
|
240
|
+
break;
|
|
241
|
+
case "SRV":
|
|
242
|
+
data = values.map((srv) => `${srv.priority} ${srv.weight} ${srv.port} ${srv.target}`);
|
|
243
|
+
break;
|
|
244
|
+
case "CAA":
|
|
245
|
+
data = values.map((caa) => `${caa.flags} ${caa.tag} "${caa.value}"`);
|
|
246
|
+
break;
|
|
247
|
+
default: data = Array.isArray(values) ? values : [values];
|
|
248
|
+
}
|
|
249
|
+
return zone.record(type.toLowerCase(), {
|
|
250
|
+
name: fqdn,
|
|
251
|
+
ttl,
|
|
252
|
+
data
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/errors/index.ts
|
|
259
|
+
/**
|
|
260
|
+
* DNS Provider Errors
|
|
261
|
+
*
|
|
262
|
+
* Custom error types and Route53 error mapping.
|
|
263
|
+
*/
|
|
264
|
+
var AFSDNSError = class extends Error {
|
|
265
|
+
code;
|
|
266
|
+
cause;
|
|
267
|
+
constructor(message, options) {
|
|
268
|
+
super(message);
|
|
269
|
+
this.name = "AFSDNSError";
|
|
270
|
+
this.code = options?.code ?? "DNS_ERROR";
|
|
271
|
+
this.cause = options?.cause;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
var AFSDNSNotFoundError = class extends AFSDNSError {
|
|
275
|
+
constructor(message, options) {
|
|
276
|
+
super(message, {
|
|
277
|
+
code: "DNS_NOT_FOUND",
|
|
278
|
+
...options
|
|
279
|
+
});
|
|
280
|
+
this.name = "AFSDNSNotFoundError";
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
var AFSDNSPermissionDeniedError = class extends AFSDNSError {
|
|
284
|
+
constructor(message, options) {
|
|
285
|
+
super(message, {
|
|
286
|
+
code: "DNS_PERMISSION_DENIED",
|
|
287
|
+
...options
|
|
288
|
+
});
|
|
289
|
+
this.name = "AFSDNSPermissionDeniedError";
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
var AFSDNSRateLimitError = class extends AFSDNSError {
|
|
293
|
+
retryAfter;
|
|
294
|
+
constructor(message, options) {
|
|
295
|
+
super(message, {
|
|
296
|
+
code: "DNS_RATE_LIMIT",
|
|
297
|
+
...options
|
|
298
|
+
});
|
|
299
|
+
this.name = "AFSDNSRateLimitError";
|
|
300
|
+
this.retryAfter = options?.retryAfter ?? 1;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
var AFSDNSInvalidArgumentError = class extends AFSDNSError {
|
|
304
|
+
constructor(message, options) {
|
|
305
|
+
super(message, {
|
|
306
|
+
code: "DNS_INVALID_ARGUMENT",
|
|
307
|
+
...options
|
|
308
|
+
});
|
|
309
|
+
this.name = "AFSDNSInvalidArgumentError";
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
var AFSDNSConflictError = class extends AFSDNSError {
|
|
313
|
+
constructor(message, options) {
|
|
314
|
+
super(message, {
|
|
315
|
+
code: "DNS_CONFLICT",
|
|
316
|
+
...options
|
|
317
|
+
});
|
|
318
|
+
this.name = "AFSDNSConflictError";
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
var AFSDNSNetworkError = class extends AFSDNSError {
|
|
322
|
+
constructor(message, options) {
|
|
323
|
+
super(message, {
|
|
324
|
+
code: "DNS_NETWORK_ERROR",
|
|
325
|
+
...options
|
|
326
|
+
});
|
|
327
|
+
this.name = "AFSDNSNetworkError";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Map Route53 error to AFS DNS error
|
|
332
|
+
*/
|
|
333
|
+
function mapRoute53Error(error) {
|
|
334
|
+
const errorName = error.name;
|
|
335
|
+
const sanitizedMessage = sanitizeErrorMessage(error.message);
|
|
336
|
+
switch (errorName) {
|
|
337
|
+
case "NoSuchHostedZone": return new AFSDNSNotFoundError("Zone not found", { cause: error });
|
|
338
|
+
case "AccessDenied":
|
|
339
|
+
case "AccessDeniedException": return new AFSDNSPermissionDeniedError("Access denied to DNS zone", { cause: error });
|
|
340
|
+
case "Throttling":
|
|
341
|
+
case "ThrottlingException": return new AFSDNSRateLimitError("Rate limit exceeded, please retry", {
|
|
342
|
+
cause: error,
|
|
343
|
+
retryAfter: extractRetryAfter(error)
|
|
344
|
+
});
|
|
345
|
+
case "InvalidInput":
|
|
346
|
+
case "InvalidChangeBatch": return new AFSDNSInvalidArgumentError(`Invalid DNS record: ${sanitizedMessage}`, { cause: error });
|
|
347
|
+
case "PriorRequestNotComplete": return new AFSDNSConflictError("Previous DNS change is still processing, please retry", { cause: error });
|
|
348
|
+
case "NetworkingError":
|
|
349
|
+
case "ECONNREFUSED":
|
|
350
|
+
case "ETIMEDOUT": return new AFSDNSNetworkError("Network error connecting to DNS service", { cause: error });
|
|
351
|
+
case "ServiceUnavailable": return new AFSDNSNetworkError("DNS service temporarily unavailable", { cause: error });
|
|
352
|
+
default: return new AFSDNSError(`DNS operation failed: ${sanitizedMessage}`, { cause: error });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Check if an error is retryable
|
|
357
|
+
*/
|
|
358
|
+
function isRetryableError(error) {
|
|
359
|
+
return [
|
|
360
|
+
"Throttling",
|
|
361
|
+
"ThrottlingException",
|
|
362
|
+
"PriorRequestNotComplete",
|
|
363
|
+
"ServiceUnavailable",
|
|
364
|
+
"ETIMEDOUT",
|
|
365
|
+
"ECONNRESET"
|
|
366
|
+
].includes(error.name);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Sanitize error message to remove sensitive information
|
|
370
|
+
*/
|
|
371
|
+
function sanitizeErrorMessage(message) {
|
|
372
|
+
let sanitized = message;
|
|
373
|
+
sanitized = sanitized.replace(/\b\d{12}\b/g, "***");
|
|
374
|
+
sanitized = sanitized.replace(/arn:aws:[^:\s]+:[^:\s]*:[^:\s]*:[^\s]+/g, "arn:aws:***");
|
|
375
|
+
sanitized = sanitized.replace(/\/hostedzone\/[A-Z0-9]+/g, "/hostedzone/***");
|
|
376
|
+
sanitized = sanitized.replace(/\bZ[A-Z0-9]{10,}\b/g, "***");
|
|
377
|
+
sanitized = sanitized.replace(/user\/[^\s,]+/g, "user/***");
|
|
378
|
+
sanitized = sanitized.replace(/role\/[^\s,]+/g, "role/***");
|
|
379
|
+
return sanitized;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Extract retry-after hint from error if available
|
|
383
|
+
*/
|
|
384
|
+
function extractRetryAfter(_error) {
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region src/permissions/types.ts
|
|
390
|
+
/**
|
|
391
|
+
* Preset definitions
|
|
392
|
+
*/
|
|
393
|
+
const PRESETS = {
|
|
394
|
+
safe: {
|
|
395
|
+
allowDelete: false,
|
|
396
|
+
dangerous: {
|
|
397
|
+
modify_ns: false,
|
|
398
|
+
modify_root: false,
|
|
399
|
+
modify_wildcard: false,
|
|
400
|
+
delete_zone: false
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
standard: {
|
|
404
|
+
allowDelete: true,
|
|
405
|
+
dangerous: {
|
|
406
|
+
modify_ns: false,
|
|
407
|
+
modify_root: false,
|
|
408
|
+
modify_wildcard: false,
|
|
409
|
+
delete_zone: false
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
full: {
|
|
413
|
+
allowDelete: true,
|
|
414
|
+
dangerous: {
|
|
415
|
+
modify_ns: true,
|
|
416
|
+
modify_root: true,
|
|
417
|
+
modify_wildcard: true,
|
|
418
|
+
delete_zone: false
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/permissions/checker.ts
|
|
425
|
+
/**
|
|
426
|
+
* DNS Permission Checker
|
|
427
|
+
*
|
|
428
|
+
* Validates operations against configured permissions.
|
|
429
|
+
*/
|
|
430
|
+
/**
|
|
431
|
+
* Check if an operation is allowed
|
|
432
|
+
*/
|
|
433
|
+
function checkPermission(context, config) {
|
|
434
|
+
if (context.operation === "read") return { allowed: true };
|
|
435
|
+
const effective = getEffectivePermissions(config);
|
|
436
|
+
if (context.operation === "delete" && !effective.allowDelete) return {
|
|
437
|
+
allowed: false,
|
|
438
|
+
reason: "Delete operations are not allowed with 'safe' preset",
|
|
439
|
+
requiredConfig: "preset = \"standard\" or \"full\"",
|
|
440
|
+
deniedOperation: "delete"
|
|
441
|
+
};
|
|
442
|
+
const dangerousOp = detectDangerousOperation(context);
|
|
443
|
+
if (dangerousOp) {
|
|
444
|
+
if (!effective.dangerous[dangerousOp]) return {
|
|
445
|
+
allowed: false,
|
|
446
|
+
reason: `Operation '${dangerousOp}' is not allowed`,
|
|
447
|
+
requiredConfig: `[mounts.options.dangerous]\n${dangerousOp} = true`,
|
|
448
|
+
deniedOperation: dangerousOp
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return { allowed: true };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Get effective permissions by merging preset with explicit config
|
|
455
|
+
*/
|
|
456
|
+
function getEffectivePermissions(config) {
|
|
457
|
+
const presetConfig = PRESETS[config.preset ?? "standard"];
|
|
458
|
+
const result = {
|
|
459
|
+
allowDelete: presetConfig.allowDelete,
|
|
460
|
+
dangerous: { ...presetConfig.dangerous }
|
|
461
|
+
};
|
|
462
|
+
if (config.dangerous) {
|
|
463
|
+
for (const [key, value] of Object.entries(config.dangerous)) if (value !== void 0) result.dangerous[key] = value;
|
|
464
|
+
}
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Detect if an operation is dangerous
|
|
469
|
+
*/
|
|
470
|
+
function detectDangerousOperation(context) {
|
|
471
|
+
const { name, type, operation } = context;
|
|
472
|
+
if (operation === "read") return null;
|
|
473
|
+
if (name === "@") return "modify_root";
|
|
474
|
+
if (type === "NS") return "modify_ns";
|
|
475
|
+
if (name === "*" || name.startsWith("*.")) return "modify_wildcard";
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Create a user-friendly error message for permission denial
|
|
480
|
+
*/
|
|
481
|
+
function formatPermissionError(result) {
|
|
482
|
+
let message = `Permission denied: ${result.reason}`;
|
|
483
|
+
if (result.requiredConfig) message += `\n\nTo enable this operation, add to your config:\n${result.requiredConfig}`;
|
|
484
|
+
return message;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/security/audit-log.ts
|
|
489
|
+
var AuditLogger = class {
|
|
490
|
+
config;
|
|
491
|
+
entries = [];
|
|
492
|
+
constructor(config = {}) {
|
|
493
|
+
this.config = config;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Log a write operation
|
|
497
|
+
*/
|
|
498
|
+
logWrite(params) {
|
|
499
|
+
const entry = {
|
|
500
|
+
timestamp: Date.now(),
|
|
501
|
+
action: params.action,
|
|
502
|
+
zone: params.zone,
|
|
503
|
+
recordName: params.recordName,
|
|
504
|
+
recordType: params.recordType,
|
|
505
|
+
values: this.config.maskValues ? this.maskValues(params.values) : params.values,
|
|
506
|
+
recordCount: Array.isArray(params.values) ? params.values.length : 1,
|
|
507
|
+
context: params.context
|
|
508
|
+
};
|
|
509
|
+
this.emit(entry);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Log a delete operation
|
|
513
|
+
*/
|
|
514
|
+
logDelete(params) {
|
|
515
|
+
const entry = {
|
|
516
|
+
timestamp: Date.now(),
|
|
517
|
+
action: "delete",
|
|
518
|
+
zone: params.zone,
|
|
519
|
+
recordName: params.recordName,
|
|
520
|
+
recordType: params.recordType,
|
|
521
|
+
context: params.context
|
|
522
|
+
};
|
|
523
|
+
this.emit(entry);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Log a permission denied event
|
|
527
|
+
*/
|
|
528
|
+
logPermissionDenied(params) {
|
|
529
|
+
const entry = {
|
|
530
|
+
timestamp: Date.now(),
|
|
531
|
+
action: "denied",
|
|
532
|
+
zone: params.zone,
|
|
533
|
+
recordName: params.recordName,
|
|
534
|
+
operation: params.operation,
|
|
535
|
+
reason: params.reason,
|
|
536
|
+
context: params.context
|
|
537
|
+
};
|
|
538
|
+
this.emit(entry);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Get recent log entries (if storeInMemory is enabled)
|
|
542
|
+
*/
|
|
543
|
+
getRecentEntries(count) {
|
|
544
|
+
return this.entries.slice(-count).reverse();
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Get entries by zone (if storeInMemory is enabled)
|
|
548
|
+
*/
|
|
549
|
+
getEntriesByZone(zone) {
|
|
550
|
+
return this.entries.filter((e) => e.zone === zone);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Emit a log entry
|
|
554
|
+
*/
|
|
555
|
+
emit(entry) {
|
|
556
|
+
if (this.config.handler) this.config.handler(entry);
|
|
557
|
+
if (this.config.storeInMemory) {
|
|
558
|
+
this.entries.push(entry);
|
|
559
|
+
if (this.config.maxEntries && this.entries.length > this.config.maxEntries) this.entries = this.entries.slice(-this.config.maxEntries);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Mask sensitive values
|
|
564
|
+
*/
|
|
565
|
+
maskValues(values) {
|
|
566
|
+
return values.map((v) => {
|
|
567
|
+
if (typeof v === "string") return this.maskString(v);
|
|
568
|
+
return v;
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Mask sensitive parts of a string
|
|
573
|
+
*/
|
|
574
|
+
maskString(value) {
|
|
575
|
+
return value.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "***@***.***");
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/security/input-sanitizer.ts
|
|
581
|
+
/**
|
|
582
|
+
* Sanitize a TXT record value
|
|
583
|
+
*/
|
|
584
|
+
function sanitizeTXTValue(value) {
|
|
585
|
+
return value;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Reject strings containing null bytes
|
|
589
|
+
*/
|
|
590
|
+
function rejectNullBytes(value) {
|
|
591
|
+
const values = Array.isArray(value) ? value : [value];
|
|
592
|
+
for (const v of values) if (v.includes("\0")) throw new Error("Value contains null byte which is not allowed");
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Validate input length based on type
|
|
596
|
+
*/
|
|
597
|
+
function validateInputLength(value, type) {
|
|
598
|
+
switch (type) {
|
|
599
|
+
case "domain":
|
|
600
|
+
if (value.length > 253) return {
|
|
601
|
+
valid: false,
|
|
602
|
+
error: "Domain name exceeds 253 characters"
|
|
603
|
+
};
|
|
604
|
+
break;
|
|
605
|
+
case "label": {
|
|
606
|
+
const labels = value.split(".");
|
|
607
|
+
for (const label of labels) if (label.length > 63) return {
|
|
608
|
+
valid: false,
|
|
609
|
+
error: "Label exceeds 63 characters"
|
|
610
|
+
};
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
case "txt":
|
|
614
|
+
if (value.length > 4e3) return {
|
|
615
|
+
valid: false,
|
|
616
|
+
error: "TXT value exceeds 4000 characters"
|
|
617
|
+
};
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
return { valid: true };
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Escape special characters based on record type
|
|
624
|
+
*/
|
|
625
|
+
function escapeSpecialCharacters(value, recordType) {
|
|
626
|
+
if (!value) return "";
|
|
627
|
+
switch (recordType) {
|
|
628
|
+
case "TXT": return value;
|
|
629
|
+
default: return value;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Sanitize a record value based on type
|
|
634
|
+
*/
|
|
635
|
+
function sanitizeRecordValue(value, recordType) {
|
|
636
|
+
try {
|
|
637
|
+
rejectNullBytes(value);
|
|
638
|
+
} catch (e) {
|
|
639
|
+
return {
|
|
640
|
+
valid: false,
|
|
641
|
+
error: e.message
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const lengthResult = validateInputLength(value, recordType === "TXT" ? "txt" : "domain");
|
|
645
|
+
if (!lengthResult.valid) return {
|
|
646
|
+
valid: false,
|
|
647
|
+
error: lengthResult.error
|
|
648
|
+
};
|
|
649
|
+
return {
|
|
650
|
+
valid: true,
|
|
651
|
+
value: escapeSpecialCharacters(value, recordType)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/security/rate-limiter.ts
|
|
657
|
+
var RateLimiter = class {
|
|
658
|
+
tokens;
|
|
659
|
+
lastRefill;
|
|
660
|
+
config;
|
|
661
|
+
queue = [];
|
|
662
|
+
constructor(config) {
|
|
663
|
+
this.config = config;
|
|
664
|
+
this.tokens = config.maxRequests;
|
|
665
|
+
this.lastRefill = Date.now();
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Try to acquire a token without waiting
|
|
669
|
+
* @returns true if token acquired, false if rate limited
|
|
670
|
+
*/
|
|
671
|
+
async tryAcquire() {
|
|
672
|
+
this.refillTokens();
|
|
673
|
+
if (this.tokens > 0) {
|
|
674
|
+
this.tokens--;
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Acquire a token, waiting if necessary
|
|
681
|
+
* Only works if queueRequests is enabled
|
|
682
|
+
*/
|
|
683
|
+
async acquire() {
|
|
684
|
+
this.refillTokens();
|
|
685
|
+
if (this.tokens > 0) {
|
|
686
|
+
this.tokens--;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (!this.config.queueRequests) throw new Error("Rate limit exceeded");
|
|
690
|
+
return new Promise((resolve) => {
|
|
691
|
+
this.queue.push(resolve);
|
|
692
|
+
this.scheduleQueueProcessing();
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Get remaining tokens
|
|
697
|
+
*/
|
|
698
|
+
getRemaining() {
|
|
699
|
+
this.refillTokens();
|
|
700
|
+
return this.tokens;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Get time until next refill in milliseconds
|
|
704
|
+
*/
|
|
705
|
+
getResetTime() {
|
|
706
|
+
const elapsed = Date.now() - this.lastRefill;
|
|
707
|
+
return Math.max(0, this.config.windowMs - elapsed);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Refill tokens based on elapsed time
|
|
711
|
+
*/
|
|
712
|
+
refillTokens() {
|
|
713
|
+
const now = Date.now();
|
|
714
|
+
if (now - this.lastRefill >= this.config.windowMs) {
|
|
715
|
+
this.tokens = this.config.maxRequests;
|
|
716
|
+
this.lastRefill = now;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Schedule processing of queued requests
|
|
721
|
+
*/
|
|
722
|
+
scheduleQueueProcessing() {
|
|
723
|
+
if (this.queue.length === 0) return;
|
|
724
|
+
const delay = this.getResetTime();
|
|
725
|
+
setTimeout(() => {
|
|
726
|
+
this.refillTokens();
|
|
727
|
+
this.processQueue();
|
|
728
|
+
}, delay);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Process queued requests
|
|
732
|
+
*/
|
|
733
|
+
processQueue() {
|
|
734
|
+
while (this.queue.length > 0 && this.tokens > 0) {
|
|
735
|
+
const resolve = this.queue.shift();
|
|
736
|
+
if (resolve) {
|
|
737
|
+
this.tokens--;
|
|
738
|
+
resolve();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (this.queue.length > 0) this.scheduleQueueProcessing();
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
/**
|
|
745
|
+
* Create a rate limiter with Route53 defaults
|
|
746
|
+
*/
|
|
747
|
+
function createRoute53RateLimiter(overrides) {
|
|
748
|
+
return new RateLimiter({
|
|
749
|
+
maxRequests: 5,
|
|
750
|
+
windowMs: 1e3,
|
|
751
|
+
queueRequests: false,
|
|
752
|
+
...overrides
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region src/types/record.ts
|
|
758
|
+
const IPV4_REGEX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
759
|
+
/**
|
|
760
|
+
* Check if string is a valid IPv4 address
|
|
761
|
+
*/
|
|
762
|
+
function isValidIPv4(ip) {
|
|
763
|
+
const match = ip.match(IPV4_REGEX);
|
|
764
|
+
if (!match) return false;
|
|
765
|
+
for (let i = 1; i <= 4; i++) {
|
|
766
|
+
const octetStr = match[i];
|
|
767
|
+
if (!octetStr) return false;
|
|
768
|
+
const octet = Number.parseInt(octetStr, 10);
|
|
769
|
+
if (octet < 0 || octet > 255) return false;
|
|
770
|
+
}
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Check if string is a valid IPv6 address
|
|
775
|
+
*/
|
|
776
|
+
function isValidIPv6(ip) {
|
|
777
|
+
if (ip === "::1" || ip === "::") return true;
|
|
778
|
+
if ((ip.match(/::/g) || []).length > 1) return false;
|
|
779
|
+
const parts = ip.split(":");
|
|
780
|
+
if (parts.length < 3 || parts.length > 8) return false;
|
|
781
|
+
for (const part of parts) {
|
|
782
|
+
if (part === "") continue;
|
|
783
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return false;
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Check if string is a valid hostname (not IP)
|
|
789
|
+
*/
|
|
790
|
+
function isValidHostname(hostname) {
|
|
791
|
+
if (!hostname) return false;
|
|
792
|
+
if (isValidIPv4(hostname) || isValidIPv6(hostname)) return false;
|
|
793
|
+
const labels = (hostname.endsWith(".") ? hostname.slice(0, -1) : hostname).split(".");
|
|
794
|
+
for (const label of labels) {
|
|
795
|
+
if (!label) return false;
|
|
796
|
+
if (label.length > 63) return false;
|
|
797
|
+
if (label.startsWith("-") || label.endsWith("-")) return false;
|
|
798
|
+
if (!/^[a-zA-Z0-9-]+$/.test(label)) return false;
|
|
799
|
+
}
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Validate A record values
|
|
804
|
+
*/
|
|
805
|
+
function validateARecord(values) {
|
|
806
|
+
if (!values || values.length === 0) return {
|
|
807
|
+
valid: false,
|
|
808
|
+
error: "A record requires at least one IPv4 address"
|
|
809
|
+
};
|
|
810
|
+
for (const value of values) if (!isValidIPv4(value)) return {
|
|
811
|
+
valid: false,
|
|
812
|
+
error: `Invalid IPv4 address: ${value}`
|
|
813
|
+
};
|
|
814
|
+
return { valid: true };
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Validate AAAA record values
|
|
818
|
+
*/
|
|
819
|
+
function validateAAAARecord(values) {
|
|
820
|
+
if (!values || values.length === 0) return {
|
|
821
|
+
valid: false,
|
|
822
|
+
error: "AAAA record requires at least one IPv6 address"
|
|
823
|
+
};
|
|
824
|
+
for (const value of values) if (!isValidIPv6(value)) return {
|
|
825
|
+
valid: false,
|
|
826
|
+
error: `Invalid IPv6 address: ${value}`
|
|
827
|
+
};
|
|
828
|
+
return { valid: true };
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Validate CNAME record value
|
|
832
|
+
*/
|
|
833
|
+
function validateCNAMERecord(value) {
|
|
834
|
+
if (!value) return {
|
|
835
|
+
valid: false,
|
|
836
|
+
error: "CNAME record requires a hostname"
|
|
837
|
+
};
|
|
838
|
+
if (!isValidHostname(value)) return {
|
|
839
|
+
valid: false,
|
|
840
|
+
error: `Invalid hostname for CNAME: ${value}`
|
|
841
|
+
};
|
|
842
|
+
return { valid: true };
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Validate MX record values
|
|
846
|
+
*/
|
|
847
|
+
function validateMXRecord(values) {
|
|
848
|
+
if (!values || values.length === 0) return {
|
|
849
|
+
valid: false,
|
|
850
|
+
error: "MX record requires at least one value"
|
|
851
|
+
};
|
|
852
|
+
for (const mx of values) {
|
|
853
|
+
if (mx.priority < 0 || mx.priority > 65535) return {
|
|
854
|
+
valid: false,
|
|
855
|
+
error: `MX priority must be 0-65535, got: ${mx.priority}`
|
|
856
|
+
};
|
|
857
|
+
if (!isValidHostname(mx.value)) return {
|
|
858
|
+
valid: false,
|
|
859
|
+
error: `Invalid hostname for MX: ${mx.value}`
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
return { valid: true };
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Validate TXT record values
|
|
866
|
+
*/
|
|
867
|
+
function validateTXTRecord(values) {
|
|
868
|
+
if (!values) return {
|
|
869
|
+
valid: false,
|
|
870
|
+
error: "TXT record requires values array"
|
|
871
|
+
};
|
|
872
|
+
return { valid: true };
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Validate NS record values
|
|
876
|
+
*/
|
|
877
|
+
function validateNSRecord(values) {
|
|
878
|
+
if (!values || values.length === 0) return {
|
|
879
|
+
valid: false,
|
|
880
|
+
error: "NS record requires at least one nameserver"
|
|
881
|
+
};
|
|
882
|
+
for (const value of values) if (!isValidHostname(value)) return {
|
|
883
|
+
valid: false,
|
|
884
|
+
error: `Invalid hostname for NS: ${value}`
|
|
885
|
+
};
|
|
886
|
+
return { valid: true };
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Validate SRV record values
|
|
890
|
+
*/
|
|
891
|
+
function validateSRVRecord(values) {
|
|
892
|
+
if (!values || values.length === 0) return {
|
|
893
|
+
valid: false,
|
|
894
|
+
error: "SRV record requires at least one value"
|
|
895
|
+
};
|
|
896
|
+
for (const srv of values) {
|
|
897
|
+
if (srv.priority < 0 || srv.priority > 65535) return {
|
|
898
|
+
valid: false,
|
|
899
|
+
error: `SRV priority must be 0-65535, got: ${srv.priority}`
|
|
900
|
+
};
|
|
901
|
+
if (srv.weight < 0 || srv.weight > 65535) return {
|
|
902
|
+
valid: false,
|
|
903
|
+
error: `SRV weight must be 0-65535, got: ${srv.weight}`
|
|
904
|
+
};
|
|
905
|
+
if (srv.port < 0 || srv.port > 65535) return {
|
|
906
|
+
valid: false,
|
|
907
|
+
error: `SRV port must be 0-65535, got: ${srv.port}`
|
|
908
|
+
};
|
|
909
|
+
if (!isValidHostname(srv.target) && srv.target !== ".") return {
|
|
910
|
+
valid: false,
|
|
911
|
+
error: `Invalid hostname for SRV target: ${srv.target}`
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
return { valid: true };
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Validate CAA record values
|
|
918
|
+
*/
|
|
919
|
+
function validateCAARecord(values) {
|
|
920
|
+
if (!values || values.length === 0) return {
|
|
921
|
+
valid: false,
|
|
922
|
+
error: "CAA record requires at least one value"
|
|
923
|
+
};
|
|
924
|
+
for (const caa of values) {
|
|
925
|
+
if (caa.flags < 0 || caa.flags > 255) return {
|
|
926
|
+
valid: false,
|
|
927
|
+
error: `CAA flags must be 0-255, got: ${caa.flags}`
|
|
928
|
+
};
|
|
929
|
+
if (!caa.tag) return {
|
|
930
|
+
valid: false,
|
|
931
|
+
error: "CAA record requires a tag"
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
return { valid: true };
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Validate TTL value
|
|
938
|
+
*/
|
|
939
|
+
function validateTTL(ttl) {
|
|
940
|
+
if (!Number.isInteger(ttl)) return {
|
|
941
|
+
valid: false,
|
|
942
|
+
error: "TTL must be an integer"
|
|
943
|
+
};
|
|
944
|
+
if (ttl < 0) return {
|
|
945
|
+
valid: false,
|
|
946
|
+
error: "TTL cannot be negative"
|
|
947
|
+
};
|
|
948
|
+
if (ttl > 2147483647) return {
|
|
949
|
+
valid: false,
|
|
950
|
+
error: "TTL cannot exceed 2147483647"
|
|
951
|
+
};
|
|
952
|
+
return { valid: true };
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Validate a complete DNS record
|
|
956
|
+
*/
|
|
957
|
+
function validateRecord(record) {
|
|
958
|
+
const errors = [];
|
|
959
|
+
if (!record.type) {
|
|
960
|
+
errors.push("Record type is required");
|
|
961
|
+
return {
|
|
962
|
+
valid: false,
|
|
963
|
+
errors
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (record.ttl !== void 0) {
|
|
967
|
+
const ttlResult = validateTTL(record.ttl);
|
|
968
|
+
if (!ttlResult.valid) errors.push(ttlResult.error);
|
|
969
|
+
}
|
|
970
|
+
let valuesResult;
|
|
971
|
+
switch (record.type) {
|
|
972
|
+
case "A":
|
|
973
|
+
valuesResult = validateARecord(record.values);
|
|
974
|
+
break;
|
|
975
|
+
case "AAAA":
|
|
976
|
+
valuesResult = validateAAAARecord(record.values);
|
|
977
|
+
break;
|
|
978
|
+
case "CNAME":
|
|
979
|
+
valuesResult = validateCNAMERecord(Array.isArray(record.values) ? record.values[0] ?? "" : record.values);
|
|
980
|
+
break;
|
|
981
|
+
case "MX":
|
|
982
|
+
valuesResult = validateMXRecord(record.values);
|
|
983
|
+
break;
|
|
984
|
+
case "TXT":
|
|
985
|
+
valuesResult = validateTXTRecord(record.values);
|
|
986
|
+
break;
|
|
987
|
+
case "NS":
|
|
988
|
+
valuesResult = validateNSRecord(record.values);
|
|
989
|
+
break;
|
|
990
|
+
case "SRV":
|
|
991
|
+
valuesResult = validateSRVRecord(record.values);
|
|
992
|
+
break;
|
|
993
|
+
case "CAA":
|
|
994
|
+
valuesResult = validateCAARecord(record.values);
|
|
995
|
+
break;
|
|
996
|
+
case "PTR":
|
|
997
|
+
valuesResult = validateCNAMERecord(Array.isArray(record.values) ? record.values[0] ?? "" : record.values);
|
|
998
|
+
break;
|
|
999
|
+
case "SOA":
|
|
1000
|
+
valuesResult = { valid: true };
|
|
1001
|
+
break;
|
|
1002
|
+
default: valuesResult = {
|
|
1003
|
+
valid: false,
|
|
1004
|
+
error: `Unknown record type: ${record.type}`
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
if (!valuesResult.valid) errors.push(valuesResult.error);
|
|
1008
|
+
return {
|
|
1009
|
+
valid: errors.length === 0,
|
|
1010
|
+
errors: errors.length > 0 ? errors : void 0
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/provider/dns-provider.ts
|
|
1016
|
+
var DNSProvider = class {
|
|
1017
|
+
name;
|
|
1018
|
+
description;
|
|
1019
|
+
accessMode;
|
|
1020
|
+
zone;
|
|
1021
|
+
adapter;
|
|
1022
|
+
permissions;
|
|
1023
|
+
auditLogger;
|
|
1024
|
+
rateLimiter;
|
|
1025
|
+
/**
|
|
1026
|
+
* Schema for configuration validation
|
|
1027
|
+
*/
|
|
1028
|
+
static schema() {
|
|
1029
|
+
return z.object({ zone: z.string() });
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Provider manifest for URI-based discovery
|
|
1033
|
+
*/
|
|
1034
|
+
static manifest() {
|
|
1035
|
+
return {
|
|
1036
|
+
name: "dns",
|
|
1037
|
+
description: "DNS zone management — Route53 and Google Cloud DNS.\n- List, create, update, and delete DNS records (A, AAAA, CNAME, MX, TXT, etc.)\n- Audit logging and rate limiting for safe zone modifications\n- Path structure: `/{zone}/{record-name}`",
|
|
1038
|
+
uriTemplate: "dns://{zone}",
|
|
1039
|
+
category: "cloud-dns",
|
|
1040
|
+
schema: z.object({ zone: z.string() }),
|
|
1041
|
+
tags: ["dns", "cloud"]
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
constructor(options) {
|
|
1045
|
+
this.zone = options.zone;
|
|
1046
|
+
this.adapter = options.adapter;
|
|
1047
|
+
this.accessMode = options.accessMode ?? "readonly";
|
|
1048
|
+
this.permissions = options.permissions ?? { preset: "standard" };
|
|
1049
|
+
this.auditLogger = new AuditLogger(options.auditLog);
|
|
1050
|
+
this.rateLimiter = options.rateLimiting !== false ? createRoute53RateLimiter() : null;
|
|
1051
|
+
this.name = `dns:${this.zone}`;
|
|
1052
|
+
this.description = `DNS zone: ${this.zone}`;
|
|
1053
|
+
}
|
|
1054
|
+
async list(path, options) {
|
|
1055
|
+
const normalizedPath = this.normalizePath(path);
|
|
1056
|
+
if ((options?.maxDepth ?? 1) === 0) return { data: [] };
|
|
1057
|
+
if (normalizedPath === "/") {
|
|
1058
|
+
const entries = [];
|
|
1059
|
+
const names = await this.adapter.listRecords(this.zone);
|
|
1060
|
+
for (const name of names) entries.push({
|
|
1061
|
+
id: name,
|
|
1062
|
+
path: `/${name}`,
|
|
1063
|
+
summary: name === "_zone" ? "Zone metadata" : `DNS record: ${name}`,
|
|
1064
|
+
meta: {
|
|
1065
|
+
kind: name === "_zone" ? "dns:zone-meta" : "dns:record",
|
|
1066
|
+
childrenCount: 0
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
return { data: entries };
|
|
1070
|
+
}
|
|
1071
|
+
const recordName = this.extractRecordName(normalizedPath);
|
|
1072
|
+
if ((await this.adapter.getRecord(this.zone, recordName)).records.length === 0 && recordName !== "_zone") return { data: [] };
|
|
1073
|
+
return { data: [{
|
|
1074
|
+
id: recordName,
|
|
1075
|
+
path: normalizedPath,
|
|
1076
|
+
summary: recordName === "_zone" ? "Zone metadata" : `DNS record: ${recordName}`,
|
|
1077
|
+
meta: {
|
|
1078
|
+
kind: recordName === "_zone" ? "dns:zone-meta" : "dns:record",
|
|
1079
|
+
childrenCount: 0
|
|
1080
|
+
}
|
|
1081
|
+
}] };
|
|
1082
|
+
}
|
|
1083
|
+
async read(path, _options) {
|
|
1084
|
+
const normalizedPath = this.normalizePath(path);
|
|
1085
|
+
if (normalizedPath === "/.meta/.capabilities") return this.readCapabilities();
|
|
1086
|
+
const { recordName, type } = this.parsePathWithQuery(normalizedPath);
|
|
1087
|
+
if (recordName === "_zone") {
|
|
1088
|
+
const metadata = await this.adapter.getZoneMetadata(this.zone);
|
|
1089
|
+
return { data: {
|
|
1090
|
+
id: "_zone",
|
|
1091
|
+
path: "/_zone",
|
|
1092
|
+
summary: `Zone metadata for ${this.zone}`,
|
|
1093
|
+
meta: { kind: "dns:zone-meta" },
|
|
1094
|
+
content: metadata
|
|
1095
|
+
} };
|
|
1096
|
+
}
|
|
1097
|
+
const recordSet = await this.adapter.getRecord(this.zone, recordName);
|
|
1098
|
+
let records = recordSet.records;
|
|
1099
|
+
if (type) records = records.filter((r) => r.type === type);
|
|
1100
|
+
if (records.length === 0) return { data: void 0 };
|
|
1101
|
+
return { data: {
|
|
1102
|
+
id: recordName,
|
|
1103
|
+
path: normalizedPath.split("?")[0] ?? normalizedPath,
|
|
1104
|
+
summary: `DNS records for ${recordSet.fqdn}`,
|
|
1105
|
+
meta: { kind: "dns:record" },
|
|
1106
|
+
content: {
|
|
1107
|
+
fqdn: recordSet.fqdn,
|
|
1108
|
+
records
|
|
1109
|
+
}
|
|
1110
|
+
} };
|
|
1111
|
+
}
|
|
1112
|
+
async write(path, content, _options) {
|
|
1113
|
+
if (this.accessMode !== "readwrite") throw new Error(`DNS provider is readonly, cannot write to ${path}`);
|
|
1114
|
+
const normalizedPath = this.normalizePath(path);
|
|
1115
|
+
const recordName = this.extractRecordName(normalizedPath);
|
|
1116
|
+
if (recordName === "validate-zone") return this.handleValidateZone();
|
|
1117
|
+
if (recordName === "export-zone") return this.handleExportZone();
|
|
1118
|
+
if (recordName === "_zone") throw new Error("Cannot write to _zone metadata");
|
|
1119
|
+
const record = content.content;
|
|
1120
|
+
if (!record || !record.type) throw new Error("Invalid record content: must include type");
|
|
1121
|
+
this.sanitizeRecordValues(record);
|
|
1122
|
+
const validation = validateRecord(record);
|
|
1123
|
+
if (!validation.valid) throw new Error(`Invalid record: ${validation.errors?.join(", ")}`);
|
|
1124
|
+
const permResult = checkPermission({
|
|
1125
|
+
name: recordName,
|
|
1126
|
+
type: record.type,
|
|
1127
|
+
operation: "write"
|
|
1128
|
+
}, this.permissions);
|
|
1129
|
+
if (!permResult.allowed) {
|
|
1130
|
+
this.auditLogger.logPermissionDenied({
|
|
1131
|
+
zone: this.zone,
|
|
1132
|
+
recordName,
|
|
1133
|
+
operation: permResult.deniedOperation ?? "write",
|
|
1134
|
+
reason: formatPermissionError(permResult)
|
|
1135
|
+
});
|
|
1136
|
+
throw new Error(formatPermissionError(permResult));
|
|
1137
|
+
}
|
|
1138
|
+
await this.acquireRateLimit();
|
|
1139
|
+
const action = (await this.adapter.getRecord(this.zone, recordName)).records.length > 0 ? "update" : "create";
|
|
1140
|
+
await this.adapter.setRecord(this.zone, recordName, [record]);
|
|
1141
|
+
this.auditLogger.logWrite({
|
|
1142
|
+
zone: this.zone,
|
|
1143
|
+
recordName,
|
|
1144
|
+
recordType: record.type,
|
|
1145
|
+
action,
|
|
1146
|
+
values: this.extractValues(record)
|
|
1147
|
+
});
|
|
1148
|
+
return { data: {
|
|
1149
|
+
id: recordName,
|
|
1150
|
+
path: normalizedPath,
|
|
1151
|
+
summary: `Updated DNS record: ${recordName}`,
|
|
1152
|
+
meta: { kind: "dns:record" },
|
|
1153
|
+
content: record
|
|
1154
|
+
} };
|
|
1155
|
+
}
|
|
1156
|
+
async delete(path, _options) {
|
|
1157
|
+
if (this.accessMode !== "readwrite") throw new Error(`DNS provider is readonly, cannot delete ${path}`);
|
|
1158
|
+
const normalizedPath = this.normalizePath(path);
|
|
1159
|
+
const { recordName, type } = this.parsePathWithQuery(normalizedPath);
|
|
1160
|
+
if (recordName === "_zone") throw new Error("Cannot delete _zone metadata");
|
|
1161
|
+
const permResult = checkPermission({
|
|
1162
|
+
name: recordName,
|
|
1163
|
+
type,
|
|
1164
|
+
operation: "delete"
|
|
1165
|
+
}, this.permissions);
|
|
1166
|
+
if (!permResult.allowed) {
|
|
1167
|
+
this.auditLogger.logPermissionDenied({
|
|
1168
|
+
zone: this.zone,
|
|
1169
|
+
recordName,
|
|
1170
|
+
operation: permResult.deniedOperation ?? "delete",
|
|
1171
|
+
reason: formatPermissionError(permResult)
|
|
1172
|
+
});
|
|
1173
|
+
throw new Error(formatPermissionError(permResult));
|
|
1174
|
+
}
|
|
1175
|
+
await this.acquireRateLimit();
|
|
1176
|
+
await this.adapter.deleteRecord(this.zone, recordName, type);
|
|
1177
|
+
this.auditLogger.logDelete({
|
|
1178
|
+
zone: this.zone,
|
|
1179
|
+
recordName,
|
|
1180
|
+
recordType: type
|
|
1181
|
+
});
|
|
1182
|
+
return { message: `Record ${recordName}${type ? ` (${type})` : ""} deleted` };
|
|
1183
|
+
}
|
|
1184
|
+
normalizePath(path) {
|
|
1185
|
+
if (!path.startsWith("/")) return `/${path}`;
|
|
1186
|
+
return path;
|
|
1187
|
+
}
|
|
1188
|
+
extractRecordName(path) {
|
|
1189
|
+
return (path.replace(/^\//, "").split("?")[0] ?? "") || "@";
|
|
1190
|
+
}
|
|
1191
|
+
parsePathWithQuery(path) {
|
|
1192
|
+
const [pathPart, queryPart] = path.split("?");
|
|
1193
|
+
const recordName = this.extractRecordName(pathPart ?? path);
|
|
1194
|
+
let type;
|
|
1195
|
+
if (queryPart) type = new URLSearchParams(queryPart).get("type") ?? void 0;
|
|
1196
|
+
return {
|
|
1197
|
+
recordName,
|
|
1198
|
+
type
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Sanitize record values to prevent injection attacks
|
|
1203
|
+
*/
|
|
1204
|
+
sanitizeRecordValues(record) {
|
|
1205
|
+
const { values } = record;
|
|
1206
|
+
if (typeof values === "string") {
|
|
1207
|
+
rejectNullBytes(values);
|
|
1208
|
+
const result = sanitizeRecordValue(values, record.type);
|
|
1209
|
+
if (!result.valid) throw new Error(`Invalid record value: ${result.error}`);
|
|
1210
|
+
} else if (Array.isArray(values)) {
|
|
1211
|
+
for (const value of values) if (typeof value === "string") {
|
|
1212
|
+
rejectNullBytes(value);
|
|
1213
|
+
const result = sanitizeRecordValue(value, record.type);
|
|
1214
|
+
if (!result.valid) throw new Error(`Invalid record value: ${result.error}`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Acquire rate limit token (waits if necessary)
|
|
1220
|
+
*/
|
|
1221
|
+
async acquireRateLimit() {
|
|
1222
|
+
if (this.rateLimiter) {
|
|
1223
|
+
if (!await this.rateLimiter.tryAcquire()) throw new Error("Rate limit exceeded. Please retry later.");
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Extract values from a record for audit logging
|
|
1228
|
+
*/
|
|
1229
|
+
extractValues(record) {
|
|
1230
|
+
const { values } = record;
|
|
1231
|
+
if (typeof values === "string") return [values];
|
|
1232
|
+
if (Array.isArray(values)) return values;
|
|
1233
|
+
return [values];
|
|
1234
|
+
}
|
|
1235
|
+
async stat(path) {
|
|
1236
|
+
const normalizedPath = this.normalizePath(path);
|
|
1237
|
+
const recordName = this.extractRecordName(normalizedPath);
|
|
1238
|
+
const recordSet = await this.adapter.getRecord(this.zone, recordName);
|
|
1239
|
+
if (recordSet.records.length === 0 && recordName !== "_zone") throw new Error(`Record not found: ${recordName}`);
|
|
1240
|
+
const firstRecord = recordSet.records[0];
|
|
1241
|
+
return { data: {
|
|
1242
|
+
id: recordName,
|
|
1243
|
+
path: normalizedPath,
|
|
1244
|
+
meta: {
|
|
1245
|
+
kind: recordName === "_zone" ? "dns:zone-meta" : "dns:record",
|
|
1246
|
+
recordType: firstRecord?.type,
|
|
1247
|
+
ttl: firstRecord?.ttl,
|
|
1248
|
+
valueCount: recordSet.records.reduce((count, r) => {
|
|
1249
|
+
const v = r.values;
|
|
1250
|
+
if (Array.isArray(v)) return count + v.length;
|
|
1251
|
+
return count + 1;
|
|
1252
|
+
}, 0)
|
|
1253
|
+
}
|
|
1254
|
+
} };
|
|
1255
|
+
}
|
|
1256
|
+
async explain(path) {
|
|
1257
|
+
const normalizedPath = this.normalizePath(path);
|
|
1258
|
+
const recordName = this.extractRecordName(normalizedPath);
|
|
1259
|
+
if (normalizedPath === "/" || recordName === "@") return this.explainRoot();
|
|
1260
|
+
if (recordName === "_zone") return this.explainZone();
|
|
1261
|
+
throw new Error(`Cannot explain path: ${path}`);
|
|
1262
|
+
}
|
|
1263
|
+
async explainRoot() {
|
|
1264
|
+
const metadata = await this.adapter.getZoneMetadata(this.zone);
|
|
1265
|
+
const names = await this.adapter.listRecords(this.zone);
|
|
1266
|
+
return {
|
|
1267
|
+
format: "markdown",
|
|
1268
|
+
content: `# DNS Zone: ${this.zone}
|
|
1269
|
+
|
|
1270
|
+
- **Backend**: ${metadata.provider || "unknown"}
|
|
1271
|
+
- **Zone ID**: ${metadata.id || "unknown"}
|
|
1272
|
+
- **Record Count**: ${names.length}
|
|
1273
|
+
- **Nameservers**: ${(metadata.nameservers || []).join(", ") || "unknown"}
|
|
1274
|
+
`
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
async explainZone() {
|
|
1278
|
+
const metadata = await this.adapter.getZoneMetadata(this.zone);
|
|
1279
|
+
const apex = await this.adapter.getRecord(this.zone, "@");
|
|
1280
|
+
const soaRecord = apex.records.find((r) => r.type === "SOA");
|
|
1281
|
+
const nsRecords = apex.records.filter((r) => r.type === "NS");
|
|
1282
|
+
const lines = [`# Zone Details: ${this.zone}`];
|
|
1283
|
+
lines.push("");
|
|
1284
|
+
lines.push(`- **Domain**: ${metadata.domain || this.zone}`);
|
|
1285
|
+
lines.push(`- **Provider**: ${metadata.provider || "unknown"}`);
|
|
1286
|
+
lines.push(`- **Record Count**: ${metadata.recordCount ?? "unknown"}`);
|
|
1287
|
+
lines.push("");
|
|
1288
|
+
if (soaRecord) {
|
|
1289
|
+
lines.push("## SOA Record");
|
|
1290
|
+
lines.push("");
|
|
1291
|
+
const values = Array.isArray(soaRecord.values) ? soaRecord.values.join(" ") : String(soaRecord.values);
|
|
1292
|
+
lines.push(`\`${values}\``);
|
|
1293
|
+
lines.push("");
|
|
1294
|
+
}
|
|
1295
|
+
if (nsRecords.length > 0) {
|
|
1296
|
+
lines.push("## NS Records");
|
|
1297
|
+
lines.push("");
|
|
1298
|
+
for (const ns of nsRecords) {
|
|
1299
|
+
const values = Array.isArray(ns.values) ? ns.values : [ns.values];
|
|
1300
|
+
for (const v of values) lines.push(`- ${v}`);
|
|
1301
|
+
}
|
|
1302
|
+
lines.push("");
|
|
1303
|
+
}
|
|
1304
|
+
return {
|
|
1305
|
+
format: "markdown",
|
|
1306
|
+
content: lines.join("\n")
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
async search(_path, query) {
|
|
1310
|
+
const names = await this.adapter.listRecords(this.zone);
|
|
1311
|
+
const results = [];
|
|
1312
|
+
for (const name of names) {
|
|
1313
|
+
if (name === "_zone") continue;
|
|
1314
|
+
const nameMatches = minimatch(name, query);
|
|
1315
|
+
let valueMatches = false;
|
|
1316
|
+
if (!nameMatches) {
|
|
1317
|
+
const recordSet = await this.adapter.getRecord(this.zone, name);
|
|
1318
|
+
for (const record of recordSet.records) {
|
|
1319
|
+
const values = Array.isArray(record.values) ? record.values : [record.values];
|
|
1320
|
+
for (const v of values) if (String(v).includes(query)) {
|
|
1321
|
+
valueMatches = true;
|
|
1322
|
+
break;
|
|
1323
|
+
}
|
|
1324
|
+
if (valueMatches) break;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (nameMatches || valueMatches) results.push({
|
|
1328
|
+
id: name,
|
|
1329
|
+
path: `/${name}`,
|
|
1330
|
+
summary: `DNS record: ${name}`,
|
|
1331
|
+
meta: { kind: "dns:record" }
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
return { data: results };
|
|
1335
|
+
}
|
|
1336
|
+
readCapabilities() {
|
|
1337
|
+
const actionCatalogs = [];
|
|
1338
|
+
actionCatalogs.push({
|
|
1339
|
+
kind: "dns:zone",
|
|
1340
|
+
description: "Zone-level operations",
|
|
1341
|
+
catalog: [{
|
|
1342
|
+
name: "validate-zone",
|
|
1343
|
+
description: "Validate zone integrity (SOA, NS records)",
|
|
1344
|
+
inputSchema: {
|
|
1345
|
+
type: "object",
|
|
1346
|
+
properties: {},
|
|
1347
|
+
description: "No parameters required"
|
|
1348
|
+
}
|
|
1349
|
+
}, {
|
|
1350
|
+
name: "export-zone",
|
|
1351
|
+
description: "Export zone as BIND zone file format",
|
|
1352
|
+
inputSchema: {
|
|
1353
|
+
type: "object",
|
|
1354
|
+
properties: {},
|
|
1355
|
+
description: "No parameters required"
|
|
1356
|
+
}
|
|
1357
|
+
}],
|
|
1358
|
+
discovery: {
|
|
1359
|
+
pathTemplate: "/{action}",
|
|
1360
|
+
note: "Write to /validate-zone or /export-zone to execute"
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
const operations = {
|
|
1364
|
+
read: true,
|
|
1365
|
+
list: true,
|
|
1366
|
+
write: this.accessMode === "readwrite",
|
|
1367
|
+
delete: this.accessMode === "readwrite",
|
|
1368
|
+
search: true,
|
|
1369
|
+
exec: false,
|
|
1370
|
+
stat: true,
|
|
1371
|
+
explain: true
|
|
1372
|
+
};
|
|
1373
|
+
return { data: {
|
|
1374
|
+
id: "/.meta/.capabilities",
|
|
1375
|
+
path: "/.meta/.capabilities",
|
|
1376
|
+
content: {
|
|
1377
|
+
schemaVersion: 1,
|
|
1378
|
+
provider: this.name,
|
|
1379
|
+
version: "1.0.0",
|
|
1380
|
+
description: this.description,
|
|
1381
|
+
tools: [],
|
|
1382
|
+
actions: actionCatalogs,
|
|
1383
|
+
operations
|
|
1384
|
+
},
|
|
1385
|
+
meta: { kind: "afs:capabilities" }
|
|
1386
|
+
} };
|
|
1387
|
+
}
|
|
1388
|
+
async handleValidateZone() {
|
|
1389
|
+
const apex = await this.adapter.getRecord(this.zone, "@");
|
|
1390
|
+
const checks = [];
|
|
1391
|
+
const soaRecord = apex.records.find((r) => r.type === "SOA");
|
|
1392
|
+
checks.push({
|
|
1393
|
+
name: "SOA",
|
|
1394
|
+
type: "SOA",
|
|
1395
|
+
valid: !!soaRecord,
|
|
1396
|
+
message: soaRecord ? "SOA record present" : "Missing SOA record"
|
|
1397
|
+
});
|
|
1398
|
+
const nsRecords = apex.records.filter((r) => r.type === "NS");
|
|
1399
|
+
checks.push({
|
|
1400
|
+
name: "NS",
|
|
1401
|
+
type: "NS",
|
|
1402
|
+
valid: nsRecords.length > 0,
|
|
1403
|
+
message: nsRecords.length > 0 ? `${nsRecords.length} NS record(s) present` : "Missing NS records"
|
|
1404
|
+
});
|
|
1405
|
+
const allValid = checks.every((c) => c.valid);
|
|
1406
|
+
return { data: {
|
|
1407
|
+
id: "validate-zone",
|
|
1408
|
+
path: "/validate-zone",
|
|
1409
|
+
summary: `Zone validation: ${allValid ? "PASSED" : "FAILED"}`,
|
|
1410
|
+
meta: { kind: "dns:action-result" },
|
|
1411
|
+
content: {
|
|
1412
|
+
valid: allValid,
|
|
1413
|
+
checks,
|
|
1414
|
+
zone: this.zone
|
|
1415
|
+
}
|
|
1416
|
+
} };
|
|
1417
|
+
}
|
|
1418
|
+
async handleExportZone() {
|
|
1419
|
+
const names = await this.adapter.listRecords(this.zone);
|
|
1420
|
+
const lines = [];
|
|
1421
|
+
lines.push(`; Zone file for ${this.zone}`);
|
|
1422
|
+
lines.push(`$ORIGIN ${this.zone}.`);
|
|
1423
|
+
lines.push("");
|
|
1424
|
+
for (const name of names) {
|
|
1425
|
+
if (name === "_zone") continue;
|
|
1426
|
+
const recordSet = await this.adapter.getRecord(this.zone, name);
|
|
1427
|
+
const displayName = name === "@" ? "@" : name;
|
|
1428
|
+
for (const record of recordSet.records) {
|
|
1429
|
+
const ttl = record.ttl ?? 300;
|
|
1430
|
+
const values = Array.isArray(record.values) ? record.values : [record.values];
|
|
1431
|
+
for (const value of values) lines.push(`${displayName}\t${ttl}\tIN\t${record.type}\t${value}`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
lines.push("");
|
|
1435
|
+
return { data: {
|
|
1436
|
+
id: "export-zone",
|
|
1437
|
+
path: "/export-zone",
|
|
1438
|
+
summary: `BIND zone file for ${this.zone}`,
|
|
1439
|
+
meta: { kind: "dns:action-result" },
|
|
1440
|
+
content: lines.join("\n")
|
|
1441
|
+
} };
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/route53/parser.ts
|
|
1447
|
+
/**
|
|
1448
|
+
* Parse Route53 hosted zones into DNSZone format
|
|
1449
|
+
*/
|
|
1450
|
+
function parseHostedZones(zones) {
|
|
1451
|
+
return zones.map((zone) => ({
|
|
1452
|
+
domain: zone.Name.replace(/\.$/, ""),
|
|
1453
|
+
id: zone.Id.replace("/hostedzone/", ""),
|
|
1454
|
+
provider: "route53",
|
|
1455
|
+
nameservers: [],
|
|
1456
|
+
recordCount: zone.ResourceRecordSetCount
|
|
1457
|
+
}));
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Parse Route53 resource record sets into grouped records by name
|
|
1461
|
+
*/
|
|
1462
|
+
function parseResourceRecordSets(recordSets, zoneName) {
|
|
1463
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1464
|
+
for (const rs of recordSets) {
|
|
1465
|
+
const name = normalizeRecordName(rs.Name, zoneName);
|
|
1466
|
+
const record = parseResourceRecordSet(rs);
|
|
1467
|
+
if (!grouped.has(name)) grouped.set(name, []);
|
|
1468
|
+
grouped.get(name).push(record);
|
|
1469
|
+
}
|
|
1470
|
+
return Array.from(grouped.entries()).map(([name, records]) => ({
|
|
1471
|
+
name,
|
|
1472
|
+
records
|
|
1473
|
+
}));
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Parse a single Route53 resource record set
|
|
1477
|
+
*/
|
|
1478
|
+
function parseResourceRecordSet(rs) {
|
|
1479
|
+
const type = rs.Type;
|
|
1480
|
+
if (rs.AliasTarget) return {
|
|
1481
|
+
type,
|
|
1482
|
+
ttl: 0,
|
|
1483
|
+
values: [],
|
|
1484
|
+
alias: {
|
|
1485
|
+
hostedZoneId: rs.AliasTarget.HostedZoneId,
|
|
1486
|
+
dnsName: rs.AliasTarget.DNSName,
|
|
1487
|
+
evaluateTargetHealth: rs.AliasTarget.EvaluateTargetHealth
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
return {
|
|
1491
|
+
type,
|
|
1492
|
+
ttl: rs.TTL ?? 0,
|
|
1493
|
+
values: parseRecordValues(type, rs.ResourceRecords?.map((r) => r.Value) ?? [])
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Parse record values based on type
|
|
1498
|
+
*/
|
|
1499
|
+
function parseRecordValues(type, rawValues) {
|
|
1500
|
+
switch (type) {
|
|
1501
|
+
case "A":
|
|
1502
|
+
case "AAAA":
|
|
1503
|
+
case "NS":
|
|
1504
|
+
case "TXT":
|
|
1505
|
+
case "PTR": return rawValues;
|
|
1506
|
+
case "CNAME": return rawValues[0] ?? "";
|
|
1507
|
+
case "MX": return rawValues.map(parseMXValue);
|
|
1508
|
+
case "SRV": return rawValues.map(parseSRVValue);
|
|
1509
|
+
case "CAA": return rawValues.map(parseCAAValue);
|
|
1510
|
+
case "SOA": return parseSOAValue(rawValues[0] ?? "");
|
|
1511
|
+
default: return rawValues;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Parse MX record value: "10 mail.example.com"
|
|
1516
|
+
*/
|
|
1517
|
+
function parseMXValue(value) {
|
|
1518
|
+
const parts = value.split(/\s+/, 2);
|
|
1519
|
+
return {
|
|
1520
|
+
priority: Number.parseInt(parts[0] ?? "0", 10),
|
|
1521
|
+
value: parts[1] ?? ""
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Parse SRV record value: "10 5 5060 sip.example.com"
|
|
1526
|
+
*/
|
|
1527
|
+
function parseSRVValue(value) {
|
|
1528
|
+
const parts = value.split(/\s+/, 4);
|
|
1529
|
+
return {
|
|
1530
|
+
priority: Number.parseInt(parts[0] ?? "0", 10),
|
|
1531
|
+
weight: Number.parseInt(parts[1] ?? "0", 10),
|
|
1532
|
+
port: Number.parseInt(parts[2] ?? "0", 10),
|
|
1533
|
+
target: parts[3] ?? ""
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Parse CAA record value: '0 issue "letsencrypt.org"'
|
|
1538
|
+
*/
|
|
1539
|
+
function parseCAAValue(value) {
|
|
1540
|
+
const match = value.match(/^(\d+)\s+(\w+)\s+"?([^"]*)"?$/);
|
|
1541
|
+
if (!match) return {
|
|
1542
|
+
flags: 0,
|
|
1543
|
+
tag: "",
|
|
1544
|
+
value: ""
|
|
1545
|
+
};
|
|
1546
|
+
return {
|
|
1547
|
+
flags: Number.parseInt(match[1] ?? "0", 10),
|
|
1548
|
+
tag: match[2] ?? "",
|
|
1549
|
+
value: match[3] ?? ""
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Parse SOA record value
|
|
1554
|
+
*/
|
|
1555
|
+
function parseSOAValue(value) {
|
|
1556
|
+
const parts = value.split(/\s+/);
|
|
1557
|
+
return {
|
|
1558
|
+
mname: parts[0] ?? "",
|
|
1559
|
+
rname: parts[1] ?? "",
|
|
1560
|
+
serial: Number.parseInt(parts[2] ?? "0", 10) || 0,
|
|
1561
|
+
refresh: Number.parseInt(parts[3] ?? "0", 10) || 0,
|
|
1562
|
+
retry: Number.parseInt(parts[4] ?? "0", 10) || 0,
|
|
1563
|
+
expire: Number.parseInt(parts[5] ?? "0", 10) || 0,
|
|
1564
|
+
minimum: Number.parseInt(parts[6] ?? "0", 10) || 0
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Normalize a Route53 record name to our format
|
|
1569
|
+
*
|
|
1570
|
+
* - Removes trailing dot
|
|
1571
|
+
* - Converts apex to @
|
|
1572
|
+
* - Extracts subdomain part
|
|
1573
|
+
*/
|
|
1574
|
+
function normalizeRecordName(fqdn, zoneName) {
|
|
1575
|
+
const name = fqdn.endsWith(".") ? fqdn.slice(0, -1) : fqdn;
|
|
1576
|
+
const normalizedZone = zoneName.endsWith(".") ? zoneName.slice(0, -1) : zoneName;
|
|
1577
|
+
if (name.toLowerCase() === normalizedZone.toLowerCase()) return "@";
|
|
1578
|
+
const suffix = `.${normalizedZone}`;
|
|
1579
|
+
if (name.toLowerCase().endsWith(suffix.toLowerCase())) return name.slice(0, -suffix.length);
|
|
1580
|
+
return name;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/route53/adapter.ts
|
|
1585
|
+
/**
|
|
1586
|
+
* Route53 DNS Adapter
|
|
1587
|
+
*
|
|
1588
|
+
* AWS Route53 implementation of the DNS provider interface.
|
|
1589
|
+
*/
|
|
1590
|
+
var Route53Adapter = class {
|
|
1591
|
+
client;
|
|
1592
|
+
zoneIdCache = /* @__PURE__ */ new Map();
|
|
1593
|
+
constructor(options = {}) {
|
|
1594
|
+
if (options.client) this.client = options.client;
|
|
1595
|
+
else this.client = new Route53Client({
|
|
1596
|
+
region: options.region ?? "us-east-1",
|
|
1597
|
+
endpoint: options.endpoint,
|
|
1598
|
+
credentials: options.credentials
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* List all hosted zones
|
|
1603
|
+
*/
|
|
1604
|
+
async listZones() {
|
|
1605
|
+
const zones = [];
|
|
1606
|
+
let marker;
|
|
1607
|
+
do {
|
|
1608
|
+
const response = await this.client.send(new ListHostedZonesCommand({ Marker: marker }));
|
|
1609
|
+
if (response.HostedZones) zones.push(...response.HostedZones);
|
|
1610
|
+
marker = response.IsTruncated ? response.NextMarker : void 0;
|
|
1611
|
+
} while (marker);
|
|
1612
|
+
return parseHostedZones(zones);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* List all record names in a zone
|
|
1616
|
+
*/
|
|
1617
|
+
async listRecords(zoneDomain) {
|
|
1618
|
+
const zoneId = await this.getZoneId(zoneDomain);
|
|
1619
|
+
const parsed = parseResourceRecordSets(await this.fetchAllRecordSets(zoneId), zoneDomain);
|
|
1620
|
+
const names = /* @__PURE__ */ new Set();
|
|
1621
|
+
for (const rs of parsed) names.add(rs.name);
|
|
1622
|
+
names.add("_zone");
|
|
1623
|
+
return Array.from(names);
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Get all records for a specific name
|
|
1627
|
+
*/
|
|
1628
|
+
async getRecord(zoneDomain, name) {
|
|
1629
|
+
const zoneId = await this.getZoneId(zoneDomain);
|
|
1630
|
+
const match = parseResourceRecordSets(await this.fetchAllRecordSets(zoneId), zoneDomain).find((rs) => rs.name === name);
|
|
1631
|
+
return {
|
|
1632
|
+
fqdn: name === "@" ? zoneDomain : `${name}.${zoneDomain}`,
|
|
1633
|
+
records: match?.records ?? []
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Get zone metadata
|
|
1638
|
+
*/
|
|
1639
|
+
async getZoneMetadata(zoneDomain) {
|
|
1640
|
+
const zoneId = await this.getZoneId(zoneDomain);
|
|
1641
|
+
const response = await this.client.send(new GetHostedZoneCommand({ Id: zoneId }));
|
|
1642
|
+
const zone = response.HostedZone;
|
|
1643
|
+
const nameservers = response.DelegationSet?.NameServers ?? [];
|
|
1644
|
+
return {
|
|
1645
|
+
domain: zone.Name.replace(/\.$/, ""),
|
|
1646
|
+
id: zone.Id.replace("/hostedzone/", ""),
|
|
1647
|
+
provider: "route53",
|
|
1648
|
+
nameservers,
|
|
1649
|
+
recordCount: zone.ResourceRecordSetCount
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Set records for a name (create or update)
|
|
1654
|
+
*/
|
|
1655
|
+
async setRecord(zoneDomain, name, records, _options) {
|
|
1656
|
+
const zoneId = await this.getZoneId(zoneDomain);
|
|
1657
|
+
const fqdn = this.buildFQDN(name, zoneDomain);
|
|
1658
|
+
const changes = [];
|
|
1659
|
+
for (const record of records) changes.push({
|
|
1660
|
+
Action: "UPSERT",
|
|
1661
|
+
ResourceRecordSet: this.buildResourceRecordSet(fqdn, record)
|
|
1662
|
+
});
|
|
1663
|
+
await this.client.send(new ChangeResourceRecordSetsCommand({
|
|
1664
|
+
HostedZoneId: zoneId,
|
|
1665
|
+
ChangeBatch: {
|
|
1666
|
+
Changes: changes,
|
|
1667
|
+
Comment: `AFS DNS Provider: set ${name}`
|
|
1668
|
+
}
|
|
1669
|
+
}));
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Delete records for a name
|
|
1673
|
+
*/
|
|
1674
|
+
async deleteRecord(zoneDomain, name, type, _options) {
|
|
1675
|
+
const zoneId = await this.getZoneId(zoneDomain);
|
|
1676
|
+
const fqdn = this.buildFQDN(name, zoneDomain);
|
|
1677
|
+
const toDelete = (await this.fetchAllRecordSets(zoneId)).filter((rs) => {
|
|
1678
|
+
if (rs.Name?.replace(/\.$/, "") !== fqdn.replace(/\.$/, "")) return false;
|
|
1679
|
+
if (type && rs.Type !== type) return false;
|
|
1680
|
+
return true;
|
|
1681
|
+
});
|
|
1682
|
+
if (toDelete.length === 0) return;
|
|
1683
|
+
const changes = toDelete.map((rs) => ({
|
|
1684
|
+
Action: "DELETE",
|
|
1685
|
+
ResourceRecordSet: rs
|
|
1686
|
+
}));
|
|
1687
|
+
await this.client.send(new ChangeResourceRecordSetsCommand({
|
|
1688
|
+
HostedZoneId: zoneId,
|
|
1689
|
+
ChangeBatch: {
|
|
1690
|
+
Changes: changes,
|
|
1691
|
+
Comment: `AFS DNS Provider: delete ${name}`
|
|
1692
|
+
}
|
|
1693
|
+
}));
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Get zone ID from domain name
|
|
1697
|
+
*/
|
|
1698
|
+
async getZoneId(zoneDomain) {
|
|
1699
|
+
if (this.zoneIdCache.has(zoneDomain)) return this.zoneIdCache.get(zoneDomain);
|
|
1700
|
+
const zone = (await this.listZones()).find((z$1) => z$1.domain.toLowerCase() === zoneDomain.toLowerCase());
|
|
1701
|
+
if (!zone) {
|
|
1702
|
+
const error = /* @__PURE__ */ new Error(`Zone not found: ${zoneDomain}`);
|
|
1703
|
+
error.name = "NoSuchHostedZone";
|
|
1704
|
+
throw error;
|
|
1705
|
+
}
|
|
1706
|
+
this.zoneIdCache.set(zoneDomain, zone.id);
|
|
1707
|
+
return zone.id;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Fetch all record sets for a zone (handles pagination)
|
|
1711
|
+
*/
|
|
1712
|
+
async fetchAllRecordSets(zoneId) {
|
|
1713
|
+
const recordSets = [];
|
|
1714
|
+
let startRecordName;
|
|
1715
|
+
let startRecordType;
|
|
1716
|
+
do {
|
|
1717
|
+
const response = await this.client.send(new ListResourceRecordSetsCommand({
|
|
1718
|
+
HostedZoneId: zoneId,
|
|
1719
|
+
StartRecordName: startRecordName,
|
|
1720
|
+
StartRecordType: startRecordType
|
|
1721
|
+
}));
|
|
1722
|
+
if (response.ResourceRecordSets) recordSets.push(...response.ResourceRecordSets);
|
|
1723
|
+
if (response.IsTruncated) {
|
|
1724
|
+
startRecordName = response.NextRecordName;
|
|
1725
|
+
startRecordType = response.NextRecordType;
|
|
1726
|
+
} else startRecordName = void 0;
|
|
1727
|
+
} while (startRecordName);
|
|
1728
|
+
return recordSets;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Build FQDN from name and zone
|
|
1732
|
+
*/
|
|
1733
|
+
buildFQDN(name, zoneDomain) {
|
|
1734
|
+
if (name === "@") return `${zoneDomain}.`;
|
|
1735
|
+
return `${name}.${zoneDomain}.`;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Build Route53 ResourceRecordSet from DNSRecord
|
|
1739
|
+
*/
|
|
1740
|
+
buildResourceRecordSet(fqdn, record) {
|
|
1741
|
+
const base = {
|
|
1742
|
+
Name: fqdn,
|
|
1743
|
+
Type: record.type,
|
|
1744
|
+
TTL: record.ttl
|
|
1745
|
+
};
|
|
1746
|
+
const resourceRecords = this.buildResourceRecords(record);
|
|
1747
|
+
if (resourceRecords.length > 0) base.ResourceRecords = resourceRecords;
|
|
1748
|
+
return base;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Build Route53 ResourceRecords from DNSRecord values
|
|
1752
|
+
*/
|
|
1753
|
+
buildResourceRecords(record) {
|
|
1754
|
+
const { type, values } = record;
|
|
1755
|
+
switch (type) {
|
|
1756
|
+
case "A":
|
|
1757
|
+
case "AAAA":
|
|
1758
|
+
case "NS":
|
|
1759
|
+
case "TXT":
|
|
1760
|
+
case "PTR": return values.map((v) => ({ Value: v }));
|
|
1761
|
+
case "CNAME": return [{ Value: typeof values === "string" ? values : values[0] ?? "" }];
|
|
1762
|
+
case "MX": return values.map((mx) => ({ Value: `${mx.priority} ${mx.value}` }));
|
|
1763
|
+
case "SRV": return values.map((srv) => ({ Value: `${srv.priority} ${srv.weight} ${srv.port} ${srv.target}` }));
|
|
1764
|
+
case "CAA": return values.map((caa) => ({ Value: `${caa.flags} ${caa.tag} "${caa.value}"` }));
|
|
1765
|
+
default: return [];
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1770
|
+
//#endregion
|
|
1771
|
+
//#region src/validation/name-validator.ts
|
|
1772
|
+
const MAX_LABEL_LENGTH = 63;
|
|
1773
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
1774
|
+
const LABEL_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
|
1775
|
+
const SERVICE_LABEL_REGEX = /^_[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^_[a-zA-Z0-9]$/;
|
|
1776
|
+
/**
|
|
1777
|
+
* Validate a domain name per RFC 1035
|
|
1778
|
+
*
|
|
1779
|
+
* @param domain - Domain name to validate (e.g., "example.com")
|
|
1780
|
+
* @returns Validation result
|
|
1781
|
+
*/
|
|
1782
|
+
function validateDomainName(domain) {
|
|
1783
|
+
if (!domain) return {
|
|
1784
|
+
valid: false,
|
|
1785
|
+
error: "Domain name cannot be empty"
|
|
1786
|
+
};
|
|
1787
|
+
const normalized = domain.endsWith(".") ? domain.slice(0, -1) : domain;
|
|
1788
|
+
if (normalized.length > MAX_DOMAIN_LENGTH) return {
|
|
1789
|
+
valid: false,
|
|
1790
|
+
error: `Domain name exceeds ${MAX_DOMAIN_LENGTH} characters`
|
|
1791
|
+
};
|
|
1792
|
+
const labels = normalized.split(".");
|
|
1793
|
+
for (let i = 0; i < labels.length; i++) {
|
|
1794
|
+
const label = labels[i];
|
|
1795
|
+
if (!label) return {
|
|
1796
|
+
valid: false,
|
|
1797
|
+
error: "Domain name contains empty label"
|
|
1798
|
+
};
|
|
1799
|
+
if (label.length > MAX_LABEL_LENGTH) return {
|
|
1800
|
+
valid: false,
|
|
1801
|
+
error: `Label "${label}" exceeds ${MAX_LABEL_LENGTH} characters`
|
|
1802
|
+
};
|
|
1803
|
+
if (label.startsWith("-")) return {
|
|
1804
|
+
valid: false,
|
|
1805
|
+
error: `Label "${label}" cannot start with hyphen`
|
|
1806
|
+
};
|
|
1807
|
+
if (label.endsWith("-")) return {
|
|
1808
|
+
valid: false,
|
|
1809
|
+
error: `Label "${label}" cannot end with hyphen`
|
|
1810
|
+
};
|
|
1811
|
+
if (!LABEL_REGEX.test(label) && !label.startsWith("xn--")) return {
|
|
1812
|
+
valid: false,
|
|
1813
|
+
error: `Label "${label}" contains invalid characters`
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
return { valid: true };
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Normalize a domain name to lowercase
|
|
1820
|
+
*
|
|
1821
|
+
* DNS is case-insensitive per RFC 1035.
|
|
1822
|
+
*
|
|
1823
|
+
* @param domain - Domain name to normalize
|
|
1824
|
+
* @returns Normalized lowercase domain name
|
|
1825
|
+
*/
|
|
1826
|
+
function normalizeDomainName(domain) {
|
|
1827
|
+
return (domain.endsWith(".") ? domain.slice(0, -1) : domain).toLowerCase();
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Validate a record name within a zone
|
|
1831
|
+
*
|
|
1832
|
+
* Record names can be:
|
|
1833
|
+
* - @ for apex (root domain)
|
|
1834
|
+
* - * for wildcard
|
|
1835
|
+
* - *.subdomain for scoped wildcard
|
|
1836
|
+
* - Simple names like "www", "api"
|
|
1837
|
+
* - Multi-level like "blog.dev"
|
|
1838
|
+
* - Service records like "_dmarc", "_acme-challenge"
|
|
1839
|
+
*
|
|
1840
|
+
* @param name - Record name to validate
|
|
1841
|
+
* @param zone - The zone this record belongs to
|
|
1842
|
+
* @returns Validation result
|
|
1843
|
+
*/
|
|
1844
|
+
function validateRecordName(name, zone) {
|
|
1845
|
+
if (!name) return {
|
|
1846
|
+
valid: false,
|
|
1847
|
+
error: "Record name cannot be empty"
|
|
1848
|
+
};
|
|
1849
|
+
if (name === "@") return { valid: true };
|
|
1850
|
+
if (name === "*") return { valid: true };
|
|
1851
|
+
if (name.includes("*")) {
|
|
1852
|
+
if (!name.startsWith("*.") && name !== "*") return {
|
|
1853
|
+
valid: false,
|
|
1854
|
+
error: "Wildcard (*) can only appear at the start of a record name"
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
const nameWithoutWildcard = name.startsWith("*.") ? name.slice(2) : name;
|
|
1858
|
+
if (`${nameWithoutWildcard}.${zone}`.length > MAX_DOMAIN_LENGTH) return {
|
|
1859
|
+
valid: false,
|
|
1860
|
+
error: `Full domain name would exceed ${MAX_DOMAIN_LENGTH} characters`
|
|
1861
|
+
};
|
|
1862
|
+
const labels = nameWithoutWildcard.split(".");
|
|
1863
|
+
for (const label of labels) {
|
|
1864
|
+
if (!label) return {
|
|
1865
|
+
valid: false,
|
|
1866
|
+
error: "Record name contains empty label"
|
|
1867
|
+
};
|
|
1868
|
+
if (label.length > MAX_LABEL_LENGTH) return {
|
|
1869
|
+
valid: false,
|
|
1870
|
+
error: `Label "${label}" exceeds ${MAX_LABEL_LENGTH} characters`
|
|
1871
|
+
};
|
|
1872
|
+
if (label.startsWith("_")) {
|
|
1873
|
+
if (!SERVICE_LABEL_REGEX.test(label)) return {
|
|
1874
|
+
valid: false,
|
|
1875
|
+
error: `Invalid service label: ${label}`
|
|
1876
|
+
};
|
|
1877
|
+
} else {
|
|
1878
|
+
if (label.startsWith("-") || label.endsWith("-")) return {
|
|
1879
|
+
valid: false,
|
|
1880
|
+
error: `Label "${label}" cannot start or end with hyphen`
|
|
1881
|
+
};
|
|
1882
|
+
if (!LABEL_REGEX.test(label)) return {
|
|
1883
|
+
valid: false,
|
|
1884
|
+
error: `Label "${label}" contains invalid characters`
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
return { valid: true };
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
//#endregion
|
|
1892
|
+
//#region src/validation/write-validator.ts
|
|
1893
|
+
/**
|
|
1894
|
+
* Validate a complete write operation
|
|
1895
|
+
*/
|
|
1896
|
+
function validateWriteOperation(recordName, records) {
|
|
1897
|
+
const setResult = validateRecordSet(records);
|
|
1898
|
+
if (!setResult.valid) return setResult;
|
|
1899
|
+
const soaResult = checkSOAProtection(records);
|
|
1900
|
+
if (!soaResult.valid) return soaResult;
|
|
1901
|
+
const apexResult = checkCNAMEAtApex(recordName, records);
|
|
1902
|
+
if (!apexResult.valid) return apexResult;
|
|
1903
|
+
const conflictResult = checkCNAMEConflict(records);
|
|
1904
|
+
if (!conflictResult.valid) return conflictResult;
|
|
1905
|
+
const dupeResult = checkDuplicateValues(records);
|
|
1906
|
+
if (!dupeResult.valid) return dupeResult;
|
|
1907
|
+
return { valid: true };
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Validate a record set (array of records)
|
|
1911
|
+
*/
|
|
1912
|
+
function validateRecordSet(records) {
|
|
1913
|
+
if (!records || records.length === 0) return {
|
|
1914
|
+
valid: false,
|
|
1915
|
+
error: "Record set must contain at least one record"
|
|
1916
|
+
};
|
|
1917
|
+
for (const record of records) {
|
|
1918
|
+
const result = validateRecord(record);
|
|
1919
|
+
if (!result.valid) return {
|
|
1920
|
+
valid: false,
|
|
1921
|
+
error: result.errors?.join(", ")
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
return { valid: true };
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Check for CNAME conflicts - CNAME cannot coexist with other record types
|
|
1928
|
+
*/
|
|
1929
|
+
function checkCNAMEConflict(records) {
|
|
1930
|
+
const hasCNAME = records.some((r) => r.type === "CNAME");
|
|
1931
|
+
const hasOtherTypes = records.some((r) => r.type !== "CNAME");
|
|
1932
|
+
if (hasCNAME && hasOtherTypes) return {
|
|
1933
|
+
valid: false,
|
|
1934
|
+
error: "CNAME cannot coexist with other record types at the same name"
|
|
1935
|
+
};
|
|
1936
|
+
if (records.filter((r) => r.type === "CNAME").length > 1) return {
|
|
1937
|
+
valid: false,
|
|
1938
|
+
error: "Only one CNAME record is allowed per name"
|
|
1939
|
+
};
|
|
1940
|
+
return { valid: true };
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Check CNAME at apex - CNAME is not allowed at the root domain
|
|
1944
|
+
*/
|
|
1945
|
+
function checkCNAMEAtApex(recordName, records) {
|
|
1946
|
+
if (records.some((r) => r.type === "CNAME") && recordName === "@") return {
|
|
1947
|
+
valid: false,
|
|
1948
|
+
error: "CNAME records are not allowed at the apex (root domain)"
|
|
1949
|
+
};
|
|
1950
|
+
return { valid: true };
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Check SOA protection - SOA records cannot be written
|
|
1954
|
+
*/
|
|
1955
|
+
function checkSOAProtection(records) {
|
|
1956
|
+
if (records.some((r) => r.type === "SOA")) return {
|
|
1957
|
+
valid: false,
|
|
1958
|
+
error: "SOA records are managed by the DNS provider and cannot be modified"
|
|
1959
|
+
};
|
|
1960
|
+
return { valid: true };
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Check for duplicate values within records
|
|
1964
|
+
*/
|
|
1965
|
+
function checkDuplicateValues(records) {
|
|
1966
|
+
for (const record of records) {
|
|
1967
|
+
const result = checkRecordForDuplicates(record);
|
|
1968
|
+
if (!result.valid) return result;
|
|
1969
|
+
}
|
|
1970
|
+
return { valid: true };
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Check a single record for duplicate values
|
|
1974
|
+
*/
|
|
1975
|
+
function checkRecordForDuplicates(record) {
|
|
1976
|
+
const { type, values } = record;
|
|
1977
|
+
if (type === "CNAME" || type === "SOA") return { valid: true };
|
|
1978
|
+
if (Array.isArray(values)) {
|
|
1979
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1980
|
+
for (const value of values) {
|
|
1981
|
+
const key = serializeValue(value, type);
|
|
1982
|
+
if (seen.has(key)) return {
|
|
1983
|
+
valid: false,
|
|
1984
|
+
error: `Duplicate value found in ${type} record: ${key}`
|
|
1985
|
+
};
|
|
1986
|
+
seen.add(key);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return { valid: true };
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Serialize a record value to a string for comparison
|
|
1993
|
+
*/
|
|
1994
|
+
function serializeValue(value, type) {
|
|
1995
|
+
if (typeof value === "string") return value;
|
|
1996
|
+
if (type === "MX") {
|
|
1997
|
+
const mx = value;
|
|
1998
|
+
return `${mx.priority}:${mx.value}`;
|
|
1999
|
+
}
|
|
2000
|
+
if (type === "SRV") {
|
|
2001
|
+
const srv = value;
|
|
2002
|
+
return `${srv.priority}:${srv.weight}:${srv.port}:${srv.target}`;
|
|
2003
|
+
}
|
|
2004
|
+
if (type === "CAA") {
|
|
2005
|
+
const caa = value;
|
|
2006
|
+
return `${caa.flags}:${caa.tag}:${caa.value}`;
|
|
2007
|
+
}
|
|
2008
|
+
return JSON.stringify(value);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
//#endregion
|
|
2012
|
+
export { AFSDNSConflictError, AFSDNSError, AFSDNSInvalidArgumentError, AFSDNSNetworkError, AFSDNSNotFoundError, AFSDNSPermissionDeniedError, AFSDNSRateLimitError, AuditLogger, CloudDNSAdapter, DNSProvider, PRESETS, RateLimiter, Route53Adapter, checkCNAMEAtApex, checkCNAMEConflict, checkDuplicateValues, checkPermission, checkSOAProtection, createRoute53RateLimiter, detectDangerousOperation, escapeSpecialCharacters, formatPermissionError, getEffectivePermissions, isRetryableError, mapRoute53Error, normalizeDomainName, normalizeRecordName, parseHostedZones, parseResourceRecordSets, rejectNullBytes, sanitizeErrorMessage, sanitizeRecordValue, sanitizeTXTValue, validateAAAARecord, validateARecord, validateCAARecord, validateCNAMERecord, validateDomainName, validateInputLength, validateMXRecord, validateNSRecord, validateRecord, validateRecordName, validateRecordSet, validateSRVRecord, validateTTL, validateTXTRecord, validateWriteOperation };
|
|
2013
|
+
//# sourceMappingURL=index.mjs.map
|