moshcode 0.86.0 → 0.88.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.86.0",
3
+ "version": "0.88.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": {
@@ -130,10 +130,12 @@ export function serviceUnit({
130
130
  function run(command, args) {
131
131
  return new Promise((resolve) => {
132
132
  const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
133
+ let out = "";
133
134
  let err = "";
135
+ child.stdout.on("data", (d) => (out += d));
134
136
  child.stderr.on("data", (d) => (err += d));
135
137
  child.on("error", (error) => resolve({ ok: false, error: error.message }));
136
- child.on("exit", (code) => resolve({ ok: code === 0, error: err.trim() }));
138
+ child.on("exit", (code) => resolve({ ok: code === 0, stdout: out, error: err.trim() }));
137
139
  });
138
140
  }
139
141
 
@@ -277,6 +279,38 @@ export function proxyServiceUnit({
277
279
  return lines.join("\n");
278
280
  }
279
281
 
282
+ /**
283
+ * Is the local root the old shape, minted for a list of endings?
284
+ *
285
+ * moshpit-proxy used to constrain its root by naming what it could certify.
286
+ * That root works only for the endings it happened to name, which is why a
287
+ * machine could reach `.2600` over HTTPS and not `.hacker` — and why the list
288
+ * could never be right, since the registry keeps selling more.
289
+ *
290
+ * The root it mints now excludes the real internet instead and covers the whole
291
+ * namespace. But a machine that ran the old proxy still has the old root on
292
+ * disk, and moshpit-proxy will not replace a root that already exists — so
293
+ * without this, upgrading leaves the narrow root in place and `.hacker` keeps
294
+ * failing with nothing to explain why.
295
+ *
296
+ * A permitted DNS subtree is the tell. The new root has none by design.
297
+ */
298
+ export async function rootIsNarrow({
299
+ home = operatorHome(),
300
+ exec = run,
301
+ exists = existsSync,
302
+ } = {}) {
303
+ const file = join(home, ".moshpit", "ca", "ca.crt");
304
+ if (!exists(file)) return { narrow: false, reason: "no root yet", file };
305
+ const described = await exec("openssl", ["x509", "-noout", "-text", "-in", file]);
306
+ // Unreadable is not narrow. Deleting a root because openssl was missing would
307
+ // throw away a working setup to fix a problem nobody had.
308
+ if (!described.ok) return { narrow: false, reason: "could not read it", file };
309
+ return described.stdout && /Permitted:/i.test(described.stdout)
310
+ ? { narrow: true, reason: "constrained to a list of endings", file }
311
+ : { narrow: false, reason: "already covers the namespace", file };
312
+ }
313
+
280
314
  /** Where moshpit-proxy's installer puts its wrapper, if it ran. */
281
315
  export function proxyWrapperPath({ home = operatorHome(), exists = existsSync } = {}) {
282
316
  const candidate = join(home, ".local/bin/moshpit-proxy");
@@ -304,6 +338,8 @@ export async function ensureProxyService({
304
338
  tlds = [],
305
339
  port = 443,
306
340
  exec = run,
341
+ narrowRoot = rootIsNarrow,
342
+ paths = proxyServicePaths,
307
343
  read = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"),
308
344
  listening = defaultPortHeld,
309
345
  waitMs = 30000,
@@ -311,7 +347,7 @@ export async function ensureProxyService({
311
347
  const wrapper = proxyWrapperPath({ home });
312
348
  if (!wrapper) return { ok: false, reason: "not-installed", steps: [] };
313
349
 
314
- const { path, systemctl } = proxyServicePaths();
350
+ const { path, systemctl } = paths();
315
351
  const unit = proxyServiceUnit({ wrapper, nodeDir, home, user, port, tlds });
316
352
  // Read before writing so the manifest can put back whatever was here — which
317
353
  // is usually nothing, and "nothing" has to be recorded as precisely as
@@ -319,6 +355,22 @@ export async function ensureProxyService({
319
355
  const before = await read(path).catch(() => null);
320
356
 
321
357
  const steps = [];
358
+
359
+ // A root minted by the old proxy names the endings it may certify, and
360
+ // moshpit-proxy will not replace a root that already exists. Left alone, an
361
+ // upgraded machine keeps the narrow root and `.hacker` keeps failing — so it
362
+ // goes, and the proxy mints the current shape on its next start.
363
+ //
364
+ // Safe to remove: it is a local root, regenerated in seconds, and `dns enable`
365
+ // installs the replacement into the trust stores in the same run. Removing it
366
+ // without that would be the destructive half on its own, which is why this
367
+ // lives here and not in the installer.
368
+ const narrow = await narrowRoot({ home, exec });
369
+ if (narrow.narrow) {
370
+ await rm(join(home, ".moshpit", "ca"), { recursive: true, force: true }).catch(() => {});
371
+ steps.push({ step: `reminted the local root — the old one was ${narrow.reason}`, ok: true });
372
+ }
373
+
322
374
  try {
323
375
  await mkdir(dirname(path), { recursive: true });
324
376
  await writeFile(path, unit);
@@ -328,7 +380,14 @@ export async function ensureProxyService({
328
380
  }
329
381
 
330
382
  const [cmd, ...flags] = systemctl;
331
- for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", PROXY_UNIT_NAME]]) {
383
+ // `enable --now` starts a stopped unit and does nothing to a running one, so
384
+ // an upgrade would leave the previous process — and the previous root, and
385
+ // the previous namespace — in place. Restart is what makes an upgrade take.
386
+ for (const args of [
387
+ [...flags, "daemon-reload"],
388
+ [...flags, "enable", PROXY_UNIT_NAME],
389
+ [...flags, "restart", PROXY_UNIT_NAME],
390
+ ]) {
332
391
  const result = await exec(cmd, args);
333
392
  steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
334
393
  if (!result.ok) return { ok: false, reason: "systemctl-failed", before, steps, path, unit };
package/src/dns.mjs CHANGED
@@ -1695,6 +1695,36 @@ export function proxyProbeFromArgs(args = [], claimed = []) {
1695
1695
  return { name: claimed[0] ? `a.${claimed[0]}` : null, invalid: false };
1696
1696
  }
1697
1697
 
1698
+ /**
1699
+ * A Moshpit name to test this machine's setup with.
1700
+ *
1701
+ * Synthetic on purpose — the proxy mints a certificate for any name inside the
1702
+ * namespace, so the probe does not have to name something anybody registered.
1703
+ * What it does have to be is a hostname the rest of the program can parse.
1704
+ *
1705
+ * `a.${tlds[0]}` was not. The first ending the registry returns is `00`, and
1706
+ * `https://a.00/` is rejected outright by the WHATWG URL parser: a final label
1707
+ * of digits makes the whole host a candidate IPv4 literal, and `a.00` is not a
1708
+ * valid one. So proxy detection asked about a name that could not be looked up,
1709
+ * concluded there was no proxy on a machine that had one running and holding
1710
+ * 443, and started the bridge without proxy mode — leaving every name to answer
1711
+ * its origin with a certificate no CA has signed.
1712
+ *
1713
+ * An all-numeric ending is legal and wanted (`.420`, `.2600`); it is unusable
1714
+ * only here, which is why this skips them for the probe rather than the registry
1715
+ * refusing to sell them. `--proxy-probe <name>` overrides, for a machine where
1716
+ * the choice has to be a specific real name.
1717
+ */
1718
+ export function probeName(tlds = [], args = []) {
1719
+ const at = args.indexOf("--proxy-probe");
1720
+ if (at >= 0) {
1721
+ const given = args[at + 1];
1722
+ if (given && !given.startsWith("--")) return given;
1723
+ }
1724
+ const usable = tlds.find((t) => !/^\d+$/.test(String(t)));
1725
+ return usable ? `a.${usable}` : null;
1726
+ }
1727
+
1698
1728
  export function upstreamsFromArgs(args = []) {
1699
1729
  const servers = [];
1700
1730
  const invalid = [];
@@ -3095,7 +3125,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3095
3125
 
3096
3126
  // The name whose resolution proves the bridge is answering, alongside the
3097
3127
  // clearnet one that proves it is forwarding.
3098
- const moshpitProbe = tlds[0] ? `a.${tlds[0]}` : null;
3128
+ const moshpitProbe = probeName(tlds, rest);
3099
3129
  // Restoring files is not a rollback on Windows: NRPT rules are not files,
3100
3130
  // so the undo is the disable plan's commands rather than the enable plan's.
3101
3131
  const undoSteps = platform === "windows" ? disablePlan({ platform, tlds, linuxBackend }).steps : undefined;