moshcode 0.24.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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +580 -0
  3. package/bin/moshcode.mjs +674 -0
  4. package/bin/moshscript.mjs +29 -0
  5. package/examples/alive.mosh +6 -0
  6. package/examples/scripting-the-cli.mosh +21 -0
  7. package/examples/team-secrets.mosh +20 -0
  8. package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
  9. package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
  10. package/examples/templates/bun-caddy-sqlite/README.md +97 -0
  11. package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
  12. package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
  13. package/examples/templates/bun-caddy-sqlite/package.json +15 -0
  14. package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
  15. package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
  16. package/examples/templates/bun-caddy-sqlite/template.json +10 -0
  17. package/examples/templates/caddy-proxy/Caddyfile +36 -0
  18. package/examples/templates/caddy-proxy/README.md +104 -0
  19. package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
  20. package/examples/templates/caddy-proxy/template.json +8 -0
  21. package/examples/templates/caddy-static/Caddyfile +16 -0
  22. package/examples/templates/caddy-static/README.md +90 -0
  23. package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
  24. package/examples/templates/caddy-static/site/index.html +11 -0
  25. package/examples/templates/caddy-static/template.json +8 -0
  26. package/install.sh +194 -0
  27. package/package.json +28 -0
  28. package/prd/0000-template.md +49 -0
  29. package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
  30. package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
  31. package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
  32. package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
  33. package/prd/0005-hosted-moshpit-resolver.md +192 -0
  34. package/prd/0006-help.md +359 -0
  35. package/prd/0007-profullstack-site-init.md +1183 -0
  36. package/prd/README.md +26 -0
  37. package/src/ads.mjs +58 -0
  38. package/src/auth.mjs +193 -0
  39. package/src/cli-schema.mjs +533 -0
  40. package/src/cli.mjs +118 -0
  41. package/src/commands.mjs +259 -0
  42. package/src/completion.mjs +594 -0
  43. package/src/console.mjs +244 -0
  44. package/src/dns-system.mjs +404 -0
  45. package/src/dns.mjs +2872 -0
  46. package/src/doh-server.mjs +256 -0
  47. package/src/doh.mjs +218 -0
  48. package/src/engines.mjs +385 -0
  49. package/src/escalate.mjs +85 -0
  50. package/src/help.mjs +443 -0
  51. package/src/integrations.mjs +265 -0
  52. package/src/mcp-catalog.mjs +50 -0
  53. package/src/mcp.mjs +155 -0
  54. package/src/mirror.mjs +187 -0
  55. package/src/notify.mjs +86 -0
  56. package/src/open-url.mjs +34 -0
  57. package/src/parking-http.mjs +65 -0
  58. package/src/pins.mjs +190 -0
  59. package/src/pit-url.mjs +13 -0
  60. package/src/prd.mjs +341 -0
  61. package/src/pty.mjs +176 -0
  62. package/src/pwd.mjs +103 -0
  63. package/src/registry.mjs +37 -0
  64. package/src/release-install.mjs +191 -0
  65. package/src/runtime.mjs +161 -0
  66. package/src/selfupdate.mjs +215 -0
  67. package/src/serve.mjs +502 -0
  68. package/src/skills.mjs +93 -0
  69. package/src/tabs.mjs +144 -0
  70. package/src/templates.mjs +456 -0
  71. package/src/tools.mjs +231 -0
  72. package/src/trade.mjs +137 -0
  73. package/src/trust.mjs +712 -0
  74. package/src/tui.mjs +736 -0
  75. package/src/ui.mjs +49 -0
  76. package/src/uninstall.mjs +113 -0
  77. package/src/upgrade.mjs +217 -0
package/src/trust.mjs ADDED
@@ -0,0 +1,712 @@
1
+ // Making a Moshpit name work in a stock client, on this machine.
2
+ //
3
+ // No certificate authority will ever vouch for `seo.rank`. The CA/Browser Forum
4
+ // Baseline Requirements banned issuance for non-IANA names — it stopped in
5
+ // November 2015 and the survivors were revoked by October 2016 — and a CA in a
6
+ // root store that issued for one would be distrusted for doing it. The rule is
7
+ // the penalty, so this is not a matter of persuasion or of trying harder.
8
+ //
9
+ // moshpit-proxy already solves it: it checks the origin's key against the pin
10
+ // the registry published for that name, then re-signs with a root it generated
11
+ // on this machine, because re-stating the result is the only language a stock
12
+ // client accepts. What was missing is that nobody wired it up — `dns enable`
13
+ // pointed names at the resolver and stopped, so they resolved and then failed
14
+ // at TLS, which is the shape a person reads as "still broken".
15
+ //
16
+ // This is the wiring. The one thing it must never do is install a root that
17
+ // could vouch for the clearnet, so that is checked here rather than assumed:
18
+ // see requireNameConstraints below.
19
+
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ import { execFileSync } from "node:child_process";
23
+
24
+ /** Where moshpit-proxy generates its root on first run. */
25
+ export function caPath({ home = os.homedir(), dir = null } = {}) {
26
+ return path.join(dir || path.join(home, ".moshpit"), "ca", "ca.crt");
27
+ }
28
+
29
+ /** Ask the shell where a user's home is, rather than assuming a layout. */
30
+ function expandHome(user) {
31
+ try {
32
+ // `~user` expansion reads passwd, so this is right on macOS (/Users) and on
33
+ // a machine where someone's home is not under /home at all.
34
+ const home = execFileSync("sh", ["-c", `printf %s ~${user}`], {
35
+ encoding: "utf8", timeout: 5000,
36
+ }).trim();
37
+ return home && home !== `~${user}` ? home : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The home of the person who ran the command, not of the account running it.
45
+ *
46
+ * Everything here is keyed off a home directory: the root moshpit-proxy wrote
47
+ * to `~/.moshpit`, and the NSS database Chrome and Firefox actually read at
48
+ * `~/.pki/nssdb`. But `dns enable` needs root, and since #274 it escalates
49
+ * itself — so by the time this runs `os.homedir()` is `/root`.
50
+ *
51
+ * Using it would look for the root in a directory moshpit-proxy never wrote to
52
+ * and report "no local root", or install into root's NSS store and report
53
+ * success for a browser profile nobody uses. That is the same $HOME-under-sudo
54
+ * trap #272 and #274 closed, and it is worth closing once here rather than
55
+ * discovering it a third time.
56
+ */
57
+ export function operatorHome({ env = process.env, homedir = () => os.homedir(), expand = expandHome } = {}) {
58
+ const who = env.SUDO_USER || env.DOAS_USER;
59
+ if (!who || who === "root") return env.HOME || homedir();
60
+ return expand(who) || env.HOME || homedir();
61
+ }
62
+
63
+ /**
64
+ * The `X509v3 Name Constraints` extension, split into what it permits and what
65
+ * it excludes.
66
+ *
67
+ * Parsed rather than string-matched because the two lists mean opposite things
68
+ * and a substring search cannot tell them apart: `DNS:.hacker` reads the same
69
+ * under `Permitted:` as under `Excluded:`, and treating the second as the first
70
+ * accepts a root that constrains nothing.
71
+ */
72
+ export function parseNameConstraints(text) {
73
+ const lines = String(text || "").split("\n");
74
+ const start = lines.findIndex((l) => /X509v3 Name Constraints:/i.test(l));
75
+ if (start === -1) return null;
76
+
77
+ const indent = lines[start].search(/\S/);
78
+ const permitted = [];
79
+ const excluded = [];
80
+ let bucket = null;
81
+
82
+ for (const line of lines.slice(start + 1)) {
83
+ if (!line.trim()) continue;
84
+ // The block ends at the next thing printed at or left of its own indent:
85
+ // the following extension, or the signature.
86
+ if (line.search(/\S/) <= indent) break;
87
+ if (/^\s*Permitted:/i.test(line)) { bucket = permitted; continue; }
88
+ if (/^\s*Excluded:/i.test(line)) { bucket = excluded; continue; }
89
+ const dns = /^\s*DNS:(\S+)/i.exec(line);
90
+ if (dns && bucket) bucket.push(dns[1].toLowerCase());
91
+ }
92
+
93
+ return {
94
+ critical: /X509v3 Name Constraints:\s*critical/i.test(lines[start]),
95
+ permitted,
96
+ excluded,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Is this a root we are willing to put in a trust store?
102
+ *
103
+ * The whole argument for installing a local CA rests on one extension. A root
104
+ * with `nameConstraints` permitting only Moshpit endings can, at absolute
105
+ * worst, forge a name its holder already controls. A root without them can
106
+ * forge your bank, and installing one would be a genuine hole dressed up as a
107
+ * convenience.
108
+ *
109
+ * So this is a gate, not a report. moshpit-proxy sets the constraint today and
110
+ * tests it; that is exactly why this must not trust it to keep doing so — a
111
+ * silent regression upstream would otherwise become a silent regression in
112
+ * every machine that ran `dns enable`.
113
+ */
114
+ export function requireNameConstraints(text, { tlds = [] } = {}) {
115
+ // `text` is what `openssl x509 -noout -text` prints, not the PEM. The PEM
116
+ // body is base64: an extension cannot be seen in it, and a check that read
117
+ // the file directly would find no constraints in a constrained root and no
118
+ // constraints in an unconstrained one — passing or failing everything.
119
+ const body = String(text || "");
120
+ if (!/Certificate:|Signature Algorithm:/i.test(body)) {
121
+ return { ok: false, kind: "unreadable", why: "could not read the certificate — openssl printed nothing usable" };
122
+ }
123
+ const constraints = parseNameConstraints(body);
124
+ if (!constraints) {
125
+ return {
126
+ ok: false,
127
+ kind: "unconstrained",
128
+ why: "the root carries no name constraints — it could vouch for any name, not just Moshpit",
129
+ };
130
+ }
131
+ // Non-critical constraints are advisory: a verifier is free to ignore an
132
+ // extension it does not recognise, which turns the guarantee into a comment.
133
+ if (!constraints.critical) {
134
+ return { ok: false, kind: "unconstrained", why: "the name constraints are not marked critical, so a verifier may ignore them" };
135
+ }
136
+ // RFC 5280 §4.2.1.10: constraints bind only the name *types* they mention. A
137
+ // root whose DNS entries are all exclusions — or whose permitted subtree
138
+ // names some other type entirely — leaves every DNS name permitted, so
139
+ // `excluded;DNS:.hacker` alone is an unconstrained root wearing the word
140
+ // "constraints". Requiring a permitted DNS subtree is what makes the rest of
141
+ // this check mean anything.
142
+ 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
+ };
148
+ }
149
+
150
+ const bare = (entry) => entry.replace(/^\./, "");
151
+ const permits = (tld) => constraints.permitted.some((entry) => bare(entry) === String(tld).toLowerCase());
152
+
153
+ const missing = tlds.filter((tld) => !permits(tld));
154
+ if (missing.length) {
155
+ return { ok: false, kind: "out-of-step", why: `the root does not permit ${missing.join(", ")}` };
156
+ }
157
+ // And nothing beyond them. One `DNS:.com` in the permitted subtree is the
158
+ // whole hole this gate exists to close, and it would otherwise sail through
159
+ // on the strength of the endings sitting next to it.
160
+ const claimed = new Set(tlds.map((t) => String(t).toLowerCase()));
161
+ const foreign = constraints.permitted.filter((entry) => !claimed.has(bare(entry)));
162
+ if (foreign.length) {
163
+ return {
164
+ ok: false,
165
+ kind: "out-of-step",
166
+ why: `the root also permits ${foreign.join(", ")}, which is not an ending we resolve — it reaches past Moshpit`,
167
+ };
168
+ }
169
+ return { ok: true, why: "constrained to Moshpit endings" };
170
+ }
171
+
172
+ /**
173
+ * The trust stores on this machine, and what each one costs to write to.
174
+ *
175
+ * Deliberately separate entries rather than one "install everywhere" step,
176
+ * because they do not fail together and conflating them produces the worst
177
+ * report: NSS succeeds without root, the system store needs it, and a summary
178
+ * that says "installed" when curl still cannot verify is how someone concludes
179
+ * the whole thing is broken again.
180
+ */
181
+ export function trustStores({ platform = process.platform, home = os.homedir(), caFile } = {}) {
182
+ const file = caFile || caPath({ home });
183
+ const stores = [];
184
+
185
+ if (platform === "darwin") {
186
+ stores.push({
187
+ id: "macos-keychain",
188
+ label: "the system keychain (curl, Safari, Chrome)",
189
+ needsRoot: true,
190
+ command: "security",
191
+ args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file],
192
+ });
193
+ return stores;
194
+ }
195
+
196
+ if (platform === "linux") {
197
+ // User-level and needs no privileges at all, which makes it the half that
198
+ // can always be done — Chrome and Firefox read it, curl does not.
199
+ stores.push({
200
+ id: "nss",
201
+ label: "the NSS store (Chrome, Firefox)",
202
+ needsRoot: false,
203
+ // The operator's database, even when root is doing the writing — which is
204
+ // why it is also handed back afterwards. Files left owned by root here
205
+ // are worse than not writing them: the browser silently stops being able
206
+ // to update its own store.
207
+ ownedDir: path.join(home, ".pki", "nssdb"),
208
+ command: "certutil",
209
+ args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-A", "-t", "C,,", "-n", "Moshpit Local CA", "-i", file],
210
+ });
211
+ stores.push({
212
+ id: "system",
213
+ label: "the system store (curl, wget, anything using OpenSSL)",
214
+ needsRoot: true,
215
+ // Two steps rather than one: the copy is the install, and the refresh is
216
+ // what makes it take effect. Reporting them together would hide which
217
+ // one failed.
218
+ copyTo: "/usr/local/share/ca-certificates/moshpit-local-ca.crt",
219
+ command: "update-ca-certificates",
220
+ args: [],
221
+ });
222
+ return stores;
223
+ }
224
+
225
+ return stores;
226
+ }
227
+
228
+ /**
229
+ * What `dns enable` should do about trust, given what it found.
230
+ *
231
+ * Pure, so the decision is testable without a certificate, a trust store or a
232
+ * machine whose TLS is a real thing to break.
233
+ */
234
+ /** How to ask openssl what is actually in the root. */
235
+ export function describeCertificateCommand(file) {
236
+ return { command: "openssl", args: ["x509", "-noout", "-text", "-in", file] };
237
+ }
238
+
239
+ export function trustPlan({
240
+ caText = null,
241
+ tlds = [],
242
+ platform = process.platform,
243
+ home = os.homedir(),
244
+ caFile = null,
245
+ isRoot = false,
246
+ haveCertutil = true,
247
+ } = {}) {
248
+ const file = caFile || caPath({ home });
249
+
250
+ if (!caText) {
251
+ return {
252
+ ok: false,
253
+ steps: [],
254
+ why: `no local root at ${file} — moshpit-proxy generates one on its first run`,
255
+ };
256
+ }
257
+
258
+ const constrained = requireNameConstraints(caText, { tlds });
259
+ if (!constrained.ok) {
260
+ // Refused rather than warned. A warning here would be read past.
261
+ return { ok: false, refused: true, kind: constrained.kind, steps: [], why: constrained.why };
262
+ }
263
+
264
+ const steps = [];
265
+ const skipped = [];
266
+ for (const store of trustStores({ platform, home, caFile: file })) {
267
+ if (store.id === "nss" && !haveCertutil) {
268
+ skipped.push({ ...store, why: "certutil is not installed (Debian/Ubuntu: libnss3-tools)" });
269
+ continue;
270
+ }
271
+ if (store.needsRoot && !isRoot) {
272
+ skipped.push({ ...store, why: "needs root" });
273
+ continue;
274
+ }
275
+ steps.push(store);
276
+ }
277
+
278
+ return { ok: true, steps, skipped, file, why: constrained.why };
279
+ }
280
+
281
+ /**
282
+ * What to do about a refusal, which depends entirely on which one it is.
283
+ *
284
+ * `out-of-step` is the common one and is not a security event: the root was
285
+ * generated when a different set of endings was claimed, so it is stale rather
286
+ * than dangerous. Regenerating costs one command, and without saying so the
287
+ * strict check reads as a dead end — which is how a safety gate ends up being
288
+ * disabled with --no-trust instead of satisfied.
289
+ *
290
+ * `unconstrained` is the dangerous one, and deliberately has no workaround
291
+ * offered: the answer is a fixed root, never a way around the check.
292
+ */
293
+ export function refusalRemedy(kind, file) {
294
+ if (kind === "out-of-step") {
295
+ return [
296
+ "the root is older than the endings claimed now — regenerating it is enough:",
297
+ ` rm -rf ${path.dirname(file)} && moshpit-proxy # writes a fresh root`,
298
+ "then re-run `moshcode dns enable`.",
299
+ ];
300
+ }
301
+ if (kind === "unreadable") {
302
+ return [`could not read ${file} — check it is a certificate and openssl is installed.`];
303
+ }
304
+ return [
305
+ "moshcode will not put a root that can vouch for names outside Moshpit",
306
+ "into your trust store. Names will resolve but not pass TLS until the",
307
+ "root is regenerated with name constraints. This is not overridable.",
308
+ ];
309
+ }
310
+
311
+ /** Run a command, never throwing — the caller reports, it does not crash. */
312
+ async function run(command, args) {
313
+ const { execFile } = await import("node:child_process");
314
+ return new Promise((resolve) => {
315
+ execFile(command, args, { timeout: 30000 }, (err, stdout, stderr) => {
316
+ resolve({ ok: !err, stdout: String(stdout || ""), stderr: String(stderr || "") });
317
+ });
318
+ });
319
+ }
320
+
321
+ /**
322
+ * The trust half of `dns enable`, reported step by step.
323
+ *
324
+ * Every outcome here is non-fatal on purpose. DNS has already been switched and
325
+ * verified by the time this runs, so failing the whole command over a trust
326
+ * store would roll back working resolution to fix a certificate — trading the
327
+ * larger thing for the smaller one. What it must not do is claim success it did
328
+ * not have, since that is what sends someone back to `curl` to be told the
329
+ * certificate is self-signed.
330
+ */
331
+ export async function applyTrust(tlds, out, deps = {}) {
332
+ const {
333
+ readFile = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"),
334
+ runner = run,
335
+ env = process.env,
336
+ // The operator's home, not root's — see operatorHome. Every path below
337
+ // depends on getting this right, and `dns enable` always runs escalated.
338
+ home = operatorHome({ env }),
339
+ platform = process.platform,
340
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
341
+ } = deps;
342
+ const owner = env.SUDO_USER || env.DOAS_USER || null;
343
+
344
+ const file = caPath({ home });
345
+ out("");
346
+ out("trust (so a stock client accepts a name no CA will ever sign for)");
347
+
348
+ const exists = await readFile(file).then(() => true, () => false);
349
+ if (!exists) {
350
+ out(` -- no local root at ${file}`);
351
+ out(" moshpit-proxy generates one on its first run: https://github.com/profullstack/moshpit-proxy");
352
+ return { ok: false, why: "no root yet" };
353
+ }
354
+
355
+ const describe = describeCertificateCommand(file);
356
+ const described = await runner(describe.command, describe.args);
357
+ // `certutil -H` prints help and exits non-zero, so its exit code says nothing
358
+ // about whether it is installed. Presence is the actual question.
359
+ const haveCertutil = (await runner("which", ["certutil"])).ok;
360
+ const plan = trustPlan({
361
+ caText: described.ok ? described.stdout : null,
362
+ tlds, platform, home, caFile: file, isRoot: uid === 0, haveCertutil,
363
+ });
364
+
365
+ if (!plan.ok) {
366
+ // A refusal is the feature working, so it says which it is.
367
+ out(plan.refused ? ` STOP ${plan.why}` : ` -- ${plan.why}`);
368
+ // ...and then how to get past it. A gate that only says "no" is a gate
369
+ // people work around, and the two refusals are nothing alike: one is a
370
+ // root that must never be installed, the other a root that is simply
371
+ // older than the endings claimed today. Telling them apart is the
372
+ // difference between "this is dangerous" and "regenerate it".
373
+ for (const line of refusalRemedy(plan.kind, file)) out(` ${line}`);
374
+ return { ok: false, why: plan.why };
375
+ }
376
+
377
+ out(` ok ${file} — ${plan.why}`);
378
+ for (const step of plan.steps) {
379
+ if (step.copyTo) {
380
+ const copied = await runner("cp", [file, step.copyTo]);
381
+ if (!copied.ok) {
382
+ out(` FAIL ${step.label} — ${copied.stderr.split("\n")[0] || "could not copy the root"}`);
383
+ continue;
384
+ }
385
+ }
386
+ const done = await runner(step.command, step.args);
387
+ if (!done.ok) {
388
+ out(` FAIL ${step.label} — ${done.stderr.split("\n")[0] || `${step.command} failed`}`);
389
+ continue;
390
+ }
391
+ // Written as root into the operator's directory, so hand it back. Left
392
+ // root-owned, the browser cannot update its own store afterwards — a
393
+ // failure that shows up long after this command, looking unrelated.
394
+ if (step.ownedDir && owner && uid === 0) {
395
+ const owned = await runner("chown", ["-R", `${owner}:`, step.ownedDir]);
396
+ if (!owned.ok) out(` -- ${step.ownedDir} is left owned by root — chown -R ${owner}: ${step.ownedDir}`);
397
+ }
398
+ out(` ok installed into ${step.label}`);
399
+ }
400
+ for (const step of plan.skipped || []) {
401
+ out(` -- ${step.label} — ${step.why}`);
402
+ if (step.needsRoot) out(` re-run with root to cover it: sudo moshcode dns enable`);
403
+ }
404
+ return { ok: true, installed: plan.steps.length, skipped: (plan.skipped || []).length };
405
+ }
406
+
407
+ /* ------------------------------------------------ trusting one name directly */
408
+
409
+ /**
410
+ * SHA-256 over the SubjectPublicKeyInfo, base64 — RFC 7469's pin format.
411
+ *
412
+ * Over the key rather than the certificate, so re-issuing for the same key (a
413
+ * longer expiry, an added name) does not invalidate a pin anybody holds.
414
+ */
415
+ export async function spkiPin(publicKeyPem) {
416
+ const crypto = await import("node:crypto");
417
+ const der = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
418
+ return crypto.createHash("sha256").update(der).digest("base64");
419
+ }
420
+
421
+ /**
422
+ * The pin of the key inside a certificate.
423
+ *
424
+ * Read with node's own X509 parser rather than by shelling out to openssl a
425
+ * second time: the pin is the value the whole decision turns on, and piping a
426
+ * PEM back through a shell to extract it adds quoting and a second process to
427
+ * the one step that must not go wrong quietly.
428
+ */
429
+ export async function pinFromCertificate(pem) {
430
+ const crypto = await import("node:crypto");
431
+ const cert = new crypto.X509Certificate(pem);
432
+ const der = cert.publicKey.export({ type: "spki", format: "der" });
433
+ return crypto.createHash("sha256").update(der).digest("base64");
434
+ }
435
+
436
+ /** The pins the registry publishes for a name. */
437
+ export async function publishedPins(name, { registryBase = "https://pit.moshcode.sh", fetchImpl = fetch } = {}) {
438
+ const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/pins?name=${encodeURIComponent(name)}`;
439
+ const res = await fetchImpl(url);
440
+ if (!res.ok) throw new Error(`registry answered ${res.status}`);
441
+ const json = await res.json();
442
+ return Array.isArray(json?.pins) ? json.pins : [];
443
+ }
444
+
445
+ /**
446
+ * Is this certificate the one the registry vouches for?
447
+ *
448
+ * The entire security of installing a leaf rests on this comparison, so it is
449
+ * a gate rather than a report. Without it, `trust <name>` would install
450
+ * whatever answered the socket — which is the definition of trusting an
451
+ * attacker who can reach the port first.
452
+ *
453
+ * Any published pin matches, not just the first: the registry lists the old
454
+ * pin alongside the new one during a key rotation precisely so a key can change
455
+ * without a flag day.
456
+ */
457
+ export function pinAccepted(pin, published) {
458
+ if (!pin || !Array.isArray(published) || !published.length) {
459
+ return { ok: false, why: "the registry publishes no pin for this name — nothing vouches for the certificate" };
460
+ }
461
+ return published.includes(pin)
462
+ ? { ok: true, why: "the served key matches a pin the registry publishes" }
463
+ : { ok: false, why: `the served key (${pin}) is not among the ${published.length} pin(s) the registry publishes` };
464
+ }
465
+
466
+ /**
467
+ * Where a trusted leaf is written, per name.
468
+ *
469
+ * One file per name rather than a bundle: removing trust for a single name has
470
+ * to be removing a single file, and a name is not something to hand-edit out of
471
+ * a concatenation.
472
+ */
473
+ export function leafPath(name, { platform = process.platform } = {}) {
474
+ // The name reaches this from a registry response, so it is not trusted input.
475
+ // Runs of dots are collapsed rather than merely stripped of slashes: `..` is
476
+ // the traversal, and leaving `....` behind produces a filename nobody can
477
+ // match back to a name even though it escapes nothing.
478
+ const safe = String(name).toLowerCase().replace(/[^a-z0-9.-]/g, "").replace(/\.{2,}/g, ".").replace(/^[.-]+|[.-]+$/g, "");
479
+ if (!safe || !safe.includes(".")) return null;
480
+ return platform === "darwin"
481
+ ? `/Library/Keychains/moshpit-${safe}.crt`
482
+ : `/usr/local/share/ca-certificates/moshpit-${safe}.crt`;
483
+ }
484
+
485
+ /**
486
+ * What `trust <name>` should do, given what the socket served and what the
487
+ * registry says about it.
488
+ *
489
+ * Pure, so the refusal path is testable without a network or a trust store.
490
+ */
491
+ export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) {
492
+ const accepted = pinAccepted(pin, published);
493
+ if (!accepted.ok) return { ok: false, refused: true, why: accepted.why };
494
+
495
+ const file = leafPath(name, { platform });
496
+ if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` };
497
+
498
+ return {
499
+ ok: true,
500
+ why: accepted.why,
501
+ file,
502
+ // A self-signed leaf is its own trust anchor, and its SAN limits it to this
503
+ // one name — so trusting it vouches for `seo.rank` and nothing else. That
504
+ // is a far smaller grant than a CA, which is why this path needs no
505
+ // name constraints argument to be defensible.
506
+ refresh: platform === "darwin"
507
+ ? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] }
508
+ : { command: "update-ca-certificates", args: [] },
509
+ };
510
+ }
511
+
512
+ /** The certificate a name is actually serving, as PEM. */
513
+ export function fetchCertificateCommand(name, { port = 443 } = {}) {
514
+ return {
515
+ command: "sh",
516
+ args: ["-c", `openssl s_client -connect ${name}:${port} -servername ${name} </dev/null 2>/dev/null | openssl x509`],
517
+ };
518
+ }
519
+
520
+ /**
521
+ * `moshcode dns trust <name>` — trust one name, on the strength of its pin.
522
+ *
523
+ * The path that needs no proxy and no certificate authority. A Moshpit name
524
+ * serves a self-signed certificate whose SAN names only itself, so trusting it
525
+ * vouches for that one name and nothing else — a far smaller grant than a root,
526
+ * and the reason this needs no name-constraints argument to be defensible.
527
+ *
528
+ * The pin check is what makes it safe rather than reckless. Installing whatever
529
+ * answered the socket is the definition of trusting whoever reached the port
530
+ * first; installing it only when the registry already vouches for that exact
531
+ * key is registry-backed trust, which is a stronger claim than domain
532
+ * validation ever made.
533
+ */
534
+ export async function trustName(name, out, deps = {}) {
535
+ const {
536
+ registryBase = "https://pit.moshcode.sh",
537
+ fetchImpl = fetch,
538
+ runner = run,
539
+ platform = process.platform,
540
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
541
+ writeFile = async (f, c) => (await import("node:fs/promises")).writeFile(f, c),
542
+ } = deps;
543
+
544
+ if (!name) {
545
+ out("which name? e.g. moshcode dns trust seo.rank");
546
+ return 1;
547
+ }
548
+
549
+ const fetchCert = fetchCertificateCommand(name);
550
+ const served = await runner(fetchCert.command, fetchCert.args);
551
+ if (!served.ok || !served.stdout.includes("BEGIN CERTIFICATE")) {
552
+ out(`could not read a certificate from ${name}:443`);
553
+ out(" the name must resolve and be serving HTTPS before its certificate can be trusted");
554
+ return 1;
555
+ }
556
+
557
+ const pin = await pinFromCertificate(served.stdout).catch(() => null);
558
+ if (!pin) {
559
+ out(`could not read the public key out of ${name}'s certificate`);
560
+ return 1;
561
+ }
562
+
563
+ let published = [];
564
+ try {
565
+ published = await publishedPins(name, { registryBase, fetchImpl });
566
+ } catch (err) {
567
+ // An outage is not a failed pin check, and must not be reported as one —
568
+ // the answer to "the registry is down" is to wait, not to distrust a name.
569
+ out(`could not reach the registry to check ${name}'s pin — ${err?.message || err}`);
570
+ out(" nothing has been trusted.");
571
+ return 1;
572
+ }
573
+
574
+ const plan = leafTrustPlan({ name, pin, published, platform });
575
+ if (!plan.ok) {
576
+ out(`REFUSED — ${plan.why}`);
577
+ if (plan.refused) {
578
+ out(` served ${pin}`);
579
+ out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)");
580
+ out(" moshcode will not trust a certificate the registry does not vouch for.");
581
+ }
582
+ return 1;
583
+ }
584
+
585
+ out(`${name} — ${plan.why}`);
586
+ out(` pin ${pin}`);
587
+
588
+ if (uid !== 0) {
589
+ out(` writing ${plan.file} needs root.`);
590
+ return 1;
591
+ }
592
+
593
+ try {
594
+ await writeFile(plan.file, served.stdout);
595
+ } catch (err) {
596
+ out(` FAIL could not write ${plan.file} — ${err?.message || err}`);
597
+ return 1;
598
+ }
599
+ const refreshed = await runner(plan.refresh.command, plan.refresh.args);
600
+ if (!refreshed.ok) {
601
+ out(` FAIL ${plan.refresh.command} — ${refreshed.stderr.split("\n")[0] || "failed"}`);
602
+ return 1;
603
+ }
604
+ out(` ok trusted — curl https://${name} now verifies without flags`);
605
+ return 0;
606
+ }
607
+
608
+ /**
609
+ * Trust every name as it is resolved, instead of one command per name.
610
+ *
611
+ * `dns trust <name>` works and does not scale: a person browsing Moshpit hits a
612
+ * certificate error on every site they have not personally thought about, which
613
+ * is indistinguishable from the namespace being broken.
614
+ *
615
+ * The registry pin is what makes doing it automatically defensible. Nothing is
616
+ * trusted on sight — a name is trusted only when the key it serves is one the
617
+ * registry already published for it, which is a stronger claim than domain
618
+ * validation ever made. A name with no pin gets nothing, silently and forever.
619
+ *
620
+ * Three properties this has to have, and each one is a way it could go wrong:
621
+ *
622
+ * - it must never block a DNS answer. Resolution is on the critical path of
623
+ * every page load; certificate work is not.
624
+ * - it must ask about a name once, not once per query. A browser sends A and
625
+ * AAAA together and retries, so "on resolve" is a firehose.
626
+ * - a failure must be quiet and final for that name until restart. Retrying a
627
+ * name whose pin does not match, on every lookup, is a loop that writes a
628
+ * log line per query and never succeeds.
629
+ */
630
+ export function createAutoTrust({
631
+ trust = trustName,
632
+ out = () => {},
633
+ registryBase,
634
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
635
+ ...deps
636
+ } = {}) {
637
+ // One entry per name for the life of the process: `true` while in flight or
638
+ // done, so neither a success nor a refusal is ever retried.
639
+ const seen = new Set();
640
+ const pending = [];
641
+ // The in-flight drain, not a boolean. A flag can say "someone else is
642
+ // draining", but it cannot be awaited — so `idle()` returned the moment it
643
+ // saw one, reporting a queue as settled while it was still being worked.
644
+ let running = null;
645
+
646
+ async function drain() {
647
+ if (running) return running;
648
+ running = (async () => {
649
+ try {
650
+ while (pending.length) {
651
+ const name = pending.shift();
652
+ // Output is deliberately only the interesting half. A resolver that
653
+ // narrated a success per name would bury its own query log.
654
+ const lines = [];
655
+ const code = await trust(name, (l) => lines.push(l), { registryBase, uid, ...deps })
656
+ .catch(() => 1);
657
+ if (code === 0) out(` trusted ${name}`);
658
+ else if (lines.some((l) => l.startsWith("REFUSED"))) out(` ! ${name} — ${lines[0]}`);
659
+ }
660
+ } finally {
661
+ running = null;
662
+ }
663
+ })();
664
+ return running;
665
+ }
666
+
667
+ return {
668
+ /** Consider a name for trust. Returns immediately; never throws. */
669
+ consider(name) {
670
+ if (!name || seen.has(name)) return false;
671
+ seen.add(name);
672
+ pending.push(name);
673
+ // Detached on purpose: the caller is a UDP handler with a reply to send.
674
+ queueMicrotask(() => { drain().catch(() => {}); });
675
+ return true;
676
+ },
677
+ /**
678
+ * Settle whatever is queued, including work added while draining.
679
+ *
680
+ * Looped rather than awaited once: a name considered mid-drain joins the
681
+ * queue behind the current pass, so a single await can return with items
682
+ * still waiting.
683
+ */
684
+ async idle() {
685
+ while (running || pending.length) await drain();
686
+ },
687
+ get size() {
688
+ return seen.size;
689
+ },
690
+ };
691
+ }
692
+
693
+ /**
694
+ * A one-line proof that the whole chain works, or the reason it does not.
695
+ *
696
+ * The check is deliberately a plain HTTPS fetch with nothing relaxed: no `-k`,
697
+ * no pin passed on the command line. Anything less would pass in exactly the
698
+ * situation this feature exists to fix.
699
+ */
700
+ export async function verifyStockTls(name, { fetchImpl = fetch, timeoutMs = 8000 } = {}) {
701
+ const controller = new AbortController();
702
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
703
+ try {
704
+ const res = await fetchImpl(`https://${name}/`, { signal: controller.signal, redirect: "manual" });
705
+ return { ok: true, status: res.status };
706
+ } catch (err) {
707
+ const why = err?.cause?.code || err?.code || err?.message || String(err);
708
+ return { ok: false, why: String(why) };
709
+ } finally {
710
+ clearTimeout(timer);
711
+ }
712
+ }