moshcode 0.24.0

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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +580 -0
  3. package/bin/moshcode.mjs +674 -0
  4. package/bin/moshscript.mjs +29 -0
  5. package/examples/alive.mosh +6 -0
  6. package/examples/scripting-the-cli.mosh +21 -0
  7. package/examples/team-secrets.mosh +20 -0
  8. package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
  9. package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
  10. package/examples/templates/bun-caddy-sqlite/README.md +97 -0
  11. package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
  12. package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
  13. package/examples/templates/bun-caddy-sqlite/package.json +15 -0
  14. package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
  15. package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
  16. package/examples/templates/bun-caddy-sqlite/template.json +10 -0
  17. package/examples/templates/caddy-proxy/Caddyfile +36 -0
  18. package/examples/templates/caddy-proxy/README.md +104 -0
  19. package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
  20. package/examples/templates/caddy-proxy/template.json +8 -0
  21. package/examples/templates/caddy-static/Caddyfile +16 -0
  22. package/examples/templates/caddy-static/README.md +90 -0
  23. package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
  24. package/examples/templates/caddy-static/site/index.html +11 -0
  25. package/examples/templates/caddy-static/template.json +8 -0
  26. package/install.sh +194 -0
  27. package/package.json +28 -0
  28. package/prd/0000-template.md +49 -0
  29. package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
  30. package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
  31. package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
  32. package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
  33. package/prd/0005-hosted-moshpit-resolver.md +192 -0
  34. package/prd/0006-help.md +359 -0
  35. package/prd/0007-profullstack-site-init.md +1183 -0
  36. package/prd/README.md +26 -0
  37. package/src/ads.mjs +58 -0
  38. package/src/auth.mjs +193 -0
  39. package/src/cli-schema.mjs +533 -0
  40. package/src/cli.mjs +118 -0
  41. package/src/commands.mjs +259 -0
  42. package/src/completion.mjs +594 -0
  43. package/src/console.mjs +244 -0
  44. package/src/dns-system.mjs +404 -0
  45. package/src/dns.mjs +2872 -0
  46. package/src/doh-server.mjs +256 -0
  47. package/src/doh.mjs +218 -0
  48. package/src/engines.mjs +385 -0
  49. package/src/escalate.mjs +85 -0
  50. package/src/help.mjs +443 -0
  51. package/src/integrations.mjs +265 -0
  52. package/src/mcp-catalog.mjs +50 -0
  53. package/src/mcp.mjs +155 -0
  54. package/src/mirror.mjs +187 -0
  55. package/src/notify.mjs +86 -0
  56. package/src/open-url.mjs +34 -0
  57. package/src/parking-http.mjs +65 -0
  58. package/src/pins.mjs +190 -0
  59. package/src/pit-url.mjs +13 -0
  60. package/src/prd.mjs +341 -0
  61. package/src/pty.mjs +176 -0
  62. package/src/pwd.mjs +103 -0
  63. package/src/registry.mjs +37 -0
  64. package/src/release-install.mjs +191 -0
  65. package/src/runtime.mjs +161 -0
  66. package/src/selfupdate.mjs +215 -0
  67. package/src/serve.mjs +502 -0
  68. package/src/skills.mjs +93 -0
  69. package/src/tabs.mjs +144 -0
  70. package/src/templates.mjs +456 -0
  71. package/src/tools.mjs +231 -0
  72. package/src/trade.mjs +137 -0
  73. package/src/trust.mjs +712 -0
  74. package/src/tui.mjs +736 -0
  75. package/src/ui.mjs +49 -0
  76. package/src/uninstall.mjs +113 -0
  77. package/src/upgrade.mjs +217 -0
package/src/dns.mjs ADDED
@@ -0,0 +1,2872 @@
1
+ // Moshpit names on the machine, not just in the browser.
2
+ //
3
+ // The registry speaks HTTP, not DNS: pit.moshcode.sh answers
4
+ // /api/moshpit/resolve?name=… and nothing is listening on port 53. That is why
5
+ // `curl https://california.oranges/` fails on a VPS while the TronBrowser
6
+ // extension can reach the same name — the extension redirects tabs, which is
7
+ // not resolution, and nothing outside a browser benefits from it.
8
+ //
9
+ // So this is a bridge: a tiny DNS server that answers A queries for Moshpit
10
+ // TLDs out of the registry's HTTP API, plus the resolver config that routes
11
+ // just those TLDs to it. Everything else on the machine keeps using the normal
12
+ // nameserver — the bridge is authoritative for claimed Moshpit TLDs and
13
+ // deliberately silent about anything else.
14
+ //
15
+ // The wire codec is pure and separate from the socket so the whole protocol is
16
+ // testable without binding a port.
17
+
18
+ import dgram from "node:dgram";
19
+ import { isIP, connect as netConnect } from "node:net";
20
+ import { Resolver } from "node:dns/promises";
21
+
22
+ export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
23
+ export const DEFAULT_PARKING_HOST = "moshcoding.com";
24
+ export const DEFAULT_PORT = 5354;
25
+ export const DEFAULT_HOST = "127.0.0.1";
26
+ // Where the pinned-TLS proxy listens. Not configurable from DNS: an A record
27
+ // cannot carry a port, so the proxy has to be on 443 for a browser to reach it
28
+ // at all — its installer moves it there for exactly this reason.
29
+ export const PROXY_PORT = 443;
30
+
31
+ export function parseDnsPort(input) {
32
+ const raw = String(input ?? "").trim();
33
+ if (!/^\d+$/.test(raw)) return null;
34
+ const port = Number(raw);
35
+ return Number.isSafeInteger(port) && port >= 1 && port <= 65535 ? port : null;
36
+ }
37
+
38
+ // Short, because a name's target can change the moment its owner points it
39
+ // somewhere. A stale A record is the one failure mode users cannot debug.
40
+ export const DEFAULT_TTL = 30;
41
+
42
+ export const TYPE_A = 1;
43
+ export const TYPE_AAAA = 28;
44
+ export const TYPE_CNAME = 5;
45
+ export const TYPE_MX = 15;
46
+ export const TYPE_TXT = 16;
47
+ const CLASS_IN = 1;
48
+
49
+ /**
50
+ * The question types answered out of the registry's record set, mapped to the
51
+ * name the registry calls them.
52
+ *
53
+ * Address questions are not in here. They are answered from `target`, which the
54
+ * registry keeps in step with the address records and which every build of this
55
+ * bridge has read since before records existed — routing them through here
56
+ * would change how a name already resolving today gets its answer, to arrive at
57
+ * the same address.
58
+ */
59
+ export const RECORD_TYPES = new Map([
60
+ [TYPE_CNAME, "CNAME"],
61
+ [TYPE_MX, "MX"],
62
+ [TYPE_TXT, "TXT"],
63
+ ]);
64
+
65
+ /**
66
+ * What fits in a UDP answer without EDNS.
67
+ *
68
+ * 512 bytes is the floor every resolver accepts. Beyond it a datagram may be
69
+ * dropped by a middlebox rather than delivered short, so the reply is trimmed
70
+ * to what fits and marked truncated instead of being sent oversized and lost.
71
+ */
72
+ export const UDP_SAFE_BYTES = 512;
73
+ const RCODE_OK = 0;
74
+ const RCODE_SERVFAIL = 2;
75
+ const RCODE_REFUSED = 5;
76
+ const RCODE_NXDOMAIN = 3;
77
+
78
+ /* ---------------------------------------------------------------- wire codec */
79
+
80
+ /** Encode a hostname as DNS labels. */
81
+ export function encodeName(name) {
82
+ const labels = String(name).replace(/\.$/, "").split(".").filter(Boolean);
83
+ const parts = labels.map((l) => {
84
+ const b = Buffer.from(l, "ascii");
85
+ if (b.length > 63) throw new Error(`label too long: ${l}`);
86
+ return Buffer.concat([Buffer.from([b.length]), b]);
87
+ });
88
+ return Buffer.concat([...parts, Buffer.from([0])]);
89
+ }
90
+
91
+ /**
92
+ * Read a QNAME starting at `offset`. Returns { name, offset } where offset is
93
+ * the first byte AFTER the name. Compression pointers are rejected rather than
94
+ * followed: they cannot legally appear in a question, and quietly accepting
95
+ * them in a parser that only reads questions invites a pointer loop.
96
+ */
97
+ export function decodeName(buf, offset) {
98
+ const labels = [];
99
+ let i = offset;
100
+ for (;;) {
101
+ if (i >= buf.length) throw new Error("truncated name");
102
+ const len = buf[i];
103
+ if (len === 0) return { name: labels.join("."), offset: i + 1 };
104
+ if ((len & 0xc0) === 0xc0) throw new Error("compression pointer in question");
105
+ i += 1;
106
+ if (i + len > buf.length) throw new Error("truncated label");
107
+ labels.push(buf.toString("ascii", i, i + len));
108
+ i += len;
109
+ }
110
+ }
111
+
112
+ /** Parse a query. Returns null for anything we should not try to answer. */
113
+ export function parseQuery(buf) {
114
+ if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
115
+ const flags = buf.readUInt16BE(2);
116
+ if (flags & 0x8000) return null; // a response, not a query
117
+ if (buf.readUInt16BE(4) !== 1) return null; // exactly one question
118
+ let name;
119
+ let offset;
120
+ try {
121
+ ({ name, offset } = decodeName(buf, 12));
122
+ } catch {
123
+ return null;
124
+ }
125
+ if (offset + 4 > buf.length) return null;
126
+ return {
127
+ id: buf.readUInt16BE(0),
128
+ recursionDesired: !!(flags & 0x0100),
129
+ name: name.toLowerCase(),
130
+ type: buf.readUInt16BE(offset),
131
+ class: buf.readUInt16BE(offset + 2),
132
+ questionEnd: offset + 4,
133
+ };
134
+ }
135
+
136
+ function header(id, { rcode, answers, recursionDesired }) {
137
+ const buf = Buffer.alloc(12);
138
+ buf.writeUInt16BE(id, 0);
139
+ // QR=1 (response), AA=1 (we are authoritative for the TLDs we serve), RD
140
+ // echoed back per RFC 1035, RA=0 — we do not offer recursion for anything.
141
+ buf.writeUInt16BE(0x8400 | (recursionDesired ? 0x0100 : 0) | rcode, 2);
142
+ buf.writeUInt16BE(1, 4); // QDCOUNT — the question is echoed
143
+ buf.writeUInt16BE(answers, 6);
144
+ return buf;
145
+ }
146
+
147
+ function ipv4(address) {
148
+ const parts = String(address).split(".");
149
+ if (parts.length !== 4) return null;
150
+ const bytes = parts.map((p) => Number(p));
151
+ if (bytes.some((b) => !Number.isInteger(b) || b < 0 || b > 255)) return null;
152
+ return Buffer.from(bytes);
153
+ }
154
+
155
+ /**
156
+ * 16 bytes of AAAA rdata.
157
+ *
158
+ * `isIP` has already ruled on the grammar, so the work here is expanding what
159
+ * the text form is allowed to leave out: the `::` run of zero groups, and the
160
+ * trailing dotted-quad an IPv4-mapped address is written with.
161
+ */
162
+ function ipv6(address) {
163
+ const raw = String(address).trim().toLowerCase().replace(/^\[|\]$/g, "");
164
+ if (isIP(raw) !== 6) return null;
165
+
166
+ let text = raw;
167
+ const mapped = text.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
168
+ if (mapped) {
169
+ const octets = mapped[2].split(".").map(Number);
170
+ text = `${mapped[1]}${(((octets[0] << 8) | octets[1]) >>> 0).toString(16)}:${(((octets[2] << 8) | octets[3]) >>> 0).toString(16)}`;
171
+ }
172
+
173
+ const [head, tail] = text.split("::");
174
+ const left = head ? head.split(":").filter(Boolean) : [];
175
+ const right = tail ? tail.split(":").filter(Boolean) : [];
176
+ const groups = text.includes("::")
177
+ ? [...left, ...Array(8 - left.length - right.length).fill("0"), ...right]
178
+ : left;
179
+ if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
180
+
181
+ const buf = Buffer.alloc(16);
182
+ groups.forEach((group, i) => buf.writeUInt16BE(parseInt(group, 16), i * 2));
183
+ return buf;
184
+ }
185
+
186
+ /**
187
+ * TXT rdata: one or more length-prefixed strings.
188
+ *
189
+ * Split at 255 bytes because that is the largest a single DNS character-string
190
+ * can be, and long TXT values are normal rather than exceptional — a DKIM key
191
+ * does not fit in one and is always carried as several. A client joins them
192
+ * back together, so the split is invisible above the wire.
193
+ *
194
+ * Split on bytes, not characters: a multi-byte character straddling the
195
+ * boundary would be cut in half and neither piece would decode.
196
+ */
197
+ export function rdataTxt(value) {
198
+ const bytes = Buffer.from(String(value), "utf8");
199
+ if (!bytes.length) return Buffer.from([0]);
200
+ const chunks = [];
201
+ for (let i = 0; i < bytes.length; i += 255) {
202
+ const chunk = bytes.subarray(i, i + 255);
203
+ chunks.push(Buffer.concat([Buffer.from([chunk.length]), chunk]));
204
+ }
205
+ return Buffer.concat(chunks);
206
+ }
207
+
208
+ /** MX rdata: a 16-bit preference, then the exchange as labels. */
209
+ export function rdataMx(priority, value) {
210
+ const preference = Buffer.alloc(2);
211
+ preference.writeUInt16BE(Math.min(65_535, Math.max(0, Number(priority) || 0)), 0);
212
+ return Buffer.concat([preference, encodeName(value)]);
213
+ }
214
+
215
+ /**
216
+ * The rdata for one record from the registry, or null when it cannot be
217
+ * encoded.
218
+ *
219
+ * Null rather than a throw: one malformed record must not take down the answer
220
+ * for the ones beside it that are fine. The registry validates on the way in,
221
+ * so this is the second line — it is reading data over HTTP from a service that
222
+ * may be a different version than this bridge.
223
+ */
224
+ export function encodeRdata(record) {
225
+ try {
226
+ if (record?.type === "TXT") return rdataTxt(record.value);
227
+ if (record?.type === "MX") return rdataMx(record.priority, record.value);
228
+ if (record?.type === "CNAME") return encodeName(record.value);
229
+ if (record?.type === "AAAA") return ipv6(record.value);
230
+ if (record?.type === "A") return ipv4(record.value);
231
+ } catch {
232
+ return null;
233
+ }
234
+ return null;
235
+ }
236
+
237
+ const TYPE_NUMBERS = new Map([["A", TYPE_A], ["CNAME", TYPE_CNAME], ["MX", TYPE_MX],
238
+ ["TXT", TYPE_TXT], ["AAAA", TYPE_AAAA]]);
239
+
240
+ /**
241
+ * A response carrying whole records rather than a bare address.
242
+ *
243
+ * Answers are fitted to `limit` and TC is set only if something was left out.
244
+ * Dropping every answer the way capResponse does is right for a relayed reply
245
+ * that cannot be re-cut, but here the answers are ours: a name with nine MX
246
+ * records should hand back the seven that fit and say it was truncated, not
247
+ * nothing at all — this bridge speaks UDP only, so a client that retries over
248
+ * TCP finds no one listening.
249
+ *
250
+ * `exists` carries the same NODATA/NXDOMAIN distinction buildResponse draws: a
251
+ * name with no TXT record still exists, and answering NXDOMAIN would deny it
252
+ * for every other type at once.
253
+ */
254
+ export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TTL, exists = true, limit = UDP_SAFE_BYTES } = {}) {
255
+ const question = buf.subarray(12, query.questionEnd);
256
+ const encoded = [];
257
+ let dropped = false;
258
+ let size = 12 + question.length;
259
+
260
+ for (const record of records) {
261
+ const rdata = encodeRdata(record);
262
+ const type = TYPE_NUMBERS.get(record?.type);
263
+ if (!rdata || !type) continue;
264
+ const answer = Buffer.alloc(12);
265
+ answer.writeUInt16BE(0xc00c, 0); // the question's name, by pointer
266
+ answer.writeUInt16BE(type, 2);
267
+ answer.writeUInt16BE(CLASS_IN, 4);
268
+ // The record's own TTL when it has one. An owner who set 60 on an address
269
+ // that moves meant it, and overriding it with the bridge's default would
270
+ // quietly hold the old answer for longer than they asked.
271
+ answer.writeUInt32BE(Number.isFinite(record.ttl) ? Math.max(0, Math.floor(record.ttl)) : ttl, 6);
272
+ answer.writeUInt16BE(rdata.length, 10);
273
+
274
+ if (size + answer.length + rdata.length > limit) { dropped = true; continue; }
275
+ size += answer.length + rdata.length;
276
+ encoded.push(answer, rdata);
277
+ }
278
+
279
+ const answers = encoded.length / 2;
280
+ const head = header(query.id, {
281
+ rcode: answers || exists ? RCODE_OK : RCODE_NXDOMAIN,
282
+ answers,
283
+ recursionDesired: query.recursionDesired,
284
+ });
285
+ if (dropped) head.writeUInt16BE(head.readUInt16BE(2) | 0x0200, 2); // TC
286
+ return Buffer.concat([head, question, ...encoded]);
287
+ }
288
+
289
+ /**
290
+ * A CNAME answer, plus the leaf addresses when we could find them.
291
+ *
292
+ * Two owner names appear in one message: the question's name owns the CNAME,
293
+ * and the CNAME's target owns the addresses. Only the first can use the 0xc00c
294
+ * pointer — it is the only name already in the message — so the target is
295
+ * written out in full for each leaf. Uncompressed is legal, and a handful of
296
+ * spare bytes is a fair price for not hand-rolling a compression table.
297
+ */
298
+ export function buildChainResponse(query, buf, { cname, addresses = [], ttl = DEFAULT_TTL } = {}) {
299
+ const question = buf.subarray(12, query.questionEnd);
300
+ const wantsV6 = query.type === TYPE_AAAA;
301
+ const target = encodeName(cname);
302
+
303
+ const head = Buffer.alloc(12);
304
+ head.writeUInt16BE(0xc00c, 0); // the question's name, by pointer
305
+ head.writeUInt16BE(TYPE_CNAME, 2);
306
+ head.writeUInt16BE(CLASS_IN, 4);
307
+ head.writeUInt32BE(ttl, 6);
308
+ head.writeUInt16BE(target.length, 10);
309
+
310
+ const parts = [head, target];
311
+ let answers = 1;
312
+ for (const address of addresses) {
313
+ const rdata = wantsV6 ? ipv6(address) : ipv4(address);
314
+ if (!rdata) continue;
315
+ const leaf = Buffer.alloc(10);
316
+ leaf.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 0);
317
+ leaf.writeUInt16BE(CLASS_IN, 2);
318
+ leaf.writeUInt32BE(ttl, 4);
319
+ leaf.writeUInt16BE(rdata.length, 8);
320
+ parts.push(target, leaf, rdata);
321
+ answers += 1;
322
+ }
323
+
324
+ return Buffer.concat([
325
+ header(query.id, { rcode: RCODE_OK, answers, recursionDesired: query.recursionDesired }),
326
+ question,
327
+ ...parts,
328
+ ]);
329
+ }
330
+
331
+ /**
332
+ * Build an address-record response for the family the query asked for.
333
+ *
334
+ * Three outcomes, and the difference between the last two is the whole reason
335
+ * this is not a one-liner. NXDOMAIN says the name does not exist, and a
336
+ * resolver is entitled to apply that to every record type at once. A name
337
+ * pointed at an IPv6 address *does* exist — it just has no A record — so the A
338
+ * query every browser sends alongside the AAAA one has to come back NOERROR
339
+ * with no answers. Answering NXDOMAIN there teaches the resolver the name is
340
+ * gone and takes the AAAA lookup down with it.
341
+ *
342
+ * `exists` is that distinction on its own. Holding an address implies the name
343
+ * exists, so it defaults to exactly that, but the reverse does not hold: a name
344
+ * can exist and have no address to hand back — because the question was for a
345
+ * type this bridge does not serve, or because the target is a hostname rather
346
+ * than an address. Those are NODATA, not NXDOMAIN.
347
+ */
348
+ export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = Boolean(address)) {
349
+ const question = buf.subarray(12, query.questionEnd);
350
+ const wantsV6 = query.type === TYPE_AAAA;
351
+ const rdata = address ? (wantsV6 ? ipv6(address) : ipv4(address)) : null;
352
+
353
+ if (!rdata) {
354
+ return Buffer.concat([
355
+ header(query.id, {
356
+ // The name is here, we just have nothing to say for this question: NODATA.
357
+ rcode: exists ? RCODE_OK : RCODE_NXDOMAIN,
358
+ answers: 0,
359
+ recursionDesired: query.recursionDesired,
360
+ }),
361
+ question,
362
+ ]);
363
+ }
364
+
365
+ const answer = Buffer.alloc(12);
366
+ answer.writeUInt16BE(0xc00c, 0); // pointer to the question's name
367
+ answer.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 2);
368
+ answer.writeUInt16BE(CLASS_IN, 4);
369
+ answer.writeUInt32BE(ttl, 6);
370
+ answer.writeUInt16BE(rdata.length, 10);
371
+ return Buffer.concat([
372
+ header(query.id, { rcode: RCODE_OK, answers: 1, recursionDesired: query.recursionDesired }),
373
+ question,
374
+ answer,
375
+ rdata,
376
+ ]);
377
+ }
378
+
379
+ /* ------------------------------------------------------------------ registry */
380
+
381
+ /**
382
+ * Names the registry can hold: exactly one label and one TLD, or a third label
383
+ * under such a name — including `*` as the whole leftmost label, the wildcard
384
+ * an owner publishes for everything under their name.
385
+ */
386
+ export function parseRegistryName(hostname) {
387
+ const host = String(hostname || "").trim().toLowerCase().replace(/\.$/, "");
388
+ if (!host || host.includes(":")) return null;
389
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null;
390
+ const parts = host.split(".");
391
+ if (parts.length !== 2 && parts.length !== 3) return null;
392
+ // Letters and digits only, matching the registry. A dash is the cheapest way
393
+ // to mint a look-alike of an ending someone else holds, and in a namespace
394
+ // one level deep and first come first served there is nowhere to retreat to.
395
+ // Keeping the rule here identical to the registry's matters more than the
396
+ // rule itself: a name this bridge accepts and the registry rejects resolves
397
+ // to a page that says it does not exist.
398
+ const LABEL = /^[a-z0-9]{1,63}$/;
399
+ if (parts.length === 3) {
400
+ const [sub, label, tld] = parts;
401
+ // `*` is a label only whole and only leftmost — `f*.chovy.hacker` and
402
+ // `foo.*.hacker` are not names the registry can be asked about.
403
+ if (sub !== "*" && !LABEL.test(sub)) return null;
404
+ if (!LABEL.test(label) || !LABEL.test(tld)) return null;
405
+ return { sub, label, tld };
406
+ }
407
+ const [label, tld] = parts;
408
+ if (!LABEL.test(label) || !LABEL.test(tld)) return null;
409
+ return { label, tld };
410
+ }
411
+
412
+ /** The TLDs currently claimed in the Pit — what we route to this resolver. */
413
+ /** The registry's own ceiling on one page. Asking for more just gets this. */
414
+ const TLD_PAGE = 1000;
415
+
416
+ /**
417
+ * Every ending, paged.
418
+ *
419
+ * This used to take the first response and stop, which is a silent truncation:
420
+ * the registry answers 200 by default and says so in `total`, but a list of 200
421
+ * looks exactly like a complete list of 200. `.eggs` sat past that line, so
422
+ * `dns install` wrote a config that quietly did not route it and the name did
423
+ * not resolve — the failure looked like DNS, three layers from the cause.
424
+ *
425
+ * Paged to exhaustion against `total`, with the page count bounded so a
426
+ * registry that misreports it cannot spin here forever.
427
+ */
428
+ export async function fetchTlds({ registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch } = {}) {
429
+ const base = `${registryBase.replace(/\/+$/, "")}/api/moshpit/tlds`;
430
+ const seen = [];
431
+ let offset = 0;
432
+ let total = null;
433
+
434
+ // A page that comes back empty ends it too, so a `total` that overstates the
435
+ // rows on hand cannot loop.
436
+ for (let page = 0; page < 64; page++) {
437
+ const res = await fetchImpl(`${base}?limit=${TLD_PAGE}&offset=${offset}`);
438
+ if (!res.ok) throw new Error(`registry returned ${res.status}`);
439
+ const json = await res.json();
440
+ const rows = json?.tlds || [];
441
+ if (!rows.length) break;
442
+
443
+ seen.push(...rows);
444
+ offset += rows.length;
445
+ if (total === null && Number.isFinite(Number(json?.total))) total = Number(json.total);
446
+ // No `total` at all means an older registry that cannot page — take what it
447
+ // gave rather than walking off the end of it.
448
+ if (total === null || offset >= total) break;
449
+ }
450
+
451
+ return seen
452
+ .map((t) => (typeof t === "string" ? t : t?.tld))
453
+ .filter((t) => typeof t === "string" && t)
454
+ .map((t) => t.toLowerCase())
455
+ .sort();
456
+ }
457
+
458
+ /**
459
+ * What address a Moshpit name should resolve to.
460
+ *
461
+ * Three outcomes, and the middle one is the whole point of parking: a claimed
462
+ * name with no target is NOT an error, it is a name waiting to be pointed
463
+ * somewhere. Handing back the parking host means `curl california.oranges`
464
+ * reaches a page that explains itself instead of failing to resolve.
465
+ *
466
+ * A third-level name adds a fourth: it exists only through its parent or a
467
+ * wildcard the parent published, so missing both is NXDOMAIN — there is
468
+ * nothing to park it to.
469
+ */
470
+ export async function resolveName(
471
+ name,
472
+ { registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = 4000, records = false } = {},
473
+ ) {
474
+ const parsed = parseRegistryName(name);
475
+ if (!parsed) return { status: "not-a-name", target: null };
476
+
477
+ const full = `${parsed.sub ? `${parsed.sub}.` : ""}${parsed.label}.${parsed.tld}`;
478
+ try {
479
+ // `&records=1` only when the question needs the whole set. Every address
480
+ // lookup on the machine comes through here, and the registry does a second
481
+ // query to answer it — a browser opening a page must not pay for records it
482
+ // will never read.
483
+ //
484
+ // The timeout is per ask rather than per call: the wildcard fallback below
485
+ // is a second request, and a budget shared with the first would give it
486
+ // whatever was left over — sometimes nothing.
487
+ const ask = async (asked) => {
488
+ const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/resolve?name=${encodeURIComponent(
489
+ asked,
490
+ )}${records ? "&records=1" : ""}`;
491
+ const controller = new AbortController();
492
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
493
+ try {
494
+ const res = await fetchImpl(url, { signal: controller.signal });
495
+ if (!res.ok) return { status: "unreachable", target: null };
496
+ const json = await res.json();
497
+ const claimed =
498
+ typeof json?.name_registered === "boolean" ? json.name_registered : json?.registered;
499
+ if (typeof claimed !== "boolean") return { status: "unreachable", target: null };
500
+ // The `records` key appears only when it was asked for. Every caller that
501
+ // wants an address deep-compares this shape, and an empty array they never
502
+ // requested is a difference they would have to be taught to ignore.
503
+ const found = records ? { records: Array.isArray(json.records) ? json.records : [] } : {};
504
+ const target = typeof json.target === "string" && json.target ? json.target : null;
505
+ if (target) return { status: "live", target, ...found };
506
+ return { status: "parked", target: null, registered: claimed, ...found };
507
+ } finally {
508
+ clearTimeout(timer);
509
+ }
510
+ };
511
+
512
+ let result = await ask(full);
513
+ // A third-level name the registry does not hold may still be covered by a
514
+ // wildcard its owner published. The registry applies that match itself;
515
+ // asking for the literal `*.parent` is the fallback for one old enough to
516
+ // only know the wildcard as a name of its own. A bare label keeps parking
517
+ // on a miss — a sub-name has nothing to park to, so missing everywhere is
518
+ // NXDOMAIN. The answer keeps the asked name either way: the wire codec
519
+ // writes the question's name into every owner field, as a wildcard answer
520
+ // should.
521
+ const missed = (r) => r.status === "parked" && r.registered === false;
522
+ if (parsed.sub && missed(result)) {
523
+ if (parsed.sub !== "*") result = await ask(`*.${parsed.label}.${parsed.tld}`);
524
+ if (missed(result)) {
525
+ return { status: "nxdomain", target: null, ...(records ? { records: [] } : {}) };
526
+ }
527
+ }
528
+ return result;
529
+ } catch {
530
+ return { status: "unreachable", target: null };
531
+ }
532
+ }
533
+
534
+ /**
535
+ * The records of one type a name publishes, and whether the name is here.
536
+ *
537
+ * Both halves matter and they are not the same question: a name with no MX
538
+ * record still exists, so the answer is NODATA, while a name nobody holds is
539
+ * NXDOMAIN. Collapsing them would let a missing MX deny the name's address too.
540
+ */
541
+ export async function answerRecords(name, options = {}) {
542
+ const { type } = options;
543
+ const result = await resolveName(name, { ...options, records: true });
544
+ const exists = result.status === "live" || result.status === "parked";
545
+ if (!exists || !type) return { exists, records: [] };
546
+ return { exists, records: (result.records || []).filter((r) => r?.type === type) };
547
+ }
548
+
549
+ /* -------------------------------------------------------------------- server */
550
+
551
+ /**
552
+ * What to say about a name: whether it is here at all, and the address to
553
+ * answer with when there is one.
554
+ *
555
+ * Kept separate from the socket so the policy is testable on its own. A name we
556
+ * could not look up is not here rather than parked: a registry outage must not
557
+ * silently redirect every name on the machine to a parking page.
558
+ *
559
+ * `wantsAddress` is false for the questions this bridge does not serve (TXT, MX,
560
+ * HTTPS/SVCB). Those still need to know the name is here, because saying
561
+ * NXDOMAIN to one question denies the name for every other one too.
562
+ */
563
+ export async function answerPolicy(name, options = {}) {
564
+ const { parkingAddress, wantsAddress = true } = options;
565
+ const result = await resolveName(name, options);
566
+ const exists = result.status === "live" || result.status === "parked";
567
+ if (!exists || !wantsAddress) return { exists, address: null };
568
+ if (result.status === "live") return { exists, address: targetAddress(result.target) };
569
+ return { exists, address: parkingAddress || null };
570
+ }
571
+
572
+ /**
573
+ * The address to answer with, or null when there is none.
574
+ */
575
+ export async function answerFor(name, options = {}) {
576
+ const { address } = await answerPolicy(name, options);
577
+ return address;
578
+ }
579
+
580
+ /**
581
+ * The bare address inside a stored target, or null when there isn't one.
582
+ *
583
+ * Targets are typed by hand and come back from the registry as `2606:...`,
584
+ * `[2606:...]:8080`, `example.com`, or with a scheme still attached. A record
585
+ * carries an address and nothing else, so the port is dropped here — a name
586
+ * whose target names a non-default port cannot be served by the resolver path
587
+ * at all, because there is no way to say "port 8080" in an A or AAAA record and
588
+ * the browser will go to 80 regardless. A hostname target is null here because
589
+ * an A record cannot hold one; `targetHostname` is the other half of the answer.
590
+ */
591
+ export function targetAddress(target) {
592
+ const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
593
+ if (!raw) return null;
594
+
595
+ const bracketed = raw.match(/^\[([0-9a-f:.]+)\](?::\d+)?$/i);
596
+ const host = bracketed ? bracketed[1] : raw;
597
+ if (isIP(host)) return host;
598
+
599
+ const at = host.lastIndexOf(":");
600
+ if (at > 0 && /^\d+$/.test(host.slice(at + 1))) {
601
+ const bare = host.slice(0, at);
602
+ if (isIP(bare) === 4) return bare;
603
+ }
604
+ return null;
605
+ }
606
+
607
+ /**
608
+ * The bare hostname inside a stored target, or null when there isn't one.
609
+ *
610
+ * The other half of `targetAddress`. Most names in the registry are pointed at
611
+ * a host, not an address — `seo.rank` targets `dev.profullstack.com` — and
612
+ * refusing to say so was the bug that made every such name look unregistered.
613
+ * A CNAME expresses exactly this and costs us no clearnet DNS: the client
614
+ * chases it, which is what a CNAME is for.
615
+ *
616
+ * A target naming a port is null on purpose. No CNAME can carry `:8080`, and
617
+ * sending the client to port 80 of the right host is a worse answer than
618
+ * admitting there is nothing here to say.
619
+ */
620
+ export function targetHostname(target) {
621
+ const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
622
+ if (!raw || targetAddress(raw)) return null;
623
+ // A colon is a port or a malformed v6 literal; a slash is a path. Neither
624
+ // survives the trip into an owner name, so neither is guessed at.
625
+ if (raw.includes(":") || raw.includes("/")) return null;
626
+ const host = raw.toLowerCase().replace(/\.$/, "");
627
+ const label = "[a-z0-9]([a-z0-9-]*[a-z0-9])?";
628
+ return new RegExp(`^${label}(\\.${label})+$`).test(host) ? host : null;
629
+ }
630
+
631
+ /**
632
+ * Is an address question on this name worth a second look for a CNAME?
633
+ *
634
+ * True when the name is here and has no address to give. A CNAME is the one
635
+ * thing that can still answer such a question, and finding out costs another
636
+ * round trip to the registry — so it is asked only on the path that would
637
+ * otherwise return nothing at all, never on a name that already has an address.
638
+ */
639
+ export function mayHaveCname({ exists, address }) {
640
+ return Boolean(exists) && !address;
641
+ }
642
+
643
+ /**
644
+ * Everything an address question needs, from a single registry lookup.
645
+ *
646
+ * The old path asked two separate questions — `answerPolicy` for the target,
647
+ * then `answerRecords` for a CNAME — and between them dropped the two cases
648
+ * that cover most of the registry. A published A/AAAA record was never
649
+ * consulted at all (addresses came only from `target`), and a hostname target
650
+ * produced nothing. Both surfaced as an authoritative NOERROR with no answers,
651
+ * which a client is entitled to treat as final: the name looked dead while the
652
+ * registry held a perfectly good answer for it.
653
+ *
654
+ * The cheap question is asked first and usually ends it: a name pointed at a
655
+ * bare address needs no record set, and every page load on the machine comes
656
+ * through here. Only a name that has nothing to say yet is worth the second
657
+ * round trip — which is the same bargain the old path struck for CNAMEs, held
658
+ * to here so the common case did not get slower in exchange for being right.
659
+ */
660
+ /**
661
+ * Is something actually listening where we are about to send every name?
662
+ *
663
+ * The guard that makes proxy mode safe to offer at all. Pointing every live
664
+ * Moshpit name at a loopback address is exactly as good as the thing behind it:
665
+ * with a proxy there, all of them work in a stock client; with nothing there,
666
+ * all of them break at once, and the resolver looks healthy while doing it —
667
+ * `dig` answers 127.0.0.1 and every connection is refused.
668
+ *
669
+ * So this is checked before the mode is allowed on, and rechecked rather than
670
+ * remembered: a proxy that dies after the resolver started is the same outage
671
+ * as one that was never running.
672
+ */
673
+ export function proxyReachable(address, port = 443, { connect = null, timeoutMs = 1500 } = {}) {
674
+ return new Promise((resolve) => {
675
+ let socket;
676
+ const done = (ok) => {
677
+ try { socket?.destroy(); } catch { /* already gone */ }
678
+ resolve(ok);
679
+ };
680
+ try {
681
+ const net = connect || netConnect;
682
+ socket = net({ host: address, port });
683
+ // Deliberately not unref'd. This timer is the only thing that guarantees
684
+ // the promise settles at all, and an unref'd one does not hold the loop
685
+ // open — so a connect that stalls without keeping a handle alive let the
686
+ // process reach an idle event loop with this still pending, which node
687
+ // reports as a cancelled await rather than the `false` the caller needs.
688
+ // It cannot outlive the probe: both settle paths clear it.
689
+ const timer = setTimeout(() => done(false), timeoutMs);
690
+ socket.once("connect", () => { clearTimeout(timer); done(true); });
691
+ socket.once("error", () => { clearTimeout(timer); done(false); });
692
+ } catch {
693
+ resolve(false);
694
+ }
695
+ });
696
+ }
697
+
698
+ export async function addressAnswer(name, options = {}) {
699
+ const { parkingAddress, wantsV6 = false, proxyAddress = null } = options;
700
+ const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });
701
+
702
+ const result = await resolveName(name, options);
703
+ const exists = result.status === "live" || result.status === "parked";
704
+ if (!exists) return { exists: false, kind: "nxdomain", records: [], address: null, cname: null };
705
+
706
+ // Parking is checked before anything the registry published: a parked name's
707
+ // whole job is to reach the page explaining that it is for sale. A third-level
708
+ // name is never for sale — it exists only through a wildcard its parent
709
+ // published — so "parked" there means the wildcard has no target, and the
710
+ // records it published are the answer.
711
+ //
712
+ // It is also checked before the proxy, deliberately. A parked name has no
713
+ // origin and no published pin, so handing it to a proxy whose entire job is
714
+ // to verify one would turn "this name is for sale" into a TLS error.
715
+ if (result.status === "parked" && !parseRegistryName(name)?.sub) {
716
+ return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata");
717
+ }
718
+
719
+ // Every live name answers the local proxy, whatever the registry says its
720
+ // target is — that is the point. The proxy reads the SNI, checks the origin's
721
+ // key against the registry pin, and re-signs with a root this machine
722
+ // generated, which is the only way a stock client can be told the result: no
723
+ // CA will ever sign for a Moshpit name.
724
+ //
725
+ // Answering the origin instead is what left the proxy running on loopback
726
+ // with nothing ever routed to it, so every name arrived at a stock client as
727
+ // a self-signed certificate no matter what was installed.
728
+ if (proxyAddress) {
729
+ const forFamily = wantsV6 ? proxyAddress.v6 : proxyAddress.v4;
730
+ // A proxy that only speaks one family is NODATA for the other, not a
731
+ // fabricated address: answering ::1 for a v4-only listener is a connection
732
+ // refused that looks like the site is down.
733
+ return forFamily ? plan("address", { address: forFamily, proxied: true }) : plan("nodata");
734
+ }
735
+
736
+ const address = targetAddress(result.target);
737
+ if (address) return plan("address", { address });
738
+
739
+ const full = await resolveName(name, { ...options, records: true });
740
+ const of = (type) => (full.records || []).filter((r) => r?.type === type);
741
+
742
+ // An address the owner published beats a CNAME to somewhere that holds one:
743
+ // it is the more specific statement, and it saves the client a lookup.
744
+ const published = of(wantsV6 ? "AAAA" : "A");
745
+ if (published.length) return plan("records", { records: published });
746
+
747
+ const cnames = of("CNAME");
748
+ if (cnames.length) return plan("records", { records: cnames });
749
+
750
+ const host = targetHostname(result.target);
751
+ return host ? plan("chain", { cname: host }) : plan("nodata");
752
+ }
753
+
754
+ /**
755
+ * The addresses a clearnet hostname holds, for finishing a CNAME chain.
756
+ *
757
+ * A bare CNAME is a legal answer and a useless one here. This bridge sets RA=0
758
+ * — it offers no recursion — so a stub that receives a dangling CNAME has been
759
+ * told, in the same breath, that nobody will chase it. systemd-resolved reports
760
+ * that as a name with no address, which is indistinguishable from broken.
761
+ *
762
+ * Best-effort by design: the chain is a courtesy on top of a CNAME that is
763
+ * already correct, so an upstream that is slow or silent costs the extra
764
+ * records, never the answer.
765
+ */
766
+ export async function resolveChain(hostname, { upstreams = [], wantsV6 = false, timeoutMs = 2000 } = {}) {
767
+ const servers = upstreams.map(resolverServer).filter(Boolean);
768
+ if (!hostname || !servers.length) return [];
769
+ try {
770
+ const resolver = new Resolver({ timeout: timeoutMs, tries: 1 });
771
+ resolver.setServers(servers);
772
+ const found = await (wantsV6 ? resolver.resolve6(hostname) : resolver.resolve4(hostname));
773
+ return Array.isArray(found) ? found : [];
774
+ } catch {
775
+ return [];
776
+ }
777
+ }
778
+
779
+ /** An upstream in `1.2.3.4#5353` form, as node's resolver wants to read it. */
780
+ function resolverServer(upstream) {
781
+ const [address, portText] = String(upstream).split("#");
782
+ const family = isIP(address);
783
+ if (!family) return null;
784
+ const port = Number(portText) || 53;
785
+ return port === 53 ? address : `${family === 6 ? `[${address}]` : address}:${port}`;
786
+ }
787
+
788
+ /**
789
+ * Start the bridge. Returns { port, address, close() }.
790
+ *
791
+ * `parkingAddress` is resolved once by the caller (an A record must carry an
792
+ * IP, not a name) and passed in, so the server itself never does clearnet DNS.
793
+ */
794
+ /* ------------------------------------------------------------------ abuse */
795
+
796
+ // An open forwarding resolver is a DDoS amplifier before it is anything else.
797
+ // The attack does not need a botnet: one host spoofs a victim's source address,
798
+ // sends a small query, and the resolver mails the large answer to the victim.
799
+ // Scanners find open resolvers within hours of them being reachable.
800
+ //
801
+ // That shape defeats most defences worth having. The source address is a lie,
802
+ // so blocking "the client" punishes the victim; there is no session to
803
+ // fingerprint and no user agent to read. What is left is limiting how much
804
+ // amplification any single query can buy, and bounding what one source can
805
+ // extract before we stop answering it.
806
+
807
+ /** The question type that exists to be abused. */
808
+ export const TYPE_ANY = 255;
809
+
810
+ /**
811
+ * A query we will not answer, or null when it is fine.
812
+ *
813
+ * ANY asks for every record a name has and is the classic amplification lever:
814
+ * a 30-byte question for a multi-kilobyte answer. Real clients stopped needing
815
+ * it years ago, and RFC 8482 blesses refusing it outright.
816
+ */
817
+ export function refusalReason(query) {
818
+ if (!query) return null;
819
+ if (query.type === TYPE_ANY) return "ANY is refused — RFC 8482";
820
+ return null;
821
+ }
822
+
823
+ /**
824
+ * What counts as "the same client" for the purposes of banning one.
825
+ *
826
+ * IPv6 is grouped by /64 and this is the whole reason the function exists. A
827
+ * single v6 address is free to change: any host worth banning has a /64 at
828
+ * minimum and often a /48, so a ban on one address is defeated by incrementing
829
+ * it. fail2ban rules written per-address in a v4 world quietly stop working
830
+ * when the traffic arrives over v6, and the failure is silent — the bans look
831
+ * like they are being applied, and the abuse continues.
832
+ *
833
+ * IPv4 is the address itself. Widening to /24 would be the equivalent move,
834
+ * but v4 is scarce enough to be shared: a /24 routinely spans unrelated
835
+ * customers behind carrier NAT, so grouping there punishes the neighbours of
836
+ * an abuser rather than the abuser.
837
+ */
838
+ export function clientKey(address) {
839
+ const raw = String(address ?? "").trim().toLowerCase();
840
+ if (!raw) return "";
841
+ // A v4-mapped v6 address is a v4 client arriving on a dual-stack socket.
842
+ const mapped = raw.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
843
+ if (mapped) return mapped[1];
844
+ if (isIP(raw) !== 6) return raw;
845
+
846
+ // Expand to the first four groups — the /64 — without a full parse.
847
+ const [head, tail = ""] = raw.split("::");
848
+ const left = head ? head.split(":").filter(Boolean) : [];
849
+ const right = tail ? tail.split(":").filter(Boolean) : [];
850
+ const groups = raw.includes("::")
851
+ ? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill("0"), ...right]
852
+ : left;
853
+ if (groups.length < 4) return raw;
854
+ return `${groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(":")}::/64`;
855
+ }
856
+
857
+ /**
858
+ * fail2ban for a resolver: repeat offenders wait exponentially longer.
859
+ *
860
+ * A flat rate limit is a toll an attacker simply pays — they lose nothing by
861
+ * being refused, and come straight back. Backoff changes the economics: each
862
+ * time a source earns another strike its ban doubles, so a persistent source
863
+ * spends most of its time banned while a client that misbehaves once is
864
+ * inconvenienced for a minute.
865
+ *
866
+ * Strikes decay after a clean spell, so a bad afternoon does not follow a
867
+ * client forever — without that, the ceiling is permanent and the first
868
+ * mistake is unforgivable.
869
+ *
870
+ * Memory is bounded for the same reason the rate limiter's is: the key space
871
+ * is attacker-controlled, so an unbounded map is the vulnerability rather than
872
+ * the mitigation.
873
+ */
874
+ export function createBanList({
875
+ baseMs = 60_000,
876
+ factor = 2,
877
+ maxMs = 24 * 60 * 60 * 1000,
878
+ forgetMs = 60 * 60 * 1000,
879
+ maxClients = 10_000,
880
+ now = () => Date.now(),
881
+ } = {}) {
882
+ const records = new Map();
883
+
884
+ const touch = (key, record) => {
885
+ records.delete(key);
886
+ if (records.size >= maxClients) {
887
+ const oldest = records.keys().next().value;
888
+ if (oldest !== undefined) records.delete(oldest);
889
+ }
890
+ records.set(key, record);
891
+ };
892
+
893
+ return {
894
+ /** Record an offence and return the ban it earned. */
895
+ strike(key) {
896
+ const at = now();
897
+ const previous = records.get(key);
898
+ // A long clean spell wipes the slate; otherwise the count carries.
899
+ const strikes = previous && at - previous.at < forgetMs ? previous.strikes + 1 : 1;
900
+ const banMs = Math.min(maxMs, baseMs * factor ** (strikes - 1));
901
+ const record = { strikes, at, until: at + banMs };
902
+ touch(key, record);
903
+ return { strikes, banMs, until: record.until };
904
+ },
905
+
906
+ /** Is this source currently serving a ban? */
907
+ banned(key) {
908
+ const record = records.get(key);
909
+ return Boolean(record) && now() < record.until;
910
+ },
911
+
912
+ get size() {
913
+ return records.size;
914
+ },
915
+ };
916
+ }
917
+
918
+ /**
919
+ * Per-source token bucket.
920
+ *
921
+ * The bucket map is itself an attack surface and that is the part worth being
922
+ * careful about: keyed by source address, with spoofed sources, an unbounded
923
+ * map is a memory exhaustion bug wearing a rate limiter's clothes. So entries
924
+ * are capped and the least recently seen are dropped when full — evicting a
925
+ * legitimate client costs it one refilled bucket, while not evicting costs the
926
+ * process.
927
+ */
928
+ export function createRateLimiter({
929
+ perSecond = 20,
930
+ burst = 40,
931
+ maxClients = 10_000,
932
+ now = () => Date.now(),
933
+ } = {}) {
934
+ const buckets = new Map();
935
+
936
+ return {
937
+ /** True when this source may be answered. */
938
+ allow(key) {
939
+ const at = now();
940
+ let bucket = buckets.get(key);
941
+ if (bucket) {
942
+ // Refill for elapsed time, capped at the burst ceiling.
943
+ bucket.tokens = Math.min(burst, bucket.tokens + ((at - bucket.at) / 1000) * perSecond);
944
+ bucket.at = at;
945
+ // Re-inserting moves it to the end, which is what makes the Map's
946
+ // insertion order usable as a least-recently-seen list.
947
+ buckets.delete(key);
948
+ } else {
949
+ bucket = { tokens: burst, at };
950
+ if (buckets.size >= maxClients) {
951
+ const oldest = buckets.keys().next().value;
952
+ if (oldest !== undefined) buckets.delete(oldest);
953
+ }
954
+ }
955
+ buckets.set(key, bucket);
956
+
957
+ if (bucket.tokens < 1) return false;
958
+ bucket.tokens -= 1;
959
+ return true;
960
+ },
961
+ get size() {
962
+ return buckets.size;
963
+ },
964
+ };
965
+ }
966
+
967
+ /**
968
+ * Hold a UDP answer to a size, truncating rather than sending a huge datagram.
969
+ *
970
+ * Amplification is a ratio, so the ceiling on an answer is the ceiling on the
971
+ * attack. A truncated answer sets TC, which tells a real client to ask again
972
+ * over TCP — where the handshake makes a spoofed source address useless. So the
973
+ * legitimate case is a retry and the abusive case is a dead end, which is the
974
+ * asymmetry worth having.
975
+ */
976
+ export function capResponse(reply, query, limit = 512) {
977
+ if (!Buffer.isBuffer(reply) || reply.length <= limit) return reply;
978
+ const header = reply.subarray(0, 12);
979
+ const truncated = Buffer.from(header);
980
+ truncated.writeUInt16BE(reply.readUInt16BE(2) | 0x0200, 2); // TC
981
+ truncated.writeUInt16BE(0, 6); // no answers survive the cut
982
+ truncated.writeUInt16BE(0, 8);
983
+ truncated.writeUInt16BE(0, 10);
984
+ return Buffer.concat([truncated, reply.subarray(12, query.questionEnd)]);
985
+ }
986
+
987
+ /**
988
+ * Send a query to an upstream nameserver and hand back its answer verbatim.
989
+ *
990
+ * Deliberately a byte proxy rather than a parse-and-rebuild. We forward
991
+ * question types this bridge has no opinion about — SVCB, SRV, DNSKEY,
992
+ * whatever arrives — and re-encoding them would mean implementing the whole
993
+ * record space correctly to avoid corrupting answers we only need to relay.
994
+ */
995
+ export function forwardQuery(msg, upstream, { timeoutMs = 3000 } = {}) {
996
+ const [address, portText] = String(upstream).split("#");
997
+ const port = Number(portText) || 53;
998
+ return new Promise((resolve) => {
999
+ const socket = dgram.createSocket(isIP(address) === 6 ? "udp6" : "udp4");
1000
+ let settled = false;
1001
+ const finish = (reply) => {
1002
+ if (settled) return;
1003
+ settled = true;
1004
+ try { socket.close(); } catch { /* already closing */ }
1005
+ resolve(reply);
1006
+ };
1007
+ const timer = setTimeout(() => finish(null), timeoutMs);
1008
+ timer.unref?.();
1009
+ socket.on("message", (reply) => { clearTimeout(timer); finish(reply); });
1010
+ socket.on("error", () => { clearTimeout(timer); finish(null); });
1011
+ try {
1012
+ socket.send(msg, port, address);
1013
+ } catch {
1014
+ clearTimeout(timer);
1015
+ finish(null);
1016
+ }
1017
+ });
1018
+ }
1019
+
1020
+ /**
1021
+ * Is this a name we are authoritative for?
1022
+ *
1023
+ * The gate that makes catch-all routing safe. With `Domains=~.` every lookup
1024
+ * on the machine arrives here, and `google.com` is two labels exactly like
1025
+ * `blue.eggs` is — so parsing alone would have us answer for the clearnet.
1026
+ * Only an ending someone has actually claimed is ours; everything else is
1027
+ * forwarded untouched.
1028
+ *
1029
+ * An unknown ending set means "not ours" rather than "ours". Failing that way
1030
+ * round costs a Moshpit name that does not resolve until the registry answers
1031
+ * again; the other way round costs the whole internet on that machine.
1032
+ */
1033
+ export function isOurs(name, tldSet) {
1034
+ if (!(tldSet instanceof Set) || tldSet.size === 0) return false;
1035
+ const parsed = parseRegistryName(name);
1036
+ return Boolean(parsed) && tldSet.has(parsed.tld);
1037
+ }
1038
+
1039
+ export function createServer(options = {}) {
1040
+ const {
1041
+ port = DEFAULT_PORT,
1042
+ host = DEFAULT_HOST,
1043
+ ttl = DEFAULT_TTL,
1044
+ onQuery = () => {},
1045
+ onError = () => {},
1046
+ // Empty by default, which keeps the old behaviour exactly: with no
1047
+ // upstreams there is nothing to forward to, so the bridge stays the
1048
+ // narrow per-ending resolver it has always been and answers only for
1049
+ // names it is authoritative for.
1050
+ upstreams = [],
1051
+ tldSet = null,
1052
+ proxyAddress = null,
1053
+ forwardTimeoutMs = 3000,
1054
+ // Off by default: a loopback bridge has one client and rate limiting it is
1055
+ // pure cost. These matter when the socket is reachable by strangers, which
1056
+ // is a deployment choice rather than a default.
1057
+ rateLimit = null,
1058
+ maxResponseBytes = 0,
1059
+ // Banning is layered on the rate limit rather than replacing it: the limit
1060
+ // decides what an offence is, the ban decides how long it costs.
1061
+ ban = null,
1062
+ } = options;
1063
+ const limiter = rateLimit ? createRateLimiter(rateLimit) : null;
1064
+ const bans = ban ? createBanList(ban) : null;
1065
+ // The socket family follows the address we were asked to bind, so the caller
1066
+ // decides by choosing a host rather than by passing a flag.
1067
+ //
1068
+ // `ipv6Only: false` is what makes `::` serve both families from one socket:
1069
+ // the kernel accepts IPv4 clients on it and reports them as `::ffff:1.2.3.4`,
1070
+ // which `socket.send` understands, so the reply path needs no special case.
1071
+ // Without it a v6 bind is v6-only and every IPv4 client silently gets nothing.
1072
+ //
1073
+ // The default stays 127.0.0.1, so a machine that upgrades keeps exactly the
1074
+ // loopback-only v4 listener it had. Serving other hosts is a deliberate act.
1075
+ const socket = isIP(host) === 6
1076
+ ? dgram.createSocket({ type: "udp6", ipv6Only: false, reuseAddr: true })
1077
+ : dgram.createSocket({ type: "udp4", reuseAddr: true });
1078
+
1079
+ socket.on("message", async (msg, rinfo) => {
1080
+ const query = parseQuery(msg);
1081
+ if (!query) return; // malformed, or a response — say nothing at all
1082
+
1083
+ // REFUSED rather than silence, for both guards below. A dropped packet
1084
+ // costs a real client a full resolver timeout before it tries elsewhere,
1085
+ // and costs an attacker nothing — they were not waiting for the answer.
1086
+ const refuse = () => {
1087
+ try {
1088
+ socket.send(
1089
+ Buffer.concat([
1090
+ header(query.id, { rcode: RCODE_REFUSED, answers: 0, recursionDesired: query.recursionDesired }),
1091
+ msg.subarray(12, query.questionEnd),
1092
+ ]),
1093
+ rinfo.port,
1094
+ rinfo.address,
1095
+ );
1096
+ } catch { /* client vanished */ }
1097
+ };
1098
+
1099
+ const refusal = refusalReason(query);
1100
+ if (refusal) {
1101
+ onQuery({ name: query.name, type: query.type, address: null, refused: refusal });
1102
+ return refuse();
1103
+ }
1104
+ // Grouped by /64 for v6, so moving within a prefix does not shake a ban.
1105
+ const source = clientKey(rinfo.address);
1106
+
1107
+ if (bans?.banned(source)) {
1108
+ onQuery({ name: query.name, type: query.type, address: null, refused: "banned" });
1109
+ return refuse();
1110
+ }
1111
+ if (limiter && !limiter.allow(source)) {
1112
+ const earned = bans?.strike(source);
1113
+ onQuery({
1114
+ name: query.name,
1115
+ type: query.type,
1116
+ address: null,
1117
+ refused: earned ? `banned ${Math.round(earned.banMs / 1000)}s (strike ${earned.strikes})` : "rate limit",
1118
+ });
1119
+ return refuse();
1120
+ }
1121
+
1122
+ // Catch-all routing puts every lookup on the machine through here. Anything
1123
+ // that is not an ending someone has claimed belongs to the ordinary
1124
+ // internet and is relayed byte for byte, including question types this
1125
+ // bridge has no opinion about.
1126
+ if (upstreams.length && !isOurs(query.name, tldSet)) {
1127
+ let relayed = null;
1128
+ for (const upstream of upstreams) {
1129
+ relayed = await forwardQuery(msg, upstream, { timeoutMs: forwardTimeoutMs });
1130
+ if (relayed) break;
1131
+ }
1132
+ onQuery({ name: query.name, type: query.type, address: null, forwarded: true });
1133
+ try {
1134
+ // SERVFAIL, not NXDOMAIN, when every upstream is silent: "I could not
1135
+ // find out" is retried elsewhere, "it does not exist" gets cached and
1136
+ // the name stays broken after the network comes back.
1137
+ socket.send(
1138
+ relayed
1139
+ ? (maxResponseBytes ? capResponse(relayed, query, maxResponseBytes) : relayed)
1140
+ : Buffer.concat([
1141
+ header(query.id, { rcode: RCODE_SERVFAIL, answers: 0, recursionDesired: query.recursionDesired }),
1142
+ msg.subarray(12, query.questionEnd),
1143
+ ]),
1144
+ rinfo.port,
1145
+ rinfo.address,
1146
+ );
1147
+ } catch {
1148
+ /* client vanished */
1149
+ }
1150
+ return;
1151
+ }
1152
+
1153
+ let address = null;
1154
+ let exists = false;
1155
+ let reply = null;
1156
+
1157
+ // Three shapes of question now. CNAME, MX and TXT are answered from the
1158
+ // record set; addresses are answered from `target` as they always have
1159
+ // been; everything else (HTTPS/SVCB and the rest) still gets an honest
1160
+ // empty NOERROR rather than a lie — a browser asks HTTPS beside every A and
1161
+ // AAAA, and NXDOMAIN to that one denies the name for the whole page load.
1162
+ const wanted = query.class === CLASS_IN ? RECORD_TYPES.get(query.type) : null;
1163
+ if (wanted) {
1164
+ const found = await answerRecords(query.name, { ...options, type: wanted }).catch(() => null);
1165
+ exists = Boolean(found?.exists);
1166
+ reply = buildRecordResponse(query, msg, found?.records || [], {
1167
+ ttl, exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
1168
+ });
1169
+ } else if (query.class === CLASS_IN) {
1170
+ const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA;
1171
+ if (!wantsAddress) {
1172
+ // HTTPS/SVCB and friends: the name's existence is the whole answer, and
1173
+ // getting it wrong here denies the name for every other question too.
1174
+ const policy = await answerPolicy(query.name, { ...options, wantsAddress: false }).catch(() => null);
1175
+ if (policy) ({ exists } = policy);
1176
+ } else {
1177
+ const plan = await addressAnswer(query.name, {
1178
+ ...options, wantsV6: query.type === TYPE_AAAA, proxyAddress,
1179
+ }).catch(() => null);
1180
+ exists = Boolean(plan?.exists);
1181
+ if (plan?.kind === "records") {
1182
+ reply = buildRecordResponse(query, msg, plan.records, {
1183
+ ttl, exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
1184
+ });
1185
+ } else if (plan?.kind === "chain") {
1186
+ const addresses = await resolveChain(plan.cname, {
1187
+ upstreams, wantsV6: query.type === TYPE_AAAA, timeoutMs: forwardTimeoutMs,
1188
+ });
1189
+ reply = buildChainResponse(query, msg, { cname: plan.cname, addresses, ttl });
1190
+ address = addresses[0] || plan.cname;
1191
+ } else {
1192
+ address = plan?.address || null;
1193
+ }
1194
+ }
1195
+ }
1196
+ onQuery({ name: query.name, type: query.type, address });
1197
+ try {
1198
+ socket.send(reply || buildResponse(query, msg, address, ttl, exists), rinfo.port, rinfo.address);
1199
+ } catch {
1200
+ /* client vanished — nothing useful to do */
1201
+ }
1202
+ });
1203
+
1204
+ return new Promise((resolve, reject) => {
1205
+ socket.once("error", reject);
1206
+ socket.bind(port, host, () => {
1207
+ // The rejector belongs to the bind and only to the bind. Left attached it
1208
+ // stays the socket's one error listener for the life of the resolver, so
1209
+ // the first error after bind called reject() on an already-settled
1210
+ // promise — swallowed, no line logged, while onQuery logs every ordinary
1211
+ // query — and the second found no listener at all and took the process
1212
+ // down. `dns start` runs in the foreground until Ctrl-C, so that is the
1213
+ // one command whose whole job is to stay up. The sibling parking server
1214
+ // already drops its rejector this way (parking-http.mjs).
1215
+ socket.removeListener("error", reject);
1216
+ // Removing it is not enough on its own: with no listener the *first*
1217
+ // error would now be the fatal one. A resolver outlives the transient
1218
+ // failures of the interface underneath it, so report and keep serving.
1219
+ socket.on("error", (err) => onError(err));
1220
+ const addr = socket.address();
1221
+ resolve({
1222
+ port: addr.port,
1223
+ address: addr.address,
1224
+ close: () => new Promise((done) => socket.close(done)),
1225
+ });
1226
+ });
1227
+ });
1228
+ }
1229
+
1230
+ /* ------------------------------------------------------- system integration */
1231
+
1232
+ /**
1233
+ * Does the bridge on this port actually forward what is not ours?
1234
+ *
1235
+ * The question that matters before writing catch-all routing, and the one the
1236
+ * previous check never asked. It verified that upstreams were discoverable —
1237
+ * a fact about the machine — and inferred from that the bridge would forward.
1238
+ * On a box where the bridge is the only global nameserver, that inference is
1239
+ * the difference between "Moshpit names do not resolve" and "nothing does".
1240
+ *
1241
+ * `dns enable` writes the routing config before starting the bridge, and a
1242
+ * bridge already listening is left alone with "bridge already running" — so an
1243
+ * older build, or one started without upstreams, keeps the port while the new
1244
+ * routing sends it every lookup on the machine. That is exactly how a desktop
1245
+ * loses DNS.
1246
+ *
1247
+ * The probe name has three labels on purpose. A two-label name is a Moshpit
1248
+ * name to any build: an older bridge answers it with the parking address, an
1249
+ * answer, which would read as working forwarding. Three labels cannot be a
1250
+ * Moshpit name, so only a bridge that forwards can produce an answer at all.
1251
+ */
1252
+ export const CLEARNET_PROBE = "pit.moshcode.sh";
1253
+
1254
+ export function probeForwarding({
1255
+ host = DEFAULT_HOST,
1256
+ port = DEFAULT_PORT,
1257
+ name = CLEARNET_PROBE,
1258
+ timeoutMs = 2000,
1259
+ } = {}) {
1260
+ return new Promise((resolve) => {
1261
+ const socket = dgram.createSocket(isIP(host) === 6 ? "udp6" : "udp4");
1262
+ let settled = false;
1263
+ const finish = (answer) => {
1264
+ if (settled) return;
1265
+ settled = true;
1266
+ try { socket.close(); } catch { /* already closing */ }
1267
+ resolve(answer);
1268
+ };
1269
+ const timer = setTimeout(() => finish(false), timeoutMs);
1270
+ timer.unref?.();
1271
+
1272
+ socket.on("message", (reply) => {
1273
+ clearTimeout(timer);
1274
+ // An answer at all is the signal: rcode 0 with at least one record.
1275
+ const ok = reply.length > 12
1276
+ && (reply.readUInt16BE(2) & 0x000f) === 0
1277
+ && reply.readUInt16BE(6) > 0;
1278
+ finish(ok);
1279
+ });
1280
+ socket.on("error", () => { clearTimeout(timer); finish(false); });
1281
+
1282
+ const head = Buffer.alloc(12);
1283
+ head.writeUInt16BE(0x7e57, 0);
1284
+ head.writeUInt16BE(0x0100, 2);
1285
+ head.writeUInt16BE(1, 4);
1286
+ const tail = Buffer.alloc(4);
1287
+ tail.writeUInt16BE(TYPE_A, 0);
1288
+ tail.writeUInt16BE(CLASS_IN, 2);
1289
+ try {
1290
+ socket.send(Buffer.concat([head, encodeName(name), tail]), port, host);
1291
+ } catch {
1292
+ clearTimeout(timer);
1293
+ finish(false);
1294
+ }
1295
+ });
1296
+ }
1297
+
1298
+ /**
1299
+ * Whether catch-all routing is safe to write right now.
1300
+ *
1301
+ * Two conditions, and both are about this machine at this moment rather than
1302
+ * about the build that is installed:
1303
+ *
1304
+ * - upstreams exist to forward to, and
1305
+ * - either no bridge holds the port (so `dns enable` starts ours, which
1306
+ * forwards), or the one holding it demonstrably forwards.
1307
+ *
1308
+ * Anything else falls back to per-ending routing. That routing is worse — it
1309
+ * truncates, silently — but its worst case is Moshpit names not resolving,
1310
+ * where catch-all against a bridge that cannot forward takes the machine off
1311
+ * the internet. Between a feature that does not work and a desktop that cannot
1312
+ * reach anything, the choice is not close.
1313
+ */
1314
+ export async function catchAllSafety({ host = DEFAULT_HOST, port = DEFAULT_PORT, probe = probeForwarding } = {}) {
1315
+ const upstreams = await discoverUpstreams();
1316
+ if (!upstreams.length) {
1317
+ return { safe: false, upstreams, why: "no upstream nameservers found to forward to" };
1318
+ }
1319
+ if (await probe({ host, port, name: CLEARNET_PROBE })) {
1320
+ return { safe: true, upstreams, why: "the running bridge forwards" };
1321
+ }
1322
+ // Nothing listening is fine: enable starts ours next, and ours forwards.
1323
+ const held = await probe({ host, port, name: "a.eggs" });
1324
+ if (!held) return { safe: true, upstreams, why: "no bridge is running yet — this one will be ours" };
1325
+
1326
+ return {
1327
+ safe: false,
1328
+ upstreams,
1329
+ why: "a bridge is already running on this port and does not forward — stop it first, then re-run",
1330
+ };
1331
+ }
1332
+
1333
+ /**
1334
+ * The upstreams this machine was using before we touched anything.
1335
+ *
1336
+ * Read once, before routing is switched, because afterwards resolv.conf may
1337
+ * point at us and the real servers are no longer discoverable from it. An
1338
+ * empty result is the signal to leave routing per-ending: catch-all with
1339
+ * nowhere to forward is every lookup on the box failing, not just Moshpit ones.
1340
+ */
1341
+ export const UPSTREAM_SOURCES = [
1342
+ // systemd-resolved's own uplink file, and the only one with real servers in
1343
+ // it on a systemd machine. /etc/resolv.conf there is a stub pointing at
1344
+ // 127.0.0.53 — which this drops as loopback, correctly, and which left
1345
+ // discovery empty on exactly the platform catch-all routing was built for.
1346
+ // The fallback to per-ending routing kept those machines safe and kept the
1347
+ // feature permanently out of reach; reading only /etc/resolv.conf was the bug.
1348
+ "/run/systemd/resolve/resolv.conf",
1349
+ "/etc/resolv.conf",
1350
+ ];
1351
+
1352
+ export async function discoverUpstreams(readImpl) {
1353
+ const read = readImpl || (async (path) => {
1354
+ const { readFile } = await import("node:fs/promises");
1355
+ return readFile(path, "utf8");
1356
+ });
1357
+
1358
+ for (const source of UPSTREAM_SOURCES) {
1359
+ const found = parseUpstreams(await read(source).catch(() => ""));
1360
+ // First file with a non-loopback server wins. A stub resolv.conf yields
1361
+ // nothing and we move on rather than concluding there are no upstreams.
1362
+ if (found.length) return found;
1363
+ }
1364
+ return [];
1365
+ }
1366
+
1367
+ /**
1368
+ * The routing suffixes the resolver actually accepted.
1369
+ *
1370
+ * Not the same question as what we wrote, which is the whole point. Writing a
1371
+ * config is not the same as the resolver honouring it, and systemd-resolved
1372
+ * caps how many search domains it will take: handed 4586 it accepted 1090
1373
+ * alphabetically, rejected the rest one journal line at a time with "Argument
1374
+ * list too long", and reported success. Status compared what it had written
1375
+ * against what the registry claimed, saw the same number twice, and said
1376
+ * everything was fine while 76% of endings did not resolve.
1377
+ *
1378
+ * So this asks the resolver instead of the file.
1379
+ */
1380
+ export function parseResolvectlDomains(text) {
1381
+ const seen = new Set();
1382
+ for (const match of String(text ?? "").matchAll(/~([a-z0-9-]+)/gi)) {
1383
+ seen.add(match[1].toLowerCase());
1384
+ }
1385
+ return [...seen];
1386
+ }
1387
+
1388
+ /**
1389
+ * What routing the running resolver has, or null when we cannot ask it.
1390
+ *
1391
+ * Null is "unknown", never "none": a machine using dnsmasq, or not systemd at
1392
+ * all, has no resolvectl and must not be told its routing is missing.
1393
+ */
1394
+ export async function acceptedDomains(runner) {
1395
+ const run = runner || (async () => {
1396
+ const { execFile } = await import("node:child_process");
1397
+ return new Promise((resolve) => {
1398
+ execFile("resolvectl", ["domain"], { timeout: 5000 }, (err, stdout) =>
1399
+ resolve(err ? null : String(stdout)));
1400
+ });
1401
+ });
1402
+ const output = await run().catch(() => null);
1403
+ return output === null || output === undefined ? null : parseResolvectlDomains(output);
1404
+ }
1405
+
1406
+ /**
1407
+ * Whether the resolver kept everything it was given, and what it dropped.
1408
+ *
1409
+ * `missing` is capped in what callers print, not here — the whole list is the
1410
+ * evidence, and an ending that is absent is exactly the thing someone is
1411
+ * searching the output for.
1412
+ */
1413
+ export function routingShortfall(written, accepted) {
1414
+ if (!Array.isArray(accepted)) return null;
1415
+ const have = new Set(accepted);
1416
+ const missing = written.filter((tld) => !have.has(tld));
1417
+ return { written: written.length, accepted: accepted.length, missing };
1418
+ }
1419
+
1420
+ /**
1421
+ * systemd-resolved drop-in routing just the Moshpit TLDs at the bridge.
1422
+ *
1423
+ * `~tld` is a routing-only domain: it sends queries for that suffix here
1424
+ * without making this resolver the default for anything else on the machine.
1425
+ */
1426
+ /* ------------------------------------------------- catch-all routing */
1427
+
1428
+ /**
1429
+ * Route every lookup here, instead of naming each claimed ending.
1430
+ *
1431
+ * The per-ending form does not scale and fails silently when it stops. Listing
1432
+ * 4586 endings on one `Domains=` line made systemd-resolved take them
1433
+ * alphabetically until it hit its own cap, reject the remaining 3496 with
1434
+ * "Argument list too long" one line at a time in the journal, and report
1435
+ * success. Names past the cut were configured on disk and absent from the
1436
+ * resolver, so `moshcode dns resolve` answered and `curl` did not — with
1437
+ * nothing in between to say why. Every new ending anyone claims makes that
1438
+ * worse.
1439
+ *
1440
+ * `~.` is one entry that never grows. The cost is that this bridge now sees
1441
+ * every lookup on the machine, so it has to be a resolver rather than an
1442
+ * oracle: anything that is not a claimed Moshpit name is forwarded upstream
1443
+ * untouched, and any failure forwards too. Breaking DNS for the whole box is a
1444
+ * far worse outcome than failing to resolve a Moshpit name.
1445
+ */
1446
+ export function resolvedCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
1447
+ return [
1448
+ "# Written by `moshcode dns install`. Sends every lookup to the local",
1449
+ "# bridge, which answers Moshpit endings and forwards the rest upstream.",
1450
+ "#",
1451
+ "# Routing each ending by name instead does not survive the registry",
1452
+ "# growing: systemd-resolved caps how many search domains it accepts and",
1453
+ "# drops the rest with no error a caller can see.",
1454
+ "[Resolve]",
1455
+ `DNS=${host}:${port}`,
1456
+ "Domains=~.",
1457
+ "",
1458
+ ].join("\n");
1459
+ }
1460
+
1461
+ /** The dnsmasq equivalent: one upstream for everything. */
1462
+ export function dnsmasqCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
1463
+ return [
1464
+ "# Written by `moshcode dns install`.",
1465
+ "# no-resolv so dnsmasq does not also inherit the upstreams from",
1466
+ "# /etc/resolv.conf, which on a machine running this bridge may point back",
1467
+ "# here and loop.",
1468
+ "no-resolv",
1469
+ `server=${host}#${port}`,
1470
+ "",
1471
+ ].join("\n");
1472
+ }
1473
+
1474
+ /**
1475
+ * The machine's real nameservers, for the bridge to forward to.
1476
+ *
1477
+ * Loopback entries are dropped: once routing points at this bridge, whatever
1478
+ * wrote 127.0.0.53 into resolv.conf is the thing sending us the query, and
1479
+ * forwarding back to it is a loop that ends in a timeout rather than an answer.
1480
+ */
1481
+ export function parseUpstreams(resolvConf) {
1482
+ const out = [];
1483
+ for (const line of String(resolvConf ?? "").split("\n")) {
1484
+ const m = line.match(/^\s*nameserver\s+(\S+)/i);
1485
+ if (!m) continue;
1486
+ const address = m[1].replace(/%.*$/, "");
1487
+ if (!isIP(address)) continue;
1488
+ if (/^127\./.test(address) || address === "::1") continue;
1489
+ if (!out.includes(address)) out.push(address);
1490
+ }
1491
+ return out;
1492
+ }
1493
+
1494
+ export function resolvedConf(tlds, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
1495
+ return [
1496
+ "# Written by `moshcode dns install`. Routes Moshpit TLDs to the local",
1497
+ "# bridge; every other name keeps using your normal resolver.",
1498
+ "[Resolve]",
1499
+ `DNS=${host}:${port}`,
1500
+ `Domains=${tlds.map((t) => `~${t}`).join(" ")}`,
1501
+ "",
1502
+ ].join("\n");
1503
+ }
1504
+
1505
+ /** The dnsmasq equivalent, for machines not running systemd-resolved. */
1506
+ export function dnsmasqConf(tlds, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
1507
+ return [
1508
+ "# Written by `moshcode dns install`.",
1509
+ ...tlds.map((t) => `server=/${t}/${host}#${port}`),
1510
+ "",
1511
+ ].join("\n");
1512
+ }
1513
+
1514
+ /* --------------------------------------- switching DNS without breaking DNS */
1515
+
1516
+ // `dns enable` repoints every lookup on the machine. Four phases, in this
1517
+ // order, because each one exists for a way the previous arrangement failed on a
1518
+ // real box: refuse when the machine is already in a state where the switch
1519
+ // cannot work, keep what was there before touching it, prove the machine can
1520
+ // still resolve afterwards, and put it back when the proof fails. The last one
1521
+ // is the load-bearing one — without it `enable` can leave a desktop with no
1522
+ // resolver at all, and no working DNS with which to look up how to fix it.
1523
+
1524
+ export const RESOLVED_DROPIN_DIR = "/etc/systemd/resolved.conf.d";
1525
+ export const MOSHPIT_DROPIN = `${RESOLVED_DROPIN_DIR}/moshpit.conf`;
1526
+
1527
+ /**
1528
+ * The suffix every backup written here gets, and why it is not `.conf`.
1529
+ *
1530
+ * systemd-resolved globs `*.conf` in the drop-in directory, so `moshpit.bak.conf`
1531
+ * would be a *second* file setting `DNS=` — the exact state the preflight below
1532
+ * refuses to run into. A suffix that sorts outside the glob is inert. macOS has
1533
+ * the same shape of problem for a different reason: it reads /etc/resolver by
1534
+ * filename, so a backup there routes a domain nobody will ever ask for.
1535
+ */
1536
+ export const BACKUP_SUFFIX = ".moshcode-backup";
1537
+
1538
+ export function backupPath(path) {
1539
+ return `${path}${BACKUP_SUFFIX}`;
1540
+ }
1541
+
1542
+ /**
1543
+ * The nameservers a resolved drop-in sets, if any.
1544
+ *
1545
+ * `DNS=` with an empty value is systemd's reset — it clears what earlier
1546
+ * drop-ins assigned rather than adding a server — so it is not a conflict and
1547
+ * must not read as one. Commented-out lines are not matched at all: `^\s*DNS`
1548
+ * cannot start with `#`.
1549
+ */
1550
+ export function dropinNameservers(content) {
1551
+ const out = [];
1552
+ for (const line of String(content ?? "").split("\n")) {
1553
+ const m = line.match(/^\s*DNS\s*=\s*(\S.*?)\s*$/i);
1554
+ if (m) out.push(...m[1].split(/\s+/));
1555
+ }
1556
+ return out;
1557
+ }
1558
+
1559
+ /**
1560
+ * Any drop-in other than ours that names a nameserver.
1561
+ *
1562
+ * Two files each setting `DNS=` do not compete — systemd-resolved appends them
1563
+ * into one global list and rotates between them, and having rotated away from a
1564
+ * server that failed it never rotates back. So a single restart of the bridge
1565
+ * moves every query on the machine to the other server, permanently. That
1566
+ * server answers NXDOMAIN for every Moshpit name, which means DNS looks
1567
+ * completely healthy while the entire namespace this command exists to serve is
1568
+ * dead, with nothing anywhere reporting a failure.
1569
+ *
1570
+ * A DigitalOcean.conf left by the cloud image did this three times in one
1571
+ * afternoon and was misdiagnosed as a bridge bug each time. Guessing which
1572
+ * server should win is not this command's call to make, so it names the file
1573
+ * and stops.
1574
+ *
1575
+ * `duplicate` is the exception, and it is common: a drop-in naming only the
1576
+ * bridge we are about to point at is not a second server, because there is
1577
+ * nothing for the resolver to rotate to. Blocking on it would refuse to run on
1578
+ * every machine an earlier installer set up by hand. It is still a second file
1579
+ * that `dns disable` will not remove, which is worth saying and not worth
1580
+ * stopping for.
1581
+ */
1582
+ export function conflictingDropins(files, { ours = "moshpit.conf", bridge = null } = {}) {
1583
+ const out = [];
1584
+ for (const file of files || []) {
1585
+ const name = String(file?.name ?? "");
1586
+ if (name === ours || !name.endsWith(".conf")) continue;
1587
+ const servers = dropinNameservers(file.content);
1588
+ if (!servers.length) continue;
1589
+ out.push({ name, servers, duplicate: Boolean(bridge) && servers.every((s) => s === bridge) });
1590
+ }
1591
+ return out;
1592
+ }
1593
+
1594
+ export async function readDropins({ dir = RESOLVED_DROPIN_DIR, readdir, read } = {}) {
1595
+ const list = readdir || (async (d) => {
1596
+ const { readdir: rd } = await import("node:fs/promises");
1597
+ return rd(d);
1598
+ });
1599
+ const readOne = read || (async (p) => {
1600
+ const { readFile: rf } = await import("node:fs/promises");
1601
+ return rf(p, "utf8");
1602
+ });
1603
+ // A missing directory is a machine that does not use resolved drop-ins, not
1604
+ // an error worth stopping an enable over.
1605
+ const names = await list(dir).catch(() => []);
1606
+ const files = [];
1607
+ for (const name of names) {
1608
+ if (!String(name).endsWith(".conf")) continue;
1609
+ files.push({ name: String(name), content: await readOne(`${dir}/${name}`).catch(() => "") });
1610
+ }
1611
+ return files;
1612
+ }
1613
+
1614
+ /**
1615
+ * UDP sockets that are listening, as `ss -lnup` sees them.
1616
+ *
1617
+ * The process column only carries an owner when the caller can see it; `enable`
1618
+ * runs as root, so on the run that matters the pid is there. Parsed rather than
1619
+ * grepped because the pid is the only thing that distinguishes our own bridge
1620
+ * from a stranger holding the same port.
1621
+ */
1622
+ export function parseUdpListeners(text) {
1623
+ const out = [];
1624
+ for (const line of String(text ?? "").split("\n")) {
1625
+ const fields = line.trim().split(/\s+/);
1626
+ if (fields.length < 4) continue;
1627
+ const m = fields[3].match(/^(.*):(\d+)$/);
1628
+ if (!m) continue;
1629
+ const owner = line.match(/users:\(\("([^"]+)",pid=(\d+)/);
1630
+ out.push({
1631
+ address: m[1].replace(/^\[/, "").replace(/\]$/, ""),
1632
+ port: Number(m[2]),
1633
+ pid: owner ? Number(owner[2]) : null,
1634
+ process: owner ? owner[1] : null,
1635
+ });
1636
+ }
1637
+ return out;
1638
+ }
1639
+
1640
+ const defaultUdpListeners = async () => {
1641
+ const { execFile } = await import("node:child_process");
1642
+ const text = await new Promise((resolve) => {
1643
+ execFile("ss", ["-lnup"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout)));
1644
+ });
1645
+ return parseUdpListeners(text);
1646
+ };
1647
+
1648
+ /**
1649
+ * Someone else on the bridge's port.
1650
+ *
1651
+ * A stale bridge from an older build is the case this is for, and it wins two
1652
+ * different ways depending on how each side bound. Bound to the same address we
1653
+ * want, it takes the port and ours cannot bind at all — and `startDaemon`
1654
+ * spawns detached with stdio ignored, so that bind failure is invisible and
1655
+ * `enable` still prints "bridge started". Bound to 127.0.0.1 while ours holds
1656
+ * 0.0.0.0, the kernel delivers to the more specific socket, so ours is running,
1657
+ * healthy and receiving nothing. Either way the routing we just wrote points
1658
+ * every lookup on the machine at a process we did not start, which answers
1659
+ * NOERROR with zero answers and eats the query.
1660
+ *
1661
+ * That took DNS down twice in one day. `catchAllSafety` cannot see it: it
1662
+ * probes the port and gets an answer either way.
1663
+ *
1664
+ * A holder whose pid matches the bridge we already recorded is ours and fine.
1665
+ * An unattributable holder — no pid visible — is only accepted when a bridge of
1666
+ * ours is recorded as running, because then it is very likely that same one;
1667
+ * with nothing of ours running there is no reading under which it is ours.
1668
+ */
1669
+ export function portHolder(listeners, { host = DEFAULT_HOST, port = DEFAULT_PORT, ourPid = null } = {}) {
1670
+ const wildcards = new Set(["0.0.0.0", "::", "*"]);
1671
+ for (const l of listeners || []) {
1672
+ if (l.port !== port) continue;
1673
+ if (l.address !== host && !wildcards.has(l.address)) continue;
1674
+ if (ourPid && l.pid === ourPid) continue;
1675
+ if (!l.pid && ourPid) continue;
1676
+ return l;
1677
+ }
1678
+ return null;
1679
+ }
1680
+
1681
+ /**
1682
+ * Everything that has to be true of the machine before the routing is written.
1683
+ *
1684
+ * Both checks refuse rather than guess. The states they find are ones where
1685
+ * writing the config produces a machine that reports success and cannot
1686
+ * resolve, which is strictly worse than not running at all — so the answer is a
1687
+ * named file, a named pid, and a stop.
1688
+ */
1689
+ export async function preflightEnable({
1690
+ host = DEFAULT_HOST,
1691
+ port = DEFAULT_PORT,
1692
+ ourPid = null,
1693
+ // Only meaningful for the systemd-resolved backend. A dnsmasq machine may
1694
+ // carry a resolved.conf.d it does not use, and blocking on it would be a
1695
+ // refusal over a file that has no effect on anything.
1696
+ checkDropins = true,
1697
+ dropins = readDropins,
1698
+ listeners = defaultUdpListeners,
1699
+ forwards = probeForwarding,
1700
+ } = {}) {
1701
+ const blockers = [];
1702
+
1703
+ const dropinFiles = checkDropins ? conflictingDropins(await dropins(), { bridge: `${host}:${port}` }) : [];
1704
+ const duplicates = dropinFiles.filter((f) => f.duplicate);
1705
+ const conflicts = dropinFiles.filter((f) => !f.duplicate);
1706
+ for (const c of conflicts) {
1707
+ blockers.push({
1708
+ kind: "conflicting-dropin",
1709
+ lines: [
1710
+ `${RESOLVED_DROPIN_DIR}/${c.name} also sets DNS= (${c.servers.join(" ")})`,
1711
+ " systemd-resolved appends both into one list and rotates between them, and never",
1712
+ " rotates back to a server that failed — so one bridge restart sends every lookup",
1713
+ " on this machine to that server for good. It answers NXDOMAIN for Moshpit names,",
1714
+ " so nothing looks broken and the whole namespace is gone.",
1715
+ ` Move it aside, or re-run with --force to accept that.`,
1716
+ ],
1717
+ });
1718
+ }
1719
+
1720
+ // Owning the port is not the offence — eating queries is. A bridge someone
1721
+ // started by hand holds the port with no pidfile to prove it is ours, and it
1722
+ // is perfectly good; refusing on identity alone would make --force the normal
1723
+ // way to run this command, which is how a safety check stops being one. So
1724
+ // the holder is asked the same clearnet question `catchAllSafety` asks, and
1725
+ // only a holder that cannot answer it is a blocker.
1726
+ const holder = portHolder(await listeners().catch(() => []), { host, port, ourPid });
1727
+ const holderForwards = holder ? await forwards({ host, port, name: CLEARNET_PROBE }).catch(() => false) : false;
1728
+ if (holder && !holderForwards) {
1729
+ const who = holder.pid ? `pid ${holder.pid}${holder.process ? ` (${holder.process})` : ""}` : "owner not visible";
1730
+ blockers.push({
1731
+ kind: "port-holder",
1732
+ lines: [
1733
+ `something is already listening on ${holder.address}:${holder.port} and does not forward — ${who}`,
1734
+ " it is not the bridge this command started, and the routing below would hand it",
1735
+ " every lookup on the machine. A stale bridge answers NOERROR with no answers,",
1736
+ " which is indistinguishable from working DNS until nothing resolves.",
1737
+ ` Stop it (kill ${holder.pid || "<pid>"}), or re-run with --force to accept that.`,
1738
+ ],
1739
+ });
1740
+ }
1741
+
1742
+ return { ok: blockers.length === 0, blockers, conflicts, duplicates, holder, holderForwards };
1743
+ }
1744
+
1745
+ const defaultSleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
1746
+
1747
+ /**
1748
+ * Does this machine still resolve?
1749
+ *
1750
+ * Two names, and both must answer. The Moshpit one proves the bridge is
1751
+ * reachable through the routing that was just written; the clearnet one proves
1752
+ * it is *forwarding* rather than swallowing, and that is the check that matters
1753
+ * — its failure is what "you broke my DNS" means to the person whose machine it
1754
+ * is. A run that only checked Moshpit names would call a box that cannot reach
1755
+ * the internet a success.
1756
+ *
1757
+ * Resolution goes through the system resolver rather than straight at the
1758
+ * bridge, deliberately: the bridge answering on 5354 is not the claim being
1759
+ * made. The claim is that a normal lookup on this machine works.
1760
+ *
1761
+ * Retried because systemd-resolved takes a moment to be ready after a restart,
1762
+ * and a rollback triggered by that gap would undo a switch that was fine.
1763
+ */
1764
+ export async function verifyResolution({
1765
+ moshpit = null,
1766
+ clearnet = CLEARNET_PROBE,
1767
+ resolve = (name) => dnsPromises.resolve4(name),
1768
+ attempts = 4,
1769
+ delayMs = 400,
1770
+ sleep = defaultSleep,
1771
+ } = {}) {
1772
+ const wanted = [
1773
+ ...(moshpit ? [{ name: moshpit, kind: "moshpit" }] : []),
1774
+ { name: clearnet, kind: "clearnet" },
1775
+ ];
1776
+ const checks = [];
1777
+ for (const { name, kind } of wanted) {
1778
+ let ok = false;
1779
+ let error = "no answer";
1780
+ for (let attempt = 0; attempt < attempts && !ok; attempt++) {
1781
+ if (attempt) await sleep(delayMs);
1782
+ try {
1783
+ const answers = await resolve(name);
1784
+ // An empty answer counts as failure. NOERROR with no records is exactly
1785
+ // what a resolver that has swallowed the query returns, and treating it
1786
+ // as success is how the silent version of this outage stayed silent.
1787
+ ok = Array.isArray(answers) ? answers.length > 0 : Boolean(answers);
1788
+ if (!ok) error = "answered, with no records";
1789
+ } catch (err) {
1790
+ error = err?.code || err?.message || String(err);
1791
+ }
1792
+ }
1793
+ checks.push({ name, kind, ok, error: ok ? null : error });
1794
+ }
1795
+ return { ok: checks.every((c) => c.ok), checks };
1796
+ }
1797
+
1798
+ const defaultReadMaybe = async (path) => {
1799
+ const { readFile: rf } = await import("node:fs/promises");
1800
+ return rf(path, "utf8").catch(() => null);
1801
+ };
1802
+
1803
+ /**
1804
+ * Apply a routing plan, prove it worked, and put the machine back if it did not.
1805
+ *
1806
+ * The contents of every file the plan overwrites are read first and held, and
1807
+ * also copied to disk next to the original — in memory is what the rollback
1808
+ * uses, on disk is what is left for a person to find if this process is killed
1809
+ * between the write and the restart. A failed backup copy is therefore reported
1810
+ * and not fatal; the rollback does not depend on it.
1811
+ *
1812
+ * A failed *apply* rolls back for the same reason a failed verification does.
1813
+ * Half-written routing is the state this whole function exists to make
1814
+ * impossible, and "some steps failed, good luck" was the previous answer to it.
1815
+ *
1816
+ * Restoring the files is not enough on its own — the resolver read them at
1817
+ * start — so the plan's own `run` steps are replayed afterwards. Windows writes
1818
+ * no files and undoes its NRPT rules with different commands entirely, which is
1819
+ * why the caller can hand in its own `rollbackSteps`.
1820
+ *
1821
+ * Deletions are snapshotted alongside overwrites, which is what lets `disable`
1822
+ * use this too. Undoing a switch is a switch: it restarts the resolver, it can
1823
+ * leave the machine unable to resolve, and a disable that cannot be undone is
1824
+ * the same outage as an enable that cannot be undone.
1825
+ */
1826
+ export async function applyWithRollback(plan, {
1827
+ apply = applyPlan,
1828
+ runner,
1829
+ read = defaultReadMaybe,
1830
+ verify = async () => ({ ok: true, checks: [] }),
1831
+ rollbackSteps = plan.steps.filter((s) => s.kind === "run"),
1832
+ } = {}) {
1833
+ const opts = runner ? { runner } : {};
1834
+ const targets = plan.steps.filter((s) => s.kind === "write" || s.kind === "remove").map((s) => s.path);
1835
+ const before = new Map();
1836
+ for (const path of targets) before.set(path, await read(path));
1837
+
1838
+ const backups = targets
1839
+ .filter((path) => before.get(path) !== null && before.get(path) !== undefined)
1840
+ .map((path) => ({
1841
+ kind: "write",
1842
+ path: backupPath(path),
1843
+ content: before.get(path),
1844
+ why: `keep the previous ${path} until this run is known to have worked`,
1845
+ }));
1846
+ const saved = backups.length ? await apply({ steps: backups }, opts) : { ok: true, results: [] };
1847
+
1848
+ const applied = await apply(plan, opts);
1849
+ const verified = applied.ok
1850
+ ? await verify()
1851
+ : { ok: false, checks: [], skipped: "not attempted — the routing was not fully applied" };
1852
+
1853
+ if (verified.ok) {
1854
+ // Nothing left to protect, and a stray backup in a config directory is its
1855
+ // own hazard on both of the platforms this writes to.
1856
+ if (backups.length) await apply({ steps: backups.map((b) => ({ kind: "remove", path: b.path, why: "the run succeeded" })) }, opts);
1857
+ return { saved, applied, verified, rolledBack: null, backups: [] };
1858
+ }
1859
+
1860
+ const restores = targets.map((path) => (before.get(path) === null || before.get(path) === undefined
1861
+ ? { kind: "remove", path, why: "there was no file here before this run" }
1862
+ : { kind: "write", path, content: before.get(path), why: "put back what was here before this run" }));
1863
+ const rolledBack = await apply({ steps: [...restores, ...rollbackSteps] }, opts);
1864
+
1865
+ // Kept when the rollback itself failed: then the backup is the only copy of
1866
+ // the machine's previous configuration, and its path is worth printing.
1867
+ if (rolledBack.ok && backups.length) {
1868
+ await apply({ steps: backups.map((b) => ({ kind: "remove", path: b.path, why: "the original is back in place" })) }, opts);
1869
+ }
1870
+ return { saved, applied, verified, rolledBack, backups: rolledBack.ok ? [] : backups.map((b) => b.path) };
1871
+ }
1872
+
1873
+ /* ------------------------------------------------------- the restore point */
1874
+
1875
+ // `disable` used to remove one hardcoded filename and report success. On a
1876
+ // machine where anything else routes to the bridge — and there is at least one
1877
+ // installer in the wild that writes `00-moshpit.conf` for exactly that purpose
1878
+ // — it removed a file, restarted the resolver, printed "Moshpit TLDs are back
1879
+ // to your normal resolver", and changed nothing anyone could observe. A
1880
+ // silently successful no-op is worse than a failure: nobody re-reads the output
1881
+ // of a command that said it worked.
1882
+ //
1883
+ // So `enable` now records what the machine looked like before it touched it,
1884
+ // and `disable` restores that recording rather than deducing an undo.
1885
+
1886
+ export const MANIFEST_VERSION = 1;
1887
+
1888
+ /**
1889
+ * Where the restore point lives.
1890
+ *
1891
+ * Not under resolved.conf.d, and not merely because of the `*.conf` glob that
1892
+ * the backup suffix already works around: a manifest is state about the machine
1893
+ * rather than configuration for the resolver, and the resolver's own config
1894
+ * directory is the one place guaranteed to be inspected, copied, templated and
1895
+ * wiped by other tooling. /var/lib is where a root command's state belongs.
1896
+ *
1897
+ * Overridable by environment because every test of this would otherwise have to
1898
+ * write to /var/lib to prove anything.
1899
+ */
1900
+ export function manifestPath(env = process.env) {
1901
+ return env.MOSHCODE_DNS_MANIFEST || "/var/lib/moshcode/dns-restore.json";
1902
+ }
1903
+
1904
+ /** The routing suffixes a drop-in sets, the other half of what steers a query. */
1905
+ export function dropinDomains(content) {
1906
+ const out = [];
1907
+ for (const line of String(content ?? "").split("\n")) {
1908
+ const m = line.match(/^\s*Domains\s*=\s*(\S.*?)\s*$/i);
1909
+ if (m) out.push(...m[1].split(/\s+/));
1910
+ }
1911
+ return out;
1912
+ }
1913
+
1914
+ /**
1915
+ * What this machine's DNS looked like before this run, in enough detail to put
1916
+ * it back byte-for-byte.
1917
+ *
1918
+ * Every drop-in that steers anything is captured whole, not just the one file
1919
+ * this repo overwrites. That is the difference between an undo and a guess: the
1920
+ * file that keeps a machine routed after `disable` is by definition one this
1921
+ * code did not write, so a snapshot limited to our own filename could never
1922
+ * have caught it.
1923
+ *
1924
+ * `Domains=` counts as steering as much as `DNS=` does. A drop-in setting only
1925
+ * `Domains=~.` sends every lookup to whatever the global scope resolves to, and
1926
+ * leaving it out of the snapshot would restore half of a routing decision.
1927
+ *
1928
+ * Paths the plan is about to write are captured too, with `content: null` when
1929
+ * nothing is there — that null is what tells `disable` to remove the file
1930
+ * rather than leave an empty one behind.
1931
+ */
1932
+ export async function captureRestorePoint({
1933
+ plan,
1934
+ platform,
1935
+ backend = "systemd-resolved",
1936
+ bridge,
1937
+ dir = RESOLVED_DROPIN_DIR,
1938
+ dropins = readDropins,
1939
+ read = defaultReadMaybe,
1940
+ now = () => new Date().toISOString(),
1941
+ } = {}) {
1942
+ const files = new Map();
1943
+ for (const file of await dropins({ dir }).catch(() => [])) {
1944
+ if (!dropinNameservers(file.content).length && !dropinDomains(file.content).length) continue;
1945
+ files.set(`${dir}/${file.name}`, file.content);
1946
+ }
1947
+ for (const step of plan?.steps || []) {
1948
+ if (step.kind !== "write" && step.kind !== "remove") continue;
1949
+ if (!files.has(step.path)) files.set(step.path, await read(step.path));
1950
+ }
1951
+
1952
+ return {
1953
+ version: MANIFEST_VERSION,
1954
+ createdAt: now(),
1955
+ platform,
1956
+ backend,
1957
+ bridge,
1958
+ // Sorted so two runs on an unchanged machine produce the same manifest, and
1959
+ // a diff between them means something.
1960
+ files: [...files.keys()].sort().map((path) => ({ path, content: files.get(path) ?? null })),
1961
+ // Carried rather than re-derived: the manifest has to be able to undo a run
1962
+ // made by a build whose idea of the restart command has since changed.
1963
+ restart: (plan?.steps || []).filter((s) => s.kind === "run").map((s) => ({ command: s.command, args: s.args })),
1964
+ };
1965
+ }
1966
+
1967
+ /**
1968
+ * A manifest, or null when there is not a usable one.
1969
+ *
1970
+ * A version this build does not know is null rather than an error: the caller's
1971
+ * fallback is detection, which is strictly better than refusing to disable
1972
+ * because a newer moshcode enabled the machine.
1973
+ */
1974
+ export function parseManifest(text) {
1975
+ let parsed;
1976
+ try {
1977
+ parsed = JSON.parse(String(text ?? ""));
1978
+ } catch {
1979
+ return null;
1980
+ }
1981
+ if (!parsed || parsed.version !== MANIFEST_VERSION || !Array.isArray(parsed.files)) return null;
1982
+ return parsed;
1983
+ }
1984
+
1985
+ /** Putting the machine back, as a plan — inspectable before it runs, like every other. */
1986
+ export function restorePlan(manifest) {
1987
+ const steps = manifest.files.map(({ path, content }) => (content === null || content === undefined
1988
+ ? { kind: "remove", path, why: "nothing was here before moshcode touched this machine" }
1989
+ : { kind: "write", path, content, why: "restore the file as it was before moshcode touched this machine" }));
1990
+ for (const r of manifest.restart || []) {
1991
+ steps.push({ kind: "run", command: r.command, args: r.args, why: "the resolver read those files at start" });
1992
+ }
1993
+ return { platform: manifest.platform, elevated: true, steps, notes: [] };
1994
+ }
1995
+
1996
+ /**
1997
+ * Files this repo did not write, and the best available guess at who did.
1998
+ *
1999
+ * Named rather than removed. Deleting a config file another tool owns is not a
2000
+ * thing to do quietly on someone's behalf — it may be the only reason their
2001
+ * machine resolves at all — so the decision is handed back with enough
2002
+ * information to make it.
2003
+ */
2004
+ export const KNOWN_DROPIN_WRITERS = new Map([
2005
+ // The one this was found on. It writes the same routing to a lower-sorting
2006
+ // filename, so `moshcode dns disable` removed its own file and left this one
2007
+ // resolving Moshpit names exactly as before.
2008
+ ["00-moshpit.conf", "the moshpit-proxy installer"],
2009
+ ]);
2010
+
2011
+ /** Our own files say so in their first line — that is what the header is for. */
2012
+ export function writtenByMoshcode(content) {
2013
+ return /^#\s*Written by `moshcode dns/m.test(String(content ?? ""));
2014
+ }
2015
+
2016
+ export function bridgeDropins(files, { bridge, ours = "moshpit.conf" } = {}) {
2017
+ const out = [];
2018
+ for (const file of files || []) {
2019
+ const name = String(file?.name ?? "");
2020
+ if (!name.endsWith(".conf")) continue;
2021
+ const servers = dropinNameservers(file.content);
2022
+ if (bridge && !servers.includes(bridge)) continue;
2023
+ if (!bridge && !servers.length) continue;
2024
+ out.push({
2025
+ name,
2026
+ servers,
2027
+ mine: name === ours || writtenByMoshcode(file.content),
2028
+ likelySource: KNOWN_DROPIN_WRITERS.get(name) || null,
2029
+ });
2030
+ }
2031
+ return out;
2032
+ }
2033
+
2034
+ /* ----------------------------------------------------------------- the verb */
2035
+
2036
+ import { promises as dnsPromises } from "node:dns";
2037
+ import { canOpenBrowser, openBrowser } from "./open-url.mjs";
2038
+ import { createParkingServer, DEFAULT_PARKING_HTTP_PORT } from "./parking-http.mjs";
2039
+ // Re-exported: the Pit URL moved to its own module so the parking responder can
2040
+ // use it without importing this one back.
2041
+ export { pitNameUrl } from "./pit-url.mjs";
2042
+ import { pitNameUrl } from "./pit-url.mjs";
2043
+ import { applyTrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs";
2044
+ import { readFile, writeFile } from "node:fs/promises";
2045
+ import { existsSync } from "node:fs";
2046
+ import { fileURLToPath } from "node:url";
2047
+ import {
2048
+ applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2049
+ requiredPort, startDaemon, stopDaemon,
2050
+ } from "./dns-system.mjs";
2051
+ import { escalateSelf } from "./escalate.mjs";
2052
+
2053
+ /** The parking host's address — an A record has to carry an IP, not a name. */
2054
+ export async function parkingAddress(host = DEFAULT_PARKING_HOST, lookup = dnsPromises.resolve4) {
2055
+ try {
2056
+ const [ip] = await lookup(host);
2057
+ return ip || null;
2058
+ } catch {
2059
+ return null;
2060
+ }
2061
+ }
2062
+
2063
+ const USAGE = `moshcode dns — resolve Moshpit names on this machine
2064
+
2065
+ moshcode dns enable run the bridge and route Moshpit TLDs to it
2066
+ moshcode dns disable stop it and remove the routing
2067
+ moshcode dns status what is running, what is routed, does it work
2068
+
2069
+ moshcode dns tlds list the TLDs claimed in the Pit
2070
+ moshcode dns resolve <name> show what a name resolves to, and why
2071
+ moshcode dns resolve <name> [--open] [--json]
2072
+ look a name up; --open opens a parked name in the Pit
2073
+ --json prints one stable document for scripts
2074
+ moshcode dns start [--port N] run the resolver in the foreground
2075
+ also serves parked names over HTTP so \`curl <name>\`
2076
+ lands on the Pit; --parking-port N, --no-parking-http
2077
+ moshcode dns install [--write] print the resolver config without applying it
2078
+
2079
+ --dry-run with enable/disable: print exactly what would be done
2080
+ --force with enable: proceed past a preflight refusal (a second drop-in
2081
+ setting DNS=, or a stranger already on the bridge's port)
2082
+ --remove-foreign
2083
+ with disable: also remove drop-ins that route to the bridge but
2084
+ were written by something other than moshcode. Named, never
2085
+ removed, without this.
2086
+ --backend linux only: systemd-resolved (default) or dnsmasq
2087
+ --port N the bridge's port (Windows must use 53 — NRPT carries no port)
2088
+ --no-trust with enable: route names but skip the local CA. They will
2089
+ resolve and then fail TLS, which is the state this flag exists
2090
+ to leave you in deliberately.
2091
+
2092
+ The registry speaks HTTP, not DNS, so nothing outside a browser can reach a
2093
+ Moshpit name until this bridge is running and your resolver points at it.
2094
+ \`enable\` edits system DNS and needs root (Administrator on Windows). It refuses
2095
+ to start from a machine where the switch cannot work, records what that machine
2096
+ looked like first, checks that both a Moshpit name and a clearnet name still
2097
+ resolve afterwards, and puts everything back if either one does not. \`disable\`
2098
+ replays that recording rather than deleting a filename it hopes is the only one.`;
2099
+
2100
+ function resolveArgument(args) {
2101
+ for (let i = 0; i < args.length; i++) {
2102
+ const arg = args[i];
2103
+ if (arg === "--registry" || arg === "--port") {
2104
+ i += 1;
2105
+ continue;
2106
+ }
2107
+ if (arg.startsWith("--registry=") || arg.startsWith("--port=") || arg.startsWith("-")) continue;
2108
+ return arg;
2109
+ }
2110
+ return null;
2111
+ }
2112
+
2113
+ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2114
+ const {
2115
+ // Injected so the decision logic in `enable` — refuse, apply, verify, roll
2116
+ // back — is testable without a resolver, a root shell or a machine whose
2117
+ // DNS is a real thing to break.
2118
+ tlds: fetchTldsImpl = fetchTlds,
2119
+ safety: catchAllSafetyImpl = catchAllSafety,
2120
+ preflight = preflightEnable,
2121
+ applyWith = applyWithRollback,
2122
+ verify = verifyResolution,
2123
+ bridgeStatus = daemonStatus,
2124
+ startBridge = startDaemon,
2125
+ proxyReachableImpl = proxyReachable,
2126
+ autoTrustImpl = createAutoTrust,
2127
+ stopBridge = stopDaemon,
2128
+ dropins = readDropins,
2129
+ manifestFile = manifestPath(),
2130
+ readManifest = async (path) => parseManifest(await defaultReadMaybe(path)),
2131
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
2132
+ escalate = escalateSelf,
2133
+ } = deps;
2134
+ const [sub, ...rest] = args;
2135
+ const flag = (name, fallback) => {
2136
+ const i = rest.indexOf(`--${name}`);
2137
+ return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
2138
+ };
2139
+ const registryBase = flag("registry", DEFAULT_REGISTRY_BASE);
2140
+
2141
+ if (!sub || sub === "help" || sub === "--help") {
2142
+ out(USAGE);
2143
+ return 0;
2144
+ }
2145
+
2146
+ const portIndex = rest.indexOf("--port");
2147
+ const rawPort = portIndex >= 0 ? rest[portIndex + 1] : DEFAULT_PORT;
2148
+ const port = parseDnsPort(rawPort);
2149
+ if (port === null) {
2150
+ out(`--port needs a decimal integer from 1 to 65535, got ${JSON.stringify(rawPort)}`);
2151
+ return 1;
2152
+ }
2153
+
2154
+ if (sub === "tlds") {
2155
+ const tlds = await fetchTlds({ registryBase });
2156
+ out(tlds.length ? tlds.map((t) => `.${t}`).join("\n") : "no TLDs claimed yet");
2157
+ return 0;
2158
+ }
2159
+
2160
+ if (sub === "trust") {
2161
+ // resolveArgument, not a bare `find(!startsWith("-"))`: the latter grabs the
2162
+ // value after `--registry`/`--port` (a URL does not start with "-"), so
2163
+ // `dns trust --registry <url> <name>` would trust the registry host instead
2164
+ // of <name>. The sibling `resolve` verb below already parses it this way.
2165
+ return trustName(resolveArgument(rest) || "", out, { registryBase, ...deps });
2166
+ }
2167
+
2168
+ if (sub === "resolve") {
2169
+ const name = resolveArgument(rest);
2170
+ if (!name) {
2171
+ out("usage: moshcode dns resolve <name>");
2172
+ return 1;
2173
+ }
2174
+ const result = await resolveName(name, { registryBase });
2175
+ // A parked name has no page at its own address — the A record points at a
2176
+ // host that routes by Host and will not answer for it. The Pit does have a
2177
+ // page for it, so say so instead of printing an IP that goes nowhere.
2178
+ const pitUrl = result.status === "parked" ? pitNameUrl(name, registryBase) : null;
2179
+ const asJson = rest.includes("--json");
2180
+ const explain = {
2181
+ live: () => `${name} → ${result.target}`,
2182
+ parked: () => `${name} → ${pitUrl} [parked — claimed but not pointed at an IP]`,
2183
+ unreachable: () => `${name} → NXDOMAIN [registry unreachable — not parking a name we could not look up]`,
2184
+ // A third-level name that missed both itself and its parent's wildcard.
2185
+ // Distinct from parked on purpose: a name under someone else's name is
2186
+ // not for sale, so there is no page to send anyone to.
2187
+ nxdomain: () => `${name} → NXDOMAIN [no such name, and its parent publishes no wildcard covering it]`,
2188
+ "not-a-name": () => `${name} → NXDOMAIN [not a Moshpit name: needs one label and one TLD, or one more label under such a name]`,
2189
+ };
2190
+ if (asJson) {
2191
+ out(JSON.stringify({
2192
+ name,
2193
+ status: result.status,
2194
+ target: result.target,
2195
+ pitUrl,
2196
+ }, null, 2));
2197
+ } else {
2198
+ out(explain[result.status]());
2199
+ }
2200
+
2201
+ // Opt-in rather than automatic: `resolve` is also what scripts and pipes
2202
+ // call, and launching a browser out of a lookup would be a surprise.
2203
+ if (rest.includes("--open") && pitUrl) {
2204
+ if (canOpenBrowser()) {
2205
+ if (!asJson) out(`opening ${pitUrl}`);
2206
+ openBrowser(pitUrl);
2207
+ } else if (!asJson) {
2208
+ out("(no browser to open here — copy the URL above)");
2209
+ }
2210
+ }
2211
+ return result.status === "live" || result.status === "parked" ? 0 : 1;
2212
+ }
2213
+
2214
+ if (sub === "start") {
2215
+ // Serve parked names ourselves when we can. The public parking host routes
2216
+ // by Host header and 404s a name it has never heard of, so pointing at
2217
+ // loopback — where the responder below is listening — is the difference
2218
+ // between `curl <name>` resolving and `curl <name>` working.
2219
+ const parkingPortIndex = rest.indexOf("--parking-port");
2220
+ const rawParkingHttpPort = parkingPortIndex >= 0 ? rest[parkingPortIndex + 1] : DEFAULT_PARKING_HTTP_PORT;
2221
+ const parkingHttpPort = parseDnsPort(rawParkingHttpPort);
2222
+ if (parkingHttpPort === null) {
2223
+ out(`--parking-port needs a decimal integer from 1 to 65535, got ${JSON.stringify(rawParkingHttpPort)}`);
2224
+ return 1;
2225
+ }
2226
+ let parking = null;
2227
+ if (!rest.includes("--no-parking-http")) {
2228
+ try {
2229
+ parking = await createParkingServer({
2230
+ port: parkingHttpPort,
2231
+ registryBase,
2232
+ onRequest: ({ host: h, target }) => out(` ${h} → ${target}`),
2233
+ });
2234
+ } catch (err) {
2235
+ const why = err?.code === "EACCES"
2236
+ ? `needs privileges to bind port ${parkingHttpPort}`
2237
+ : err?.code === "EADDRINUSE"
2238
+ ? `port ${parkingHttpPort} is already in use`
2239
+ : err?.message || String(err);
2240
+ out(`! parked names will not serve over HTTP — ${why}`);
2241
+ out(` (run with sudo, or pass --parking-port N and point your client at it)`);
2242
+ }
2243
+ }
2244
+
2245
+ // Loopback when we are answering for parked names; otherwise the public
2246
+ // parking host, which is all there ever was.
2247
+ const park = parking ? parking.address : await parkingAddress();
2248
+ if (!park) out("! parking host did not resolve — unpointed names will return NXDOMAIN");
2249
+ // Without these the bridge answers only for endings it is authoritative
2250
+ // for, which is correct for per-ending routing and fatal for catch-all.
2251
+ const upstreams = await discoverUpstreams();
2252
+ // Swallowing this was the quietest way to turn the namespace off. An empty
2253
+ // ending set makes isOurs() say no to every name, so with upstreams present
2254
+ // the bridge forwards the whole of Moshpit to the clearnet, which denies it
2255
+ // — every name NXDOMAIN, `dig` answering promptly, google.com fine, nothing
2256
+ // in any log. The line below has always warned about missing upstreams; the
2257
+ // list of what we answer for is worth at least as much.
2258
+ // `enable` already takes this injected; `start` reached past it to the
2259
+ // module, which is why the branch below had never been exercised.
2260
+ const tlds = await fetchTldsImpl({ registryBase }).then(
2261
+ (found) => ({ found }),
2262
+ (err) => ({ error: err?.message || String(err) }),
2263
+ );
2264
+ const tldSet = new Set(tlds.found || []);
2265
+ if (upstreams.length) out(`forwarding non-Moshpit lookups to ${upstreams.join(", ")}`);
2266
+ else out("! no upstreams found in /etc/resolv.conf — this bridge can only answer Moshpit names");
2267
+ if (tldSet.size) out(`answering for ${tldSet.size} endings`);
2268
+ else {
2269
+ out(`! could not read the ending list from ${registryBase}${tlds.error ? ` — ${tlds.error}` : ""}`);
2270
+ // Named as the outcome rather than the cause: "no endings loaded" reads
2271
+ // as a detail, and this is the whole namespace being off.
2272
+ out(upstreams.length
2273
+ ? " every Moshpit name will be forwarded to the clearnet and answer NXDOMAIN until this is fixed"
2274
+ : " this bridge has nothing to answer for and nothing to forward to");
2275
+ }
2276
+
2277
+ // Proxy mode: answer every live name with the local pinned-TLS proxy rather
2278
+ // than its origin, so a stock client gets a certificate it can verify.
2279
+ const proxyIndex = rest.indexOf("--proxy");
2280
+ let proxyAddress = null;
2281
+ if (proxyIndex >= 0) {
2282
+ const given = rest[proxyIndex + 1];
2283
+ const host = given && !given.startsWith("-") ? given : null;
2284
+ // A host name passes the reachability probe (connect resolves it) but a
2285
+ // DNS answer can only carry an address — isIP would leave both families
2286
+ // null, so the mode would announce success and then NODATA every live
2287
+ // name. That is the very outage the gate below exists to refuse, so it is
2288
+ // refused here for the same reason rather than warned about.
2289
+ if (host && !isIP(host)) {
2290
+ out(`! --proxy needs an IP address, not a host name like "${host}"`);
2291
+ out(" a name here answers every live Moshpit name with nothing, which reads");
2292
+ out(" as a total outage — pass the proxy's address (127.0.0.1 or ::1) instead.");
2293
+ return 1;
2294
+ }
2295
+ const candidates = host ? [host] : ["127.0.0.1", "::1"];
2296
+ const reachable = [];
2297
+ for (const candidate of candidates) {
2298
+ if (await proxyReachableImpl(candidate, PROXY_PORT)) reachable.push(candidate);
2299
+ }
2300
+ if (!reachable.length) {
2301
+ // Refused rather than warned. With the mode on and nothing behind it,
2302
+ // every Moshpit name on the machine resolves and then refuses the
2303
+ // connection — a total outage that reads as "the sites are down".
2304
+ out(`! nothing is listening on ${candidates.map((c) => `${c}:${PROXY_PORT}`).join(" or ")}`);
2305
+ out(" --proxy points every live Moshpit name there, so turning it on now would");
2306
+ out(" break all of them at once rather than fix their certificates.");
2307
+ out(" start moshpit-proxy first: https://github.com/profullstack/moshpit-proxy");
2308
+ return 1;
2309
+ }
2310
+ proxyAddress = {
2311
+ v4: reachable.find((a) => isIP(a) === 4) || null,
2312
+ v6: reachable.find((a) => isIP(a) === 6) || null,
2313
+ };
2314
+ out(`proxying every live name to ${reachable.join(", ")}:${PROXY_PORT} — certificates are verified there`);
2315
+ }
2316
+
2317
+ // The same two error codes the parking server above already explains, on
2318
+ // the port this command exists to bind. Without this they arrived as an
2319
+ // unhandled rejection — bin/moshcode calls main() with no top-level catch —
2320
+ // so a busy port answered with a node:dgram stack trace. This one is fatal
2321
+ // where the parking server's is not, so it ends the command rather than
2322
+ // carrying on: the shape serve.mjs uses for a step it cannot complete.
2323
+ // Trust every name as it resolves, rather than one command per name. Only
2324
+ // useful as root — the trust store is not writable otherwise — so it says
2325
+ // so once here instead of failing per name, forever, in the query log.
2326
+ const wantsTrustAll = rest.includes("--trust-all");
2327
+ if (wantsTrustAll && uid !== 0) {
2328
+ out("! --trust-all needs root to write to the trust store — certificates will not be installed");
2329
+ }
2330
+ const autoTrust = wantsTrustAll && uid === 0
2331
+ ? autoTrustImpl({ registryBase, out, uid })
2332
+ : null;
2333
+ if (autoTrust) out("trusting names as they resolve — only where the registry publishes a matching pin");
2334
+
2335
+ let server;
2336
+ try {
2337
+ server = await createServer({
2338
+ port,
2339
+ registryBase,
2340
+ parkingAddress: park,
2341
+ upstreams,
2342
+ tldSet,
2343
+ proxyAddress,
2344
+ onQuery: ({ name, address, forwarded }) => {
2345
+ out(` ${name} → ${address || "NXDOMAIN"}`);
2346
+ // Only a name that actually resolved to something of ours. A forwarded
2347
+ // clearnet name is not ours to trust, and NXDOMAIN has no origin to
2348
+ // fetch a certificate from.
2349
+ if (autoTrust && address && !forwarded) autoTrust.consider(name);
2350
+ },
2351
+ onError: (err) => out(`! resolver socket error — ${err?.message || err}`),
2352
+ });
2353
+ } catch (err) {
2354
+ const why = err?.code === "EACCES"
2355
+ ? `needs privileges to bind port ${port}`
2356
+ : err?.code === "EADDRINUSE"
2357
+ ? `port ${port} is already in use`
2358
+ : err?.message || String(err);
2359
+ out(`! resolver could not start — ${why}`);
2360
+ out(err?.code === "EACCES"
2361
+ ? " (run with sudo, or pass --port N and point your resolver there)"
2362
+ : ` (stop what is on port ${port}, or pass --port N and point your resolver there)`);
2363
+ // Opened before the bind was attempted, so it is listening right now. The
2364
+ // crash used to close it by killing the process; returning cannot, and an
2365
+ // orphaned listener holds the event loop open — the command would hang on
2366
+ // a busy port instead of exiting.
2367
+ await parking?.close();
2368
+ return 1;
2369
+ }
2370
+ if (parking) out(`parked names → http://${parking.address}:${parking.port} → ${registryBase}/n/<name>`);
2371
+ out(`moshpit resolver on ${server.address}:${server.port} (registry ${registryBase})`);
2372
+ out("point your resolver here with: moshcode dns install");
2373
+ return new Promise(() => {}); // foreground until Ctrl-C
2374
+ }
2375
+
2376
+ if (sub === "install") {
2377
+ const tlds = await fetchTlds({ registryBase });
2378
+ if (!tlds.length) {
2379
+ out("no TLDs claimed yet — nothing to route");
2380
+ return 1;
2381
+ }
2382
+ const conf = resolvedConf(tlds, { port });
2383
+ const target = MOSHPIT_DROPIN;
2384
+ if (rest.includes("--write")) {
2385
+ try {
2386
+ await writeFile(target, conf);
2387
+ out(`wrote ${target}`);
2388
+ out("now run: sudo systemctl restart systemd-resolved");
2389
+ return 0;
2390
+ } catch (err) {
2391
+ out(`could not write ${target}: ${err.message}`);
2392
+ out("(needs root — rerun with sudo, or install the config by hand below)");
2393
+ }
2394
+ }
2395
+ out(`# ${target}`);
2396
+ out(conf);
2397
+ out("# ...or, for dnsmasq:");
2398
+ out(dnsmasqConf(tlds, { port }));
2399
+ return 0;
2400
+ }
2401
+
2402
+ if (sub === "enable" || sub === "disable") {
2403
+ const platform = detectPlatform();
2404
+ if (!platform) {
2405
+ out(`unsupported platform: ${process.platform}`);
2406
+ return 1;
2407
+ }
2408
+ const dryRun = rest.includes("--dry-run");
2409
+ const force = rest.includes("--force");
2410
+ const linuxBackend = flag("backend", "systemd-resolved");
2411
+ const wanted = requiredPort(platform, port);
2412
+
2413
+ let tlds = [];
2414
+ let tldError = null;
2415
+ try {
2416
+ tlds = await fetchTldsImpl({ registryBase });
2417
+ } catch (err) {
2418
+ // disable does not need the list on Linux, and on macOS a stale list is
2419
+ // better than refusing to clean up because the registry is unreachable.
2420
+ // enable does need it, and the reason it is empty is the whole difference
2421
+ // between "nobody has claimed an ending" and "we could not ask" — see the
2422
+ // refusal below, which used to report the second as the first.
2423
+ tlds = [];
2424
+ tldError = err?.message || String(err);
2425
+ }
2426
+
2427
+ // Phase 1, and it runs before every other question is asked — including the
2428
+ // forwarding probe, whose answer means nothing while a stranger holds the
2429
+ // port it is probing.
2430
+ let cleared = { ok: true, blockers: [] };
2431
+ if (sub === "enable") {
2432
+ const recorded = await bridgeStatus().catch(() => ({ pid: null, running: false }));
2433
+ cleared = await preflight({
2434
+ port: wanted,
2435
+ ourPid: recorded.running ? recorded.pid : null,
2436
+ checkDropins: platform === "linux" && linuxBackend === "systemd-resolved",
2437
+ });
2438
+ out(cleared.ok
2439
+ ? `preflight clear — no competing DNS= drop-in, nothing eating queries on ${DEFAULT_HOST}:${wanted}`
2440
+ : "preflight BLOCKED");
2441
+ // Said out loud rather than passed over: a bridge nothing here started is
2442
+ // about to be handed every lookup on the machine, and the one line saying
2443
+ // so is what makes that a decision instead of a surprise.
2444
+ if (cleared.holder && cleared.holderForwards) {
2445
+ out(` note ${DEFAULT_HOST}:${wanted} is held by pid ${cleared.holder.pid || "?"}, which this run did not start — it forwards, so it is being used as-is`);
2446
+ }
2447
+ for (const dup of cleared.duplicates || []) {
2448
+ // Not a blocker, but this file is what keeps a disabled machine routed.
2449
+ // `disable` restores it rather than removing it, which is the correct
2450
+ // undo of *this* run and still leaves Moshpit names resolving — so the
2451
+ // fact is said here, before the switch, rather than discovered later.
2452
+ out(` note ${RESOLVED_DROPIN_DIR}/${dup.name} already points at this bridge — \`dns disable\` restores it, it does not remove it`);
2453
+ }
2454
+ for (const blocker of cleared.blockers) {
2455
+ out("");
2456
+ for (const line of blocker.lines) out(` ${line}`);
2457
+ }
2458
+ out("");
2459
+ // A dry run still says what it found and still describes the rest, which
2460
+ // is the point of asking for one while a machine is in this state.
2461
+ if (!cleared.ok && !force && !dryRun) {
2462
+ out("Refusing to switch this machine's DNS into a state it cannot resolve out of.");
2463
+ out("Nothing has been changed.");
2464
+ return 1;
2465
+ }
2466
+ if (!cleared.ok && force) out("--force: proceeding anyway.");
2467
+ }
2468
+
2469
+ // Decided before anything is written, because the routing config is written
2470
+ // first and the bridge is started after — so by the time a bad bridge is
2471
+ // visible, every lookup on the machine is already pointed at it.
2472
+ const safety = sub === "enable"
2473
+ ? await catchAllSafetyImpl({ port: wanted })
2474
+ : { safe: false, upstreams: [] };
2475
+ if (sub === "enable") {
2476
+ out(safety.safe
2477
+ ? `routing every lookup here — ${safety.why}`
2478
+ : `routing each ending by name — ${safety.why}`);
2479
+ if (!safety.safe && safety.upstreams.length) {
2480
+ out(" (that list is capped by the resolver and silently truncated; catch-all is the fix,");
2481
+ out(" but not at the price of this machine's DNS)");
2482
+ }
2483
+ out("");
2484
+ }
2485
+
2486
+ if (sub === "enable" && !tlds.length) {
2487
+ // Two very different situations, and reporting the second as the first
2488
+ // sends someone to claim an ending they already own. The registry holds
2489
+ // thousands; a machine that sees none of them has almost certainly failed
2490
+ // to ask rather than found an empty namespace.
2491
+ if (tldError) {
2492
+ out(`could not read the ending list from ${registryBase} — ${tldError}`);
2493
+ out(" nothing has been changed. This is a failure to ask, not an empty registry:");
2494
+ out(` check with curl -s '${registryBase}/api/moshpit/tlds?limit=5&offset=0'`);
2495
+ } else {
2496
+ out("the registry reports no claimed endings — nothing to route");
2497
+ out(` that is the registry's answer, not a local failure: ${registryBase}/api/moshpit/tlds`);
2498
+ }
2499
+ return 1;
2500
+ }
2501
+
2502
+ // What `disable` should do is a question about this machine, not a template.
2503
+ // The old answer — remove one hardcoded filename — is wrong on any box where
2504
+ // something else routes to the bridge, and wrong silently.
2505
+ const detectable = platform === "linux" && linuxBackend === "systemd-resolved";
2506
+ const removeForeign = rest.includes("--remove-foreign");
2507
+ let restore = null;
2508
+ let routed = [];
2509
+ let foreign = [];
2510
+ if (sub === "disable") {
2511
+ restore = await readManifest(manifestFile).catch(() => null);
2512
+ if (detectable) {
2513
+ routed = bridgeDropins(await dropins().catch(() => []), { bridge: `${DEFAULT_HOST}:${wanted}` });
2514
+ foreign = routed.filter((d) => !d.mine);
2515
+ }
2516
+ out(restore
2517
+ ? `restore point ${manifestFile} (taken ${restore.createdAt})`
2518
+ : `restore point none — falling back to detection${detectable ? "" : " (no drop-ins to read on this backend)"}`);
2519
+ for (const d of routed) {
2520
+ const who = d.mine ? "written by moshcode" : `not written by moshcode${d.likelySource ? ` — likely ${d.likelySource}` : ""}`;
2521
+ out(` found ${RESOLVED_DROPIN_DIR}/${d.name} → ${d.servers.join(" ")} (${who})`);
2522
+ }
2523
+ out("");
2524
+
2525
+ // Nothing points here and there is no recording to replay. Restarting the
2526
+ // resolver anyway is a real, if brief, DNS outage in exchange for nothing.
2527
+ if (!restore && detectable && !routed.length) {
2528
+ out("Moshpit names are not routed on this machine — nothing to undo.");
2529
+ const idle = await stopBridge();
2530
+ out(idle.stopped ? " ok bridge stopped" : ` -- bridge was not running${idle.reason ? ` (${idle.reason})` : ""}`);
2531
+ return 0;
2532
+ }
2533
+ }
2534
+
2535
+ let plan;
2536
+ try {
2537
+ if (sub === "enable") {
2538
+ plan = enablePlan({ platform, tlds, port: wanted, linuxBackend, upstreams: safety.safe ? safety.upstreams : [] });
2539
+ } else if (restore) {
2540
+ plan = restorePlan(restore);
2541
+ } else {
2542
+ plan = disablePlan({ platform, tlds, linuxBackend });
2543
+ }
2544
+ } catch (err) {
2545
+ out(err.message);
2546
+ return 1;
2547
+ }
2548
+
2549
+ // Asked for explicitly, and only then. Deleting a config file another tool
2550
+ // owns may be the only reason this machine resolves anything; that is not a
2551
+ // call to make on someone's behalf while they are not looking. When it is
2552
+ // asked for it overrides the restore point, which would otherwise put the
2553
+ // file back exactly as it was.
2554
+ if (sub === "disable" && removeForeign && foreign.length) {
2555
+ const paths = new Set(foreign.map((f) => `${RESOLVED_DROPIN_DIR}/${f.name}`));
2556
+ const files = plan.steps.filter((s) => s.kind !== "run" && !paths.has(s.path));
2557
+ const runs = plan.steps.filter((s) => s.kind === "run");
2558
+ plan = {
2559
+ ...plan,
2560
+ steps: [
2561
+ ...files,
2562
+ ...[...paths].map((path) => ({ kind: "remove", path, why: "--remove-foreign: it routes Moshpit names and moshcode did not write it" })),
2563
+ ...runs,
2564
+ ],
2565
+ };
2566
+ }
2567
+
2568
+ // The name whose resolution proves the bridge is answering, alongside the
2569
+ // clearnet one that proves it is forwarding.
2570
+ const moshpitProbe = tlds[0] ? `a.${tlds[0]}` : null;
2571
+ // Restoring files is not a rollback on Windows: NRPT rules are not files,
2572
+ // so the undo is the disable plan's commands rather than the enable plan's.
2573
+ const undoSteps = platform === "windows" ? disablePlan({ platform, tlds, linuxBackend }).steps : undefined;
2574
+
2575
+ if (dryRun) {
2576
+ out(`# ${sub} on ${platform} — nothing below has been run`);
2577
+ if (sub === "enable") out(`record ${manifestFile} # what this machine looks like right now, so disable can put it back`);
2578
+ out(describePlan(plan));
2579
+ // The phases that have no plan steps of their own. Printed because "what
2580
+ // would this do to my machine" has to include the part that undoes it,
2581
+ // and because a dry run is how someone decides whether to hand this
2582
+ // command root.
2583
+ out("");
2584
+ out(sub === "enable"
2585
+ ? "verify (both must answer, through this machine's own resolver)"
2586
+ : "verify (this machine must still resolve after the undo)");
2587
+ if (sub === "enable" && moshpitProbe) out(` ${moshpitProbe} # a Moshpit name — the bridge is reachable`);
2588
+ out(` ${CLEARNET_PROBE} # clearnet — ${sub === "enable" ? "the bridge forwards rather than swallows" : "restoring a broken prior state is the same outage in reverse"}`);
2589
+ out("");
2590
+ out("rollback (only if verify fails)");
2591
+ for (const step of plan.steps.filter((s) => s.kind === "write" || s.kind === "remove")) {
2592
+ out(` restore ${step.path}, or remove it if it did not exist before this run`);
2593
+ }
2594
+ for (const step of undoSteps || plan.steps.filter((s) => s.kind === "run")) {
2595
+ out(` run ${step.command} ${step.args.join(" ")}`);
2596
+ }
2597
+ if (sub === "disable" && foreign.length && !removeForeign) {
2598
+ out("");
2599
+ out("NOT removed — these route Moshpit names and moshcode did not write them:");
2600
+ for (const f of foreign) out(` ${RESOLVED_DROPIN_DIR}/${f.name}${f.likelySource ? ` # likely ${f.likelySource}` : ""}`);
2601
+ out(" re-run with --remove-foreign to remove them too");
2602
+ }
2603
+ return 0;
2604
+ }
2605
+
2606
+ // Checked before doing half of it: every step here needs root, and a
2607
+ // partial apply is worse than a clean refusal with the command to retry.
2608
+ //
2609
+ // Escalate this one command rather than telling the operator to re-run the
2610
+ // whole CLI. `sudo moshcode …` is a habit with a sharp edge — `moshcode
2611
+ // update` re-runs the installer, whose paths all come from $HOME, so an
2612
+ // escalated update installs into /root. The DNS state this writes lives in
2613
+ // /etc and /var/lib, never the operator's home, so raising just this
2614
+ // command loses nothing.
2615
+ if (plan.elevated && uid !== 0) {
2616
+ const escalated = escalate({
2617
+ args: ["dns", sub, ...rest],
2618
+ what: `dns ${sub}`,
2619
+ out,
2620
+ });
2621
+ if (escalated.ran) return escalated.code;
2622
+
2623
+ // No tty, no sudo, or already escalated and still not root: fall back to
2624
+ // the advice, and say why it could not just do it.
2625
+ out(`dns ${sub} edits system DNS and needs root.`);
2626
+ out(` sudo moshcode dns ${sub}${rest.length ? " " + rest.join(" ") : ""}`);
2627
+ out("");
2628
+ out("or see exactly what it would do first:");
2629
+ out(` moshcode dns ${sub} --dry-run`);
2630
+ return 1;
2631
+ }
2632
+
2633
+ const report = (results) => {
2634
+ for (const r of results) {
2635
+ const what = r.step.kind === "run" ? `${r.step.command} ${r.step.args.join(" ")}` : r.step.path;
2636
+ out(` ${r.ok ? "ok " : "FAIL"} ${r.step.kind.padEnd(6)} ${what}${r.ok ? "" : ` — ${r.error}`}`);
2637
+ }
2638
+ };
2639
+
2640
+ if (sub === "disable") {
2641
+ // Undone through the same machinery as the switch itself, because it is
2642
+ // the same risk: it rewrites resolver config and restarts the resolver,
2643
+ // and restoring a prior state that turns out not to resolve is the same
2644
+ // outage in the other direction. No Moshpit name is asked for — after a
2645
+ // disable those are *supposed* to stop resolving, so requiring one would
2646
+ // roll back every successful undo.
2647
+ const outcome = await applyWith(plan, { verify: () => verify({ moshpit: null }) });
2648
+ report(outcome.applied.results);
2649
+ for (const note of plan.notes || []) out(` note ${note}`);
2650
+ for (const check of outcome.verified.checks) {
2651
+ out(` ${check.ok ? "ok " : "FAIL"} verify ${check.name} (${check.kind})${check.ok ? "" : ` — ${check.error}`}`);
2652
+ }
2653
+ if (outcome.verified.skipped) out(` -- verify ${outcome.verified.skipped}`);
2654
+
2655
+ if (outcome.rolledBack) {
2656
+ out("");
2657
+ report(outcome.rolledBack.results);
2658
+ out("");
2659
+ out(outcome.applied.ok
2660
+ ? "Undoing the routing left this machine unable to resolve, so the undo was undone."
2661
+ : "Some steps failed, so the undo was never complete and has been reversed.");
2662
+ out(outcome.rolledBack.ok
2663
+ ? "Nothing has changed: the machine is as it was before this command ran."
2664
+ : "! the reversal did not fully succeed — this machine's DNS needs attention now.");
2665
+ for (const path of outcome.backups) out(` the previous config is at ${path}`);
2666
+ return 1;
2667
+ }
2668
+
2669
+ const stopped = await stopBridge();
2670
+ out(stopped.stopped ? " ok bridge stopped" : ` ok bridge was not running${stopped.reason ? ` (${stopped.reason})` : ""}`);
2671
+ // Consumed. Leaving it would let a later `disable` restore a machine to a
2672
+ // state that is two changes old.
2673
+ if (restore) {
2674
+ const cleared2 = await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the restore point has been used" }] });
2675
+ if (cleared2.ok) out(` ok remove ${manifestFile}`);
2676
+ }
2677
+ out("");
2678
+
2679
+ // The line the old implementation printed unconditionally, now only when
2680
+ // it is true. Whatever still points at the bridge is named instead.
2681
+ const left = removeForeign ? [] : foreign;
2682
+ if (left.length) {
2683
+ out("! Moshpit names still resolve here — these route to the bridge and were left alone:");
2684
+ for (const f of left) out(` ${RESOLVED_DROPIN_DIR}/${f.name}${f.likelySource ? ` (likely ${f.likelySource})` : ""}`);
2685
+ out(" moshcode did not write them, so removing them is your call:");
2686
+ out(` sudo moshcode dns disable --remove-foreign`);
2687
+ return 1;
2688
+ }
2689
+ out("Moshpit TLDs are back to your normal resolver.");
2690
+ return 0;
2691
+ }
2692
+
2693
+ // Recorded before anything moves, and written before anything moves, so a
2694
+ // run killed halfway leaves behind the one thing needed to undo it. The
2695
+ // per-file backup covers the file this run overwrites; this covers the
2696
+ // machine, which is a different question and the one `disable` has to ask.
2697
+ const point = await captureRestorePoint({
2698
+ plan,
2699
+ platform,
2700
+ backend: platform === "linux" ? linuxBackend : platform,
2701
+ bridge: `${DEFAULT_HOST}:${wanted}`,
2702
+ dropins,
2703
+ });
2704
+ const recorded2 = await applyPlan({
2705
+ steps: [{ kind: "write", path: manifestFile, content: `${JSON.stringify(point, null, 2)}\n`, why: "so disable can put this machine back" }],
2706
+ });
2707
+ out(recorded2.ok
2708
+ ? ` ok record ${manifestFile} (${point.files.length} file${point.files.length === 1 ? "" : "s"})`
2709
+ : ` warn could not record a restore point — \`dns disable\` will fall back to detection`);
2710
+
2711
+ // The bridge comes up before the routing points at it. The old order wrote
2712
+ // the config, restarted the resolver, and started the daemon afterwards —
2713
+ // which on catch-all routing is a window where every lookup on the machine
2714
+ // goes to a port with nothing behind it. It also left nothing to verify
2715
+ // against: there is no answer to ask for until the bridge exists.
2716
+ // Unless a bridge this run did not start already holds the port and
2717
+ // forwards. Preflight has just said out loud that it is being used as-is,
2718
+ // and starting ours anyway makes that line a lie: `startDaemon` decides
2719
+ // "already running" from our pidfile alone, so a stranger on the port is
2720
+ // invisible to it and it spawns a second daemon. Both then bind — the
2721
+ // socket is created with reuseAddr — and the kernel delivers to whichever
2722
+ // took the more specific address, so the holder the note promised would
2723
+ // serve is silently shadowed by the bridge it said would not be started.
2724
+ // Honoring the note is the whole of the fix.
2725
+ const reusing = cleared.holder && cleared.holderForwards ? cleared.holder : null;
2726
+ const started = reusing
2727
+ ? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true }
2728
+ : await startBridge({ port: wanted, registryBase, entry: cliEntry() });
2729
+ out(started.reused
2730
+ ? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
2731
+ : started.alreadyRunning
2732
+ ? ` ok bridge already running (pid ${started.pid})`
2733
+ : ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid})`);
2734
+
2735
+ const outcome = await applyWith(plan, {
2736
+ verify: () => verify({ moshpit: moshpitProbe }),
2737
+ rollbackSteps: undoSteps,
2738
+ });
2739
+ report(outcome.applied.results);
2740
+ for (const note of plan.notes || []) out(` note ${note}`);
2741
+
2742
+ for (const check of outcome.verified.checks) {
2743
+ out(` ${check.ok ? "ok " : "FAIL"} verify ${check.name} (${check.kind})${check.ok ? "" : ` — ${check.error}`}`);
2744
+ }
2745
+ if (outcome.verified.skipped) out(` -- verify ${outcome.verified.skipped}`);
2746
+ if (outcome.saved && !outcome.saved.ok) {
2747
+ // Not fatal: the rollback restores from what was read into memory before
2748
+ // anything was written. The on-disk copy only matters if this process is
2749
+ // killed mid-run, which is worth one line and not worth refusing over.
2750
+ out(" warn backup copy could not be written — an interrupted run would need manual repair");
2751
+ }
2752
+
2753
+ if (!outcome.rolledBack) {
2754
+ out("");
2755
+ // Resolving was never the whole job. A name that resolves and then fails
2756
+ // its TLS handshake reads as broken to the person who typed the URL, and
2757
+ // no CA will ever sign for a Moshpit name — so the local root that
2758
+ // moshpit-proxy generates is the only thing that closes it.
2759
+ if (!rest.includes("--no-trust")) {
2760
+ const trusted = await applyTrust(tlds, out, deps);
2761
+ // The claim this whole feature makes is that an ordinary client now
2762
+ // works, so check it as an ordinary client would — a plain HTTPS GET
2763
+ // with nothing relaxed. Only worth asking when a root actually went in
2764
+ // and there is a real name to ask about, and never fatal: a name that
2765
+ // resolves but serves no HTTPS is not a failure of the trust store.
2766
+ if (trusted?.ok && trusted.installed && moshpitProbe) {
2767
+ const proof = await verifyStockTls(moshpitProbe);
2768
+ out(proof.ok
2769
+ ? ` ok https://${moshpitProbe}/ verified by a stock client (${proof.status})`
2770
+ : ` -- https://${moshpitProbe}/ not verified yet — ${proof.why}`);
2771
+ }
2772
+ }
2773
+
2774
+ out("");
2775
+ out(`Moshpit names now resolve on this machine. Try: moshcode dns resolve ${moshpitProbe || "<name>"}`);
2776
+ out(`Routing covers the ${tlds.length} TLDs claimed right now. New ones do not route`);
2777
+ out("until you re-run this — there is no common suffix to match, so every TLD is listed.");
2778
+ out("Note: the bridge does not yet survive a reboot. Re-run `moshcode dns enable` after one.");
2779
+ return 0;
2780
+ }
2781
+
2782
+ out("");
2783
+ report(outcome.rolledBack.results);
2784
+ // Started by this run and no longer routed to, so leaving it would be a
2785
+ // process holding 5354 that the next enable's preflight refuses to run past.
2786
+ if (started.started) {
2787
+ const stopped = await stopBridge();
2788
+ if (stopped.stopped) out(" ok remove bridge started by this run");
2789
+ }
2790
+ // The machine is back where it started, so there is nothing to restore to.
2791
+ // A manifest that outlives its rollback is a loaded gun: the next `disable`
2792
+ // would replay it against a machine it no longer describes.
2793
+ if (recorded2.ok) {
2794
+ const dropped = await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the run it described was rolled back" }] });
2795
+ if (dropped.ok) out(` ok remove ${manifestFile}`);
2796
+ }
2797
+ out("");
2798
+ const failed = outcome.verified.checks.filter((c) => !c.ok).map((c) => c.name);
2799
+ out(outcome.applied.ok
2800
+ ? `Verification failed — ${failed.join(" and ")} did not resolve after the switch.`
2801
+ : "Some routing steps failed, so the switch was never complete.");
2802
+ if (outcome.rolledBack.ok) {
2803
+ out("Rolled back: this machine's DNS is exactly as it was before this command ran.");
2804
+ } else {
2805
+ out("! the rollback did not fully succeed — this machine's DNS needs attention now.");
2806
+ for (const path of outcome.backups) out(` the previous config is at ${path}`);
2807
+ out(" restore it, then: systemctl restart systemd-resolved");
2808
+ }
2809
+ return 1;
2810
+ }
2811
+
2812
+ if (sub === "status") {
2813
+ const platform = detectPlatform();
2814
+ const daemon = await daemonStatus();
2815
+ out(`platform ${platform || process.platform}`);
2816
+ out(`bridge ${daemon.running ? `running (pid ${daemon.pid})` : daemon.stale ? `NOT running — stale pidfile for ${daemon.pid}` : "not running"}`);
2817
+
2818
+ // Routing is read off the filesystem rather than remembered, so a config
2819
+ // someone edited or removed by hand is reported as it actually is.
2820
+ const marker = platform === "macos" ? "/etc/resolver" : MOSHPIT_DROPIN;
2821
+ const routed = platform === "linux" ? existsSync(marker) : platform === "macos" ? existsSync(marker) : null;
2822
+ out(`routing ${routed === null ? "(check NRPT: Get-DnsClientNrptRule)" : routed ? `configured (${marker})` : "not configured"}`);
2823
+
2824
+ // The state worth shouting about: names are pointed at a bridge that is not
2825
+ // there, so every Moshpit name fails instead of falling through.
2826
+ if (routed && !daemon.running) {
2827
+ out("");
2828
+ out("! routing is in place but the bridge is not running — Moshpit names will fail.");
2829
+ out(" fix with: sudo moshcode dns enable undo with: sudo moshcode dns disable");
2830
+ }
2831
+
2832
+ const known = await fetchTlds({ registryBase }).catch(() => null);
2833
+ const probe = known
2834
+ ? await resolveName(`probe.${known[0] || "moshpit"}`, { registryBase }).catch(() => null)
2835
+ : null;
2836
+ out(probe ? `registry reachable — ${known.length} TLDs claimed` : "registry unreachable");
2837
+
2838
+ // Routing is a snapshot: the TLD list is enumerated at enable time because
2839
+ // arbitrary endings share no suffix to match on. Drift is silent otherwise
2840
+ // — a name claimed after you enabled simply does not resolve.
2841
+ if (routed && known && platform === "linux") {
2842
+ const conf = await readFile(marker, "utf8").catch(() => "");
2843
+ const written = [...new Set((conf.match(/~[a-z0-9-]+/g) || []).map((t) => t.slice(1).toLowerCase()))];
2844
+ if (written.length && written.length !== known.length) {
2845
+ out("");
2846
+ out(`! routing covers ${written.length} TLDs but ${known.length} are claimed — re-run \`sudo moshcode dns enable\``);
2847
+ }
2848
+
2849
+ // The check that was missing. Comparing the file against the registry
2850
+ // compares two things we control and agrees with itself; the resolver is
2851
+ // the one that gets a vote, and it silently declines to take them all.
2852
+ const shortfall = routingShortfall(written, await acceptedDomains());
2853
+ if (shortfall && shortfall.missing.length) {
2854
+ out("");
2855
+ out(`! wrote ${shortfall.written} endings, the resolver accepted ${shortfall.accepted} — ${shortfall.missing.length} are not routed`);
2856
+ out(` missing: ${shortfall.missing.slice(0, 8).join(" ")}${shortfall.missing.length > 8 ? ` … and ${shortfall.missing.length - 8} more` : ""}`);
2857
+ out(" systemd-resolved caps how many search domains it takes and drops the rest:");
2858
+ out(" journalctl -u systemd-resolved | grep 'Argument list too long'");
2859
+ out(" a name in that list answers `moshcode dns resolve` and fails `curl`.");
2860
+ }
2861
+ }
2862
+ return 0;
2863
+ }
2864
+
2865
+ out(`unknown: dns ${sub}\n\n${USAGE}`);
2866
+ return 1;
2867
+ }
2868
+
2869
+ /** The CLI's own entry point, so the daemon re-invokes this same binary. */
2870
+ function cliEntry() {
2871
+ return fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));
2872
+ }