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 +1 -1
- package/src/dns-service.mjs +70 -7
- package/src/trust.mjs +45 -6
package/package.json
CHANGED
package/src/dns-service.mjs
CHANGED
|
@@ -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
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
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 } =
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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));
|