moshcode 0.77.0 → 0.78.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.
@@ -7,8 +7,22 @@
7
7
  # and every Moshpit name stops resolving with no obvious cause. This unit is
8
8
  # the missing half.
9
9
  #
10
- # sudo cp deploy/moshcode-dns.service /etc/systemd/system/
11
- # sudo systemctl enable --now moshcode-dns
10
+ # GENERATE IT, DO NOT COPY THIS ONE:
11
+ #
12
+ # moshcode dns service --write # this user's own service, needs no root
13
+ # moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service
14
+ #
15
+ # `dns service` writes ExecStart from the running process, so it names the node
16
+ # that demonstrably works and the install that is actually on the box. A static
17
+ # file cannot: moshcode installs under $HOME, and its wrapper execs whatever
18
+ # `node` is first on PATH — which on any mise, nvm or asdf machine is another
19
+ # shim under $HOME. systemd has neither on its PATH and, with ProtectHome or
20
+ # DynamicUser set, could not read them if it did. A unit that gets this wrong
21
+ # fails at 203/EXEC with nothing useful in the journal.
22
+ #
23
+ # What is left below therefore assumes the one case it can safely assume: both
24
+ # moshcode and node installed system-wide, service running as root. Check
25
+ # `systemctl status moshcode-dns` before believing it.
12
26
  #
13
27
  # Install this on machines that need to REACH Moshpit names. A box that only
14
28
  # serves one does not need it — Caddy answers whatever Host header arrives and
@@ -22,18 +36,15 @@ Before=systemd-resolved.service
22
36
 
23
37
  [Service]
24
38
  Type=simple
25
- # Port 5354 is unprivileged, so this does not need root. The trade-off is that
26
- # the parking responder cannot take port 80 and falls back to the public
27
- # parking address — which only affects names that point nowhere yet.
28
- ExecStart=/usr/bin/env moshcode dns start --port 5354
39
+ # Port 5354 is unprivileged, so this does not need root for the DNS side. The
40
+ # trade-off is that the parking responder cannot take port 80 and falls back to
41
+ # the public parking address — which only affects names that point nowhere yet.
42
+ ExecStart=/usr/local/bin/moshcode dns start --port 5354
29
43
  Restart=always
30
44
  RestartSec=2
31
45
 
32
- DynamicUser=yes
33
46
  NoNewPrivileges=yes
34
47
  PrivateTmp=yes
35
- ProtectSystem=strict
36
- ProtectHome=yes
37
48
 
38
49
  [Install]
39
50
  WantedBy=multi-user.target
@@ -7,8 +7,22 @@
7
7
  # and every Moshpit name stops resolving with no obvious cause. This unit is
8
8
  # the missing half.
9
9
  #
10
- # sudo cp deploy/moshcode-dns.service /etc/systemd/system/
11
- # sudo systemctl enable --now moshcode-dns
10
+ # GENERATE IT, DO NOT COPY THIS ONE:
11
+ #
12
+ # moshcode dns service --write # this user's own service, needs no root
13
+ # moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service
14
+ #
15
+ # `dns service` writes ExecStart from the running process, so it names the node
16
+ # that demonstrably works and the install that is actually on the box. A static
17
+ # file cannot: moshcode installs under $HOME, and its wrapper execs whatever
18
+ # `node` is first on PATH — which on any mise, nvm or asdf machine is another
19
+ # shim under $HOME. systemd has neither on its PATH and, with ProtectHome or
20
+ # DynamicUser set, could not read them if it did. A unit that gets this wrong
21
+ # fails at 203/EXEC with nothing useful in the journal.
22
+ #
23
+ # What is left below therefore assumes the one case it can safely assume: both
24
+ # moshcode and node installed system-wide, service running as root. Check
25
+ # `systemctl status moshcode-dns` before believing it.
12
26
  #
13
27
  # Install this on machines that need to REACH Moshpit names. A box that only
14
28
  # serves one does not need it — Caddy answers whatever Host header arrives and
@@ -22,18 +36,15 @@ Before=systemd-resolved.service
22
36
 
23
37
  [Service]
24
38
  Type=simple
25
- # Port 5354 is unprivileged, so this does not need root. The trade-off is that
26
- # the parking responder cannot take port 80 and falls back to the public
27
- # parking address — which only affects names that point nowhere yet.
28
- ExecStart=/usr/bin/env moshcode dns start --port 5354
39
+ # Port 5354 is unprivileged, so this does not need root for the DNS side. The
40
+ # trade-off is that the parking responder cannot take port 80 and falls back to
41
+ # the public parking address — which only affects names that point nowhere yet.
42
+ ExecStart=/usr/local/bin/moshcode dns start --port 5354
29
43
  Restart=always
30
44
  RestartSec=2
31
45
 
32
- DynamicUser=yes
33
46
  NoNewPrivileges=yes
34
47
  PrivateTmp=yes
35
- ProtectSystem=strict
36
- ProtectHome=yes
37
48
 
38
49
  [Install]
39
50
  WantedBy=multi-user.target
@@ -7,8 +7,22 @@
7
7
  # and every Moshpit name stops resolving with no obvious cause. This unit is
8
8
  # the missing half.
9
9
  #
10
- # sudo cp deploy/moshcode-dns.service /etc/systemd/system/
11
- # sudo systemctl enable --now moshcode-dns
10
+ # GENERATE IT, DO NOT COPY THIS ONE:
11
+ #
12
+ # moshcode dns service --write # this user's own service, needs no root
13
+ # moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service
14
+ #
15
+ # `dns service` writes ExecStart from the running process, so it names the node
16
+ # that demonstrably works and the install that is actually on the box. A static
17
+ # file cannot: moshcode installs under $HOME, and its wrapper execs whatever
18
+ # `node` is first on PATH — which on any mise, nvm or asdf machine is another
19
+ # shim under $HOME. systemd has neither on its PATH and, with ProtectHome or
20
+ # DynamicUser set, could not read them if it did. A unit that gets this wrong
21
+ # fails at 203/EXEC with nothing useful in the journal.
22
+ #
23
+ # What is left below therefore assumes the one case it can safely assume: both
24
+ # moshcode and node installed system-wide, service running as root. Check
25
+ # `systemctl status moshcode-dns` before believing it.
12
26
  #
13
27
  # Install this on machines that need to REACH Moshpit names. A box that only
14
28
  # serves one does not need it — Caddy answers whatever Host header arrives and
@@ -22,18 +36,15 @@ Before=systemd-resolved.service
22
36
 
23
37
  [Service]
24
38
  Type=simple
25
- # Port 5354 is unprivileged, so this does not need root. The trade-off is that
26
- # the parking responder cannot take port 80 and falls back to the public
27
- # parking address — which only affects names that point nowhere yet.
28
- ExecStart=/usr/bin/env moshcode dns start --port 5354
39
+ # Port 5354 is unprivileged, so this does not need root for the DNS side. The
40
+ # trade-off is that the parking responder cannot take port 80 and falls back to
41
+ # the public parking address — which only affects names that point nowhere yet.
42
+ ExecStart=/usr/local/bin/moshcode dns start --port 5354
29
43
  Restart=always
30
44
  RestartSec=2
31
45
 
32
- DynamicUser=yes
33
46
  NoNewPrivileges=yes
34
47
  PrivateTmp=yes
35
- ProtectSystem=strict
36
- ProtectHome=yes
37
48
 
38
49
  [Install]
39
50
  WantedBy=multi-user.target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "type": "module",
5
5
  "description": "moshcode \u2014 a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -390,7 +390,7 @@ export const CORE_CLI_COMMANDS = [
390
390
  ["--no-proxy", "with enable: answer origins rather than the local proxy", ""],
391
391
  ],
392
392
  examples: [
393
- ["sudo moshcode dns enable", "route Moshpit endings here"],
393
+ ["moshcode dns enable", "route Moshpit endings here (it escalates itself)"],
394
394
  ["moshcode dns resolve blue.eggs", "what a machine actually gets"],
395
395
  ],
396
396
  seeAlso: ["doh", "site"],
@@ -1009,7 +1009,16 @@ export const DNS_VERBS = [
1009
1009
  { name: "refresh", description: "re-apply routing for endings claimed since" },
1010
1010
  { name: "start", description: "run the bridge in the foreground" },
1011
1011
  { name: "install", description: "print the resolver config without applying it" },
1012
- { name: "service", description: "install or remove the background service" },
1012
+ {
1013
+ name: "service",
1014
+ description: "keep the bridge running across reboots — the half `enable` does not survive",
1015
+ synopsis: [
1016
+ ["moshcode dns service", "print the unit for this install, change nothing"],
1017
+ ["moshcode dns service --write", "install and start it as this user; needs no root"],
1018
+ ["moshcode dns service --system", "a system unit instead — place it with sudo tee"],
1019
+ ["moshcode dns service --remove", "stop it and take the unit away"],
1020
+ ],
1021
+ },
1013
1022
  { name: "tlds", description: "list the endings claimed in the Pit" },
1014
1023
  { name: "resolve", description: "what a name resolves to, and why" },
1015
1024
  { name: "trust", description: "trust one name's certificate, after checking it against the registry pin" },
@@ -0,0 +1,157 @@
1
+ // Keeping the bridge running across reboots.
2
+ //
3
+ // `dns enable` sets up two halves with different lifetimes: a systemd-resolved
4
+ // drop-in, which is a file and survives a reboot on its own, and the bridge
5
+ // process, which does not. After a restart the routing still points at a port
6
+ // with nothing behind it and every Moshpit name stops resolving with no
7
+ // obvious cause. This is the missing half, and it is why Moshpit DNS has held
8
+ // on servers — which got a unit installed by hand — and quietly fallen over on
9
+ // desktops, which never did.
10
+ //
11
+ // The unit is *generated* rather than shipped as a file, because no static
12
+ // unit can be correct for this tool. moshcode installs under the invoking
13
+ // user's $HOME, and its wrapper execs whatever `node` is first on PATH — which
14
+ // on any mise, nvm or asdf box is another shim under $HOME. The unit that
15
+ // shipped in examples/templates said:
16
+ //
17
+ // ExecStart=/usr/bin/env moshcode dns start --port 5354
18
+ // DynamicUser=yes
19
+ // ProtectHome=yes
20
+ //
21
+ // and could not start on an ordinary install three times over: `moshcode` is
22
+ // not on systemd's PATH, the `node` its wrapper needs is not on it either, and
23
+ // ProtectHome hides the install from the service even if both had been found.
24
+ // It starts only where moshcode and node are both installed system-wide, which
25
+ // is the server case — the one that was already working.
26
+ //
27
+ // So the unit is written from the running process instead. `process.execPath`
28
+ // is an interpreter demonstrably able to run this code, because it is running
29
+ // it, and the entry is the script this very command was invoked from. Nothing
30
+ // is guessed and nothing depends on PATH.
31
+ import { spawn } from "node:child_process";
32
+ import { mkdir, rm, writeFile } from "node:fs/promises";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+
36
+ export const UNIT_NAME = "moshcode-dns.service";
37
+
38
+ /**
39
+ * Where the unit goes, and which systemctl reaches it.
40
+ *
41
+ * The user scope is the default because it is the one that fits how moshcode
42
+ * is installed: a per-user tool, run by the user who owns the install, with
43
+ * $HOME and the mise shims reachable exactly as they are in a shell. It also
44
+ * puts the pidfile where the unprivileged `dns status` looks — systemd sets
45
+ * XDG_RUNTIME_DIR for user units, so `pidfilePath()` resolves to
46
+ * /run/user/<uid>/moshpit-dns.pid for the daemon and for the person asking
47
+ * after it. Under a system unit those are two different paths.
48
+ */
49
+ export function servicePaths({ system = false, home = homedir() } = {}) {
50
+ return system
51
+ ? { path: join("/etc/systemd/system", UNIT_NAME), systemctl: ["systemctl"], scope: "system" }
52
+ : { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: ["systemctl", "--user"], scope: "user" };
53
+ }
54
+
55
+ /**
56
+ * The unit text, pinned to this install.
57
+ *
58
+ * `ProtectHome` is deliberately absent rather than set to a weaker value: the
59
+ * whole program lives under $HOME, so there is no setting of it that both
60
+ * protects anything and lets the service start. The hardening that survives is
61
+ * the hardening that does not contradict where the code is.
62
+ */
63
+ export function serviceUnit({
64
+ system = false,
65
+ execPath = process.execPath,
66
+ entry,
67
+ port,
68
+ registryBase = null,
69
+ user = process.env.USER || process.env.LOGNAME,
70
+ } = {}) {
71
+ if (!entry) throw new Error("serviceUnit needs the entry script to run");
72
+
73
+ const args = [entry, "dns", "start", "--port", String(port)];
74
+ if (registryBase) args.push("--registry", registryBase);
75
+ const exec = [execPath, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
76
+
77
+ const lines = [
78
+ "# Generated by `moshcode dns service`. Regenerate it rather than editing:",
79
+ "# the paths below are this install's, and a moshcode or node that moves",
80
+ "# leaves a unit that fails at 203/EXEC with nothing else to say.",
81
+ "[Unit]",
82
+ "Description=Moshpit DNS bridge",
83
+ "Documentation=https://github.com/moshcoder/moshcode",
84
+ "After=network-online.target",
85
+ "Wants=network-online.target",
86
+ "",
87
+ "[Service]",
88
+ "Type=simple",
89
+ `ExecStart=${exec}`,
90
+ "Restart=always",
91
+ "RestartSec=2",
92
+ // 5354 is unprivileged, so nothing here needs root or a capability.
93
+ "NoNewPrivileges=yes",
94
+ "PrivateTmp=yes",
95
+ ];
96
+
97
+ if (system) {
98
+ // A system unit has no user of its own, so it has to be told which install
99
+ // to run — and it is the only scope where that question has a wrong answer.
100
+ lines.push(`User=${user}`);
101
+ }
102
+
103
+ lines.push(
104
+ "",
105
+ "[Install]",
106
+ system ? "WantedBy=multi-user.target" : "WantedBy=default.target",
107
+ "",
108
+ );
109
+ return lines.join("\n");
110
+ }
111
+
112
+ function run(command, args) {
113
+ return new Promise((resolve) => {
114
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
115
+ let err = "";
116
+ child.stderr.on("data", (d) => (err += d));
117
+ child.on("error", (error) => resolve({ ok: false, error: error.message }));
118
+ child.on("exit", (code) => resolve({ ok: code === 0, error: err.trim() }));
119
+ });
120
+ }
121
+
122
+ /** Write the unit and start it. Returns the steps taken, in order, for printing. */
123
+ export async function installService(unit, { system = false, home = homedir(), exec = run } = {}) {
124
+ const { path, systemctl, scope } = servicePaths({ system, home });
125
+ const steps = [];
126
+ try {
127
+ await mkdir(dirname(path), { recursive: true });
128
+ await writeFile(path, unit);
129
+ steps.push({ step: `wrote ${path}`, ok: true });
130
+ } catch (error) {
131
+ return { ok: false, path, scope, steps: [{ step: `could not write ${path}: ${error.message}`, ok: false }] };
132
+ }
133
+
134
+ const [cmd, ...flags] = systemctl;
135
+ for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", UNIT_NAME]]) {
136
+ const result = await exec(cmd, args);
137
+ steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
138
+ if (!result.ok) return { ok: false, path, scope, steps };
139
+ }
140
+ return { ok: true, path, scope, steps };
141
+ }
142
+
143
+ /** Stop it and take the unit away. Missing is not a failure — removal is idempotent. */
144
+ export async function removeService({ system = false, home = homedir(), exec = run } = {}) {
145
+ const { path, systemctl, scope } = servicePaths({ system, home });
146
+ const [cmd, ...flags] = systemctl;
147
+ const steps = [];
148
+ for (const args of [[...flags, "disable", "--now", UNIT_NAME]]) {
149
+ const result = await exec(cmd, args);
150
+ steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
151
+ }
152
+ await rm(path, { force: true });
153
+ steps.push({ step: `removed ${path}`, ok: true });
154
+ const reload = await exec(cmd, [...flags, "daemon-reload"]);
155
+ steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error });
156
+ return { ok: true, path, scope, steps };
157
+ }
package/src/dns.mjs CHANGED
@@ -2278,6 +2278,7 @@ import { applyTrust, applyUntrust, createAutoTrust, trustName, verifyStockTls }
2278
2278
  import { readFile, writeFile } from "node:fs/promises";
2279
2279
  import { existsSync } from "node:fs";
2280
2280
  import { fileURLToPath } from "node:url";
2281
+ import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "./dns-service.mjs";
2281
2282
  import {
2282
2283
  applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2283
2284
  probeResolver, requiredPort, startDaemon, stopDaemon,
@@ -2310,6 +2311,10 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine
2310
2311
  lands on the Pit; --parking-port N, --no-parking-http
2311
2312
  --no-filter runs it with blocklists off
2312
2313
  moshcode dns install [--write] print the resolver config without applying it
2314
+ moshcode dns service print a systemd unit that keeps the bridge
2315
+ running across reboots; --write installs and
2316
+ starts it, --system for a system unit rather
2317
+ than this user's, --remove takes it away
2313
2318
 
2314
2319
  moshcode dns filter block ads, trackers, malware and phishing at the
2315
2320
  resolver — \`moshcode dns filter help\` for the verbs
@@ -2684,6 +2689,53 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2684
2689
  return 0;
2685
2690
  }
2686
2691
 
2692
+ // The half of `enable` that a reboot takes away. Kept a separate verb rather
2693
+ // than folded into `enable`, because installing a service is exactly the kind
2694
+ // of thing that must stay something a person asked for.
2695
+ if (sub === "service") {
2696
+ const system = rest.includes("--system");
2697
+ const { path: unitPath, systemctl, scope } = servicePaths({ system });
2698
+
2699
+ const report = (steps) => {
2700
+ for (const s of steps) out(`${s.ok ? "ok" : "!!"} ${s.step}${s.error ? ` — ${s.error}` : ""}`);
2701
+ };
2702
+
2703
+ if (rest.includes("--remove")) {
2704
+ report((await removeService({ system })).steps);
2705
+ out("");
2706
+ out("the routing is untouched — `moshcode dns disable` is what removes that");
2707
+ return 0;
2708
+ }
2709
+
2710
+ const unit = serviceUnit({ system, entry: cliEntry(), port, registryBase });
2711
+
2712
+ if (rest.includes("--write")) {
2713
+ const result = await installService(unit, { system });
2714
+ report(result.steps);
2715
+ if (!result.ok) {
2716
+ out("");
2717
+ out(system
2718
+ ? `(writing ${unitPath} needs root — pipe it instead: moshcode dns service --system | sudo tee ${unitPath})`
2719
+ : "(the user scope needs no privileges — this failure is something else)");
2720
+ return 1;
2721
+ }
2722
+ out("");
2723
+ out(`the bridge now starts with the machine (${scope} scope)`);
2724
+ if (!system) {
2725
+ const who = process.env.USER || process.env.LOGNAME || "$USER";
2726
+ out(` a user service stops at logout — to survive one: loginctl enable-linger ${who}`);
2727
+ }
2728
+ out(" check it: moshcode dns status");
2729
+ return 0;
2730
+ }
2731
+
2732
+ out(`# ${unitPath}`);
2733
+ out(unit);
2734
+ out(`# install it with: moshcode dns service --write${system ? " --system" : ""}`);
2735
+ out(`# then: ${systemctl.join(" ")} status ${UNIT_NAME}`);
2736
+ return 0;
2737
+ }
2738
+
2687
2739
  if (sub === "enable" || sub === "disable") {
2688
2740
  const platform = platformImpl();
2689
2741
  if (!platform) {