diff --git a/README.md b/README.md index fa18767..463ffe9 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ moshpit-dns service install keep the bridge running across reboots 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 start [--ttl N] run the bridge in the foreground @@ -68,14 +70,16 @@ moshpit-dns install print the resolver config without applying it `--dry-run` prints the exact file contents and commands before anything runs. This edits system DNS under sudo, so being inspectable first is the point. -`tlds`, `resolve`, and `status` accept `--json` for scripts and monitoring. The -JSON includes registry reachability, the final DNS address for a resolution, -and structured status warnings without mixing human-readable lines into stdout. +`tlds`, `records`, `resolve`, and `status` accept `--json` for scripts and +monitoring. The JSON includes registry reachability, published CNAME/MX/TXT +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. ```sh moshpit-dns resolve california.oranges --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 a5fba01..e84e9a4 100755 --- a/bin/moshpit-dns.mjs +++ b/bin/moshpit-dns.mjs @@ -37,13 +37,15 @@ const USAGE = `moshpit-dns — resolve Moshpit names on this machine 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] show what a name resolves to, and why moshpit-dns start run the bridge in the foreground moshpit-dns install print the resolver config without applying it --dry-run with enable/disable/refresh/service: print what would be done - --json with tlds/resolve/status: print machine-readable diagnostics + --json with tlds/records/resolve/status: print machine-readable diagnostics --backend linux only: systemd-resolved (default) or dnsmasq --port N the bridge's port (Windows must use 53 — NRPT carries no port) --ttl N DNS answer lifetime in seconds (default: ${DEFAULT_TTL}) @@ -56,6 +58,7 @@ so every other name keeps using your normal resolver.`; const MAX_TTL = 0xffffffff; const MAX_TIMEOUT_MS = 0x7fffffff; +const PUBLISHED_RECORD_TYPES = new Set(["CNAME", "MX", "TXT"]); /** Parse a DNS TTL without accepting fractions, signs, or numeric shorthand. */ export function parseTtl(value) { @@ -141,6 +144,28 @@ const resolutionReasons = { "not-a-name": "not a Moshpit name: one label and one ending", }; +function normalizePublishedRecord(record) { + if (!record || typeof record !== "object" || Array.isArray(record)) return null; + const type = typeof record.type === "string" ? record.type.trim().toUpperCase() : null; + if (!PUBLISHED_RECORD_TYPES.has(type) || typeof record.value !== "string") return null; + + const normalized = { type, value: record.value }; + if (record.priority === null) { + normalized.priority = null; + } else if ( + type === "MX" + && Number.isInteger(record.priority) + && record.priority >= 0 + && record.priority <= 65_535 + ) { + normalized.priority = record.priority; + } + if (Number.isInteger(record.ttl) && record.ttl >= 0 && record.ttl <= MAX_TTL) { + normalized.ttl = record.ttl; + } + return normalized; +} + /** A stable resolution record for scripts, including the address DNS will answer. */ export function buildResolutionReport(name, result, address, registry = DEFAULT_REGISTRY_BASE) { return { @@ -154,6 +179,38 @@ export function buildResolutionReport(name, result, address, registry = DEFAULT_ }; } +/** Build a stable, sanitized view of the records returned by the registry. */ +export function buildRecordsReport( + name, + result, + type = null, + registry = DEFAULT_REGISTRY_BASE, +) { + const registered = result.status === "live" + ? true + : result.status === "parked" ? result.registered === true : null; + const exists = registered === true; + const records = exists && Array.isArray(result.records) + ? result.records + .map(normalizePublishedRecord) + .filter((record) => record && (!type || record.type === type)) + : []; + + return { + registry, + name, + status: result.status, + exists, + registered, + type, + count: records.length, + records, + error: result.status === "unreachable" + ? "registry unreachable" + : result.status === "not-a-name" ? "invalid Moshpit name" : null, + }; +} + /** Turn the platform probes into one machine-readable diagnostic snapshot. */ export function buildStatusReport({ platform, @@ -247,6 +304,67 @@ export async function run(argv = process.argv.slice(2)) { } } + if (sub === "records") { + const [name, requestedType] = positionals(); + const type = requestedType?.toUpperCase() || null; + if (!name) { + if (json) { + outJson({ + registry: registryBase, + name: null, + status: null, + exists: false, + registered: null, + type: null, + count: 0, + records: [], + error: "missing name", + }); + } + else out("usage: moshpit-dns records [CNAME|MX|TXT]"); + return 1; + } + if (type && !PUBLISHED_RECORD_TYPES.has(type)) { + if (json) { + outJson({ + registry: registryBase, + name, + status: null, + exists: false, + registered: null, + type, + count: 0, + records: [], + error: "unsupported record type", + }); + } + else out(`unsupported record type: ${requestedType} (expected CNAME, MX, or TXT)`); + return 1; + } + + const result = await resolveName(name, { registryBase, timeoutMs, records: true }); + const report = buildRecordsReport(name, result, type, registryBase); + + if (json) { + outJson(report); + } else if (report.error) { + out(`${name}: ${report.error}`); + } else if (!report.exists) { + out(`${name}: name is not registered`); + } else if (!report.records.length) { + out(type ? `no ${type} records published for ${name}` : `no records published for ${name}`); + } else { + for (const record of report.records) { + const priority = record.type === "MX" && record.priority != null + ? ` ${record.priority}` + : ""; + const recordTtl = record.ttl != null ? ` (TTL ${record.ttl})` : ""; + out(`${record.type}${priority} ${record.value}${recordTtl}`); + } + } + return report.error ? 1 : 0; + } + if (sub === "resolve") { const name = positionals()[0]; if (!name) { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index fd047d2..35ffa63 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -6,6 +6,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { + buildRecordsReport, buildResolutionReport, buildStatusReport, parseTimeout, @@ -31,15 +32,54 @@ function run(args) { }); } -async function startRegistry(t) { +async function startRegistry(t, onRequest = () => {}) { const server = createServer((request, response) => { + onRequest(request); response.setHeader("content-type", "application/json"); if (request.url === "/api/moshpit/tlds") { response.end(JSON.stringify({ tlds: [{ tld: "Eggs" }, "agent"] })); return; } - if (request.url === "/api/moshpit/resolve?name=blue.eggs") { - response.end(JSON.stringify({ name_registered: true, target: "203.0.113.9" })); + if (request.url === "/api/moshpit/resolve?name=blue.eggs" + || request.url === "/api/moshpit/resolve?name=blue.eggs&records=1") { + response.end(JSON.stringify({ + name_registered: true, + target: "203.0.113.9", + ...(request.url.endsWith("records=1") ? { + records: [ + { type: "MX", value: "mail.example.com", priority: 10, ttl: 300 }, + { type: "TXT", value: "v=spf1 -all", priority: null, ttl: 60 }, + ], + } : {}), + })); + return; + } + if (request.url === "/api/moshpit/resolve?name=dirty.eggs&records=1") { + response.end(JSON.stringify({ + name_registered: true, + target: null, + records: [ + null, + { type: "TXT" }, + { value: "missing type" }, + { type: "AAAA", value: "2001:db8::1" }, + { + type: "mx", + value: "mail.example.com", + priority: { invalid: true }, + ttl: "not-a-number", + extra: "not part of the record contract", + }, + ], + })); + return; + } + if (request.url === "/api/moshpit/resolve?name=free.eggs&records=1") { + response.end(JSON.stringify({ name_registered: false, target: null, records: [] })); + return; + } + if (request.url === "/api/moshpit/resolve?name=empty.eggs&records=1") { + response.end(JSON.stringify({ name_registered: true, target: null, records: [] })); return; } response.statusCode = 404; @@ -100,6 +140,159 @@ test("resolve --json reports the address and decision reason", async (t) => { }); }); +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]); + assert.equal(human.status, 0); + assert.equal(human.stderr, ""); + assert.equal(human.stdout, "MX 10 mail.example.com (TTL 300)\n"); + + const allHuman = await run(["records", "blue.eggs", "--registry", registry]); + assert.equal(allHuman.status, 0); + assert.equal(allHuman.stderr, ""); + assert.equal( + allHuman.stdout, + "MX 10 mail.example.com (TTL 300)\nTXT v=spf1 -all (TTL 60)\n", + ); + + const result = await run(["records", "blue.eggs", "--registry", registry, "--json"]); + assert.equal(result.status, 0); + assert.deepEqual(jsonOutput(result), { + registry, + name: "blue.eggs", + status: "live", + exists: true, + registered: true, + type: null, + count: 2, + records: [ + { type: "MX", value: "mail.example.com", priority: 10, ttl: 300 }, + { type: "TXT", value: "v=spf1 -all", priority: null, ttl: 60 }, + ], + error: null, + }); +}); + +test("records rejects unsupported types before contacting the registry", async (t) => { + let requests = 0; + const registry = await startRegistry(t, () => { requests += 1; }); + const result = await run(["records", "blue.eggs", "SRV", "--registry", registry, "--json"]); + + assert.equal(result.status, 1); + assert.equal(requests, 0); + assert.deepEqual(jsonOutput(result), { + registry, + name: "blue.eggs", + status: null, + exists: false, + registered: null, + type: "SRV", + count: 0, + records: [], + error: "unsupported record type", + }); +}); + +test("records reports an empty published set in human mode", async (t) => { + const registry = await startRegistry(t); + const all = await run(["records", "empty.eggs", "--registry", registry]); + assert.equal(all.status, 0); + assert.equal(all.stderr, ""); + assert.equal(all.stdout, "no records published for empty.eggs\n"); + + const filtered = await run(["records", "empty.eggs", "TXT", "--registry", registry]); + assert.equal(filtered.status, 0); + assert.equal(filtered.stderr, ""); + assert.equal(filtered.stdout, "no TXT records published for empty.eggs\n"); +}); + +test("records sanitizes malformed registry entries and normalizes record types", async (t) => { + const registry = await startRegistry(t); + const human = await run(["records", "dirty.eggs", "MX", "--registry", registry]); + assert.equal(human.status, 0); + assert.equal(human.stderr, ""); + assert.equal(human.stdout, "MX mail.example.com\n"); + + const result = await run(["records", "dirty.eggs", "MX", "--registry", registry, "--json"]); + + assert.equal(result.status, 0); + assert.deepEqual(jsonOutput(result), { + registry, + name: "dirty.eggs", + status: "parked", + exists: true, + registered: true, + type: "MX", + count: 1, + records: [{ type: "MX", value: "mail.example.com" }], + error: null, + }); +}); + +test("records distinguishes an unregistered name from a parked name", async (t) => { + const registry = await startRegistry(t); + const human = await run(["records", "free.eggs", "--registry", registry]); + assert.equal(human.status, 0); + assert.equal(human.stderr, ""); + assert.equal(human.stdout, "free.eggs: name is not registered\n"); + + const result = await run(["records", "free.eggs", "--registry", registry, "--json"]); + assert.equal(result.status, 0); + assert.equal(jsonOutput(result).exists, false); + assert.equal(jsonOutput(result).registered, false); +}); + +test("records keeps missing and malformed names machine-readable", async () => { + const missing = await run(["records", "--json"]); + assert.equal(missing.status, 1); + assert.deepEqual(jsonOutput(missing), { + registry: DEFAULT_REGISTRY_BASE, + name: null, + status: null, + exists: false, + registered: null, + type: null, + count: 0, + records: [], + error: "missing name", + }); + + const malformed = await run(["records", "localhost", "--json"]); + assert.equal(malformed.status, 1); + assert.deepEqual(jsonOutput(malformed), { + registry: DEFAULT_REGISTRY_BASE, + name: "localhost", + status: "not-a-name", + exists: false, + registered: null, + type: null, + count: 0, + records: [], + error: "invalid Moshpit name", + }); +}); + +test("buildRecordsReport keeps malformed records out of the JSON contract", () => { + const report = buildRecordsReport("dirty.eggs", { + status: "parked", + registered: true, + records: [ + null, + { type: "TXT" }, + { + type: "txt", + value: "hello", + priority: { invalid: true }, + ttl: "bad", + extra: true, + }, + ], + }); + + assert.equal(report.count, 1); + assert.deepEqual(report.records, [{ type: "TXT", value: "hello" }]); +}); + test("resolve --json keeps malformed and missing names machine-readable", async () => { const malformed = await run(["resolve", "localhost", "--json"]); assert.equal(malformed.status, 1);