moshcode 0.82.0 → 0.84.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.82.0",
3
+ "version": "0.84.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": {
@@ -30,6 +30,7 @@
30
30
  // is guessed and nothing depends on PATH.
31
31
  import { spawn } from "node:child_process";
32
32
  import { mkdir, rm, writeFile } from "node:fs/promises";
33
+ import { existsSync } from "node:fs";
33
34
  import { homedir } from "node:os";
34
35
  import { dirname, join } from "node:path";
35
36
 
@@ -171,3 +172,196 @@ export async function removeService({ system = false, home = homedir(), exec = r
171
172
  steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error });
172
173
  return { ok: true, path, scope, steps };
173
174
  }
175
+
176
+ /* --------------------------------------------------- the pinned-TLS proxy */
177
+
178
+ /**
179
+ * The other half of a machine that can actually reach Moshpit names.
180
+ *
181
+ * The bridge makes names resolve. It cannot make them verifiable: no CA will
182
+ * ever sign for `.eggs`, so without a proxy every name answers its origin's own
183
+ * self-signed leaf and a stock client refuses it. moshpit-proxy terminates TLS
184
+ * with a local root instead — one root for every ending, rather than trusting
185
+ * certificates one name at a time.
186
+ *
187
+ * moshpit-proxy ships no unit of its own, so nothing ever started it. It was
188
+ * installed, trusted, and idle, which reads exactly like "not installed" from
189
+ * every direction: nothing on 443, no certificate, and `dns enable` correctly
190
+ * reporting no proxy on a machine that had one.
191
+ */
192
+ export const PROXY_UNIT_NAME = "moshpit-proxy.service";
193
+
194
+ /**
195
+ * A system unit, unlike the bridge's.
196
+ *
197
+ * 443 is privileged and the proxy must have it: DNS carries an address and has
198
+ * nowhere to put a port, so a browser sent to a Moshpit name goes to 443 or
199
+ * nowhere. A user unit cannot bind it. So this is a system unit that drops to
200
+ * the operator's account and is granted the one capability it needs — rather
201
+ * than running as root, which it has no other use for.
202
+ */
203
+ export function proxyServicePaths() {
204
+ return { path: join("/etc/systemd/system", PROXY_UNIT_NAME), systemctl: ["systemctl"], scope: "system" };
205
+ }
206
+
207
+ /**
208
+ * The unit text, pinned to this install.
209
+ *
210
+ * `ExecStart` runs moshpit-proxy's own wrapper rather than reaching past it to
211
+ * an entry script, so a change to that project's layout does not silently break
212
+ * this. The wrapper execs `node`, which systemd's PATH does not have on a mise,
213
+ * nvm or asdf box — so PATH is set from the interpreter running this code,
214
+ * which is by definition one that works. That mistake has now been made three
215
+ * times in this codebase; it is made here on purpose and only once.
216
+ */
217
+ export function proxyServiceUnit({
218
+ wrapper,
219
+ nodeDir,
220
+ home = homedir(),
221
+ user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME,
222
+ port = 443,
223
+ tlds = [],
224
+ } = {}) {
225
+ if (!wrapper) throw new Error("proxyServiceUnit needs the moshpit-proxy wrapper path");
226
+ if (!user) throw new Error("proxyServiceUnit needs the account the proxy runs as");
227
+
228
+ const path = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"].filter(Boolean).join(":");
229
+ // `.moshpit` under the operator's home is where the local root already lives,
230
+ // put there by moshpit-proxy's own installer. Naming it explicitly keeps the
231
+ // service off /root/.moshpit, which is where a system unit would otherwise
232
+ // look and where there is nothing.
233
+ const dir = join(home, ".moshpit");
234
+
235
+ const lines = [
236
+ "# Generated by `moshcode dns enable`. Regenerate rather than editing:",
237
+ "# the paths below are this install's, and a node or proxy that moves leaves",
238
+ "# a unit that fails at 203/EXEC with nothing else to say.",
239
+ "[Unit]",
240
+ "Description=Moshpit pinned-TLS proxy",
241
+ "Documentation=https://github.com/profullstack/moshpit-proxy",
242
+ "After=network-online.target",
243
+ "Wants=network-online.target",
244
+ "",
245
+ "[Service]",
246
+ "Type=simple",
247
+ `User=${user}`,
248
+ `Environment=PATH=${path}`,
249
+ `Environment=MOSHPIT_PROXY_PORT=${port}`,
250
+ `Environment=MOSHPIT_PROXY_DIR=${dir}`,
251
+ ];
252
+ // Only the endings it is asked to serve. Left unset it defaults to `.moshpit`
253
+ // alone, which is why a proxy can be running, healthy, and unable to present
254
+ // a certificate for the name someone is actually trying to reach.
255
+ if (tlds.length) lines.push(`Environment=MOSHPIT_PROXY_TLDS=${tlds.join(",")}`);
256
+
257
+ lines.push(
258
+ `ExecStart=${wrapper}`,
259
+ "Restart=always",
260
+ "RestartSec=2",
261
+ // The whole reason this is a system unit. Granted rather than inherited:
262
+ // the proxy runs as the operator and needs exactly one privilege.
263
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE",
264
+ "CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
265
+ "NoNewPrivileges=yes",
266
+ "PrivateTmp=yes",
267
+ "",
268
+ "[Install]",
269
+ "WantedBy=multi-user.target",
270
+ "",
271
+ );
272
+ return lines.join("\n");
273
+ }
274
+
275
+ /** Where moshpit-proxy's installer puts its wrapper, if it ran. */
276
+ export function proxyWrapperPath({ home = homedir(), exists = existsSync } = {}) {
277
+ const candidate = join(home, ".local/bin/moshpit-proxy");
278
+ return exists(candidate) ? candidate : null;
279
+ }
280
+
281
+ /**
282
+ * Put the proxy under supervision, and wait for it to actually hold 443.
283
+ *
284
+ * Returns a plain report rather than throwing, and every caller treats a
285
+ * failure as "no proxy" rather than as a failed run. A machine without a
286
+ * working proxy resolves Moshpit names and cannot verify them, which is worse
287
+ * than it sounds but is still enormously better than a machine whose DNS was
288
+ * refused because an optional component would not start.
289
+ *
290
+ * `listening` is asked rather than assumed: `Type=simple` reports active the
291
+ * moment it forks, so "started" and "serving" are different questions and this
292
+ * has to answer the second one. The proxy fetches a registry pin before it can
293
+ * answer, so the wait is generous.
294
+ */
295
+ export async function ensureProxyService({
296
+ home = homedir(),
297
+ user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME,
298
+ nodeDir = dirname(process.execPath),
299
+ tlds = [],
300
+ port = 443,
301
+ exec = run,
302
+ read = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"),
303
+ listening = defaultPortHeld,
304
+ waitMs = 30000,
305
+ } = {}) {
306
+ const wrapper = proxyWrapperPath({ home });
307
+ if (!wrapper) return { ok: false, reason: "not-installed", steps: [] };
308
+
309
+ const { path, systemctl } = proxyServicePaths();
310
+ const unit = proxyServiceUnit({ wrapper, nodeDir, home, user, port, tlds });
311
+ // Read before writing so the manifest can put back whatever was here — which
312
+ // is usually nothing, and "nothing" has to be recorded as precisely as
313
+ // content would be, or disable leaves a unit nobody asked for.
314
+ const before = await read(path).catch(() => null);
315
+
316
+ const steps = [];
317
+ try {
318
+ await mkdir(dirname(path), { recursive: true });
319
+ await writeFile(path, unit);
320
+ steps.push({ step: `wrote ${path}`, ok: true });
321
+ } catch (error) {
322
+ return { ok: false, reason: "write-failed", error: error.message, before, steps };
323
+ }
324
+
325
+ const [cmd, ...flags] = systemctl;
326
+ for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", PROXY_UNIT_NAME]]) {
327
+ const result = await exec(cmd, args);
328
+ steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
329
+ if (!result.ok) return { ok: false, reason: "systemctl-failed", before, steps, path, unit };
330
+ }
331
+
332
+ const held = await listening(port, waitMs);
333
+ steps.push({ step: `proxy holds 127.0.0.1:${port}`, ok: held });
334
+ return { ok: held, reason: held ? null : "not-listening", before, steps, path, unit };
335
+ }
336
+
337
+ /** Poll rather than sleep once: a proxy that comes up in 2s should not cost 30. */
338
+ async function defaultPortHeld(port, waitMs) {
339
+ const { connect } = await import("node:net");
340
+ const deadline = Date.now() + waitMs;
341
+ while (Date.now() < deadline) {
342
+ const open = await new Promise((resolve) => {
343
+ const socket = connect({ host: "127.0.0.1", port });
344
+ const done = (v) => { try { socket.destroy(); } catch { /* gone */ } resolve(v); };
345
+ socket.once("connect", () => done(true));
346
+ socket.once("error", () => done(false));
347
+ setTimeout(() => done(false), 1000);
348
+ });
349
+ if (open) return true;
350
+ await new Promise((r) => setTimeout(r, 1000));
351
+ }
352
+ return false;
353
+ }
354
+
355
+ /** Take the proxy service away. Missing is not a failure. */
356
+ export async function removeProxyService({ exec = run } = {}) {
357
+ const { path, systemctl } = proxyServicePaths();
358
+ const [cmd, ...flags] = systemctl;
359
+ const steps = [];
360
+ const off = await exec(cmd, [...flags, "disable", "--now", PROXY_UNIT_NAME]);
361
+ steps.push({ step: `${cmd} ${[...flags, "disable", "--now", PROXY_UNIT_NAME].join(" ")}`, ok: off.ok, error: off.error });
362
+ await rm(path, { force: true });
363
+ steps.push({ step: `removed ${path}`, ok: true });
364
+ const reload = await exec(cmd, [...flags, "daemon-reload"]);
365
+ steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error });
366
+ return { ok: true, path, steps };
367
+ }
package/src/dns.mjs CHANGED
@@ -1664,6 +1664,37 @@ export function dnsmasqCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT }
1664
1664
  * Repeatable and comma-separated both work. `address#port` matches resolv.conf
1665
1665
  * and dnsmasq rather than inventing a third spelling.
1666
1666
  */
1667
+ /**
1668
+ * Which name to detect the pinned-TLS proxy with.
1669
+ *
1670
+ * The probe is a TLS handshake with the name in SNI, and the proxy can only
1671
+ * present a certificate for a name that really exists — it fetches the
1672
+ * registry pin to mint one. A synthesised `a.<ending>` is never real, so the
1673
+ * handshake yields no certificate and a proxy that is installed, trusted and
1674
+ * listening reports as absent. Measured against a running proxy:
1675
+ *
1676
+ * a.moshpit -> no certificate
1677
+ * a.2600 -> no certificate
1678
+ * alt.2600 -> issuer=CN=Moshpit Local CA
1679
+ *
1680
+ * No registry endpoint lists names, so a real one cannot be discovered here.
1681
+ * `--proxy-probe <name>` supplies it. A bare flag is a typo rather than a
1682
+ * request to probe with nothing, and is reported instead of silently falling
1683
+ * back to the synthetic name that cannot work.
1684
+ */
1685
+ export function proxyProbeFromArgs(args = [], claimed = []) {
1686
+ const at = args.indexOf("--proxy-probe");
1687
+ if (at >= 0) {
1688
+ const value = args[at + 1];
1689
+ if (value === undefined || value.startsWith("--")) return { name: null, invalid: true };
1690
+ return { name: value, invalid: false };
1691
+ }
1692
+ // The historical default. Kept because it is right on a machine whose proxy
1693
+ // serves every ending, and because removing it would turn "detected nothing"
1694
+ // into "refused to look".
1695
+ return { name: claimed[0] ? `a.${claimed[0]}` : null, invalid: false };
1696
+ }
1697
+
1667
1698
  export function upstreamsFromArgs(args = []) {
1668
1699
  const servers = [];
1669
1700
  const invalid = [];
@@ -2230,12 +2261,21 @@ export async function captureRestorePoint({
2230
2261
  dropins = readDropins,
2231
2262
  read = defaultReadMaybe,
2232
2263
  now = () => new Date().toISOString(),
2264
+ // Files this run creates that are not part of the resolver plan — the two
2265
+ // service units, and the local root in the system trust store. Recorded the
2266
+ // same way as everything else: prior content, or null for "was not here", so
2267
+ // `disable` replays rather than guesses. A unit removed because it happened
2268
+ // to exist is the failure this shape prevents.
2269
+ extraPaths = [],
2233
2270
  } = {}) {
2234
2271
  const files = new Map();
2235
2272
  for (const file of await dropins({ dir }).catch(() => [])) {
2236
2273
  if (!dropinNameservers(file.content).length && !dropinDomains(file.content).length) continue;
2237
2274
  files.set(`${dir}/${file.name}`, file.content);
2238
2275
  }
2276
+ for (const path of extraPaths) {
2277
+ if (path && !files.has(path)) files.set(path, await read(path));
2278
+ }
2239
2279
  for (const step of plan?.steps || []) {
2240
2280
  if (step.kind !== "write" && step.kind !== "remove") continue;
2241
2281
  if (!files.has(step.path)) files.set(step.path, await read(step.path));
@@ -2337,7 +2377,7 @@ import { readFile, writeFile } from "node:fs/promises";
2337
2377
  import { existsSync } from "node:fs";
2338
2378
  import { fileURLToPath } from "node:url";
2339
2379
  import { isRealTld } from "./iana-tlds.mjs";
2340
- import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "./dns-service.mjs";
2380
+ import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME, ensureProxyService, removeProxyService, proxyServicePaths, proxyWrapperPath } from "./dns-service.mjs";
2341
2381
  import {
2342
2382
  applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2343
2383
  probeResolver, requiredPort, startDaemon, stopDaemon,
@@ -2377,6 +2417,8 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine
2377
2417
  running across reboots; --write installs and
2378
2418
  starts it, --system for a system unit rather
2379
2419
  than this user's, --remove takes it away
2420
+ --proxy-probe NAME a real Moshpit name to detect
2421
+ the pinned-TLS proxy with (it cannot serve a made-up one)
2380
2422
 
2381
2423
  moshcode dns filter block ads, trackers, malware and phishing at the
2382
2424
  resolver — \`moshcode dns filter help\` for the verbs
@@ -2440,6 +2482,12 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2440
2482
  findLocalProxyImpl = findLocalProxy,
2441
2483
  autoTrustImpl = createAutoTrust,
2442
2484
  stopBridge = stopDaemon,
2485
+ // The two proxy-service calls, injected for the same reason as every
2486
+ // other system call here: a test must be able to exercise the branch
2487
+ // without shelling out to systemctl or writing to /etc.
2488
+ ensureProxy = ensureProxyService,
2489
+ removeProxy = removeProxyService,
2490
+ proxyWrapper = proxyWrapperPath,
2443
2491
  dropins = readDropins,
2444
2492
  manifestFile = manifestPath(),
2445
2493
  readManifest = async (path) => parseManifest(await defaultReadMaybe(path)),
@@ -2792,10 +2840,31 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2792
2840
  // that resolves but cannot complete a TLS handshake reads as broken to the
2793
2841
  // person who typed the URL. Asked now, while this command can ask; the unit
2794
2842
  // it writes runs at boot and has no way to find out later.
2843
+ // The probe is a TLS handshake with the name in SNI, and the proxy can only
2844
+ // present a certificate for a name that actually exists — it fetches the
2845
+ // registry pin to make one. A synthesised `a.<ending>` is never a real name,
2846
+ // so the handshake yields no certificate and a proxy that is installed,
2847
+ // trusted and listening reports as absent. Measured on a running proxy:
2848
+ //
2849
+ // a.moshpit -> no certificate
2850
+ // a.2600 -> no certificate
2851
+ // alt.2600 -> issuer=CN=Moshpit Local CA
2852
+ //
2853
+ // There is no registry endpoint that lists names, so the tool cannot find a
2854
+ // real one by itself. `--proxy-probe <name>` supplies one. The issuer check
2855
+ // still runs against it: naming a probe says which name to ask about, never
2856
+ // that a proxy is there.
2795
2857
  let proxy = null;
2796
2858
  if (!rest.includes("--no-proxy")) {
2797
- const claimed = await fetchTlds({ registryBase }).catch(() => []);
2798
- const probeName = claimed[0] ? `a.${claimed[0]}` : null;
2859
+ const explicit = proxyProbeFromArgs(rest);
2860
+ if (explicit.invalid) {
2861
+ out("! --proxy-probe needs a Moshpit name, e.g. --proxy-probe blue.eggs");
2862
+ out("");
2863
+ }
2864
+ // The registry is only consulted when no name was given: fetching 18000
2865
+ // endings to build a probe that cannot work is pure cost.
2866
+ const claimed = explicit.name ? [] : await fetchTlds({ registryBase }).catch(() => []);
2867
+ const probeName = proxyProbeFromArgs(rest, claimed).name;
2799
2868
  if (probeName) {
2800
2869
  const local = await findLocalProxyImpl(probeName).catch(() => ({ found: false }));
2801
2870
  if (local.found) proxy = local.address.v4 || local.address.v6;
@@ -2810,9 +2879,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2810
2879
  } else if (!rest.includes("--no-proxy")) {
2811
2880
  out("! no pinned-TLS proxy found on this machine");
2812
2881
  out(" names will answer their origin, and a stock client cannot verify those —");
2813
- out(" https:// will fail even though the name resolves. Install it with:");
2882
+ out(" https:// will fail even though the name resolves.");
2883
+ out("");
2884
+ out(" If it is not installed:");
2814
2885
  out(" curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh");
2815
- out(" then re-run this command so the unit points names at it.");
2886
+ out("");
2887
+ out(" If it IS installed and listening, the probe used a name that does not");
2888
+ out(" exist — the proxy can only serve a certificate for a real one. Name one:");
2889
+ out(" moshcode dns service --proxy-probe <a.real.name> --write");
2816
2890
  out("");
2817
2891
  }
2818
2892
 
@@ -3122,6 +3196,30 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3122
3196
 
3123
3197
  const stopped = await stopBridge();
3124
3198
  out(stopped.stopped ? " ok bridge stopped" : ` ok bridge was not running${stopped.reason ? ` (${stopped.reason})` : ""}`);
3199
+
3200
+ // The proxy service `enable` installed. Taken away here, because a
3201
+ // supervised proxy left holding 443 after Moshpit is turned off is a
3202
+ // service the operator never asked to keep and would not think to look
3203
+ // for. Safe from an escalated run: it is a system unit, so root is
3204
+ // exactly the right context to stop it in.
3205
+ //
3206
+ // The manifest still carries whatever was at that path beforehand, so a
3207
+ // machine that already had a unit of its own gets it back rather than
3208
+ // losing it to a cleanup it never asked for.
3209
+ // Only when there is one. A unit that was never installed needs no
3210
+ // systemctl call to not exist, and reaching for /etc on a machine that
3211
+ // never had a proxy is a side effect nobody asked this command for.
3212
+ // Gated on the restore point, not on what happens to be in /etc. `disable`
3213
+ // undoes what `enable` did; a proxy unit this tool never installed is
3214
+ // somebody else's, and stopping it because it shares a filename is
3215
+ // exactly the guessing the manifest exists to prevent.
3216
+ const proxyWasOurs = (restore?.files || []).some((f) => f.path === proxyServicePaths().path);
3217
+ if (platform === "linux" && proxyWasOurs) {
3218
+ const px = await removeProxy();
3219
+ for (const step of px.steps || []) {
3220
+ if (step.ok) out(` ok ${step.step}`);
3221
+ }
3222
+ }
3125
3223
  // Consumed. Leaving it would let a later `disable` restore a machine to a
3126
3224
  // state that is two changes old.
3127
3225
  if (restore) {
@@ -3156,12 +3254,38 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3156
3254
  // run killed halfway leaves behind the one thing needed to undo it. The
3157
3255
  // per-file backup covers the file this run overwrites; this covers the
3158
3256
  // machine, which is a different question and the one `disable` has to ask.
3257
+ // Supervised before anything goes looking for it. moshpit-proxy ships no
3258
+ // unit of its own, so on every machine that installed it, it sat there
3259
+ // installed, trusted and never started — which is indistinguishable from
3260
+ // absent: nothing on 443, no certificate, and this command correctly
3261
+ // reporting no proxy on a box that had one.
3262
+ //
3263
+ // Non-fatal in every direction. A machine without a working proxy resolves
3264
+ // Moshpit names and cannot verify them, which is bad; a machine whose DNS
3265
+ // was refused because an optional component would not start is worse.
3266
+ let proxyUnitPath = null;
3267
+ if (!rest.includes("--no-proxy") && platform === "linux") {
3268
+ if (!proxyWrapper()) {
3269
+ out(" -- no pinned-TLS proxy installed — https:// on a name will not verify");
3270
+ out(" moshcode update installs it, or:");
3271
+ out(" curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh");
3272
+ } else {
3273
+ proxyUnitPath = proxyServicePaths().path;
3274
+ const ensured = await ensureProxy({ tlds });
3275
+ for (const step of ensured.steps || []) {
3276
+ out(` ${step.ok ? "ok " : "-- "} ${step.step}${step.error ? ` — ${step.error}` : ""}`);
3277
+ }
3278
+ if (!ensured.ok) out(` -- the proxy is not serving (${ensured.reason}) — https:// will not verify`);
3279
+ }
3280
+ }
3281
+
3159
3282
  const point = await captureRestorePoint({
3160
3283
  plan,
3161
3284
  platform,
3162
3285
  backend: platform === "linux" ? linuxBackend : platform,
3163
3286
  bridge: `${DEFAULT_HOST}:${wanted}`,
3164
3287
  dropins,
3288
+ extraPaths: [proxyUnitPath, servicePaths({ system: false }).path].filter(Boolean),
3165
3289
  });
3166
3290
  const recorded2 = await applyPlan({
3167
3291
  steps: [{ kind: "write", path: manifestFile, content: `${JSON.stringify(point, null, 2)}\n`, why: "so disable can put this machine back" }],