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
package/README.md
CHANGED
|
@@ -14,28 +14,6 @@ curl -fsSL https://moshcoding.com/install.sh | sh
|
|
|
14
14
|
Zero-dependency ESM — all it needs is Node.js 18+. Later: `… | sh -s -- update`
|
|
15
15
|
to upgrade, `… | sh -s -- remove` to uninstall.
|
|
16
16
|
|
|
17
|
-
## What is in this repo
|
|
18
|
-
|
|
19
|
-
The CLI is the root package — `bin/`, `src/`, `plugins/` — and that is what
|
|
20
|
-
`npm publish` and the installer above ship. Two apps live alongside it:
|
|
21
|
-
|
|
22
|
-
| path | what it is | deployed as |
|
|
23
|
-
|---|---|---|
|
|
24
|
-
| `apps/pwa` | the Moshpit registry and account app | pit.moshcode.sh, app.moshcode.sh |
|
|
25
|
-
| `apps/qryptchat-web` | QryptChat, the post-quantum messenger | qrypt.chat |
|
|
26
|
-
|
|
27
|
-
One repo, but **not one dependency tree**. Each app installs itself, with its
|
|
28
|
-
own lockfile, and the root `pnpm install` does not reach into either. That is
|
|
29
|
-
deliberate on both counts and `pnpm-workspace.yaml` says why. The practical
|
|
30
|
-
rule: run `pnpm install` in the directory you are working in, not at the root
|
|
31
|
-
and not the other way round.
|
|
32
|
-
|
|
33
|
-
The two apps share a feature rather than code — a person can sign in to
|
|
34
|
-
QryptChat by proving they hold a Moshpit name, which `moshcode name link`
|
|
35
|
-
produces the proof for. The shared half of that is still duplicated in both
|
|
36
|
-
trees; see `pnpm-workspace.yaml` for what has to be fixed before it can be
|
|
37
|
-
lifted into `packages/`.
|
|
38
|
-
|
|
39
17
|
## Commands
|
|
40
18
|
|
|
41
19
|
`moshcode help <command>` drills into any of these — flags, examples and all.
|
|
@@ -74,6 +52,7 @@ or miss one that does. A test fails the build when it drifts.
|
|
|
74
52
|
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
|
|
75
53
|
| `moshcode site` <br>`serve` | hosting | install web-server config for a Moshpit name |
|
|
76
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 |
|
|
77
56
|
| `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — twenty-two games, no menus |
|
|
78
57
|
| `moshcode pwd` <br>`where` | system | show the current directory and git context |
|
|
79
58
|
| `moshcode engines` | engines | list engines and installation status |
|
|
@@ -722,6 +701,35 @@ engines, `mcpjam` tells you whether that server is healthy first — health
|
|
|
722
701
|
checks, OAuth conformance, tool-surface diffing, and structured triage from the
|
|
723
702
|
terminal or CI. Re-running `moshcode install mcpjam` is also its upgrade path.
|
|
724
703
|
|
|
704
|
+
### ElevenLabs — Eleven Agents, voices, and speech
|
|
705
|
+
|
|
706
|
+
```sh
|
|
707
|
+
moshcode install elevenlabs # npm i -g @elevenlabs/cli
|
|
708
|
+
|
|
709
|
+
moshcode elevenlabs auth login # PKCE OAuth, stored in the system keyring
|
|
710
|
+
moshcode elevenlabs agents list
|
|
711
|
+
moshcode elevenlabs agents push # upload local agent configs to the platform
|
|
712
|
+
moshcode elevenlabs text-to-speech convert …
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
[ElevenLabs](https://elevenlabs.io/docs/eleven-agents/operate/cli)' CLI manages
|
|
716
|
+
**Eleven Agents** — conversational voice agents you define in files and
|
|
717
|
+
`push`/`pull` against their platform, alongside the knowledge bases, tools,
|
|
718
|
+
tests and phone numbers those agents use. The same binary reaches the rest of
|
|
719
|
+
the API: voices, text-to-speech, dubbing, transcription, music, and workspace
|
|
720
|
+
usage.
|
|
721
|
+
|
|
722
|
+
They are agents in a different sense than the ones under `/agents`: you deploy
|
|
723
|
+
them and callers talk to them, rather than handing your terminal to one. That is
|
|
724
|
+
why it sits here with the workflow CLIs — every subcommand runs one request and
|
|
725
|
+
exits, so it pipes like the rest of the roster. `--format json` (the default
|
|
726
|
+
when stdout is not a TTY) and `--query` (JMESPath) are what a script wants.
|
|
727
|
+
|
|
728
|
+
The npm package is a small shim over a native binary shipped as an optional
|
|
729
|
+
dependency, so install it without `--omit=optional` or the `elevenlabs` on your
|
|
730
|
+
PATH will refuse to run. Re-running `moshcode install elevenlabs` is its upgrade
|
|
731
|
+
path.
|
|
732
|
+
|
|
725
733
|
### Alchemy — onchain data, wallets, and x402
|
|
726
734
|
|
|
727
735
|
```sh
|
|
@@ -1271,6 +1279,41 @@ Full walkthrough, including the layer-by-layer way to debug it and the limits
|
|
|
1271
1279
|
worth knowing before you build:
|
|
1272
1280
|
**[docs/hosting-a-moshpit-name.md](docs/hosting-a-moshpit-name.md)**.
|
|
1273
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
|
+
|
|
1274
1317
|
## Shell completion
|
|
1275
1318
|
|
|
1276
1319
|
MoshCode can print context-aware completion scripts for its commands, engines,
|
|
@@ -1460,6 +1503,7 @@ chmod +x deploy.mosh
|
|
|
1460
1503
|
| `mcpjam(args…)` | drive the MCPJam CLI (test, debug, and validate MCP servers) |
|
|
1461
1504
|
| `spinifex(args…)` | drive the Spinifex CLI (`spx` — AWS-compatible cloud on your own hardware) |
|
|
1462
1505
|
| `alchemy(args…)` | drive the Alchemy CLI (onchain data, apps, wallets, x402) |
|
|
1506
|
+
| `elevenlabs(args…)` | drive the ElevenLabs CLI (Eleven Agents, voices, TTS, dubbing) |
|
|
1463
1507
|
| `trade(args…)` | look up tickers, inspect markets, preview/place Alpaca orders |
|
|
1464
1508
|
| `stocks(args…)` | research tickers via advis0r (`stocksRead` returns the data) |
|
|
1465
1509
|
| `crypto(args…)` | research crypto pairs via advis0r (`cryptoRead` returns the data) |
|
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.
|
|
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": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "node bin/moshcode.mjs",
|
|
20
20
|
"demo": "node bin/moshcode.mjs run examples/alive.mosh",
|
|
21
|
-
"test": "node --test
|
|
21
|
+
"test": "node --test"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"bin",
|
package/src/cli-schema.mjs
CHANGED
|
@@ -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,
|
package/src/commands.mjs
CHANGED
|
@@ -683,6 +683,7 @@ const COMMANDS = [
|
|
|
683
683
|
cliVerb("mcpjam", "drive the MCPJam CLI (test, debug, and validate MCP servers)"),
|
|
684
684
|
cliVerb("spinifex", "drive the Spinifex CLI (spx — AWS-compatible cloud on your own hardware)"),
|
|
685
685
|
cliVerb("alchemy", "drive the Alchemy CLI (onchain data, apps, wallets, x402)"),
|
|
686
|
+
cliVerb("elevenlabs", "drive the ElevenLabs CLI (Eleven Agents, voices, TTS, dubbing)"),
|
|
686
687
|
cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
|
|
687
688
|
cliVerb("pwd", "print the current repo/location"),
|
|
688
689
|
|
package/src/completion.mjs
CHANGED
|
@@ -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
|
+
}
|