moshcode 0.67.0 → 0.69.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.
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Blocklist filtering for the Moshpit bridge.
3
+ *
4
+ * With catch-all routing on, the bridge already sees every lookup this machine
5
+ * makes — that is what makes Moshpit names resolve at all. Filtering is the
6
+ * other thing a resolver in that position can do: refuse the names that exist
7
+ * only to track, mine, phish or advertise, before the connection is ever made.
8
+ *
9
+ * The policy lives here rather than in `dns.mjs` for two reasons. `dns.mjs` is a
10
+ * vendored copy of `@moshcoder/moshpit-dns` and every line added to it is a line
11
+ * to port by hand at the next sync; and the decision "is this name blocked" is
12
+ * pure — a name, some sets, an answer — which is worth being able to test
13
+ * without a socket.
14
+ *
15
+ * Three things this deliberately does not do:
16
+ *
17
+ * - It never turns DNS routing on. `moshcode dns filter on` writes a file and
18
+ * nothing else; a machine with no bridge in its query path is unaffected by
19
+ * it. Enabling the bridge stays something a human types.
20
+ * - It never fetches a list on its own. Lists are downloaded by
21
+ * `dns filter update` and read from a cache after that, so a resolver in the
22
+ * hot path of every lookup on the machine never waits on the network to
23
+ * decide, and an offline box keeps answering exactly as it did.
24
+ * - An allow entry always beats a block entry. A blocklist someone else
25
+ * maintains will eventually take down something you need, and the fix has to
26
+ * be one command that cannot be undone by the next `update`.
27
+ */
28
+
29
+ import { promises as fs } from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+
33
+ export const FILTER_VERSION = 1;
34
+
35
+ /**
36
+ * The lists on offer, all public and all fetched by URL at `update` time.
37
+ *
38
+ * `format` is how the file is written, not what it contains: `hosts` is the
39
+ * `0.0.0.0 name` shape, `domains` is one name per line. Both are parsed by
40
+ * `parseList`, which is lenient enough that the distinction is documentation —
41
+ * it matters when reading a source, not when reading a cache.
42
+ */
43
+ export const FILTER_CATALOG = [
44
+ {
45
+ id: "ads",
46
+ title: "Ads and trackers",
47
+ format: "hosts",
48
+ url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
49
+ note: "StevenBlack unified — the baseline nearly every blocker starts from",
50
+ },
51
+ {
52
+ id: "malware",
53
+ title: "Malware distribution",
54
+ format: "hosts",
55
+ url: "https://urlhaus.abuse.ch/downloads/hostfile/",
56
+ note: "URLhaus, abuse.ch — hosts serving malware payloads right now",
57
+ },
58
+ {
59
+ id: "phishing",
60
+ title: "Phishing",
61
+ format: "domains",
62
+ url: "https://phishing.army/download/phishing_army_blocklist_extended.txt",
63
+ note: "Phishing Army, extended",
64
+ },
65
+ {
66
+ id: "mining",
67
+ title: "Cryptomining",
68
+ format: "hosts",
69
+ url: "https://raw.githubusercontent.com/hoshsadiq/adblock-nocoin-list/master/hosts.txt",
70
+ note: "in-browser miners",
71
+ },
72
+ {
73
+ id: "adult",
74
+ title: "Adult content",
75
+ format: "hosts",
76
+ url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/porn/clefspeare13/hosts",
77
+ note: "off by default",
78
+ },
79
+ {
80
+ id: "gambling",
81
+ title: "Gambling",
82
+ format: "hosts",
83
+ url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/gambling/sinfonietta/hosts",
84
+ note: "off by default",
85
+ },
86
+ {
87
+ id: "social",
88
+ title: "Social networks",
89
+ format: "hosts",
90
+ url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/social/sinfonietta/hosts",
91
+ note: "off by default — blocks the sites themselves, not just their trackers",
92
+ },
93
+ {
94
+ id: "fakenews",
95
+ title: "Fake news",
96
+ format: "hosts",
97
+ url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/fakenews/hosts",
98
+ note: "off by default",
99
+ },
100
+ ];
101
+
102
+ export const CATALOG_BY_ID = new Map(FILTER_CATALOG.map((entry) => [entry.id, entry]));
103
+
104
+ /**
105
+ * What `filter on` turns on when told nothing else: the four categories that
106
+ * block things nobody asks for. Everything that blocks content a person might
107
+ * actually want — adult, gambling, social, fakenews — is opt-in by name.
108
+ */
109
+ export const DEFAULT_CATEGORIES = ["ads", "malware", "phishing", "mining"];
110
+
111
+ /**
112
+ * How a blocked name is answered.
113
+ *
114
+ * nxdomain the name does not exist. Fastest failure in a browser, and the
115
+ * one that caches; the default for that reason.
116
+ * zero 0.0.0.0 / :: — an address that goes nowhere. Slower to fail, but
117
+ * it keeps the name existing, which some captive software insists
118
+ * on before it will show its own error rather than hang.
119
+ * refuse REFUSED. Honest — "I will not answer this" — and the only mode a
120
+ * client can tell apart from a real absence, so it is the one to
121
+ * use while working out whether the filter is what broke something.
122
+ */
123
+ export const BLOCK_MODES = ["nxdomain", "zero", "refuse"];
124
+ export const DEFAULT_MODE = "nxdomain";
125
+
126
+ /** Where the config, the cached lists and the counters live. */
127
+ export function filterDir(env = process.env, home = os.homedir()) {
128
+ return env.MOSHCODE_DNS_FILTER_DIR || path.join(home, ".moshcode", "dns-filter");
129
+ }
130
+
131
+ export const configPath = (dir) => path.join(dir, "filter.json");
132
+ export const listPath = (dir, id) => path.join(dir, "lists", `${id}.txt`);
133
+ export const statsPath = (dir) => path.join(dir, "stats.json");
134
+
135
+ /**
136
+ * A name as this module compares them: lowercase, no trailing dot, no leading
137
+ * dot, and nothing that is not a name at all. Every entry in every set goes
138
+ * through here too, so a list written with mixed case or absolute names matches
139
+ * a query written the other way.
140
+ */
141
+ export function normaliseName(name) {
142
+ const clean = String(name ?? "").trim().toLowerCase().replace(/\.+$/, "").replace(/^\.+/, "");
143
+ if (!clean || clean.length > 253) return null;
144
+ if (!/^[a-z0-9_*](?:[a-z0-9_*.-]*[a-z0-9_*])?$/.test(clean)) return null;
145
+ return clean;
146
+ }
147
+
148
+ // The left-hand column of a hosts file: where the list points a name it is
149
+ // killing. These are not names to block — blocking 0.0.0.0 is meaningless and
150
+ // blocking 127.0.0.1 would be a bad afternoon.
151
+ const SINKHOLES = new Set([
152
+ "0.0.0.0", "127.0.0.1", "255.255.255.255", "::", "::1", "ff00::0", "ff02::1", "ff02::2", "ff02::3",
153
+ "fe80::1%lo0", "0000:0000:0000:0000:0000:0000:0000:0000",
154
+ ]);
155
+
156
+ // Names a hosts file always carries and that must never end up in a blocklist:
157
+ // they are the machine describing itself to itself.
158
+ const NEVER_BLOCK = new Set([
159
+ "localhost", "localhost.localdomain", "local", "broadcasthost",
160
+ "ip6-localhost", "ip6-loopback", "ip6-localnet", "ip6-mcastprefix",
161
+ "ip6-allnodes", "ip6-allrouters", "ip6-allhosts",
162
+ ]);
163
+
164
+ /**
165
+ * Read a blocklist in any of the shapes these sources ship in.
166
+ *
167
+ * Hosts lines (`0.0.0.0 a.example b.example`), plain one-name-per-line lists,
168
+ * and the `||name^` form an adblock-syntax list uses — the last only because it
169
+ * costs one regex and turns a whole class of source from "unparseable" into
170
+ * "works", not because anything in the catalogue needs it.
171
+ *
172
+ * Anything else on a line is dropped rather than guessed at. A blocklist parser
173
+ * that improvises produces a resolver that blocks something nobody can explain.
174
+ */
175
+ export function parseList(text) {
176
+ const out = [];
177
+ const seen = new Set();
178
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
179
+ const line = raw.split("#")[0].split("!")[0].trim();
180
+ if (!line) continue;
181
+
182
+ const fields = line.split(/\s+/);
183
+ let candidates;
184
+ if (fields.length > 1) {
185
+ // A hosts line is only a hosts line if the first field is a sinkhole. A
186
+ // two-field line that starts with a real address is somebody's actual
187
+ // /etc/hosts entry and none of our business.
188
+ if (!SINKHOLES.has(fields[0].toLowerCase())) continue;
189
+ candidates = fields.slice(1);
190
+ } else {
191
+ const adblock = fields[0].match(/^\|\|([^/^$]+)\^?$/);
192
+ candidates = [adblock ? adblock[1] : fields[0]];
193
+ }
194
+
195
+ for (const candidate of candidates) {
196
+ const name = normaliseName(candidate);
197
+ // A blocklist entry must have a dot. Without this rule one malformed line
198
+ // reading `com` blocks the internet, and the failure looks like the
199
+ // network being down rather than like a bad list.
200
+ if (!name || !name.includes(".") || name.includes("*")) continue;
201
+ if (NEVER_BLOCK.has(name) || SINKHOLES.has(name)) continue;
202
+ if (seen.has(name)) continue;
203
+ seen.add(name);
204
+ out.push(name);
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Does `name`, or any parent of it, appear in `set`?
212
+ *
213
+ * Blocking a name blocks everything under it — a list naming `doubleclick.net`
214
+ * means the tracker at `stats.g.doubleclick.net` too, and every list in the
215
+ * catalogue is written on that assumption. Returns the entry that matched, so
216
+ * the answer to "why was this blocked" is a rule someone can look up rather
217
+ * than a boolean.
218
+ *
219
+ * The walk includes the bare rightmost label. No fetched list can contain one
220
+ * (`parseList` requires a dot), so this only ever fires for something typed by
221
+ * hand — which is how `filter block eggs` takes out a whole Moshpit ending.
222
+ */
223
+ export function matchSuffix(name, set) {
224
+ const clean = normaliseName(name);
225
+ if (!clean || !set || set.size === 0) return null;
226
+ const labels = clean.split(".");
227
+ for (let i = 0; i < labels.length; i++) {
228
+ const candidate = labels.slice(i).join(".");
229
+ if (set.has(candidate)) return candidate;
230
+ }
231
+ return null;
232
+ }
233
+
234
+ const toSet = (names) => {
235
+ const set = new Set();
236
+ for (const name of names || []) {
237
+ const clean = normaliseName(name);
238
+ if (clean) set.add(clean);
239
+ }
240
+ return set;
241
+ };
242
+
243
+ /**
244
+ * The decision half, with no filesystem under it.
245
+ *
246
+ * `lists` is a Map of category id to a Set of names, iterated in order so the
247
+ * category reported for a name that several lists carry is stable rather than
248
+ * whichever one happened to be built first.
249
+ */
250
+ export function createFilter({
251
+ enabled = true,
252
+ mode = DEFAULT_MODE,
253
+ lists = new Map(),
254
+ allow = [],
255
+ block = [],
256
+ } = {}) {
257
+ const allowSet = toSet(allow);
258
+ const blockSet = toSet(block);
259
+ const counters = { queries: 0, blocked: 0, byList: Object.create(null), recent: [] };
260
+
261
+ const decide = (name) => {
262
+ counters.queries += 1;
263
+ if (!enabled) return null;
264
+ const clean = normaliseName(name);
265
+ if (!clean) return null;
266
+
267
+ // Allow first, and unconditionally. This is the escape hatch for a list
268
+ // that took down something real, so nothing below may override it.
269
+ if (matchSuffix(clean, allowSet)) return null;
270
+
271
+ let hit = null;
272
+ const custom = matchSuffix(clean, blockSet);
273
+ if (custom) hit = { list: "custom", rule: custom };
274
+ else {
275
+ for (const [id, set] of lists) {
276
+ const rule = matchSuffix(clean, set);
277
+ if (rule) { hit = { list: id, rule }; break; }
278
+ }
279
+ }
280
+ if (!hit) return null;
281
+
282
+ counters.blocked += 1;
283
+ counters.byList[hit.list] = (counters.byList[hit.list] || 0) + 1;
284
+ // A short tail, not a log. Enough to answer "what did it just block" in
285
+ // `filter status`; not enough to become a record of everything a person
286
+ // looked up, which is not a thing a resolver should keep by default.
287
+ counters.recent.unshift({ name: clean, ...hit });
288
+ if (counters.recent.length > 20) counters.recent.pop();
289
+ return { ...hit, mode };
290
+ };
291
+
292
+ return {
293
+ enabled,
294
+ mode,
295
+ decide,
296
+ counters,
297
+ stats: () => ({
298
+ queries: counters.queries,
299
+ blocked: counters.blocked,
300
+ byList: { ...counters.byList },
301
+ recent: counters.recent.slice(),
302
+ }),
303
+ sizes: () => {
304
+ const out = {};
305
+ for (const [id, set] of lists) out[id] = set.size;
306
+ if (blockSet.size) out.custom = blockSet.size;
307
+ return out;
308
+ },
309
+ };
310
+ }
311
+
312
+ /** The shape written to `filter.json`, with every field defaulted. */
313
+ export function normaliseConfig(raw = {}) {
314
+ const categories = Array.isArray(raw.categories)
315
+ ? raw.categories.filter((id) => CATALOG_BY_ID.has(id))
316
+ : DEFAULT_CATEGORIES.slice();
317
+ return {
318
+ version: FILTER_VERSION,
319
+ enabled: Boolean(raw.enabled),
320
+ mode: BLOCK_MODES.includes(raw.mode) ? raw.mode : DEFAULT_MODE,
321
+ categories,
322
+ block: Array.from(toSet(raw.block)),
323
+ allow: Array.from(toSet(raw.allow)),
324
+ updatedAt: raw.updatedAt || null,
325
+ };
326
+ }
327
+
328
+ export async function readConfig(dir) {
329
+ try {
330
+ return normaliseConfig(JSON.parse(await fs.readFile(configPath(dir), "utf8")));
331
+ } catch (err) {
332
+ // A missing file is the default answer — filtering off — not an error. A
333
+ // corrupt one is reported, because silently reverting to "off" is how a
334
+ // machine ends up unfiltered while its owner believes otherwise.
335
+ if (err?.code === "ENOENT") return normaliseConfig({ enabled: false });
336
+ throw new Error(`${configPath(dir)} is not readable as JSON — ${err?.message || err}`);
337
+ }
338
+ }
339
+
340
+ export async function writeConfig(dir, config) {
341
+ const next = normaliseConfig(config);
342
+ await fs.mkdir(dir, { recursive: true });
343
+ await fs.writeFile(configPath(dir), `${JSON.stringify(next, null, 2)}\n`);
344
+ return next;
345
+ }
346
+
347
+ /** What is cached for a category, without loading the whole thing. */
348
+ export async function listStatus(dir, id) {
349
+ try {
350
+ const stat = await fs.stat(listPath(dir, id));
351
+ return { id, cached: true, bytes: stat.size, at: stat.mtime.toISOString() };
352
+ } catch {
353
+ return { id, cached: false, bytes: 0, at: null };
354
+ }
355
+ }
356
+
357
+ export async function readCachedList(dir, id) {
358
+ try {
359
+ const text = await fs.readFile(listPath(dir, id), "utf8");
360
+ return toSet(text.split("\n"));
361
+ } catch (err) {
362
+ if (err?.code === "ENOENT") return null;
363
+ throw err;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Fetch one category and write it to the cache.
369
+ *
370
+ * The write goes to a temporary file and is renamed into place, so a bridge
371
+ * reloading in the middle of an update reads either the old list or the new one
372
+ * and never half of either.
373
+ */
374
+ export async function updateList(dir, id, { fetchImpl = fetch, timeoutMs = 60000 } = {}) {
375
+ const source = CATALOG_BY_ID.get(id);
376
+ if (!source) throw new Error(`no such list: ${id}`);
377
+ const response = await fetchImpl(source.url, { signal: AbortSignal.timeout(timeoutMs), redirect: "follow" });
378
+ if (!response.ok) throw new Error(`${source.url} answered ${response.status}`);
379
+ const names = parseList(await response.text());
380
+ // A source that parses to nothing is a source that changed shape, moved, or
381
+ // answered with an error page carrying a 200. Overwriting a good cache with
382
+ // that would quietly unfilter the machine.
383
+ if (!names.length) throw new Error(`${source.url} parsed to nothing — leaving the cached copy alone`);
384
+ await fs.mkdir(path.dirname(listPath(dir, id)), { recursive: true });
385
+ const temp = `${listPath(dir, id)}.tmp`;
386
+ await fs.writeFile(temp, `${names.join("\n")}\n`);
387
+ await fs.rename(temp, listPath(dir, id));
388
+ return { id, count: names.length, url: source.url };
389
+ }
390
+
391
+ /**
392
+ * Load config and cached lists into a live filter, and keep it current.
393
+ *
394
+ * The bridge is a long-lived process and the config is edited by a separate
395
+ * command, so a handle re-reads when the config file's mtime moves. It checks at
396
+ * most every `reloadMs`, off the back of a query rather than on a timer: a timer
397
+ * in a resolver is a thing that keeps a process alive after its socket has
398
+ * closed, and this way an idle bridge does no work at all.
399
+ */
400
+ export async function openFilter({ dir = filterDir(), reloadMs = 5000, now = () => Date.now() } = {}) {
401
+ let filter = createFilter({ enabled: false });
402
+ let config = normaliseConfig({ enabled: false });
403
+ let stamp = null;
404
+ let checkedAt = now();
405
+ let loading = null;
406
+ // Both clocks start now rather than at zero, so opening a handle does not
407
+ // write a stats file and re-read a config on its very first query. A process
408
+ // that asks one question and exits should leave nothing behind.
409
+ let flushedAt = now();
410
+ let flushing = false;
411
+
412
+ const configStamp = async () => {
413
+ try {
414
+ return (await fs.stat(configPath(dir))).mtimeMs;
415
+ } catch {
416
+ return null;
417
+ }
418
+ };
419
+
420
+ const load = async () => {
421
+ config = await readConfig(dir);
422
+ const lists = new Map();
423
+ for (const id of config.categories) {
424
+ const set = await readCachedList(dir, id);
425
+ if (set) lists.set(id, set);
426
+ }
427
+ const carried = filter.counters;
428
+ filter = createFilter({
429
+ enabled: config.enabled,
430
+ mode: config.mode,
431
+ lists,
432
+ allow: config.allow,
433
+ block: config.block,
434
+ });
435
+ // Counters survive a reload. They describe what this bridge has done since
436
+ // it started, and losing them every time a name is allowlisted would make
437
+ // the numbers meaningless exactly when someone is watching them.
438
+ Object.assign(filter.counters, carried);
439
+ stamp = await configStamp();
440
+ return filter;
441
+ };
442
+
443
+ const refresh = () => {
444
+ if (loading) return loading;
445
+ loading = (async () => {
446
+ try {
447
+ if (await configStamp() !== stamp) await load();
448
+ } catch {
449
+ // Keep serving with what is already loaded. A resolver that stops
450
+ // answering because a config file went strange is worse than one
451
+ // running a slightly stale policy.
452
+ } finally {
453
+ loading = null;
454
+ }
455
+ })();
456
+ return loading;
457
+ };
458
+
459
+ const flush = () => {
460
+ if (flushing) return;
461
+ flushing = true;
462
+ const payload = { at: new Date(now()).toISOString(), ...filter.stats(), lists: filter.sizes() };
463
+ fs.mkdir(dir, { recursive: true })
464
+ .then(() => fs.writeFile(statsPath(dir), `${JSON.stringify(payload, null, 2)}\n`))
465
+ .catch(() => {})
466
+ .finally(() => { flushing = false; });
467
+ };
468
+
469
+ await load();
470
+
471
+ return {
472
+ get config() { return config; },
473
+ get mode() { return filter.mode; },
474
+ get enabled() { return filter.enabled; },
475
+ reload: load,
476
+ stats: () => filter.stats(),
477
+ sizes: () => filter.sizes(),
478
+ decide(name) {
479
+ const at = now();
480
+ if (at - checkedAt >= reloadMs) {
481
+ checkedAt = at;
482
+ refresh(); // deliberately not awaited — this query uses the loaded policy
483
+ }
484
+ const verdict = filter.decide(name);
485
+ if (at - flushedAt >= 10000) {
486
+ flushedAt = at;
487
+ flush();
488
+ }
489
+ return verdict;
490
+ },
491
+ };
492
+ }
493
+
494
+ export async function readStats(dir) {
495
+ try {
496
+ return JSON.parse(await fs.readFile(statsPath(dir), "utf8"));
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
package/src/dns.mjs CHANGED
@@ -376,6 +376,31 @@ export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = B
376
376
  ]);
377
377
  }
378
378
 
379
+ /**
380
+ * The answer for a name a blocklist says no to.
381
+ *
382
+ * Three shapes, because the right one depends on what is asking. See the note
383
+ * on `BLOCK_MODES` in dns-filter.mjs for which to reach for; the wire detail is
384
+ * that `zero` only means an address for a question that wanted one — answering
385
+ * 0.0.0.0 to an MX or an HTTPS query is not a lie a client knows how to read,
386
+ * so those get NODATA and the name goes on existing.
387
+ */
388
+ export function blockedReply(query, buf, mode = "nxdomain", ttl = DEFAULT_TTL) {
389
+ const question = buf.subarray(12, query.questionEnd);
390
+ const bare = (rcode) => Buffer.concat([
391
+ header(query.id, { rcode, answers: 0, recursionDesired: query.recursionDesired }),
392
+ question,
393
+ ]);
394
+ if (mode === "refuse") return bare(RCODE_REFUSED);
395
+ if (mode === "zero") {
396
+ const wantsAddress = query.class === CLASS_IN && (query.type === TYPE_A || query.type === TYPE_AAAA);
397
+ return wantsAddress
398
+ ? buildResponse(query, buf, query.type === TYPE_AAAA ? "::" : "0.0.0.0", ttl, true)
399
+ : bare(RCODE_OK);
400
+ }
401
+ return bare(RCODE_NXDOMAIN);
402
+ }
403
+
379
404
  /* ------------------------------------------------------------------ registry */
380
405
 
381
406
  /**
@@ -1164,6 +1189,10 @@ export function createServer(options = {}) {
1164
1189
  // Banning is layered on the rate limit rather than replacing it: the limit
1165
1190
  // decides what an offence is, the ban decides how long it costs.
1166
1191
  ban = null,
1192
+ // A handle from dns-filter.mjs, or anything with `decide(name)`. Null means
1193
+ // no filtering at all — not an empty blocklist, which would still cost a
1194
+ // walk up the labels of every name on the machine.
1195
+ filter = null,
1167
1196
  } = options;
1168
1197
  const limiter = rateLimit ? createRateLimiter(rateLimit) : null;
1169
1198
  const bans = ban ? createBanList(ban) : null;
@@ -1224,6 +1253,23 @@ export function createServer(options = {}) {
1224
1253
  return refuse();
1225
1254
  }
1226
1255
 
1256
+ // Blocklist filtering, above the fork between "ours" and "forwarded" on
1257
+ // purpose: a name a list says no to is refused whether it is a Moshpit name
1258
+ // or a clearnet one. Below the fork it would only ever cover one of them,
1259
+ // which is a filter with a documented way around it.
1260
+ //
1261
+ // The decision is synchronous by design — it is a walk up the labels of one
1262
+ // name through some Sets — so the ordinary query path gains no await and no
1263
+ // network call from having filtering on.
1264
+ const verdict = filter ? filter.decide(query.name) : null;
1265
+ if (verdict) {
1266
+ onQuery({ name: query.name, type: query.type, address: null, blocked: verdict });
1267
+ try {
1268
+ socket.send(blockedReply(query, msg, verdict.mode, ttl), rinfo.port, rinfo.address);
1269
+ } catch { /* client vanished */ }
1270
+ return;
1271
+ }
1272
+
1227
1273
  // Catch-all routing puts every lookup on the machine through here. Anything
1228
1274
  // that is not an ending someone has claimed belongs to the ordinary
1229
1275
  // internet and is relayed byte for byte, including question types this
@@ -2262,8 +2308,12 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine
2262
2308
  moshcode dns start [--port N] run the resolver in the foreground
2263
2309
  also serves parked names over HTTP so \`curl <name>\`
2264
2310
  lands on the Pit; --parking-port N, --no-parking-http
2311
+ --no-filter runs it with blocklists off
2265
2312
  moshcode dns install [--write] print the resolver config without applying it
2266
2313
 
2314
+ moshcode dns filter block ads, trackers, malware and phishing at the
2315
+ resolver — \`moshcode dns filter help\` for the verbs
2316
+
2267
2317
  --dry-run with enable/disable: print exactly what would be done
2268
2318
  --force with enable: proceed past a preflight refusal (a second drop-in
2269
2319
  setting DNS=, or a stranger already on the bridge's port)
@@ -2325,6 +2375,11 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2325
2375
  readManifest = async (path) => parseManifest(await defaultReadMaybe(path)),
2326
2376
  uid = typeof process.getuid === "function" ? process.getuid() : 0,
2327
2377
  escalate = escalateSelf,
2378
+ // Injected for the same reason as everything above: the enable/disable
2379
+ // decision tree reads the host's drop-ins only on linux/systemd-resolved,
2380
+ // and a test running on any other OS must be able to say "linux" without
2381
+ // lying about the machine it is on.
2382
+ platform: platformImpl = detectPlatform,
2328
2383
  } = deps;
2329
2384
  const [sub, ...rest] = args;
2330
2385
  const flag = (name, fallback) => {
@@ -2346,6 +2401,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2346
2401
  return 1;
2347
2402
  }
2348
2403
 
2404
+ // Its own verb, its own file. `dns filter` reads and writes a config the
2405
+ // bridge picks up on its own, so it needs none of the machinery above and
2406
+ // never escalates: nothing it does touches the machine's resolver.
2407
+ if (sub === "filter") {
2408
+ const { filterCommand } = await import("./dns-filter-cli.mjs");
2409
+ return filterCommand(rest, out, deps.filter || {});
2410
+ }
2411
+
2349
2412
  if (sub === "tlds") {
2350
2413
  const tlds = await fetchTlds({ registryBase });
2351
2414
  out(tlds.length ? tlds.map((t) => `.${t}`).join("\n") : "no TLDs claimed yet");
@@ -2527,6 +2590,28 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2527
2590
  : null;
2528
2591
  if (autoTrust) out("trusting names as they resolve — only where the registry publishes a matching pin");
2529
2592
 
2593
+ // Read once at start and reloaded by the handle when the config file moves,
2594
+ // so `dns filter` never has to restart the bridge to take effect. A failure
2595
+ // here is reported and then ignored: a resolver that will not start because
2596
+ // a blocklist would not load is a worse outcome than an unfiltered one.
2597
+ let filter = null;
2598
+ if (!rest.includes("--no-filter")) {
2599
+ try {
2600
+ // Imported here rather than at the top: this file is the vendored copy
2601
+ // of @moshcoder/moshpit-dns and a top-level import of a moshcode-only
2602
+ // module is one more hunk to reconcile at every sync.
2603
+ const { openFilter } = await import("./dns-filter.mjs");
2604
+ filter = await openFilter();
2605
+ if (filter.enabled) {
2606
+ const sizes = filter.sizes();
2607
+ const total = Object.values(sizes).reduce((sum, n) => sum + n, 0);
2608
+ out(`filtering ${total.toLocaleString()} names (${Object.keys(sizes).join(", ") || "no lists cached"}) — blocked names answered as ${filter.mode}`);
2609
+ }
2610
+ } catch (err) {
2611
+ out(`! filtering is off — ${err?.message || err}`);
2612
+ }
2613
+ }
2614
+
2530
2615
  let server;
2531
2616
  try {
2532
2617
  server = await createServer({
@@ -2536,7 +2621,9 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2536
2621
  upstreams,
2537
2622
  tldSet,
2538
2623
  proxyAddress,
2539
- onQuery: ({ name, address, forwarded }) => {
2624
+ filter,
2625
+ onQuery: ({ name, address, forwarded, blocked }) => {
2626
+ if (blocked) return out(` ${name} ✗ blocked (${blocked.list}: ${blocked.rule})`);
2540
2627
  out(` ${name} → ${address || "NXDOMAIN"}`);
2541
2628
  // Only a name that actually resolved to something of ours. A forwarded
2542
2629
  // clearnet name is not ours to trust, and NXDOMAIN has no origin to
@@ -2595,7 +2682,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2595
2682
  }
2596
2683
 
2597
2684
  if (sub === "enable" || sub === "disable") {
2598
- const platform = detectPlatform();
2685
+ const platform = platformImpl();
2599
2686
  if (!platform) {
2600
2687
  out(`unsupported platform: ${process.platform}`);
2601
2688
  return 1;