moshcode 0.85.0 → 0.87.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.85.0",
3
+ "version": "0.87.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
 
@@ -250,10 +252,14 @@ export function proxyServiceUnit({
250
252
  `Environment=MOSHPIT_PROXY_PORT=${port}`,
251
253
  `Environment=MOSHPIT_PROXY_DIR=${dir}`,
252
254
  ];
253
- // Only the endings it is asked to serve. Left unset it defaults to `.moshpit`
254
- // alone, which is why a proxy can be running, healthy, and unable to present
255
- // a certificate for the name someone is actually trying to reach.
256
- if (tlds.length) lines.push(`Environment=MOSHPIT_PROXY_TLDS=${tlds.join(",")}`);
255
+ // Deliberately not written any more.
256
+ //
257
+ // It used to be set from the endings the registry had sold 18224 of them, a
258
+ // ~150 KB environment variable in a unit file, for a list stale the next time
259
+ // one is sold. moshpit-proxy now reads an unset value as "every Moshpit
260
+ // ending", defining the namespace by excluding the real internet rather than
261
+ // by enumerating what Moshpit owns, so there is nothing left to pass. Setting
262
+ // it there still narrows, which is a deployment's choice and not this unit's.
257
263
 
258
264
  lines.push(
259
265
  `ExecStart=${wrapper}`,
@@ -273,6 +279,38 @@ export function proxyServiceUnit({
273
279
  return lines.join("\n");
274
280
  }
275
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
+
276
314
  /** Where moshpit-proxy's installer puts its wrapper, if it ran. */
277
315
  export function proxyWrapperPath({ home = operatorHome(), exists = existsSync } = {}) {
278
316
  const candidate = join(home, ".local/bin/moshpit-proxy");
@@ -300,6 +338,8 @@ export async function ensureProxyService({
300
338
  tlds = [],
301
339
  port = 443,
302
340
  exec = run,
341
+ narrowRoot = rootIsNarrow,
342
+ paths = proxyServicePaths,
303
343
  read = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"),
304
344
  listening = defaultPortHeld,
305
345
  waitMs = 30000,
@@ -307,7 +347,7 @@ export async function ensureProxyService({
307
347
  const wrapper = proxyWrapperPath({ home });
308
348
  if (!wrapper) return { ok: false, reason: "not-installed", steps: [] };
309
349
 
310
- const { path, systemctl } = proxyServicePaths();
350
+ const { path, systemctl } = paths();
311
351
  const unit = proxyServiceUnit({ wrapper, nodeDir, home, user, port, tlds });
312
352
  // Read before writing so the manifest can put back whatever was here — which
313
353
  // is usually nothing, and "nothing" has to be recorded as precisely as
@@ -315,6 +355,22 @@ export async function ensureProxyService({
315
355
  const before = await read(path).catch(() => null);
316
356
 
317
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
+
318
374
  try {
319
375
  await mkdir(dirname(path), { recursive: true });
320
376
  await writeFile(path, unit);
@@ -324,7 +380,14 @@ export async function ensureProxyService({
324
380
  }
325
381
 
326
382
  const [cmd, ...flags] = systemctl;
327
- 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
+ ]) {
328
391
  const result = await exec(cmd, args);
329
392
  steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
330
393
  if (!result.ok) return { ok: false, reason: "systemctl-failed", before, steps, path, unit };
package/src/trust.mjs CHANGED
@@ -20,6 +20,7 @@
20
20
  import path from "node:path";
21
21
  import os from "node:os";
22
22
  import { execFileSync } from "node:child_process";
23
+ import { IANA_TLDS } from "./iana-tlds.mjs";
23
24
 
24
25
  /** Where moshpit-proxy generates its root on first run. */
25
26
  export function caPath({ home = os.homedir(), dir = null } = {}) {
@@ -139,15 +140,53 @@ export function requireNameConstraints(text, { tlds = [] } = {}) {
139
140
  // `excluded;DNS:.hacker` alone is an unconstrained root wearing the word
140
141
  // "constraints". Requiring a permitted DNS subtree is what makes the rest of
141
142
  // this check mean anything.
143
+ const bare = (entry) => entry.replace(/^\./, "");
144
+
145
+ // Two shapes are acceptable, and they make the same promise a different way.
146
+ //
147
+ // The old one permits a list of Moshpit endings and is bounded by what it
148
+ // names. It cannot scale: there are 18224 endings, a permitted subtree each
149
+ // is roughly 214 KB of constraints on every handshake, and the list is stale
150
+ // the next time the registry sells one — which is why a machine could reach
151
+ // `.2600` over HTTPS and not `.hacker`.
152
+ //
153
+ // The new one names nothing and excludes the real internet instead: all 1438
154
+ // delegated top-level domains, about 15 KB, covering every Moshpit ending
155
+ // that exists or ever will. RFC 5280 4.2.1.10 leaves a name type unrestricted
156
+ // when no permitted subtree names it, which is what makes that work — and is
157
+ // also exactly what an unconstrained root looks like, so the difference has
158
+ // to be established rather than assumed.
159
+ //
160
+ // It is established against the same IANA list this tool refuses to sell
161
+ // endings from. A root that excludes the internet cannot forge your bank,
162
+ // which is the whole property the old shape bought by enumeration.
142
163
  if (!constraints.permitted.length) {
143
- return {
144
- ok: false,
145
- kind: "unconstrained",
146
- why: "the root permits no DNS subtree, so every name it does not exclude is allowed — it could vouch for any name",
147
- };
164
+ const excluded = new Set(constraints.excluded.map(bare));
165
+ const covered = [...IANA_TLDS].filter((tld) => excluded.has(tld));
166
+ const uncovered = [...IANA_TLDS].filter((tld) => !excluded.has(tld));
167
+
168
+ // A handful of exclusions is not this shape; it is an unconstrained root
169
+ // with a few names crossed out, which is the thing this gate exists to
170
+ // refuse. The threshold sits far below the real count on purpose: the
171
+ // question is "is the internet excluded", not "is this list current".
172
+ if (covered.length < 1000) {
173
+ return {
174
+ ok: false,
175
+ kind: "unconstrained",
176
+ why: covered.length
177
+ ? `the root excludes only ${covered.length} real top-level domains — it could still vouch for the rest of the internet`
178
+ : "the root permits no DNS subtree and excludes no real domains, so it could vouch for any name",
179
+ };
180
+ }
181
+
182
+ // A root minted before a TLD was delegated does not exclude it. A real gap,
183
+ // and a small one, so it is reported rather than made fatal: refusing here
184
+ // would mean refusing every root the day IANA adds a name.
185
+ return uncovered.length
186
+ ? { ok: true, why: `excludes ${covered.length} real top-level domains`, uncovered }
187
+ : { ok: true, why: `excludes all ${covered.length} real top-level domains` };
148
188
  }
149
189
 
150
- const bare = (entry) => entry.replace(/^\./, "");
151
190
  const permits = (tld) => constraints.permitted.some((entry) => bare(entry) === String(tld).toLowerCase());
152
191
 
153
192
  const missing = tlds.filter((tld) => !permits(tld));