Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export const CORE_CLI_COMMANDS = [
["--port <n>", "port for the bridge", "5354"],
["--registry <url>", "registry to resolve against", "https://pit.moshcode.sh"],
["--no-trust", "with enable: route names but skip the local CA", ""],
["--no-proxy", "with enable: answer origins rather than the local proxy", ""],
],
examples: [
["sudo moshcode dns enable", "route Moshpit endings here"],
Expand Down
6 changes: 5 additions & 1 deletion src/dns-system.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -373,13 +373,17 @@ export async function daemonStatus(path = pidfilePath()) {
* not survive a reboot. `moshcode dns status` says so plainly rather than
* letting someone discover it when their names stop resolving.
*/
export async function startDaemon({ port, registryBase, path = pidfilePath(), entry }) {
export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) {
const existing = await daemonStatus(path);
if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };

await mkdir(dirname(path), { recursive: true });
const args = [entry, "dns", "start", "--port", String(port)];
if (registryBase) args.push("--registry", registryBase);
// Passed at spawn time because it is what the resolver answers with, not
// something it can be told later — there is no channel to a detached daemon
// short of restarting it, which is why `enable` decides this before starting.
if (proxy) args.push("--proxy", proxy);

const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
child.unref();
Expand Down
168 changes: 167 additions & 1 deletion src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,111 @@
});
}

/** The root moshpit-proxy signs with. Its leaves are how the proxy is recognised. */
export const PROXY_ROOT_CN = "Moshpit Local CA";

/**
* Is the thing on that address *our proxy*, or merely something on port 443?
*
* `proxyReachable` answers the second question, and on one common class of
* machine the two answers differ in the worst possible way. An origin runs
* nginx on `0.0.0.0:443`, which covers loopback — so a bare connect succeeds,
* proxy mode is turned on, and every live Moshpit name on the machine is
* pointed at a web server that knows nothing about them. That is not a
* certificate problem, it is every name on the machine serving the wrong site
* at once, and the connect probe cannot see it coming.
*
* So this asks the question that actually distinguishes them: complete a TLS
* handshake and look at who issued the certificate. The proxy mints a leaf per
* name from the root it generated on this machine, so the issuer is that root.
* Anything else — nginx with the origin's own self-signed certificate, some
* unrelated service — is issued by something else and is refused.
*
* `rejectUnauthorized` is off deliberately, and it is not a hole: nothing is
* sent, the peer certificate is read rather than trusted, and the only thing
* accepted from it is the issuer name. Verifying properly would require the
* root to already be installed, which is a step that has not happened yet at
* the point this runs.
*/
export async function proxyServes(address, name, {
port = PROXY_PORT,
timeoutMs = 2500,
tlsConnect = null,
} = {}) {
const connectImpl = tlsConnect || (await import("node:tls")).connect;
return new Promise((resolve) => {
let socket;
const done = (result) => {
try { socket?.destroy(); } catch { /* already gone */ }
resolve(result);
};
try {
socket = connectImpl({
host: address,
port,
servername: name,
rejectUnauthorized: false,
// The proxy forces http/1.1; offering nothing keeps this a pure
// handshake rather than a protocol negotiation that could be declined.
ALPNProtocols: ["http/1.1"],
});
// Not unref'd, for the reason proxyReachable spells out: this timer is the
// only guarantee the promise settles.
const timer = setTimeout(() => done({ ok: false, why: "timed out" }), timeoutMs);
socket.once("secureConnect", () => {
clearTimeout(timer);
const cert = socket.getPeerCertificate?.() || {};
const issuer = cert.issuer?.CN || "";
if (issuer === PROXY_ROOT_CN) return done({ ok: true, issuer });
done({
ok: false,
issuer,
// Named as what it means rather than what was seen: "issuer is
// chovy.hacker" is a fact, "something else owns 443" is the reason
// proxy mode must stay off.
why: issuer
? `something other than the proxy owns ${address}:${port} — it served a certificate issued by ${JSON.stringify(issuer)}`
: `something other than the proxy owns ${address}:${port}`,
});
});
socket.once("error", (err) => {
clearTimeout(timer);
done({ ok: false, why: err?.code || err?.message || "connection failed" });
});
} catch (err) {
resolve({ ok: false, why: err?.message || "connection failed" });
}
});
}

/**
* Which loopback addresses have the proxy behind them, if any.
*
* Both families are asked because answering one of them wrongly is an outage:
* a v6-only answer for a v4-only listener is a refused connection that reads as
* the site being down. `addressAnswer` handles the asymmetry; this just reports
* what is actually there.
*/
export async function findLocalProxy(name, { candidates = ["127.0.0.1", "::1"], ...options } = {}) {
const reachable = [];
let why = null;
for (const address of candidates) {
const result = await proxyServes(address, name, options);
if (result.ok) reachable.push(address);
// Keep the most informative refusal: "something else owns 443" is worth
// saying out loud, where "ECONNREFUSED" just means no proxy is installed.
else if (result.issuer && !why) why = result.why;
}
return {
found: reachable.length > 0,
why,
address: {
v4: reachable.find((a) => isIP(a) === 4) || null,
v6: reachable.find((a) => isIP(a) === 6) || null,
},
};
}

export async function addressAnswer(name, options = {}) {
const { parkingAddress, wantsV6 = false, proxyAddress = null } = options;
const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });
Expand Down Expand Up @@ -2088,6 +2193,10 @@
--no-trust with enable: route names but skip the local CA. They will
resolve and then fail TLS, which is the state this flag exists
to leave you in deliberately.
--no-proxy with enable: answer each name's origin rather than the local
pinned-TLS proxy. Only the proxy can hand a stock client a
certificate it will accept, so this is the other half of the
same deliberate breakage.

The registry speaks HTTP, not DNS, so nothing outside a browser can reach a
Moshpit name until this bridge is running and your resolver points at it.
Expand Down Expand Up @@ -2123,6 +2232,7 @@
bridgeStatus = daemonStatus,
startBridge = startDaemon,
proxyReachableImpl = proxyReachable,
findLocalProxyImpl = findLocalProxy,
autoTrustImpl = createAutoTrust,
stopBridge = stopDaemon,
dropins = readDropins,
Expand Down Expand Up @@ -2723,9 +2833,65 @@
// serve is silently shadowed by the bridge it said would not be started.
// Honoring the note is the whole of the fix.
const reusing = cleared.holder && cleared.holderForwards ? cleared.holder : null;

// Proxy mode, decided here because the bridge cannot be told later: what a
// resolver answers with is fixed when it starts.
//
// This is the step that was missing, and its absence is why the whole
// feature read as broken. Everything else was built — the proxy verifies
// origins against registry pins and re-signs with a root `dns enable`
// installs, and `addressAnswer` knows how to point names at it — but
// nothing ever turned it on, so names resolved straight to their origin and
// a stock client got a certificate no CA had signed. Trust was installed
// for a proxy that was never on the path.
//
// Refusing is the safe direction and the default: with proxy mode on and
// nothing behind it, every Moshpit name on the machine resolves and then
// refuses the connection.
let proxyAddress = null;
if (reusing) {
// A bridge this run did not start keeps whatever mode it was started
// with: `startDaemon` decides "already running" from our pidfile, and
// there is no channel to a detached daemon to change its mind. So the
// probe is skipped rather than run and then discarded — announcing a
// proxy and retracting it two lines later is worse than not looking.
out(" -- the bridge already running was not started by this run, so it keeps its own");
out(" mode — to pick up proxy mode: moshcode dns disable && moshcode dns enable");
} else if (!rest.includes("--no-proxy")) {
const probeName = moshpitProbe || "";
if (!probeName) {
out(" -- proxy mode not checked — no Moshpit name to probe with");
} else {
const local = await findLocalProxyImpl(probeName);
if (local.found) {
proxyAddress = local.address;
const at = [local.address.v4, local.address.v6].filter(Boolean).join(", ");
out(` ok pinned-TLS proxy on ${at}:${PROXY_PORT} — every live name will answer there`);
} else if (local.why) {
// The origin case, and the one worth naming precisely. A machine that
// serves Moshpit names has nginx on 443, so the proxy cannot be on the
// path here and pointing names at loopback would hand all of them to
// a web server that has never heard of them.
out(` -- ${local.why}`);
out(" proxy mode stays off — names will answer their origin.");
} else {
out(" -- no pinned-TLS proxy on this machine — names will answer their origin");
out(" a stock client cannot verify those: https://github.com/profullstack/moshpit-proxy");
}
}
}

const started = reusing
? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true }
: await startBridge({ port: wanted, registryBase, entry: cliEntry() });
: await startBridge({
port: wanted,
registryBase,
entry: cliEntry(),
// v4 by preference: `dns start --proxy` takes one address and probes
// both families itself, so handing it the v4 loopback lets it find ::1
// too rather than pinning the answer to one family.
proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
});
out(started.reused
? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
: started.alreadyRunning
Expand Down
67 changes: 60 additions & 7 deletions src/trust.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -482,27 +482,65 @@ export function leafPath(name, { platform = process.platform } = {}) {
: `/usr/local/share/ca-certificates/moshpit-${safe}.crt`;
}

/**
* Is this certificate marked as a certificate authority?
*
* Read with node's X509 parser rather than by grepping openssl's text, because
* the answer decides whether a key gets authority over the whole clearnet and
* "CA:FALSE" is a substring of nothing but is adjacent to plenty.
*
* A certificate carrying no basicConstraints at all answers false: absent is
* not the same as asserted, and RFC 5280 §4.2.1.9 treats such a certificate as
* an end entity.
*/
export async function isCertificateAuthority(pem) {
const crypto = await import("node:crypto");
return new crypto.X509Certificate(pem).ca === true;
}

/**
* What `trust <name>` should do, given what the socket served and what the
* registry says about it.
*
* Pure, so the refusal path is testable without a network or a trust store.
*/
export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) {
export function leafTrustPlan({ name, pin, published, platform = process.platform, ca = false } = {}) {
const accepted = pinAccepted(pin, published);
if (!accepted.ok) return { ok: false, refused: true, why: accepted.why };

// A certificate installed here is installed as a *trust anchor*, and an
// anchor marked CA:TRUE may issue for any name in the world. The SAN says
// what the certificate speaks for; it says nothing about what a key trusted
// as an authority may go on to sign — so `subjectAltName=DNS:seo.rank` on a
// CA:TRUE certificate is not the bound it looks like, and trusting one would
// hand its holder google.com along with their own name.
//
// This is the same hole `requireNameConstraints` exists to close on the root
// path, arriving by the other door. It went unnoticed because openssl's
// `req -x509` defaults to CA:TRUE, so every origin set up before that default
// was overridden serves exactly the shape that must be refused — and it looks
// identical to a correct one until someone trusts it.
if (ca) {
return {
ok: false,
refused: true,
kind: "ca",
why: `${name} serves a certificate marked CA:TRUE — trusted directly, its key could vouch for any name`,
};
}

const file = leafPath(name, { platform });
if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` };

return {
ok: true,
why: accepted.why,
file,
// A self-signed leaf is its own trust anchor, and its SAN limits it to this
// one name — so trusting it vouches for `seo.rank` and nothing else. That
// is a far smaller grant than a CA, which is why this path needs no
// name constraints argument to be defensible.
// With CA:FALSE established above, a self-signed leaf is its own trust
// anchor and its SAN limits it to this one name — so trusting it vouches
// for `seo.rank` and nothing else. That is a far smaller grant than a CA,
// which is why this path needs no name-constraints argument to be
// defensible. It is only true because of the check above.
refresh: platform === "darwin"
? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] }
: { command: "update-ca-certificates", args: [] },
Expand Down Expand Up @@ -571,10 +609,25 @@ export async function trustName(name, out, deps = {}) {
return 1;
}

const plan = leafTrustPlan({ name, pin, published, platform });
// Read off the certificate rather than assumed: an origin set up before
// `setup-origin.sh` overrode openssl's default serves CA:TRUE, and that is
// the one shape this must not install.
const ca = await isCertificateAuthority(served.stdout).catch(() => true);

const plan = leafTrustPlan({ name, pin, published, platform, ca });
if (!plan.ok) {
out(`REFUSED — ${plan.why}`);
if (plan.refused) {
if (plan.kind === "ca") {
// A refusal with no way forward is a refusal people route around, and
// this one has a cheap way forward that costs nothing anywhere else: the
// pin is over the key, so re-issuing the certificate from the same key
// leaves the published pin untouched. Nothing has to be republished and
// no client holding the old pin breaks.
out(" its SAN says what it speaks for, not what it may sign — an anchor");
out(" marked CA:TRUE is not limited to the name printed on it.");
out(" re-issue it as CA:FALSE; the key is reused, so the pin does not move:");
out(` sudo sh scripts/setup-origin.sh ${name} # from moshpit-proxy`);
} else if (plan.refused) {
out(` served ${pin}`);
out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)");
out(" moshcode will not trust a certificate the registry does not vouch for.");
Expand Down
4 changes: 4 additions & 0 deletions test/dns-enable-rollback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,10 @@ function noSystem() {
preflight: async () => ({ ok: true, blockers: [], conflicts: [], holder: null }),
verify: async () => ({ ok: true, checks: [] }),
bridgeStatus: async () => ({ running: false, pid: null, stale: false }),
// No proxy, which is the state these tests were written in. Stubbed rather
// than left to the real probe, which would open a TLS connection to
// whatever holds 443 on the machine running the suite.
findLocalProxyImpl: async () => ({ found: false, why: null, address: { v4: null, v6: null } }),
startBridge: async () => ({ started: true, pid: 1, alreadyRunning: false }),
stopBridge: async () => ({ stopped: true, reason: null }),
dropins: async () => [],
Expand Down
Loading
Loading