moshcode 0.68.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 CHANGED
@@ -52,6 +52,7 @@ or miss one that does. A test fails the build when it drifts.
52
52
  | `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
53
53
  | `moshcode site` <br>`serve` | hosting | install web-server config for a Moshpit name |
54
54
  | `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
55
+ | `moshcode shorten` <br>`short` `link` | hosting | mint a short link on the pit — /f/<code> follows to your url |
55
56
  | `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — twenty-two games, no menus |
56
57
  | `moshcode pwd` <br>`where` | system | show the current directory and git context |
57
58
  | `moshcode engines` | engines | list engines and installation status |
@@ -1278,6 +1279,41 @@ Full walkthrough, including the layer-by-layer way to debug it and the limits
1278
1279
  worth knowing before you build:
1279
1280
  **[docs/hosting-a-moshpit-name.md](docs/hosting-a-moshpit-name.md)**.
1280
1281
 
1282
+ ### Filtering what resolves
1283
+
1284
+ With `dns enable` on, the bridge already sees every lookup this machine makes.
1285
+ `dns filter` is the other thing a resolver in that position can do: refuse the
1286
+ names that exist only to advertise, track, mine or phish, before a connection is
1287
+ ever opened. Nothing is filtered until you ask for it.
1288
+
1289
+ ```sh
1290
+ moshcode dns filter on # ads, malware, phishing, mining
1291
+ moshcode dns filter update # fetch the lists — nothing downloads on its own
1292
+ moshcode dns filter # what is on, and what it has blocked
1293
+ moshcode dns filter test ads.example.com # would this be blocked, and by which rule
1294
+ moshcode dns filter allow news.example # never block it, whatever any list says
1295
+ ```
1296
+
1297
+ | list | what it blocks |
1298
+ |---|---|
1299
+ | `ads` | ads and trackers — StevenBlack unified, on by default |
1300
+ | `malware` | hosts serving malware — URLhaus, on by default |
1301
+ | `phishing` | Phishing Army, on by default |
1302
+ | `mining` | in-browser cryptominers, on by default |
1303
+ | `adult` `gambling` `social` `fakenews` | opt in by name with `filter add <list>` |
1304
+
1305
+ Three things worth knowing. Blocking a name blocks everything under it, and an
1306
+ `allow` rule always wins — that is the escape hatch for the day a list someone
1307
+ else maintains takes down something you need. Changes reach a running bridge
1308
+ within about five seconds, so nothing has to be restarted. And a blocked name is
1309
+ answered `NXDOMAIN` by default; `--mode zero` answers `0.0.0.0` instead, and
1310
+ `--mode refuse` says `REFUSED`, which is the one a client can tell apart from a
1311
+ real absence while you work out whether the filter is what broke something.
1312
+
1313
+ `dns filter` never turns DNS routing on — it writes a config and nothing else,
1314
+ so on a machine whose resolver has never heard of the bridge it changes nothing.
1315
+ Its status says so rather than reporting `on` and leaving you to find out.
1316
+
1281
1317
  ## Shell completion
1282
1318
 
1283
1319
  MoshCode can print context-aware completion scripts for its commands, engines,
package/bin/moshcode.mjs CHANGED
@@ -36,6 +36,7 @@ import { dnsCommand } from "../src/dns.mjs";
36
36
  import { nameCommand } from "../src/name-link.mjs";
37
37
  import { templateCommand } from "../src/templates.mjs";
38
38
  import { serveCommand } from "../src/serve.mjs";
39
+ import { shortenCommand } from "../src/shorten.mjs";
39
40
  import { createDohServer, nginxDohSite, parseDohPort, parseGuardArgs, DEFAULT_DOH_PORT, DOH_PATH } from "../src/doh-server.mjs";
40
41
  import { completionScript } from "../src/completion.mjs";
41
42
  import { CORE_CLI_COMMAND_NAMES } from "../src/cli-schema.mjs";
@@ -587,6 +588,10 @@ async function main() {
587
588
  process.exitCode = (await templateCommand(rest)) || 0;
588
589
  return;
589
590
  }
591
+ if (cmd === "shorten" || cmd === "short" || cmd === "link") {
592
+ process.exitCode = (await shortenCommand(rest, { prefix: `moshcode ${cmd}` })) || 0;
593
+ return;
594
+ }
590
595
 
591
596
  if (cmd === "pwd" || cmd === "where") {
592
597
  const location = locate();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -455,6 +455,30 @@ export const CORE_CLI_COMMANDS = [
455
455
  seeAlso: ["site"],
456
456
  },
457
457
  { name: "templates", aliasOf: "template", description: "alias for template" },
458
+ {
459
+ name: "shorten",
460
+ group: "hosting",
461
+ description: "mint a short link on the pit — /f/<code> follows to your url",
462
+ synopsis: [
463
+ ["moshcode shorten <url>", "mint one and print it"],
464
+ ["moshcode shorten <url> --name <name>", "file it under a Moshpit name you hold"],
465
+ ["moshcode shorten list", "every link you have minted, newest first"],
466
+ ["moshcode shorten rm <code>", "take one down"],
467
+ ],
468
+ flags: [
469
+ ["--name <name>", "file the link under a Moshpit name you hold", "none"],
470
+ ["--json", "print the link (or the list) as machine-readable JSON", ""],
471
+ ],
472
+ examples: [
473
+ ["moshcode shorten https://pit.moshcode.sh/n/blue.eggs", "→ pit.moshcode.sh/f/k7mq2xd"],
474
+ ["moshcode shorten list --json", "pipe your links into a script"],
475
+ ["moshcode shorten rm k7mq2xd", "the code stops resolving"],
476
+ ],
477
+ seeAlso: ["login", "name", "site"],
478
+ note: "needs an account — run `moshcode login` first. Shortening the same url twice returns the same code.",
479
+ },
480
+ { name: "short", aliasOf: "shorten", description: "alias for shorten" },
481
+ { name: "link", aliasOf: "shorten", description: "alias for shorten" },
458
482
  {
459
483
  name: "games",
460
484
  group: "arcade",
@@ -815,6 +839,17 @@ export const DNS_VERBS = [
815
839
  { name: "tlds", description: "list the endings claimed in the Pit" },
816
840
  { name: "resolve", description: "what a name resolves to, and why" },
817
841
  { name: "trust", description: "trust one name's certificate, after checking it against the registry pin" },
842
+ {
843
+ name: "filter",
844
+ description: "block ads, trackers, malware and phishing at the resolver",
845
+ synopsis: [
846
+ ["moshcode dns filter", "what is on, and what it has blocked"],
847
+ ["moshcode dns filter on [--mode nxdomain|zero|refuse] [--lists a,b]", ""],
848
+ ["moshcode dns filter update", "fetch the lists — nothing downloads on its own"],
849
+ ["moshcode dns filter allow <name>", "never block it, whatever any list says"],
850
+ ["moshcode dns filter test <name>", "would this be blocked, and by which rule"],
851
+ ],
852
+ },
818
853
  ];
819
854
 
820
855
  /**
@@ -1166,6 +1201,8 @@ export const PIT_COMMANDS = [
1166
1201
  description: "install moshcode's slash commands into Claude Code" },
1167
1202
  { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
1168
1203
  description: "the arcade — tetris, invaders, pac-man, frogger, kong, outrun, chess and more" },
1204
+ { name: "shorten", aliases: ["short", "link"], args: "<url> | list | rm <code>", cli: "shorten",
1205
+ description: "mint a short link on the pit — /f/<code> follows to your url" },
1169
1206
  { name: "socials", aliases: ["social"], pitOnly: true,
1170
1207
  description: "list social networks available for posting" },
1171
1208
  { name: "post", args: '<social> "message"', pitOnly: true,
@@ -336,7 +336,7 @@ _moshcode_completion() {
336
336
  ;;
337
337
  dns)
338
338
  if (( COMP_CWORD == 2 )); then
339
- choices="enable disable status tlds resolve start install trust"
339
+ choices="enable disable status tlds resolve start install trust filter"
340
340
  elif [[ "$nested" == "resolve" && "$cur" == -* ]]; then
341
341
  choices="--json --open --registry"
342
342
  fi
@@ -485,7 +485,7 @@ _moshcode() {
485
485
  ;;
486
486
  dns)
487
487
  if (( CURRENT == 3 )); then
488
- _values "dns command" enable disable status tlds resolve start install trust
488
+ _values "dns command" enable disable status tlds resolve start install trust filter
489
489
  elif [[ "\${words[3]}" == "resolve" && "$PREFIX" == -* ]]; then
490
490
  _values "dns resolve option" --json --open --registry
491
491
  else
@@ -566,7 +566,7 @@ complete -c moshcode -n '${atSecondToken("console")}' -a '--url' -d 'print a gat
566
566
  complete -c moshcode -n '__moshcode_nested_is console serve' -l port -r -d 'local HTTP port'
567
567
  complete -c moshcode -n '__moshcode_nested_is console serve' -l ttyd -r -d 'ttyd host and port'
568
568
  complete -c moshcode -n '__moshcode_nested_is console serve' -l bind -r -d 'bind address'
569
- complete -c moshcode -n '${atSecondToken("dns")}' -a 'enable disable status tlds resolve start install trust' -d 'dns command'
569
+ complete -c moshcode -n '${atSecondToken("dns")}' -a 'enable disable status tlds resolve start install trust filter' -d 'dns command'
570
570
  complete -c moshcode -n '__moshcode_nested_is dns resolve' -l json -d 'print JSON'
571
571
  complete -c moshcode -n '__moshcode_nested_is dns resolve' -l open -d 'open a parked name in the Pit'
572
572
  complete -c moshcode -n '__moshcode_nested_is dns resolve' -l registry -r -d 'registry base URL'
@@ -0,0 +1,407 @@
1
+ /**
2
+ * `moshcode dns filter` — the verb over the policy in `dns-filter.mjs`.
3
+ *
4
+ * Kept out of `dns.mjs` for the same reason the policy is: that file is vendored
5
+ * from `@moshcoder/moshpit-dns` and is ported by hand, so it gets the hook and
6
+ * nothing else.
7
+ *
8
+ * The thing this command has to be honest about, in every subcommand that could
9
+ * mislead, is that a filter only filters what passes through the bridge. Writing
10
+ * `enabled: true` into a file on a machine whose resolver has never heard of the
11
+ * bridge changes nothing at all, and a status line that says `on` without saying
12
+ * that is the same lie as `bridge started` being printed by a run that never
13
+ * wrote the routing.
14
+ */
15
+
16
+ import {
17
+ BLOCK_MODES,
18
+ CATALOG_BY_ID,
19
+ DEFAULT_CATEGORIES,
20
+ DEFAULT_MODE,
21
+ FILTER_CATALOG,
22
+ configPath,
23
+ filterDir,
24
+ listPath,
25
+ listStatus,
26
+ matchSuffix,
27
+ normaliseName,
28
+ readCachedList,
29
+ readConfig,
30
+ readStats,
31
+ updateList,
32
+ writeConfig,
33
+ } from "./dns-filter.mjs";
34
+ import { DEFAULT_HOST, DEFAULT_PORT, bridgePresence, describeBridge, parseDnsPort } from "./dns.mjs";
35
+ import { daemonStatus } from "./dns-system.mjs";
36
+
37
+ const USAGE = `moshcode dns filter — block names before they are ever looked up
38
+
39
+ moshcode dns filter what is on, what it has blocked
40
+ moshcode dns filter on start filtering (${DEFAULT_CATEGORIES.join(", ")})
41
+ moshcode dns filter off stop filtering; keeps the lists and the rules
42
+ moshcode dns filter lists the catalogue, and what is cached here
43
+ moshcode dns filter add <list>... turn a category on
44
+ moshcode dns filter remove <list>... turn one off
45
+ moshcode dns filter update [<list>] fetch the lists — nothing downloads on its own
46
+ moshcode dns filter block <name>... always block this name and everything under it
47
+ moshcode dns filter allow <name>... never block it, whatever any list says
48
+ moshcode dns filter unblock <name>...
49
+ moshcode dns filter unallow <name>...
50
+ moshcode dns filter test <name> would this be blocked, and by which rule
51
+
52
+ --mode nxdomain|zero|refuse how a blocked name is answered (default ${DEFAULT_MODE})
53
+ --lists a,b with \`on\`: the categories to run, instead of the default
54
+ --json with status, lists or test: one document for scripts
55
+
56
+ Filtering happens in the bridge, so it applies to exactly the queries the bridge
57
+ sees: with \`dns enable\` on, that is every lookup this machine makes. Changes are
58
+ picked up by a running bridge within about five seconds — no restart, no reload.
59
+ This command never turns DNS routing on.`;
60
+
61
+ const flagValue = (args, name) => {
62
+ const index = args.indexOf(name);
63
+ if (index >= 0 && args[index + 1]) return args[index + 1];
64
+ const inline = args.find((a) => a.startsWith(`${name}=`));
65
+ return inline ? inline.slice(name.length + 1) : null;
66
+ };
67
+
68
+ const positional = (args) => {
69
+ const out = [];
70
+ for (let i = 0; i < args.length; i++) {
71
+ const arg = args[i];
72
+ if (arg === "--mode" || arg === "--lists" || arg === "--port") { i += 1; continue; }
73
+ if (arg.startsWith("-")) continue;
74
+ out.push(arg);
75
+ }
76
+ return out;
77
+ };
78
+
79
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
80
+
81
+ /**
82
+ * Is there a bridge that could be applying this policy?
83
+ *
84
+ * Probed rather than read from the pidfile. A bridge started by systemd, by
85
+ * hand, or by an escalated `dns enable` leaves no pidfile this process can see,
86
+ * and reporting "no bridge" for a machine that is filtering every lookup would
87
+ * send someone to fix the wrong thing.
88
+ */
89
+ async function bridgeLine({ host, port, presence = bridgePresence, recorded = daemonStatus }) {
90
+ try {
91
+ const found = await presence({ host, port, recorded: await recorded().catch(() => undefined) });
92
+ return { found, text: describeBridge(found, { host, port }) };
93
+ } catch {
94
+ return { found: { kind: "unknown", answering: false }, text: "could not be determined" };
95
+ }
96
+ }
97
+
98
+ export async function filterCommand(args = [], out = console.log, deps = {}) {
99
+ const {
100
+ dir = filterDir(),
101
+ fetchImpl = fetch,
102
+ presence = bridgePresence,
103
+ recorded = daemonStatus,
104
+ now = () => new Date(),
105
+ } = deps;
106
+
107
+ const sub = positional(args)[0] || "status";
108
+ const rest = positional(args).slice(1);
109
+ const json = args.includes("--json");
110
+ const host = DEFAULT_HOST;
111
+ const port = parseDnsPort(flagValue(args, "--port")) || DEFAULT_PORT;
112
+
113
+ if (sub === "help" || args.includes("--help") || args.includes("-h")) {
114
+ out(USAGE);
115
+ return 0;
116
+ }
117
+
118
+ let config;
119
+ try {
120
+ config = await readConfig(dir);
121
+ } catch (err) {
122
+ out(`! ${err.message}`);
123
+ out(` fix or remove ${configPath(dir)} — filtering is off until it parses`);
124
+ return 1;
125
+ }
126
+
127
+ /* ------------------------------------------------------------------ lists */
128
+
129
+ if (sub === "lists") {
130
+ const rows = [];
131
+ for (const entry of FILTER_CATALOG) {
132
+ const cached = await listStatus(dir, entry.id);
133
+ rows.push({ ...entry, ...cached, on: config.categories.includes(entry.id) });
134
+ }
135
+ if (json) {
136
+ out(JSON.stringify({ dir, categories: config.categories, lists: rows }, null, 2));
137
+ return 0;
138
+ }
139
+ for (const row of rows) {
140
+ const state = row.on ? "on " : "off";
141
+ const cache = row.cached
142
+ ? `cached ${row.bytes < 1024 ? "<1" : Math.round(row.bytes / 1024)}k, ${row.at.slice(0, 10)}`
143
+ : "not fetched";
144
+ out(` ${state} ${row.id.padEnd(9)} ${row.title.padEnd(22)} ${cache}`);
145
+ out(` ${row.note}`);
146
+ }
147
+ out("");
148
+ out("fetch what is on with: moshcode dns filter update");
149
+ return 0;
150
+ }
151
+
152
+ /* ------------------------------------------------------------------- test */
153
+
154
+ if (sub === "test") {
155
+ const name = rest[0];
156
+ if (!name) {
157
+ out("usage: moshcode dns filter test <name>");
158
+ return 1;
159
+ }
160
+ const clean = normaliseName(name);
161
+ if (!clean) {
162
+ out(`! ${name} is not a name this can match`);
163
+ return 1;
164
+ }
165
+ // Read straight from the cache rather than through a filter handle: this
166
+ // has to answer for a category that is cached but switched off, so that
167
+ // "why is this not blocked" has an answer other than silence.
168
+ const allowed = matchSuffix(clean, new Set(config.allow));
169
+ const blockedBy = matchSuffix(clean, new Set(config.block));
170
+ const hits = [];
171
+ if (blockedBy) hits.push({ list: "custom", rule: blockedBy, on: true });
172
+ for (const entry of FILTER_CATALOG) {
173
+ const set = await readCachedList(dir, entry.id);
174
+ if (!set) continue;
175
+ const rule = matchSuffix(clean, set);
176
+ if (rule) hits.push({ list: entry.id, rule, on: config.categories.includes(entry.id) });
177
+ }
178
+ const live = hits.filter((h) => h.on);
179
+ const blocked = config.enabled && !allowed && live.length > 0;
180
+
181
+ if (json) {
182
+ out(JSON.stringify({ name: clean, blocked, mode: config.mode, allowed, hits }, null, 2));
183
+ return 0;
184
+ }
185
+ if (!config.enabled) out("filtering is off — this is what would happen with it on");
186
+ if (allowed) {
187
+ out(`${clean} — allowed by your rule \`${allowed}\``);
188
+ if (hits.length) out(` (${plural(hits.length, "list")} would otherwise block it: ${hits.map((h) => h.list).join(", ")})`);
189
+ return 0;
190
+ }
191
+ if (!live.length) {
192
+ out(`${clean} — not blocked`);
193
+ const dormant = hits.filter((h) => !h.on);
194
+ if (dormant.length) {
195
+ out(` it is in ${dormant.map((h) => h.list).join(", ")}, which ${dormant.length === 1 ? "is" : "are"} not switched on`);
196
+ out(` turn one on with: moshcode dns filter add ${dormant[0].list}`);
197
+ }
198
+ return 0;
199
+ }
200
+ out(`${clean} — blocked by ${live[0].list} (rule \`${live[0].rule}\`), answered as ${config.mode}`);
201
+ if (live.length > 1) out(` also in: ${live.slice(1).map((h) => h.list).join(", ")}`);
202
+ out(` keep it working with: moshcode dns filter allow ${clean}`);
203
+ return 0;
204
+ }
205
+
206
+ /* ----------------------------------------------------------------- update */
207
+
208
+ if (sub === "update") {
209
+ const wanted = rest.length ? rest : config.categories;
210
+ if (!wanted.length) {
211
+ out("no categories are on — nothing to fetch");
212
+ out(` turn one on with: moshcode dns filter add ${DEFAULT_CATEGORIES[0]}`);
213
+ return 1;
214
+ }
215
+ const unknown = wanted.filter((id) => !CATALOG_BY_ID.has(id));
216
+ if (unknown.length) {
217
+ out(`! no such list: ${unknown.join(", ")}`);
218
+ out(` the catalogue is: ${FILTER_CATALOG.map((e) => e.id).join(", ")}`);
219
+ return 1;
220
+ }
221
+ let failed = 0;
222
+ for (const id of wanted) {
223
+ try {
224
+ const result = await updateList(dir, id, { fetchImpl });
225
+ out(`ok ${id.padEnd(9)} ${result.count.toLocaleString()} names`);
226
+ } catch (err) {
227
+ failed += 1;
228
+ // Named and survived rather than thrown: one dead source should not
229
+ // stop the other seven from refreshing.
230
+ out(`! ${id.padEnd(9)} ${err?.message || err}`);
231
+ }
232
+ }
233
+ await writeConfig(dir, { ...config, updatedAt: now().toISOString() });
234
+ if (failed) out(`\n${plural(failed, "list")} did not refresh — the cached copy is still in use`);
235
+ if (config.enabled) out("\na running bridge picks these up within about five seconds");
236
+ else out("\nfiltering is off — turn it on with: moshcode dns filter on");
237
+ return failed === wanted.length ? 1 : 0;
238
+ }
239
+
240
+ /* --------------------------------------------------------------- on / off */
241
+
242
+ if (sub === "on" || sub === "off") {
243
+ const mode = flagValue(args, "--mode");
244
+ if (mode && !BLOCK_MODES.includes(mode)) {
245
+ out(`! --mode must be one of: ${BLOCK_MODES.join(", ")}`);
246
+ return 1;
247
+ }
248
+ const chosen = flagValue(args, "--lists");
249
+ const categories = chosen
250
+ ? chosen.split(",").map((s) => s.trim()).filter(Boolean)
251
+ : (config.categories.length ? config.categories : DEFAULT_CATEGORIES.slice());
252
+ const unknown = categories.filter((id) => !CATALOG_BY_ID.has(id));
253
+ if (unknown.length) {
254
+ out(`! no such list: ${unknown.join(", ")}`);
255
+ out(` the catalogue is: ${FILTER_CATALOG.map((e) => e.id).join(", ")}`);
256
+ return 1;
257
+ }
258
+ const next = await writeConfig(dir, {
259
+ ...config,
260
+ enabled: sub === "on",
261
+ mode: mode || config.mode,
262
+ categories,
263
+ });
264
+ if (sub === "off") {
265
+ out("filtering off — lists and rules kept, nothing is being blocked");
266
+ return 0;
267
+ }
268
+
269
+ const missing = [];
270
+ for (const id of next.categories) {
271
+ if (!(await listStatus(dir, id)).cached) missing.push(id);
272
+ }
273
+ out(`filtering on — ${next.categories.join(", ")}, blocked names answered as ${next.mode}`);
274
+ if (missing.length) {
275
+ // The state that would otherwise read as success and block nothing.
276
+ out(`! ${plural(missing.length, "list")} ${missing.length === 1 ? "has" : "have"} never been fetched: ${missing.join(", ")}`);
277
+ out(" nothing is blocked from them until you run: moshcode dns filter update");
278
+ }
279
+ const bridge = await bridgeLine({ host, port, presence, recorded });
280
+ if (!bridge.found.answering) {
281
+ out(`! no bridge is answering on ${host}:${port} — ${bridge.text}`);
282
+ out(" the filter runs inside the bridge, so nothing is filtered until one does.");
283
+ out(" turn DNS on deliberately with: sudo moshcode dns enable");
284
+ } else if (bridge.found.forwards === false) {
285
+ out(`! the bridge on ${host}:${port} answers Moshpit names but does not forward clearnet ones`);
286
+ out(" it is not in the path of ordinary lookups, so only Moshpit names are filtered");
287
+ }
288
+ return 0;
289
+ }
290
+
291
+ /* ------------------------------------------------------ categories, rules */
292
+
293
+ const listVerbs = { add: true, remove: true };
294
+ if (listVerbs[sub]) {
295
+ if (!rest.length) {
296
+ out(`usage: moshcode dns filter ${sub} <list>...`);
297
+ return 1;
298
+ }
299
+ const unknown = rest.filter((id) => !CATALOG_BY_ID.has(id));
300
+ if (unknown.length) {
301
+ out(`! no such list: ${unknown.join(", ")}`);
302
+ out(` the catalogue is: ${FILTER_CATALOG.map((e) => e.id).join(", ")}`);
303
+ return 1;
304
+ }
305
+ const set = new Set(config.categories);
306
+ for (const id of rest) (sub === "add" ? set.add(id) : set.delete(id));
307
+ const next = await writeConfig(dir, { ...config, categories: Array.from(set) });
308
+ out(next.categories.length ? `lists: ${next.categories.join(", ")}` : "lists: none");
309
+ if (sub === "add") {
310
+ const missing = [];
311
+ for (const id of rest) if (!(await listStatus(dir, id)).cached) missing.push(id);
312
+ if (missing.length) out(` fetch ${missing.join(", ")} with: moshcode dns filter update ${missing.join(" ")}`);
313
+ }
314
+ return 0;
315
+ }
316
+
317
+ const ruleVerbs = {
318
+ block: { field: "block", add: true, said: "blocked" },
319
+ allow: { field: "allow", add: true, said: "allowed" },
320
+ unblock: { field: "block", add: false, said: "no longer blocked by rule" },
321
+ unallow: { field: "allow", add: false, said: "no longer allowed by rule" },
322
+ };
323
+ if (ruleVerbs[sub]) {
324
+ const { field, add, said } = ruleVerbs[sub];
325
+ if (!rest.length) {
326
+ out(`usage: moshcode dns filter ${sub} <name>...`);
327
+ return 1;
328
+ }
329
+ const names = [];
330
+ for (const raw of rest) {
331
+ const clean = normaliseName(raw);
332
+ if (!clean) {
333
+ out(`! ${raw} is not a name`);
334
+ return 1;
335
+ }
336
+ names.push(clean);
337
+ }
338
+ const set = new Set(config[field]);
339
+ for (const name of names) (add ? set.add(name) : set.delete(name));
340
+ const next = await writeConfig(dir, { ...config, [field]: Array.from(set) });
341
+ out(`${names.join(", ")} — ${said}${add ? ", along with everything under it" : ""}`);
342
+ out(` ${plural(next[field].length, "rule")} in your ${field} list`);
343
+ if (!next.enabled) out(" filtering is off, so this takes effect when you turn it on");
344
+ return 0;
345
+ }
346
+
347
+ /* ----------------------------------------------------------------- status */
348
+
349
+ if (sub !== "status") {
350
+ out(`unknown: moshcode dns filter ${sub}`);
351
+ out(USAGE);
352
+ return 1;
353
+ }
354
+
355
+ const stats = await readStats(dir);
356
+ const cached = [];
357
+ for (const id of config.categories) cached.push(await listStatus(dir, id));
358
+ const bridge = await bridgeLine({ host, port, presence, recorded });
359
+
360
+ if (json) {
361
+ out(JSON.stringify({
362
+ dir,
363
+ enabled: config.enabled,
364
+ mode: config.mode,
365
+ categories: config.categories,
366
+ block: config.block,
367
+ allow: config.allow,
368
+ lists: cached,
369
+ bridge: { kind: bridge.found.kind, answering: Boolean(bridge.found.answering), forwards: Boolean(bridge.found.forwards) },
370
+ stats,
371
+ }, null, 2));
372
+ return 0;
373
+ }
374
+
375
+ out(`filter ${config.enabled ? `on — answering blocked names as ${config.mode}` : "off"}`);
376
+ out(`bridge ${bridge.text}`);
377
+ out(`lists ${config.categories.length ? config.categories.join(", ") : "none"}`);
378
+ const never = cached.filter((c) => !c.cached);
379
+ if (never.length) out(` ! never fetched: ${never.map((c) => c.id).join(", ")} — run \`moshcode dns filter update\``);
380
+ if (config.block.length || config.allow.length) {
381
+ out(`rules ${plural(config.block.length, "block")}, ${plural(config.allow.length, "allow")}`);
382
+ }
383
+ if (stats) {
384
+ const share = stats.queries ? `${((stats.blocked / stats.queries) * 100).toFixed(1)}%` : "0%";
385
+ out(`blocked ${stats.blocked.toLocaleString()} of ${stats.queries.toLocaleString()} queries (${share}) as of ${String(stats.at).slice(0, 19).replace("T", " ")}`);
386
+ for (const [id, count] of Object.entries(stats.byList || {}).sort((a, b) => b[1] - a[1])) {
387
+ out(` ${String(count).padStart(7)} ${id}`);
388
+ }
389
+ if (stats.recent?.length) {
390
+ out("recent " + stats.recent.slice(0, 5).map((r) => r.name).join(", "));
391
+ }
392
+ } else if (config.enabled) {
393
+ // The counters are written by the bridge, so their absence is a fact about
394
+ // the bridge rather than about the filter.
395
+ out("blocked no counts yet — the bridge writes them once it is answering");
396
+ }
397
+
398
+ if (config.enabled && !bridge.found.answering) {
399
+ out("");
400
+ out(`! nothing is being filtered: the filter runs inside the bridge and none is answering on ${host}:${port}`);
401
+ out(" turn DNS on deliberately with: sudo moshcode dns enable");
402
+ }
403
+ out("");
404
+ out(`config ${configPath(dir)}`);
405
+ out(`lists at ${listPath(dir, "<list>")}`);
406
+ return 0;
407
+ }