greybull 0.1.0 → 0.2.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/dist/index.js +216 -10
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ var ApiError = class extends Error {
|
|
|
94
94
|
this.status = status;
|
|
95
95
|
}
|
|
96
96
|
};
|
|
97
|
-
var VERSION = "0.1
|
|
97
|
+
var VERSION = "0.2.1";
|
|
98
98
|
function buildRequest(opts) {
|
|
99
99
|
const url = new URL(opts.apiUrl.replace(/\/+$/, "") + opts.path);
|
|
100
100
|
if (opts.query) {
|
|
@@ -148,13 +148,21 @@ async function apiRequest(opts) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// src/output.ts
|
|
151
|
-
function formatTable(headers, rows) {
|
|
151
|
+
function formatTable(headers, rows, opts = {}) {
|
|
152
152
|
if (rows.length === 0) return "(none)";
|
|
153
|
+
const max = opts.max ?? [];
|
|
154
|
+
const clip = (s, i) => {
|
|
155
|
+
const w = max[i];
|
|
156
|
+
return w && s.length > w ? s.slice(0, w - 1) + "\u2026" : s;
|
|
157
|
+
};
|
|
158
|
+
const hdr = headers.map((h, i) => clip(h, i));
|
|
159
|
+
const cells = rows.map((r) => headers.map((_, i) => clip(String(r[i] ?? ""), i)));
|
|
160
|
+
const last = headers.length - 1;
|
|
153
161
|
const widths = headers.map(
|
|
154
|
-
(
|
|
162
|
+
(_, i) => Math.max(hdr[i].length, ...cells.map((r) => r[i].length))
|
|
155
163
|
);
|
|
156
|
-
const line = (
|
|
157
|
-
return [line(
|
|
164
|
+
const line = (row) => row.map((c, i) => i === last ? c : c.padEnd(widths[i])).join(" ").trimEnd();
|
|
165
|
+
return [line(hdr), ...cells.map(line)].join("\n");
|
|
158
166
|
}
|
|
159
167
|
function fail(error, opts) {
|
|
160
168
|
let message = error instanceof Error ? error.message : String(error);
|
|
@@ -300,7 +308,38 @@ function page(title, body) {
|
|
|
300
308
|
}
|
|
301
309
|
|
|
302
310
|
// src/commands/dns.ts
|
|
303
|
-
|
|
311
|
+
import fs2 from "fs";
|
|
312
|
+
import os3 from "os";
|
|
313
|
+
import path2 from "path";
|
|
314
|
+
import readline2 from "readline/promises";
|
|
315
|
+
import { spawnSync } from "child_process";
|
|
316
|
+
var RECORD_TYPES = [
|
|
317
|
+
"A",
|
|
318
|
+
"AAAA",
|
|
319
|
+
"ALIAS",
|
|
320
|
+
"CAA",
|
|
321
|
+
"CERT",
|
|
322
|
+
"CNAME",
|
|
323
|
+
"DNSKEY",
|
|
324
|
+
"DS",
|
|
325
|
+
"HINFO",
|
|
326
|
+
"HTTPS",
|
|
327
|
+
"LOC",
|
|
328
|
+
"MX",
|
|
329
|
+
"NAPTR",
|
|
330
|
+
"NS",
|
|
331
|
+
"OPENPGPKEY",
|
|
332
|
+
"PTR",
|
|
333
|
+
"RP",
|
|
334
|
+
"SMIMEA",
|
|
335
|
+
"SPF",
|
|
336
|
+
"SRV",
|
|
337
|
+
"SSHFP",
|
|
338
|
+
"SVCB",
|
|
339
|
+
"TLSA",
|
|
340
|
+
"TXT",
|
|
341
|
+
"URI"
|
|
342
|
+
];
|
|
304
343
|
function buildListRecords(apiUrl, zone, opts) {
|
|
305
344
|
return buildRequest({
|
|
306
345
|
apiUrl,
|
|
@@ -368,11 +407,11 @@ async function listCmd(ctx2, zone, opts) {
|
|
|
368
407
|
const rows = res.data.map((r) => [
|
|
369
408
|
r.name,
|
|
370
409
|
r.type,
|
|
371
|
-
r.content,
|
|
372
410
|
String(r.ttl),
|
|
373
|
-
r.priority !== void 0 ? String(r.priority) : ""
|
|
411
|
+
r.priority !== void 0 ? String(r.priority) : "",
|
|
412
|
+
r.content
|
|
374
413
|
]);
|
|
375
|
-
process.stdout.write(formatTable(["NAME", "TYPE", "
|
|
414
|
+
process.stdout.write(formatTable(["NAME", "TYPE", "TTL", "PRIO", "CONTENT"], rows, { max: [48] }) + "\n");
|
|
376
415
|
}
|
|
377
416
|
async function addCmd(ctx2, zone, type, name, content, opts) {
|
|
378
417
|
const upperType = assertType(type, ctx2);
|
|
@@ -424,6 +463,170 @@ async function deleteCmd(ctx2, zone, type, name, content, opts) {
|
|
|
424
463
|
function runSpec(spec, ctx2) {
|
|
425
464
|
return performRequest(spec, ctx2.apiKey).catch((err) => fail(err, ctx2));
|
|
426
465
|
}
|
|
466
|
+
function renderZoneFile(zone, records) {
|
|
467
|
+
const rows = records.map((r) => {
|
|
468
|
+
const content = r.type === "MX" && r.priority !== void 0 ? `${r.priority} ${r.content}` : r.content;
|
|
469
|
+
return { name: r.name, ttl: String(r.ttl), type: r.type, content };
|
|
470
|
+
}).sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
|
|
471
|
+
const nameW = Math.max(1, ...rows.map((r) => r.name.length));
|
|
472
|
+
const ttlW = Math.max(3, ...rows.map((r) => r.ttl.length));
|
|
473
|
+
const typeW = Math.max(4, ...rows.map((r) => r.type.length));
|
|
474
|
+
const header = [
|
|
475
|
+
// Editor mode hints (comments, so the parser ignores them): Emacs dns-mode
|
|
476
|
+
// on line 1, Vim bindzone via the modeline at the end. Together with the
|
|
477
|
+
// .zone temp-file extension these give BIND zone syntax highlighting.
|
|
478
|
+
"; -*- mode: dns -*-",
|
|
479
|
+
`; Zone: ${zone}`,
|
|
480
|
+
"; Edit records below, then save and close your editor to apply.",
|
|
481
|
+
"; Format: NAME TTL TYPE CONTENT (use @ for the zone apex)",
|
|
482
|
+
"; TXT values keep their quotes; MX/SRV keep priority in CONTENT.",
|
|
483
|
+
"; SOA and the apex NS records are managed for you and not shown.",
|
|
484
|
+
";"
|
|
485
|
+
];
|
|
486
|
+
const body = rows.map(
|
|
487
|
+
(r) => `${r.name.padEnd(nameW)} ${r.ttl.padEnd(ttlW)} ${r.type.padEnd(typeW)} ${r.content}`
|
|
488
|
+
);
|
|
489
|
+
return [...header, ...body, "; vim: set ft=bindzone:", ""].join("\n");
|
|
490
|
+
}
|
|
491
|
+
function parseZoneFile(text) {
|
|
492
|
+
const recs = [];
|
|
493
|
+
const lines = text.split("\n");
|
|
494
|
+
for (let i = 0; i < lines.length; i++) {
|
|
495
|
+
const trimmed = lines[i].trim();
|
|
496
|
+
if (!trimmed || trimmed.startsWith(";")) continue;
|
|
497
|
+
const m = trimmed.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?([A-Za-z][A-Za-z0-9]*)\s+(.+)$/i);
|
|
498
|
+
if (!m) {
|
|
499
|
+
throw new Error(`line ${i + 1}: could not parse "${trimmed}" (expected: NAME TTL TYPE CONTENT)`);
|
|
500
|
+
}
|
|
501
|
+
const [, name, ttlStr, typeRaw, content] = m;
|
|
502
|
+
recs.push({ name, type: typeRaw.toUpperCase(), content: content.trim(), ttl: parseInt(ttlStr) });
|
|
503
|
+
}
|
|
504
|
+
return recs;
|
|
505
|
+
}
|
|
506
|
+
function zoneDiff(before, after) {
|
|
507
|
+
const norm = (t) => t.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith(";")).map((l) => l.replace(/\s+/g, " "));
|
|
508
|
+
const a = new Set(norm(before));
|
|
509
|
+
const b = new Set(norm(after));
|
|
510
|
+
return {
|
|
511
|
+
removed: [...a].filter((l) => !b.has(l)).sort(),
|
|
512
|
+
added: [...b].filter((l) => !a.has(l)).sort()
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
async function prompt(question, valid, def, nonTtyDefault = def) {
|
|
516
|
+
if (!process.stdin.isTTY) return nonTtyDefault;
|
|
517
|
+
const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
|
|
518
|
+
try {
|
|
519
|
+
for (; ; ) {
|
|
520
|
+
const ans = (await rl.question(question)).trim().toLowerCase();
|
|
521
|
+
if (!ans) return def;
|
|
522
|
+
if (valid.includes(ans[0])) return ans[0];
|
|
523
|
+
process.stdout.write(`Please choose one of: ${valid.join(", ")}
|
|
524
|
+
`);
|
|
525
|
+
}
|
|
526
|
+
} finally {
|
|
527
|
+
rl.close();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function openInEditor(zone, content) {
|
|
531
|
+
const file = path2.join(os3.tmpdir(), `greybull-${zone}-${process.pid}.zone`);
|
|
532
|
+
fs2.writeFileSync(file, content);
|
|
533
|
+
const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad" : "vi");
|
|
534
|
+
const [cmd, ...args] = editor.split(" ");
|
|
535
|
+
try {
|
|
536
|
+
const r = spawnSync(cmd, [...args, file], { stdio: "inherit" });
|
|
537
|
+
if (r.error) return null;
|
|
538
|
+
return fs2.readFileSync(file, "utf8");
|
|
539
|
+
} finally {
|
|
540
|
+
try {
|
|
541
|
+
fs2.unlinkSync(file);
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
var ABORTED = "Aborted. No changes made.\n";
|
|
547
|
+
async function editCmd(ctx2, zone) {
|
|
548
|
+
const res = await apiRequest({
|
|
549
|
+
apiUrl: ctx2.apiUrl,
|
|
550
|
+
apiKey: ctx2.apiKey,
|
|
551
|
+
method: "GET",
|
|
552
|
+
path: `/api/v1/dns/zones/${encodeURIComponent(zone)}/records`
|
|
553
|
+
}).catch((err) => fail(err, ctx2));
|
|
554
|
+
const originalText = renderZoneFile(zone, res.data);
|
|
555
|
+
let buffer = originalText;
|
|
556
|
+
for (; ; ) {
|
|
557
|
+
const edited = openInEditor(zone, buffer);
|
|
558
|
+
if (edited === null) fail(new Error("Could not launch editor. Set $EDITOR or $VISUAL."), ctx2);
|
|
559
|
+
let records;
|
|
560
|
+
try {
|
|
561
|
+
records = parseZoneFile(edited);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
process.stdout.write(`
|
|
564
|
+
Error parsing the zone: ${err.message}
|
|
565
|
+
`);
|
|
566
|
+
const c2 = await prompt(
|
|
567
|
+
"Options are: (e)dit your changes, (r)etry with original zone, (a)bort? [e] ",
|
|
568
|
+
["e", "r", "a"],
|
|
569
|
+
"e",
|
|
570
|
+
"a"
|
|
571
|
+
);
|
|
572
|
+
if (c2 === "a") return void process.stdout.write(ABORTED);
|
|
573
|
+
buffer = c2 === "r" ? originalText : edited;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const { added, removed } = zoneDiff(originalText, edited);
|
|
577
|
+
if (added.length === 0 && removed.length === 0) {
|
|
578
|
+
return void process.stdout.write("No changes.\n");
|
|
579
|
+
}
|
|
580
|
+
process.stdout.write("\nChanges to apply:\n");
|
|
581
|
+
removed.forEach((l) => process.stdout.write(` - ${l}
|
|
582
|
+
`));
|
|
583
|
+
added.forEach((l) => process.stdout.write(` + ${l}
|
|
584
|
+
`));
|
|
585
|
+
process.stdout.write("\n");
|
|
586
|
+
const c = await prompt(
|
|
587
|
+
"(a)pply these changes, (e)dit again, (r)etry with original zone, (q)uit? [a] ",
|
|
588
|
+
["a", "e", "r", "q"],
|
|
589
|
+
"a"
|
|
590
|
+
);
|
|
591
|
+
if (c === "q") return void process.stdout.write(ABORTED);
|
|
592
|
+
if (c === "e") {
|
|
593
|
+
buffer = edited;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (c === "r") {
|
|
597
|
+
buffer = originalText;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const put = await apiRequest({
|
|
602
|
+
apiUrl: ctx2.apiUrl,
|
|
603
|
+
apiKey: ctx2.apiKey,
|
|
604
|
+
method: "PUT",
|
|
605
|
+
path: `/api/v1/dns/zones/${encodeURIComponent(zone)}`,
|
|
606
|
+
body: { records }
|
|
607
|
+
});
|
|
608
|
+
process.stdout.write(`Applied. ${zone} now has ${put.data.applied} record(s).
|
|
609
|
+
`);
|
|
610
|
+
return;
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (err instanceof ApiError && (err.status === 422 || err.status === 400)) {
|
|
613
|
+
process.stdout.write(`
|
|
614
|
+
The DNS server rejected the zone: ${err.message}
|
|
615
|
+
`);
|
|
616
|
+
const c2 = await prompt(
|
|
617
|
+
"Options are: (e)dit your changes, (r)etry with original zone, (a)bort? [e] ",
|
|
618
|
+
["e", "r", "a"],
|
|
619
|
+
"e",
|
|
620
|
+
"a"
|
|
621
|
+
);
|
|
622
|
+
if (c2 === "a") return void process.stdout.write(ABORTED);
|
|
623
|
+
buffer = c2 === "r" ? originalText : edited;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
fail(err, ctx2);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
427
630
|
|
|
428
631
|
// src/commands/domains.ts
|
|
429
632
|
async function domainsListCmd(ctx2) {
|
|
@@ -464,7 +667,7 @@ async function domainsCheckCmd(ctx2, query, opts) {
|
|
|
464
667
|
|
|
465
668
|
// src/index.ts
|
|
466
669
|
var program = new Command();
|
|
467
|
-
program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.1
|
|
670
|
+
program.name("greybull").description("Manage Greybull DNS records and domains from the command line").version("0.2.1").option("--json", "output raw JSON", false).option("--api-url <url>", "portal base URL (or set GREYBULL_API_URL)");
|
|
468
671
|
function ctx(requireAuth = true) {
|
|
469
672
|
const opts = program.opts();
|
|
470
673
|
const apiUrl = resolveApiUrl(opts.apiUrl);
|
|
@@ -506,6 +709,9 @@ dns.command("update <zone> <type> <name> <old-content> <new-content>").descripti
|
|
|
506
709
|
dns.command("delete <zone> <type> <name> <content>").description("Delete one specific record (content required \u2014 sibling-safe)").option("--priority <n>", "priority (MX/SRV)").action(async (zone, type, name, content, opts) => {
|
|
507
710
|
await deleteCmd(ctx(), zone, type, name, content, opts);
|
|
508
711
|
});
|
|
712
|
+
dns.command("edit <zone>").description("Edit the whole zone in $EDITOR (like pdnsutil edit-zone)").action(async (zone) => {
|
|
713
|
+
await editCmd(ctx(), zone);
|
|
714
|
+
});
|
|
509
715
|
var domains = program.command("domains").description("Manage domains");
|
|
510
716
|
domains.command("list").description("List your registered domains").action(async () => {
|
|
511
717
|
await domainsListCmd(ctx());
|