moshcode 0.89.0 → 0.90.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.89.0",
3
+ "version": "0.90.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": {
@@ -29,9 +29,8 @@
29
29
  // it, and the entry is the script this very command was invoked from. Nothing
30
30
  // is guessed and nothing depends on PATH.
31
31
  import { spawn } from "node:child_process";
32
- import { mkdir, rm, writeFile } from "node:fs/promises";
33
- import { existsSync } from "node:fs";
34
- import { homedir } from "node:os";
32
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
33
+ import { existsSync, statSync } from "node:fs";
35
34
  import { dirname, join } from "node:path";
36
35
  import { operatorHome } from "./trust.mjs";
37
36
 
@@ -48,10 +47,49 @@ export const UNIT_NAME = "moshcode-dns.service";
48
47
  * /run/user/<uid>/moshpit-dns.pid for the daemon and for the person asking
49
48
  * after it. Under a system unit those are two different paths.
50
49
  */
51
- export function servicePaths({ system = false, home = homedir() } = {}) {
50
+ export function servicePaths({ system = false, home = operatorHome(), env = process.env } = {}) {
52
51
  return system
53
52
  ? { path: join("/etc/systemd/system", UNIT_NAME), systemctl: ["systemctl"], scope: "system" }
54
- : { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: ["systemctl", "--user"], scope: "user" };
53
+ : { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: userSystemctl(env), scope: "user" };
54
+ }
55
+
56
+ /**
57
+ * How to reach the operator's own systemd from wherever this is running.
58
+ *
59
+ * `systemctl --user` talks to the session of whoever is running it. `dns enable`
60
+ * escalates, so from there it is root's session — which has no bridge in it, has
61
+ * never had one, and reports every query about one as "not loaded". Meanwhile
62
+ * the operator's bridge keeps running with whatever it started with.
63
+ *
64
+ * That is why enabling proxy mode could be detected, written, and still not take
65
+ * effect: the unit that had to change belongs to a session the escalated half of
66
+ * the command cannot see.
67
+ *
68
+ * So an escalated run drops back to the invoking user, and hands them the runtime
69
+ * directory their session bus lives in — deriving it rather than inheriting it,
70
+ * because sudo does not carry XDG_RUNTIME_DIR across and the default under sudo
71
+ * points at root's.
72
+ */
73
+ export function userSystemctl(env = process.env, { home = operatorHome({ env }), owner = ownerOf } = {}) {
74
+ const user = env.SUDO_USER || env.DOAS_USER;
75
+ // Not escalated, or escalated from root itself: the session in reach is the
76
+ // right one.
77
+ if (!user || user === "root") return ["systemctl", "--user"];
78
+ // sudo publishes the uid; doas publishes only the name. Falling back to the
79
+ // owner of the operator's home covers that, and covers an escalator that
80
+ // publishes neither — without it, a doas machine would quietly address root's
81
+ // session, which has no bridge in it and never will.
82
+ const uid = env.SUDO_UID || env.DOAS_UID || owner(home);
83
+ if (uid === null || uid === undefined) return ["systemctl", "--user"];
84
+ return ["sudo", "-u", user, "env", `XDG_RUNTIME_DIR=/run/user/${uid}`, "systemctl", "--user"];
85
+ }
86
+
87
+ function ownerOf(path) {
88
+ try {
89
+ return statSync(path).uid;
90
+ } catch {
91
+ return null;
92
+ }
55
93
  }
56
94
 
57
95
  /**
@@ -127,6 +165,8 @@ export function serviceUnit({
127
165
  return lines.join("\n");
128
166
  }
129
167
 
168
+ const defaultRead = async (path) => readFile(path, "utf8").catch(() => "");
169
+
130
170
  function run(command, args) {
131
171
  return new Promise((resolve) => {
132
172
  const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -140,8 +180,8 @@ function run(command, args) {
140
180
  }
141
181
 
142
182
  /** Write the unit and start it. Returns the steps taken, in order, for printing. */
143
- export async function installService(unit, { system = false, home = homedir(), exec = run } = {}) {
144
- const { path, systemctl, scope } = servicePaths({ system, home });
183
+ export async function installService(unit, { system = false, home = operatorHome(), exec = run, env = process.env } = {}) {
184
+ const { path, systemctl, scope } = servicePaths({ system, home, env });
145
185
  const steps = [];
146
186
  try {
147
187
  await mkdir(dirname(path), { recursive: true });
@@ -152,7 +192,14 @@ export async function installService(unit, { system = false, home = homedir(), e
152
192
  }
153
193
 
154
194
  const [cmd, ...flags] = systemctl;
155
- for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", UNIT_NAME]]) {
195
+ // Enable *and* restart. `enable --now` starts a stopped unit and does nothing
196
+ // to a running one, so rewriting the unit to add `--proxy` would leave the old
197
+ // bridge running without it — the change on disk, no change in behaviour.
198
+ for (const args of [
199
+ [...flags, "daemon-reload"],
200
+ [...flags, "enable", UNIT_NAME],
201
+ [...flags, "restart", UNIT_NAME],
202
+ ]) {
156
203
  const result = await exec(cmd, args);
157
204
  steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
158
205
  if (!result.ok) return { ok: false, path, scope, steps };
@@ -160,9 +207,119 @@ export async function installService(unit, { system = false, home = homedir(), e
160
207
  return { ok: true, path, scope, steps };
161
208
  }
162
209
 
210
+ /**
211
+ * The `--upstream` servers an installed unit already forwards to.
212
+ *
213
+ * Read back rather than recomputed. A supervised bridge needs upstreams to hand
214
+ * the clearnet to, and the machine may already be routing every lookup at that
215
+ * bridge — so asking the system resolver what its upstreams are can answer
216
+ * "this bridge", and a bridge whose upstream is itself resolves nothing at all.
217
+ * Whatever the unit was working with is the safe answer to keep.
218
+ */
219
+ export function unitUpstreams(text) {
220
+ const line = String(text ?? "").split("\n").find((l) => l.startsWith("ExecStart="));
221
+ if (!line) return [];
222
+ const parts = line.trim().split(/\s+/);
223
+ const found = [];
224
+ for (let i = 0; i < parts.length; i += 1) {
225
+ if (parts[i] !== "--upstream") continue;
226
+ const value = parts[i + 1];
227
+ if (!value || value.startsWith("--")) continue;
228
+ if (!found.includes(value)) found.push(value);
229
+ }
230
+ return found;
231
+ }
232
+
233
+ /**
234
+ * Re-describe the bridge unit to match the run happening now, and restart it so
235
+ * the description becomes the truth.
236
+ *
237
+ * This is the step that made proxy mode arrive one reboot late. A supervised
238
+ * bridge is not started by `enable`: it is already up under `Restart=always`,
239
+ * so `startDaemon` finds a live pidfile and reports "already running" — true,
240
+ * and useless, because what a resolver answers with is fixed when it spawns. A
241
+ * bridge that came up before the proxy existed goes on answering origins
242
+ * forever, and stopping it by hand does not help, since systemd brings the same
243
+ * ExecStart straight back.
244
+ *
245
+ * The only thing that changes a supervised bridge's mind is rewriting its unit
246
+ * and restarting it. That is all this is.
247
+ *
248
+ * With no unit installed it does nothing and says so. An unsupervised machine
249
+ * is `startDaemon`'s business, and writing a unit here would be `enable`
250
+ * quietly making the bridge outlive a reboot on a machine that never asked for
251
+ * that — a different decision, and one `dns service --write` exists to make.
252
+ */
253
+ export async function refreshService({
254
+ entry,
255
+ port,
256
+ registryBase = null,
257
+ proxy = null,
258
+ system = false,
259
+ home = operatorHome(),
260
+ env = process.env,
261
+ exec = run,
262
+ exists = existsSync,
263
+ read = defaultRead,
264
+ } = {}) {
265
+ const { path, scope } = servicePaths({ system, home, env });
266
+ if (!exists(path)) return { refreshed: false, reason: "no unit installed", path, scope, upstreams: [], steps: [] };
267
+
268
+ const current = await read(path);
269
+ const upstreams = unitUpstreams(current);
270
+ const unit = serviceUnit({ system, entry, port, registryBase, upstreams, proxy });
271
+
272
+ // Deliberately not short-circuited on `current === unit`. Matching text says
273
+ // the unit describes the right bridge, not that the bridge is running it: a
274
+ // unit can be installed and stopped, installed and never enabled, or running
275
+ // what it was spawned with before the file last changed. Since the whole
276
+ // point here is to make what is running match what is written, the enable and
277
+ // restart happen either way, and cost a moment of no resolver during a
278
+ // command that is already rewriting the machine's routing.
279
+
280
+ const result = await installService(unit, { system, home, env, exec });
281
+ return {
282
+ refreshed: result.ok,
283
+ reason: result.ok ? null : "systemctl refused the unit",
284
+ path,
285
+ scope,
286
+ upstreams,
287
+ steps: result.steps,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Stop the supervised bridge and leave the unit where it is.
293
+ *
294
+ * `stopDaemon` cannot do this. It reads the pidfile and signals that process,
295
+ * which is right for a bridge started by hand and useless for one systemd owns:
296
+ * the unit is `Restart=always`, so the pid dies and the same ExecStart is back
297
+ * within the second. `disable` printed "bridge stopped" and left a bridge
298
+ * running — on a machine whose routing had just been put back, so the bridge
299
+ * was still up, still answering, and no longer on anybody's path.
300
+ *
301
+ * The unit file stays. Removing it is a different decision than turning
302
+ * resolution off for an afternoon, and `enable` re-enables what it finds — so
303
+ * leaving it costs nothing and deleting it would quietly take away a unit the
304
+ * operator may have written themselves.
305
+ */
306
+ export async function stopService({ system = false, home = operatorHome(), env = process.env, exec = run, exists = existsSync } = {}) {
307
+ const { path, systemctl, scope } = servicePaths({ system, home, env });
308
+ if (!exists(path)) return { stopped: false, reason: "no unit installed", path, scope, steps: [] };
309
+ const [cmd, ...flags] = systemctl;
310
+ const result = await exec(cmd, [...flags, "disable", "--now", UNIT_NAME]);
311
+ return {
312
+ stopped: result.ok,
313
+ reason: result.ok ? null : (result.error || "systemctl refused"),
314
+ path,
315
+ scope,
316
+ steps: [{ step: `${cmd} ${flags.join(" ")} disable --now ${UNIT_NAME}`, ok: result.ok, error: result.error }],
317
+ };
318
+ }
319
+
163
320
  /** Stop it and take the unit away. Missing is not a failure — removal is idempotent. */
164
- export async function removeService({ system = false, home = homedir(), exec = run } = {}) {
165
- const { path, systemctl, scope } = servicePaths({ system, home });
321
+ export async function removeService({ system = false, home = operatorHome(), exec = run, env = process.env } = {}) {
322
+ const { path, systemctl, scope } = servicePaths({ system, home, env });
166
323
  const [cmd, ...flags] = systemctl;
167
324
  const steps = [];
168
325
  for (const args of [[...flags, "disable", "--now", UNIT_NAME]]) {
package/src/dns.mjs CHANGED
@@ -2148,6 +2148,59 @@ export async function verifyResolution({
2148
2148
  return { ok: checks.every((c) => c.ok), checks };
2149
2149
  }
2150
2150
 
2151
+ /**
2152
+ * Wait for a bridge that systemd has just restarted to start answering.
2153
+ *
2154
+ * `startDaemon` cannot be asked this. It spawns, watches its own child, and
2155
+ * decides from a pidfile — none of which describes a unit that systemd owns and
2156
+ * has just cycled. Calling it here would either report the pre-restart pid as
2157
+ * "already running" or, on a pidfile not yet rewritten, spawn a second bridge
2158
+ * against the one systemd is bringing up.
2159
+ *
2160
+ * `Type=simple` reports active the moment the process forks, so systemd saying
2161
+ * the restart worked is not yet a resolver that answers. Hence the probe.
2162
+ *
2163
+ * A timeout is reported as started-but-unverified rather than as a failure, the
2164
+ * same way `startDaemon` treats a live process that has not answered yet: the
2165
+ * unit is active, and refusing this machine its DNS over a slow first registry
2166
+ * fetch would be the worse mistake.
2167
+ */
2168
+ export async function supervisedReady({
2169
+ host = DEFAULT_HOST,
2170
+ port,
2171
+ probe = probeResolver,
2172
+ status = daemonStatus,
2173
+ timeoutMs = READY_TIMEOUT_MS,
2174
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
2175
+ } = {}) {
2176
+ const deadline = Date.now() + timeoutMs;
2177
+ let answered = false;
2178
+ while (Date.now() < deadline) {
2179
+ if (await probe({ host, port }).catch(() => false)) {
2180
+ answered = true;
2181
+ break;
2182
+ }
2183
+ await sleep(150);
2184
+ }
2185
+ const current = await Promise.resolve(status()).catch(() => null);
2186
+ if (!answered) {
2187
+ // Reported as a failure, unlike `startDaemon`'s slow-but-alive case, and
2188
+ // for a reason that does not apply there: that one has watched its own
2189
+ // child and knows it is running. Nothing here has. `systemctl restart`
2190
+ // returns as soon as a Type=simple unit forks, so it returns 0 for a bridge
2191
+ // that forked and died — and the next thing this run does is point every
2192
+ // lookup on the machine at that port. Refusing is the safe direction.
2193
+ return {
2194
+ started: false,
2195
+ alreadyRunning: false,
2196
+ pid: current?.pid ?? null,
2197
+ supervised: true,
2198
+ error: `${UNIT_NAME} restarted but the bridge did not answer on ${host}:${port}`,
2199
+ };
2200
+ }
2201
+ return { started: true, pid: current?.pid ?? null, alreadyRunning: false, supervised: true, verified: true };
2202
+ }
2203
+
2151
2204
  const defaultReadMaybe = async (path) => {
2152
2205
  const { readFile: rf } = await import("node:fs/promises");
2153
2206
  return rf(path, "utf8").catch(() => null);
@@ -2407,10 +2460,10 @@ import { readFile, writeFile } from "node:fs/promises";
2407
2460
  import { existsSync } from "node:fs";
2408
2461
  import { fileURLToPath } from "node:url";
2409
2462
  import { isRealTld } from "./iana-tlds.mjs";
2410
- import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME, ensureProxyService, removeProxyService, proxyServicePaths, proxyWrapperPath } from "./dns-service.mjs";
2463
+ import { installService, refreshService, removeService, serviceUnit, servicePaths, stopService, UNIT_NAME, ensureProxyService, removeProxyService, proxyServicePaths, proxyWrapperPath } from "./dns-service.mjs";
2411
2464
  import {
2412
2465
  applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2413
- probeResolver, requiredPort, startDaemon, stopDaemon,
2466
+ probeResolver, READY_TIMEOUT_MS, requiredPort, startDaemon, stopDaemon,
2414
2467
  } from "./dns-system.mjs";
2415
2468
  import { escalateSelf } from "./escalate.mjs";
2416
2469
 
@@ -2508,10 +2561,13 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2508
2561
  presenceImpl = bridgePresence,
2509
2562
  exists = existsSync,
2510
2563
  startBridge = startDaemon,
2564
+ refreshBridge = refreshService,
2565
+ bridgeReady = supervisedReady,
2511
2566
  proxyReachableImpl = proxyReachable,
2512
2567
  findLocalProxyImpl = findLocalProxy,
2513
2568
  autoTrustImpl = createAutoTrust,
2514
2569
  stopBridge = stopDaemon,
2570
+ stopSupervised = stopService,
2515
2571
  // The two proxy-service calls, injected for the same reason as every
2516
2572
  // other system call here: a test must be able to exercise the branch
2517
2573
  // without shelling out to systemctl or writing to /etc.
@@ -3224,6 +3280,19 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3224
3280
  return 1;
3225
3281
  }
3226
3282
 
3283
+ // The supervised bridge first, and by asking systemd rather than by
3284
+ // signalling a pid. `stopDaemon` kills what the pidfile names, and the
3285
+ // unit is `Restart=always` — so the process died, systemd replaced it
3286
+ // within the second, and this command reported "bridge stopped" on a
3287
+ // machine where the bridge was still up and answering, just no longer on
3288
+ // anything's path.
3289
+ const unsupervised = await stopSupervised();
3290
+ if (unsupervised.reason !== "no unit installed") {
3291
+ out(unsupervised.stopped
3292
+ ? ` ok ${UNIT_NAME} stopped and disabled`
3293
+ : ` -- could not stop ${UNIT_NAME} (${unsupervised.reason}) — it will restart itself`);
3294
+ }
3295
+
3227
3296
  const stopped = await stopBridge();
3228
3297
  out(stopped.stopped ? " ok bridge stopped" : ` ok bridge was not running${stopped.reason ? ` (${stopped.reason})` : ""}`);
3229
3298
 
@@ -3412,17 +3481,42 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3412
3481
  }
3413
3482
  }
3414
3483
 
3484
+ // A bridge under systemd is re-described, not started — and this is the step
3485
+ // whose absence left the last of this to be done by hand.
3486
+ //
3487
+ // `startBridge` below reports "already running" for a supervised bridge and
3488
+ // leaves it alone. That is correct, and it is also why proxy mode arrived a
3489
+ // reboot late: what a resolver answers with is fixed when it spawns, so a
3490
+ // bridge that came up before the proxy existed goes on answering origins no
3491
+ // matter what this run decides. Stopping it does not help either, since
3492
+ // `Restart=always` brings the same ExecStart back.
3493
+ //
3494
+ // So the unit is rewritten to match the run happening now, and restarted.
3495
+ // v4 by preference: `dns start --proxy` takes one address and probes both
3496
+ // families itself, so handing it the v4 loopback lets it find ::1 too
3497
+ // rather than pinning the answer to one family.
3498
+ const proxyArg = proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null;
3499
+
3500
+ let refreshed = null;
3501
+ if (!reusing && platform === "linux") {
3502
+ refreshed = await refreshBridge({ entry: cliEntry(), port: wanted, registryBase, proxy: proxyArg });
3503
+ for (const step of refreshed.steps || []) {
3504
+ out(` ${step.ok ? "ok " : "-- "} ${step.step}${step.error ? ` — ${step.error}` : ""}`);
3505
+ }
3506
+ if (refreshed.refreshed) {
3507
+ const forwarding = refreshed.upstreams?.length ? `, forwarding the clearnet to ${refreshed.upstreams.join(", ")}` : "";
3508
+ out(` ok ${UNIT_NAME} restarted with proxy mode ${proxyArg ? "on" : "off"}${forwarding}`);
3509
+ } else if (refreshed.reason !== "no unit installed") {
3510
+ out(` -- could not update ${UNIT_NAME} (${refreshed.reason})`);
3511
+ out(" the bridge already running keeps the mode it started with");
3512
+ }
3513
+ }
3514
+
3415
3515
  const started = reusing
3416
3516
  ? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true }
3417
- : await startBridge({
3418
- port: wanted,
3419
- registryBase,
3420
- entry: cliEntry(),
3421
- // v4 by preference: `dns start --proxy` takes one address and probes
3422
- // both families itself, so handing it the v4 loopback lets it find ::1
3423
- // too rather than pinning the answer to one family.
3424
- proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
3425
- });
3517
+ : refreshed?.refreshed
3518
+ ? await bridgeReady({ host: DEFAULT_HOST, port: wanted })
3519
+ : await startBridge({ port: wanted, registryBase, entry: cliEntry(), proxy: proxyArg });
3426
3520
  // The routing this is about to install is catch-all — every lookup on the
3427
3521
  // machine, not just Moshpit ones — so a bridge that did not come up is not
3428
3522
  // a degraded feature, it is the machine's resolver pointed at nothing.
@@ -3504,7 +3598,13 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3504
3598
  out(`Moshpit names now resolve on this machine. Try: moshcode dns resolve ${moshpitProbe || "<name>"}`);
3505
3599
  out(`Routing covers the ${tlds.length} TLDs claimed right now. New ones do not route`);
3506
3600
  out("until you re-run this — there is no common suffix to match, so every TLD is listed.");
3507
- out("Note: the bridge does not yet survive a reboot. Re-run `moshcode dns enable` after one.");
3601
+ // Only where it is still true. On a supervised machine the unit was just
3602
+ // enabled and restarted, so the bridge does come back — and telling
3603
+ // someone to re-run a command they do not need is how advice stops being
3604
+ // read at all.
3605
+ out(started.supervised
3606
+ ? `Note: ${UNIT_NAME} brings the bridge back after a reboot.`
3607
+ : "Note: the bridge does not yet survive a reboot. Re-run `moshcode dns enable` after one.");
3508
3608
  return 0;
3509
3609
  }
3510
3610
 
@@ -3512,7 +3612,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3512
3612
  report(outcome.rolledBack.results);
3513
3613
  // Started by this run and no longer routed to, so leaving it would be a
3514
3614
  // process holding 5354 that the next enable's preflight refuses to run past.
3515
- if (started.started) {
3615
+ //
3616
+ // A supervised bridge is exempt: this run did not start it, only restarted
3617
+ // it, so it was holding that port before the run and is meant to go on
3618
+ // holding it. Signalling its pid would not stop it anyway — `Restart=always`
3619
+ // replaces it within the second — so the only thing the old line achieved
3620
+ // there was printing "remove bridge started by this run" about a bridge
3621
+ // that was neither started by this run nor removed.
3622
+ if (started.started && !started.supervised) {
3516
3623
  const stopped = await stopBridge();
3517
3624
  if (stopped.stopped) out(" ok remove bridge started by this run");
3518
3625
  }