|
| 1 | +/** |
| 2 | + * Pointing every live name at the local proxy, so a stock client can verify one. |
| 3 | + * |
| 4 | + * No CA will ever sign for a Moshpit name, so the only way to hand `curl` a |
| 5 | + * certificate it accepts is to terminate TLS locally: moshpit-proxy checks the |
| 6 | + * origin's key against the registry pin and re-signs with a root this machine |
| 7 | + * generated. That was already built, and nothing routed to it — the resolver |
| 8 | + * answered the origin, so the proxy sat on loopback and every name arrived at a |
| 9 | + * stock client as a self-signed certificate no matter what was installed. |
| 10 | + * |
| 11 | + * The mode is dangerous in exactly one direction, and these tests are mostly |
| 12 | + * about that direction: with the proxy there, every name works; with nothing |
| 13 | + * there, every name resolves and then refuses the connection, which reads as |
| 14 | + * "all my sites are down" while `dig` looks perfectly healthy. |
| 15 | + */ |
| 16 | +import test from "node:test"; |
| 17 | +import assert from "node:assert/strict"; |
| 18 | +import { EventEmitter } from "node:events"; |
| 19 | +import dgram from "node:dgram"; |
| 20 | + |
| 21 | +import { addressAnswer, dnsCommand, proxyReachable, PROXY_PORT } from "../src/dns.mjs"; |
| 22 | + |
| 23 | +/** Hold a UDP port so the bind fails and `start` returns instead of serving. */ |
| 24 | +function holdUdp() { |
| 25 | + const socket = dgram.createSocket({ type: "udp4" }); |
| 26 | + return new Promise((resolve) => { |
| 27 | + socket.bind(0, "127.0.0.1", () => resolve({ |
| 28 | + port: socket.address().port, |
| 29 | + release: () => new Promise((done) => socket.close(done)), |
| 30 | + })); |
| 31 | + }); |
| 32 | +} |
| 33 | + |
| 34 | +/** A registry answering one verdict for any name. */ |
| 35 | +function registry({ target = "dev.profullstack.com", registered = true, records = [] } = {}) { |
| 36 | + return { |
| 37 | + fetchImpl: async (url) => ({ |
| 38 | + ok: true, |
| 39 | + json: async () => ({ |
| 40 | + name_registered: registered, |
| 41 | + target, |
| 42 | + ...(url.includes("records=1") ? { records } : {}), |
| 43 | + }), |
| 44 | + }), |
| 45 | + }; |
| 46 | +} |
| 47 | + |
| 48 | +const PROXY = { v4: "127.0.0.1", v6: "::1" }; |
| 49 | + |
| 50 | +/* --------------------------------------------------------------- the routing */ |
| 51 | + |
| 52 | +test("every live name answers the proxy, whatever its target says", async () => { |
| 53 | + // The point of the mode: the origin is the proxy's business, not the |
| 54 | + // client's. A name pointed at a host, an address, or a published record all |
| 55 | + // arrive at the same place. |
| 56 | + for (const target of ["dev.profullstack.com", "203.0.113.7", "https://box.example.com"]) { |
| 57 | + const plan = await addressAnswer("scrambled.eggs", { |
| 58 | + ...registry({ target }), proxyAddress: PROXY, |
| 59 | + }); |
| 60 | + assert.equal(plan.kind, "address", target); |
| 61 | + assert.equal(plan.address, "127.0.0.1", target); |
| 62 | + assert.equal(plan.proxied, true); |
| 63 | + } |
| 64 | +}); |
| 65 | + |
| 66 | +test("the AAAA question gets the proxy's v6 address", async () => { |
| 67 | + const plan = await addressAnswer("scrambled.eggs", { |
| 68 | + ...registry(), proxyAddress: PROXY, wantsV6: true, |
| 69 | + }); |
| 70 | + assert.equal(plan.address, "::1"); |
| 71 | +}); |
| 72 | + |
| 73 | +test("a proxy that speaks one family is NODATA for the other, not a fabricated address", async () => { |
| 74 | + // Answering ::1 for a v4-only listener is a connection refused that looks |
| 75 | + // like the site is down. |
| 76 | + const plan = await addressAnswer("scrambled.eggs", { |
| 77 | + ...registry(), proxyAddress: { v4: "127.0.0.1", v6: null }, wantsV6: true, |
| 78 | + }); |
| 79 | + assert.equal(plan.kind, "nodata"); |
| 80 | + assert.equal(plan.address, null); |
| 81 | + assert.equal(plan.exists, true, "the name is still here — this is NODATA, not NXDOMAIN"); |
| 82 | +}); |
| 83 | + |
| 84 | +/* ------------------------------------------------ what the mode must not swallow */ |
| 85 | + |
| 86 | +test("a name nobody holds is still NXDOMAIN with the proxy on", async () => { |
| 87 | + // Without this, every typo on the machine resolves to loopback and the proxy |
| 88 | + // is asked to verify a pin for a name that does not exist. |
| 89 | + const plan = await addressAnswer("scrambled.eggs", { |
| 90 | + fetchImpl: async () => ({ ok: false, json: async () => ({}) }), |
| 91 | + proxyAddress: PROXY, |
| 92 | + }); |
| 93 | + assert.equal(plan.kind, "nxdomain"); |
| 94 | + assert.equal(plan.exists, false); |
| 95 | +}); |
| 96 | + |
| 97 | +test("a parked name still reaches the parking page, not the proxy", async () => { |
| 98 | + // A parked name has no origin and no published pin, so handing it to a proxy |
| 99 | + // whose whole job is to verify one turns "this name is for sale" into a TLS |
| 100 | + // error. |
| 101 | + const plan = await addressAnswer("scrambled.eggs", { |
| 102 | + ...registry({ target: null }), proxyAddress: PROXY, parkingAddress: "198.51.100.9", |
| 103 | + }); |
| 104 | + assert.equal(plan.address, "198.51.100.9"); |
| 105 | + assert.notEqual(plan.proxied, true); |
| 106 | +}); |
| 107 | + |
| 108 | +test("without the mode, nothing changes", async () => { |
| 109 | + const plan = await addressAnswer("scrambled.eggs", { ...registry() }); |
| 110 | + assert.equal(plan.kind, "chain"); |
| 111 | + assert.equal(plan.cname, "dev.profullstack.com"); |
| 112 | +}); |
| 113 | + |
| 114 | +/* ------------------------------------------------------------- the safety gate */ |
| 115 | + |
| 116 | +/** A fake connect() that succeeds or fails on demand. */ |
| 117 | +function connector(reachable) { |
| 118 | + return ({ host }) => { |
| 119 | + const socket = new EventEmitter(); |
| 120 | + socket.destroy = () => {}; |
| 121 | + queueMicrotask(() => socket.emit(reachable.includes(host) ? "connect" : "error", new Error("ECONNREFUSED"))); |
| 122 | + return socket; |
| 123 | + }; |
| 124 | +} |
| 125 | + |
| 126 | +test("reachability is what the gate actually measures", async () => { |
| 127 | + assert.equal(await proxyReachable("127.0.0.1", PROXY_PORT, { connect: connector(["127.0.0.1"]) }), true); |
| 128 | + assert.equal(await proxyReachable("127.0.0.1", PROXY_PORT, { connect: connector([]) }), false); |
| 129 | +}); |
| 130 | + |
| 131 | +test("a connect that never resolves is unreachable, not a hang", async () => { |
| 132 | + const stalls = () => { |
| 133 | + const socket = new EventEmitter(); |
| 134 | + socket.destroy = () => {}; |
| 135 | + return socket; // never emits |
| 136 | + }; |
| 137 | + assert.equal(await proxyReachable("127.0.0.1", PROXY_PORT, { connect: stalls, timeoutMs: 50 }), false); |
| 138 | +}); |
| 139 | + |
| 140 | +test("--proxy with nothing listening refuses to start", async () => { |
| 141 | + // The whole reason this gate exists. Starting anyway would point every live |
| 142 | + // name on the machine at a closed port. |
| 143 | + const lines = []; |
| 144 | + const code = await dnsCommand(["start", "--proxy", "--port", "15971"], (l) => lines.push(l), { |
| 145 | + tlds: async () => ["eggs"], |
| 146 | + proxyReachableImpl: async () => false, |
| 147 | + }); |
| 148 | + |
| 149 | + assert.equal(code, 1); |
| 150 | + const text = lines.join("\n"); |
| 151 | + assert.match(text, /nothing is listening on/); |
| 152 | + assert.match(text, /break all of them at once/, "the cost is named, not just the fact"); |
| 153 | + assert.doesNotMatch(text, /moshpit resolver on/, "and it must not claim to have started"); |
| 154 | +}); |
| 155 | + |
| 156 | +test("--proxy names the address it will send everything to", async () => { |
| 157 | + const held = await holdUdp(); |
| 158 | + try { |
| 159 | + const lines = []; |
| 160 | + const seen = []; |
| 161 | + await dnsCommand(["start", "--proxy", "--port", String(held.port)], (l) => lines.push(l), { |
| 162 | + tlds: async () => ["eggs"], |
| 163 | + proxyReachableImpl: async (host) => { |
| 164 | + seen.push(host); |
| 165 | + return host === "127.0.0.1"; |
| 166 | + }, |
| 167 | + }); |
| 168 | + |
| 169 | + assert.deepEqual(seen, ["127.0.0.1", "::1"], "both families are probed before either is used"); |
| 170 | + assert.match(lines.join("\n"), /proxying every live name to 127\.0\.0\.1:443/); |
| 171 | + } finally { |
| 172 | + await held.release(); |
| 173 | + } |
| 174 | +}); |
| 175 | + |
| 176 | +test("an explicit --proxy host is the only one probed", async () => { |
| 177 | + const held = await holdUdp(); |
| 178 | + try { |
| 179 | + const seen = []; |
| 180 | + await dnsCommand(["start", "--proxy", "10.0.0.5", "--port", String(held.port)], () => {}, { |
| 181 | + tlds: async () => ["eggs"], |
| 182 | + proxyReachableImpl: async (host) => { |
| 183 | + seen.push(host); |
| 184 | + return true; |
| 185 | + }, |
| 186 | + }); |
| 187 | + assert.deepEqual(seen, ["10.0.0.5"]); |
| 188 | + } finally { |
| 189 | + await held.release(); |
| 190 | + } |
| 191 | +}); |
0 commit comments