moshcode 0.62.0 → 0.63.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.62.0",
3
+ "version": "0.63.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": {
@@ -332,8 +332,24 @@ function defaultRunner(command, args) {
332
332
  * root to listen on 5354, and requiring it to write a pidfile somewhere
333
333
  * privileged would make the whole daemon need privileges it otherwise does not.
334
334
  */
335
- export function pidfilePath() {
336
- const base = process.env.XDG_RUNTIME_DIR || join(homedir(), ".moshcode") || tmpdir();
335
+ export function pidfilePath(env = process.env, exists = existsSync) {
336
+ // `dns enable` escalates, so the run that *starts* the bridge is root and the
337
+ // runs that later ask about it are not. Under sudo both XDG_RUNTIME_DIR and
338
+ // HOME belong to root, so the pidfile went to /root/.moshcode — a path the
339
+ // unprivileged `dns status` and `dns disable` never look at and could not
340
+ // read if they did. The bridge was reported "not running" for the rest of its
341
+ // life, and the fix status advised started a second one on top of it.
342
+ //
343
+ // So an escalated run records against the invoking user's runtime dir, and
344
+ // only when that directory is really there: deriving /run/user/<uid> on a
345
+ // machine without one trades an unreadable path for a nonexistent one. macOS
346
+ // has no /run/user and falls through unchanged — the escalated paths this
347
+ // matters for are the systemd-resolved ones.
348
+ const invoker = env.SUDO_UID ? `/run/user/${env.SUDO_UID}` : null;
349
+ const base = (invoker && exists(invoker) ? invoker : null)
350
+ || env.XDG_RUNTIME_DIR
351
+ || join(homedir(), ".moshcode")
352
+ || tmpdir();
337
353
  return join(base, "moshpit-dns.pid");
338
354
  }
339
355
 
package/src/dns.mjs CHANGED
@@ -1742,7 +1742,7 @@ export function parseUdpListeners(text) {
1742
1742
  return out;
1743
1743
  }
1744
1744
 
1745
- const defaultUdpListeners = async () => {
1745
+ export const defaultUdpListeners = async () => {
1746
1746
  const { execFile } = await import("node:child_process");
1747
1747
  const text = await new Promise((resolve) => {
1748
1748
  execFile("ss", ["-lnup"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout)));
@@ -1783,6 +1783,89 @@ export function portHolder(listeners, { host = DEFAULT_HOST, port = DEFAULT_PORT
1783
1783
  return null;
1784
1784
  }
1785
1785
 
1786
+ /**
1787
+ * What is actually on the bridge's port, rather than what our pidfile claims.
1788
+ *
1789
+ * `status` used to answer this from the pidfile alone, and that file only ever
1790
+ * describes a bridge *this tool* started, in *this* privilege context. Every
1791
+ * other way a bridge reaches 5354 read as "not running": a systemd unit, a
1792
+ * hand-started `dns start`, or — the common one — an `enable` that escalated to
1793
+ * root and therefore wrote its pidfile under root's HOME instead of the
1794
+ * invoking user's runtime dir.
1795
+ *
1796
+ * That is not a cosmetic lie. Status followed it with "routing is in place but
1797
+ * the bridge is not running", and the fix it advised starts a second bridge on
1798
+ * 127.0.0.1 while the working one holds 0.0.0.0. The kernel delivers to the
1799
+ * more specific socket, so the advice shadows the bridge it was meant to
1800
+ * rescue and the machine stops resolving — the outage `portHolder` above
1801
+ * already describes, arrived at this time by following our own instructions.
1802
+ *
1803
+ * So the port gets asked. A bridge nothing here started is still a bridge.
1804
+ * Both questions are put because they fail differently: a resolver that has
1805
+ * stopped answering Moshpit names loses a namespace, and one that has stopped
1806
+ * forwarding takes the machine off the internet.
1807
+ */
1808
+ export async function bridgePresence({
1809
+ host = DEFAULT_HOST,
1810
+ port = DEFAULT_PORT,
1811
+ recorded = { running: false, pid: null, stale: false },
1812
+ listeners = defaultUdpListeners,
1813
+ answers = probeResolver,
1814
+ forwards = probeForwarding,
1815
+ } = {}) {
1816
+ const [moshpit, clearnet] = await Promise.all([
1817
+ answers({ host, port }).catch(() => false),
1818
+ forwards({ host, port, name: CLEARNET_PROBE }).catch(() => false),
1819
+ ]);
1820
+ const answering = Boolean(moshpit || clearnet);
1821
+
1822
+ // Ours and alive is the ordinary case, and the probe still runs first: a
1823
+ // recorded pid that no longer answers is worth saying out loud rather than
1824
+ // reporting as a healthy bridge on the strength of the file alone.
1825
+ if (recorded.running) {
1826
+ return { kind: "ours", pid: recorded.pid, answering, forwards: clearnet, moshpit };
1827
+ }
1828
+
1829
+ if (!answering) {
1830
+ return recorded.stale
1831
+ ? { kind: "stale", pid: recorded.pid, answering: false, forwards: false, moshpit: false }
1832
+ : { kind: "none", pid: null, answering: false, forwards: false, moshpit: false };
1833
+ }
1834
+
1835
+ // Only asked once something is known to be there, because `ss` is the
1836
+ // expensive half and an unattributable owner is not a reason to call a
1837
+ // demonstrably answering bridge absent.
1838
+ const holder = portHolder(await listeners().catch(() => []), { host, port });
1839
+ return {
1840
+ kind: "foreign",
1841
+ pid: holder?.pid ?? null,
1842
+ process: holder?.process ?? null,
1843
+ answering: true,
1844
+ forwards: clearnet,
1845
+ moshpit,
1846
+ };
1847
+ }
1848
+
1849
+ /** One line for `status`, kept next to the states it names. */
1850
+ export function describeBridge(presence, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
1851
+ switch (presence.kind) {
1852
+ case "ours":
1853
+ return presence.answering
1854
+ ? `running (pid ${presence.pid})`
1855
+ : `running (pid ${presence.pid}) — but not answering on ${host}:${port}`;
1856
+ case "foreign": {
1857
+ const who = presence.pid
1858
+ ? `pid ${presence.pid}${presence.process ? `, ${presence.process}` : ""}`
1859
+ : "owner not visible";
1860
+ return `answering on ${host}:${port} (${who}) — started by something other than \`dns enable\``;
1861
+ }
1862
+ case "stale":
1863
+ return `NOT running — stale pidfile for ${presence.pid}`;
1864
+ default:
1865
+ return "not running";
1866
+ }
1867
+ }
1868
+
1786
1869
  /**
1787
1870
  * Everything that has to be true of the machine before the routing is written.
1788
1871
  *
@@ -2151,7 +2234,7 @@ import { existsSync } from "node:fs";
2151
2234
  import { fileURLToPath } from "node:url";
2152
2235
  import {
2153
2236
  applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2154
- requiredPort, startDaemon, stopDaemon,
2237
+ probeResolver, requiredPort, startDaemon, stopDaemon,
2155
2238
  } from "./dns-system.mjs";
2156
2239
  import { escalateSelf } from "./escalate.mjs";
2157
2240
 
@@ -2230,6 +2313,8 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2230
2313
  applyWith = applyWithRollback,
2231
2314
  verify = verifyResolution,
2232
2315
  bridgeStatus = daemonStatus,
2316
+ presenceImpl = bridgePresence,
2317
+ exists = existsSync,
2233
2318
  startBridge = startDaemon,
2234
2319
  proxyReachableImpl = proxyReachable,
2235
2320
  findLocalProxyImpl = findLocalProxy,
@@ -3009,25 +3094,48 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3009
3094
 
3010
3095
  if (sub === "status") {
3011
3096
  const platform = detectPlatform();
3012
- const daemon = await daemonStatus();
3097
+ const daemon = await bridgeStatus();
3098
+ const statusPort = requiredPort(platform, port);
3099
+ const presence = await presenceImpl({ port: statusPort, recorded: daemon });
3013
3100
  out(`platform ${platform || process.platform}`);
3014
- out(`bridge ${daemon.running ? `running (pid ${daemon.pid})` : daemon.stale ? `NOT running — stale pidfile for ${daemon.pid}` : "not running"}`);
3101
+ out(`bridge ${describeBridge(presence, { port: statusPort })}`);
3015
3102
 
3016
3103
  // Routing is read off the filesystem rather than remembered, so a config
3017
3104
  // someone edited or removed by hand is reported as it actually is.
3018
3105
  const marker = platform === "macos" ? "/etc/resolver" : MOSHPIT_DROPIN;
3019
- const routed = platform === "linux" ? existsSync(marker) : platform === "macos" ? existsSync(marker) : null;
3106
+ // Injected like every other system call this command makes. Read straight
3107
+ // off the filesystem, "is this machine routed" made the status tests depend
3108
+ // on whether the machine running them happened to have Moshpit enabled —
3109
+ // green on a developer's box, red on a clean runner.
3110
+ const routed = platform === "linux" || platform === "macos" ? exists(marker) : null;
3020
3111
  out(`routing ${routed === null ? "(check NRPT: Get-DnsClientNrptRule)" : routed ? `configured (${marker})` : "not configured"}`);
3021
3112
 
3022
- // The state worth shouting about: names are pointed at a bridge that is not
3023
- // there, so every Moshpit name fails instead of falling through.
3024
- if (routed && !daemon.running) {
3113
+ // The state worth shouting about, and the condition is "nothing answers"
3114
+ // rather than "our pidfile is empty". Those are not the same machine, and
3115
+ // shouting on the second one sent people to start a bridge that shadowed
3116
+ // the working one they already had.
3117
+ //
3118
+ // The advice drops its `sudo` too: the CLI escalates the one step that
3119
+ // needs root, and teaching `sudo moshcode` is how `sudo moshcode update`
3120
+ // ends up reinstalling the whole tool into /root.
3121
+ if (routed && !presence.answering) {
3122
+ out("");
3123
+ out(`! routing is in place but nothing answers on ${DEFAULT_HOST}:${statusPort} — Moshpit names will fail.`);
3124
+ out(" fix with: moshcode dns enable undo with: moshcode dns disable");
3125
+ }
3126
+
3127
+ // Answering but not forwarding is the dangerous half, and it is invisible
3128
+ // from the Moshpit side: names resolve, and everything else on the machine
3129
+ // stops. Catch-all routing is what makes it total.
3130
+ if (routed && presence.answering && !presence.forwards) {
3025
3131
  out("");
3026
- out("! routing is in place but the bridge is not running Moshpit names will fail.");
3027
- out(" fix with: sudo moshcode dns enable undo with: sudo moshcode dns disable");
3132
+ out(`! the bridge on ${DEFAULT_HOST}:${statusPort} answers Moshpit names but is not forwarding`);
3133
+ out(" clearnet lookups routed through it will fail. Restart it, or `moshcode dns disable`.");
3028
3134
  }
3029
3135
 
3030
- const known = await fetchTlds({ registryBase }).catch(() => null);
3136
+ // The injected one, like every other caller. Reaching past it here made
3137
+ // `status` the one subcommand that could not be tested without a network.
3138
+ const known = await fetchTldsImpl({ registryBase }).catch(() => null);
3031
3139
  const probe = known
3032
3140
  ? await resolveName(`probe.${known[0] || "moshpit"}`, { registryBase }).catch(() => null)
3033
3141
  : null;