moshcode 0.83.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.83.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
@@ -2261,12 +2261,21 @@ export async function captureRestorePoint({
2261
2261
  dropins = readDropins,
2262
2262
  read = defaultReadMaybe,
2263
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 = [],
2264
2270
  } = {}) {
2265
2271
  const files = new Map();
2266
2272
  for (const file of await dropins({ dir }).catch(() => [])) {
2267
2273
  if (!dropinNameservers(file.content).length && !dropinDomains(file.content).length) continue;
2268
2274
  files.set(`${dir}/${file.name}`, file.content);
2269
2275
  }
2276
+ for (const path of extraPaths) {
2277
+ if (path && !files.has(path)) files.set(path, await read(path));
2278
+ }
2270
2279
  for (const step of plan?.steps || []) {
2271
2280
  if (step.kind !== "write" && step.kind !== "remove") continue;
2272
2281
  if (!files.has(step.path)) files.set(step.path, await read(step.path));
@@ -2368,7 +2377,7 @@ import { readFile, writeFile } from "node:fs/promises";
2368
2377
  import { existsSync } from "node:fs";
2369
2378
  import { fileURLToPath } from "node:url";
2370
2379
  import { isRealTld } from "./iana-tlds.mjs";
2371
- 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";
2372
2381
  import {
2373
2382
  applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2374
2383
  probeResolver, requiredPort, startDaemon, stopDaemon,
@@ -2473,6 +2482,12 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2473
2482
  findLocalProxyImpl = findLocalProxy,
2474
2483
  autoTrustImpl = createAutoTrust,
2475
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,
2476
2491
  dropins = readDropins,
2477
2492
  manifestFile = manifestPath(),
2478
2493
  readManifest = async (path) => parseManifest(await defaultReadMaybe(path)),
@@ -3181,6 +3196,30 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3181
3196
 
3182
3197
  const stopped = await stopBridge();
3183
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
+ }
3184
3223
  // Consumed. Leaving it would let a later `disable` restore a machine to a
3185
3224
  // state that is two changes old.
3186
3225
  if (restore) {
@@ -3215,12 +3254,38 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
3215
3254
  // run killed halfway leaves behind the one thing needed to undo it. The
3216
3255
  // per-file backup covers the file this run overwrites; this covers the
3217
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
+
3218
3282
  const point = await captureRestorePoint({
3219
3283
  plan,
3220
3284
  platform,
3221
3285
  backend: platform === "linux" ? linuxBackend : platform,
3222
3286
  bridge: `${DEFAULT_HOST}:${wanted}`,
3223
3287
  dropins,
3288
+ extraPaths: [proxyUnitPath, servicePaths({ system: false }).path].filter(Boolean),
3224
3289
  });
3225
3290
  const recorded2 = await applyPlan({
3226
3291
  steps: [{ kind: "write", path: manifestFile, content: `${JSON.stringify(point, null, 2)}\n`, why: "so disable can put this machine back" }],