Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,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 <name> [AAAA|CNAME|MX|TXT] [--json]
inspect records published for a name
moshpit-dns records <name...> [--type AAAA|CNAME|MX|TXT] [--json]
inspect records published for names
moshpit-dns resolve <name...> [--concurrency N] [--json]
what names resolve to, and why
moshpit-dns start [--ttl N] run the bridge in the foreground
Expand All @@ -84,8 +84,8 @@ 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
For compatibility, resolving or inspecting one name returns the established
JSON object. Multiple names return an ordered array and exit 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.
Batch lookups run eight at a time by default; use `--concurrency N` to lower the
Expand All @@ -100,6 +100,7 @@ everything under their name, and `foo.chovy.hacker` resolves through it.
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 records california.oranges blue.eggs --type MX --json | jq '.[].records'
moshpit-dns status --json | jq '.warnings[]?.code'
```

Expand Down
120 changes: 83 additions & 37 deletions bin/moshpit-dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ 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 <name> [AAAA|CNAME|MX|TXT] [--json]
inspect records published for a name
moshpit-dns records <name...> [--type AAAA|CNAME|MX|TXT] [--json]
inspect records published for names
moshpit-dns resolve <name...> [--concurrency N] [--json]
show what names resolve to, and why
moshpit-dns start run the bridge in the foreground
Expand All @@ -51,6 +51,7 @@ const USAGE = `moshpit-dns — resolve Moshpit names on this machine
--ttl N DNS answer lifetime in seconds (default: ${DEFAULT_TTL})
--timeout N registry request deadline in milliseconds (default: ${DEFAULT_TIMEOUT_MS})
--concurrency N maximum simultaneous resolve lookups (default: 8)
--type TYPE filter published records to AAAA, CNAME, MX, or TXT

The registry speaks HTTP, not DNS, so nothing outside a browser can reach a
Moshpit name until this bridge is running and your resolver points at it.
Expand Down Expand Up @@ -102,7 +103,7 @@ export function parseConcurrency(value) {
}

const VALUE_FLAGS = new Set([
"--backend", "--port", "--registry", "--timeout", "--ttl", "--concurrency",
"--backend", "--port", "--registry", "--timeout", "--ttl", "--concurrency", "--type",
]);

let sub, rest, flag, has, positionals, registryBase, port, ttl, ttlError;
Expand All @@ -118,7 +119,7 @@ const setup = (argv) => {
const values = [];
for (let i = 0; i < rest.length; i += 1) {
if (VALUE_FLAGS.has(rest[i])) {
i += 1;
if (rest[i + 1] && !rest[i + 1].startsWith("--")) i += 1;
} else if (!rest[i].startsWith("-")) {
values.push(rest[i]);
}
Expand Down Expand Up @@ -354,9 +355,22 @@ export async function run(argv = process.argv.slice(2)) {
}

if (sub === "records") {
const [name, requestedType] = positionals();
const values = positionals();
const hasTypeFlag = has("type");
const trailing = values.at(-1);
const legacyType = !hasTypeFlag
&& values.length > 1
&& (PUBLISHED_RECORD_TYPES.has(trailing.toUpperCase()) || !trailing.includes("."))
? trailing
: null;
const names = legacyType ? values.slice(0, -1) : values;
const typeIndex = rest.indexOf("--type");
const typeValue = typeIndex >= 0 ? rest[typeIndex + 1] : null;
const requestedType = hasTypeFlag && typeValue && !typeValue.startsWith("--")
? typeValue
: hasTypeFlag ? null : legacyType;
const type = requestedType?.toUpperCase() || null;
if (!name) {
if (!names.length) {
if (json) {
outJson({
registry: registryBase,
Expand All @@ -370,48 +384,80 @@ export async function run(argv = process.argv.slice(2)) {
error: "missing name",
});
}
else out("usage: moshpit-dns records <name> [AAAA|CNAME|MX|TXT]");
else out("usage: moshpit-dns records <name...> [--type AAAA|CNAME|MX|TXT]");
return 1;
}
if (type && !PUBLISHED_RECORD_TYPES.has(type)) {
if ((hasTypeFlag && !requestedType) || (type && !PUBLISHED_RECORD_TYPES.has(type))) {
const reports = names.map((name) => ({
registry: registryBase,
name,
status: null,
exists: false,
registered: null,
type,
count: 0,
records: [],
error: "unsupported record type",
}));
if (json) {
outJson({
registry: registryBase,
name,
status: null,
exists: false,
registered: null,
type,
count: 0,
records: [],
error: "unsupported record type",
});
outJson(reports.length === 1 ? reports[0] : reports);
}
else out(`unsupported record type: ${requestedType} (expected AAAA, CNAME, MX, or TXT)`);
else out(`unsupported record type: ${requestedType ?? ""} (expected AAAA, CNAME, MX, or TXT)`);
return 1;
}

const result = await resolveName(name, { registryBase, timeoutMs, records: true });
const report = buildRecordsReport(name, result, type, registryBase);
const lookups = new Map();
const inspectOne = async (name) => {
let lookup = lookups.get(name);
if (!lookup) {
lookup = resolveName(name, { registryBase, timeoutMs, records: true });
lookups.set(name, lookup);
}
const result = await lookup;
return buildRecordsReport(name, result, type, registryBase);
};
const reports = await mapWithConcurrency(names, concurrency, inspectOne);

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}`);
outJson(reports.length === 1 ? reports[0] : reports);
} else if (reports.length === 1) {
const [report] = reports;
if (report.error) {
out(`${report.name}: ${report.error}`);
} else if (!report.exists) {
out(`${report.name}: name is not registered`);
} else if (!report.records.length) {
out(type
? `no ${type} records published for ${report.name}`
: `no records published for ${report.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}`);
}
}
} else {
const sections = reports.map((report) => {
const lines = [report.name];
if (report.error) lines.push(` ${report.error}`);
else if (!report.exists) lines.push(" name is not registered");
else if (!report.records.length) {
lines.push(type ? ` no ${type} records published` : " no records published");
} 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})` : "";
lines.push(` ${record.type}${priority} ${record.value}${recordTtl}`);
}
return lines.join("\n");
});
out(sections.join("\n\n"));
}
return report.error ? 1 : 0;
return reports.some((report) => report.error) ? 1 : 0;
}

if (sub === "resolve") {
Expand Down
54 changes: 54 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,43 @@ test("records inspects and filters the registry record set", async (t) => {
});
});

test("records inspects batches in order and reuses repeated lookups", async (t) => {
let requests = 0;
const registry = await startRegistry(t, (request) => {
if (request.url.includes("records=1")) requests += 1;
});
const result = await run([
"records",
"blue.eggs",
"free.eggs",
"blue.eggs",
"--type",
"txt",
"--concurrency",
"2",
"--registry",
registry,
"--json",
]);

assert.equal(result.status, 0);
assert.equal(requests, 2);
const reports = jsonOutput(result);
assert.deepEqual(reports.map(({ name }) => name), ["blue.eggs", "free.eggs", "blue.eggs"]);
assert.equal(reports[0].type, "TXT");
assert.equal(reports[0].count, 1);
assert.equal(reports[1].exists, false);
assert.deepEqual(reports[0], reports[2]);

const human = await run([
"records", "blue.eggs", "free.eggs", "--registry", registry,
]);
assert.equal(human.status, 0);
assert.equal(human.stderr, "");
assert.match(human.stdout, /^blue\.eggs\n MX 10 mail\.example\.com/m);
assert.match(human.stdout, /\n\nfree\.eggs\n name is not registered\n$/);
});

test("records rejects unsupported types before contacting the registry", async (t) => {
let requests = 0;
const registry = await startRegistry(t, () => { requests += 1; });
Expand All @@ -357,6 +394,23 @@ test("records rejects unsupported types before contacting the registry", async (
records: [],
error: "unsupported record type",
});

const missing = await run([
"records", "blue.eggs", "--type", "--registry", registry, "--json",
]);
assert.equal(missing.status, 1);
assert.equal(requests, 0);
assert.deepEqual(jsonOutput(missing), {
registry,
name: "blue.eggs",
status: null,
exists: false,
registered: null,
type: null,
count: 0,
records: [],
error: "unsupported record type",
});
});

test("records follows a third-level name to its wildcard", async (t) => {
Expand Down
Loading