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.
- package/LICENSE +21 -0
- package/README.md +580 -0
- package/bin/moshcode.mjs +674 -0
- package/bin/moshscript.mjs +29 -0
- package/examples/alive.mosh +6 -0
- package/examples/scripting-the-cli.mosh +21 -0
- package/examples/team-secrets.mosh +20 -0
- package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
- package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
- package/examples/templates/bun-caddy-sqlite/README.md +97 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
- package/examples/templates/bun-caddy-sqlite/package.json +15 -0
- package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
- package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
- package/examples/templates/bun-caddy-sqlite/template.json +10 -0
- package/examples/templates/caddy-proxy/Caddyfile +36 -0
- package/examples/templates/caddy-proxy/README.md +104 -0
- package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-proxy/template.json +8 -0
- package/examples/templates/caddy-static/Caddyfile +16 -0
- package/examples/templates/caddy-static/README.md +90 -0
- package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-static/site/index.html +11 -0
- package/examples/templates/caddy-static/template.json +8 -0
- package/install.sh +194 -0
- package/package.json +28 -0
- package/prd/0000-template.md +49 -0
- package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
- package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
- package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
- package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
- package/prd/0005-hosted-moshpit-resolver.md +192 -0
- package/prd/0006-help.md +359 -0
- package/prd/0007-profullstack-site-init.md +1183 -0
- package/prd/README.md +26 -0
- package/src/ads.mjs +58 -0
- package/src/auth.mjs +193 -0
- package/src/cli-schema.mjs +533 -0
- package/src/cli.mjs +118 -0
- package/src/commands.mjs +259 -0
- package/src/completion.mjs +594 -0
- package/src/console.mjs +244 -0
- package/src/dns-system.mjs +404 -0
- package/src/dns.mjs +2872 -0
- package/src/doh-server.mjs +256 -0
- package/src/doh.mjs +218 -0
- package/src/engines.mjs +385 -0
- package/src/escalate.mjs +85 -0
- package/src/help.mjs +443 -0
- package/src/integrations.mjs +265 -0
- package/src/mcp-catalog.mjs +50 -0
- package/src/mcp.mjs +155 -0
- package/src/mirror.mjs +187 -0
- package/src/notify.mjs +86 -0
- package/src/open-url.mjs +34 -0
- package/src/parking-http.mjs +65 -0
- package/src/pins.mjs +190 -0
- package/src/pit-url.mjs +13 -0
- package/src/prd.mjs +341 -0
- package/src/pty.mjs +176 -0
- package/src/pwd.mjs +103 -0
- package/src/registry.mjs +37 -0
- package/src/release-install.mjs +191 -0
- package/src/runtime.mjs +161 -0
- package/src/selfupdate.mjs +215 -0
- package/src/serve.mjs +502 -0
- package/src/skills.mjs +93 -0
- package/src/tabs.mjs +144 -0
- package/src/templates.mjs +456 -0
- package/src/tools.mjs +231 -0
- package/src/trade.mjs +137 -0
- package/src/trust.mjs +712 -0
- package/src/tui.mjs +736 -0
- package/src/ui.mjs +49 -0
- package/src/uninstall.mjs +113 -0
- 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
|
+
}
|