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.
- package/README.md +66 -22
- package/bin/moshcode.mjs +5 -0
- package/package.json +2 -2
- package/src/cli-schema.mjs +37 -0
- package/src/commands.mjs +1 -0
- package/src/completion.mjs +3 -3
- package/src/dns-filter-cli.mjs +407 -0
- package/src/dns-filter.mjs +500 -0
- package/src/dns.mjs +89 -2
- package/src/shorten.mjs +212 -0
- package/src/tools.mjs +26 -0
- package/src/tui.mjs +7 -0
|
@@ -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
|
-
|
|
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 =
|
|
2685
|
+
const platform = platformImpl();
|
|
2599
2686
|
if (!platform) {
|
|
2600
2687
|
out(`unsupported platform: ${process.platform}`);
|
|
2601
2688
|
return 1;
|