diff --git a/README.md b/README.md index 463ffe9..321e292 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ moshpit-dns service uninstall stop doing that moshpit-dns tlds [--json] list the endings claimed in the Pit moshpit-dns records [CNAME|MX|TXT] [--json] inspect records published for a name -moshpit-dns resolve [--json] - what a name resolves to, and why +moshpit-dns resolve [--json] + what names resolve to, and why moshpit-dns start [--ttl N] run the bridge in the foreground moshpit-dns install print the resolver config without applying it ``` @@ -76,9 +76,14 @@ records, the final DNS address for a resolution, and structured status warnings without mixing human-readable lines into stdout. Failures such as an unreachable registry still produce valid JSON and a non-zero exit status where the command normally fails. +For compatibility, resolving one name returns the established JSON object. +Resolving multiple names returns an ordered array and exits non-zero when any +name is invalid or the registry cannot answer it. Repeated names share one +registry lookup while still retaining their original positions in the output. ```sh moshpit-dns resolve california.oranges --json | jq .address +moshpit-dns resolve california.oranges blue.eggs --json | jq '.[].address' moshpit-dns records california.oranges MX --json | jq '.records[]' moshpit-dns status --json | jq '.warnings[]?.code' ``` diff --git a/bin/moshpit-dns.mjs b/bin/moshpit-dns.mjs index e84e9a4..283b996 100755 --- a/bin/moshpit-dns.mjs +++ b/bin/moshpit-dns.mjs @@ -39,8 +39,8 @@ const USAGE = `moshpit-dns — resolve Moshpit names on this machine moshpit-dns tlds [--json] list the endings claimed in the Pit moshpit-dns records [CNAME|MX|TXT] [--json] inspect records published for a name - moshpit-dns resolve [--json] - show what a name resolves to, and why + moshpit-dns resolve [--json] + show what names resolve to, and why moshpit-dns start run the bridge in the foreground moshpit-dns install print the resolver config without applying it @@ -366,27 +366,44 @@ export async function run(argv = process.argv.slice(2)) { } if (sub === "resolve") { - const name = positionals()[0]; - if (!name) { + const names = positionals(); + if (!names[0]) { if (json) outJson({ name: null, error: "missing name" }); else out("usage: moshpit-dns resolve "); return 1; } - const result = await resolveName(name, { registryBase, timeoutMs }); - const park = result.status === "parked" ? await parkingAddress() : null; - const address = result.status === "live" ? result.target : park; - const report = buildResolutionReport(name, result, address, registryBase); + + const lookups = new Map(); + let parkingLookup; + const reports = []; + for (const name of names) { + let lookup = lookups.get(name); + if (!lookup) { + lookup = resolveName(name, { registryBase, timeoutMs }); + lookups.set(name, lookup); + } + const result = await lookup; + if (result.status === "parked" && !parkingLookup) parkingLookup = parkingAddress(); + const park = result.status === "parked" ? await parkingLookup : null; + const address = result.status === "live" ? result.target : park; + const report = buildResolutionReport(name, result, address, registryBase); + reports.push({ name, result, park, report }); + } if (json) { - outJson(report); + outJson(reports.length === 1 ? reports[0].report : reports.map(({ report }) => report)); } else { - out({ - live: () => `${name} → ${result.target}`, - parked: () => `${name} → ${park || "(parking host unresolvable)"} [${report.reason}]`, - unreachable: () => `${name} → NXDOMAIN [${report.reason}]`, - "not-a-name": () => `${name} → NXDOMAIN [${report.reason}]`, - }[result.status]()); + for (const { name, result, park, report } of reports) { + out({ + live: () => `${name} → ${result.target}`, + parked: () => `${name} → ${park || "(parking host unresolvable)"} [${report.reason}]`, + unreachable: () => `${name} → NXDOMAIN [${report.reason}]`, + "not-a-name": () => `${name} → NXDOMAIN [${report.reason}]`, + }[result.status]()); + } } - return result.status === "live" || result.status === "parked" ? 0 : 1; + return reports.every(({ result }) => result.status === "live" || result.status === "parked") + ? 0 + : 1; } if (sub === "start") { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 35ffa63..fe96d4d 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -78,7 +78,8 @@ async function startRegistry(t, onRequest = () => {}) { response.end(JSON.stringify({ name_registered: false, target: null, records: [] })); return; } - if (request.url === "/api/moshpit/resolve?name=empty.eggs&records=1") { + if (request.url === "/api/moshpit/resolve?name=empty.eggs" + || request.url === "/api/moshpit/resolve?name=empty.eggs&records=1") { response.end(JSON.stringify({ name_registered: true, target: null, records: [] })); return; } @@ -140,6 +141,113 @@ test("resolve --json reports the address and decision reason", async (t) => { }); }); +test("resolve reports multiple names in input order", async (t) => { + let requests = 0; + const registry = await startRegistry(t, (request) => { + if (request.url.startsWith("/api/moshpit/resolve")) requests += 1; + }); + const result = await run([ + "resolve", + "--timeout", + "1500", + "blue.eggs", + "--registry", + registry, + "localhost", + "blue.eggs", + "--json", + ]); + + assert.equal(result.status, 1); + assert.equal(requests, 1); + assert.deepEqual(jsonOutput(result), [ + { + registry, + name: "blue.eggs", + status: "live", + address: "203.0.113.9", + target: "203.0.113.9", + registered: null, + reason: "registry target", + }, + { + registry, + name: "localhost", + status: "not-a-name", + address: null, + target: null, + registered: null, + reason: "not a Moshpit name: one label and one ending", + }, + { + registry, + name: "blue.eggs", + status: "live", + address: "203.0.113.9", + target: "203.0.113.9", + registered: null, + reason: "registry target", + }, + ]); + + const human = await run([ + "resolve", + "blue.eggs", + "localhost", + "--registry", + registry, + ]); + assert.equal(human.status, 1); + assert.equal(human.stderr, ""); + assert.equal( + human.stdout, + "blue.eggs → 203.0.113.9\n" + + "localhost → NXDOMAIN [not a Moshpit name: one label and one ending]\n", + ); +}); + +test("resolve returns success when every batch entry resolves", async (t) => { + const registry = await startRegistry(t); + const result = await run([ + "resolve", + "blue.eggs", + "blue.eggs", + "--registry", + registry, + "--json", + ]); + + assert.equal(result.status, 0); + const reports = jsonOutput(result); + assert.equal(reports.length, 2); + assert.deepEqual(reports[0], reports[1]); +}); + +test("resolve reuses lookups for repeated parked names", async (t) => { + let requests = 0; + const registry = await startRegistry(t, (request) => { + if (request.url === "/api/moshpit/resolve?name=empty.eggs") requests += 1; + }); + const result = await run([ + "resolve", + "empty.eggs", + "empty.eggs", + "--registry", + registry, + "--json", + ]); + + assert.equal(result.status, 0); + assert.equal(requests, 1); + const reports = jsonOutput(result); + assert.equal(reports.length, 2); + assert.equal(reports[0].status, "parked"); + assert.equal(reports[0].target, null); + assert.equal(reports[0].registered, true); + assert.equal(reports[0].reason, "claimed, not pointed at an IP"); + assert.deepEqual(reports[0], reports[1]); +}); + test("records inspects and filters the registry record set", async (t) => { const registry = await startRegistry(t); const human = await run(["records", "blue.eggs", "mx", "--registry", registry]); @@ -309,6 +417,10 @@ test("resolve --json keeps malformed and missing names machine-readable", async const missing = await run(["resolve", "--json"]); assert.equal(missing.status, 1); assert.deepEqual(jsonOutput(missing), { name: null, error: "missing name" }); + + const empty = await run(["resolve", "", "--json"]); + assert.equal(empty.status, 1); + assert.deepEqual(jsonOutput(empty), { name: null, error: "missing name" }); }); test("tlds --json returns valid JSON when the registry is unreachable", async () => {