From aa63af8f25e96535721eeeba832ead51983445d3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 18:29:46 +0000 Subject: [PATCH 1/3] feat(dns): --proxy points every live name at the local pinned-TLS proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parked, not shipped. Complete and green (11 tests), but shelved in favour of per-name trust and a trust-all mode. Every live Moshpit name answers the local proxy instead of its origin, so the proxy can verify the registry pin and re-sign with a root this machine generated — the only language a stock client accepts. Refuses to start when nothing is listening where it would send them: with the mode on and no proxy behind it, every Moshpit name resolves and then refuses the connection, which reads as 'all my sites are down' while dig looks perfectly healthy. Unclaimed names stay NXDOMAIN and parked names still reach the parking page. Co-Authored-By: Claude Opus 5 --- src/dns.mjs | 96 +++++++++++++++++- test/dns-proxy-mode.test.mjs | 191 +++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 test/dns-proxy-mode.test.mjs diff --git a/src/dns.mjs b/src/dns.mjs index 7ae5e42..22341bc 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -16,13 +16,17 @@ // testable without binding a port. import dgram from "node:dgram"; -import { isIP } from "node:net"; +import { isIP, connect as netConnect } from "node:net"; import { Resolver } from "node:dns/promises"; export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh"; export const DEFAULT_PARKING_HOST = "moshcoding.com"; export const DEFAULT_PORT = 5354; export const DEFAULT_HOST = "127.0.0.1"; +// Where the pinned-TLS proxy listens. Not configurable from DNS: an A record +// cannot carry a port, so the proxy has to be on 443 for a browser to reach it +// at all — its installer moves it there for exactly this reason. +export const PROXY_PORT = 443; export function parseDnsPort(input) { const raw = String(input ?? "").trim(); @@ -610,8 +614,41 @@ export function mayHaveCname({ exists, address }) { * round trip — which is the same bargain the old path struck for CNAMEs, held * to here so the common case did not get slower in exchange for being right. */ +/** + * Is something actually listening where we are about to send every name? + * + * The guard that makes proxy mode safe to offer at all. Pointing every live + * Moshpit name at a loopback address is exactly as good as the thing behind it: + * with a proxy there, all of them work in a stock client; with nothing there, + * all of them break at once, and the resolver looks healthy while doing it — + * `dig` answers 127.0.0.1 and every connection is refused. + * + * So this is checked before the mode is allowed on, and rechecked rather than + * remembered: a proxy that dies after the resolver started is the same outage + * as one that was never running. + */ +export function proxyReachable(address, port = 443, { connect = null, timeoutMs = 1500 } = {}) { + return new Promise((resolve) => { + let socket; + const done = (ok) => { + try { socket?.destroy(); } catch { /* already gone */ } + resolve(ok); + }; + try { + const net = connect || netConnect; + socket = net({ host: address, port }); + const timer = setTimeout(() => done(false), timeoutMs); + timer.unref?.(); + socket.once("connect", () => { clearTimeout(timer); done(true); }); + socket.once("error", () => { clearTimeout(timer); done(false); }); + } catch { + resolve(false); + } + }); +} + export async function addressAnswer(name, options = {}) { - const { parkingAddress, wantsV6 = false } = options; + const { parkingAddress, wantsV6 = false, proxyAddress = null } = options; const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra }); const result = await resolveName(name, options); @@ -620,10 +657,31 @@ export async function addressAnswer(name, options = {}) { // Parking is checked before anything the registry published: a parked name's // whole job is to reach the page explaining that it is for sale. + // + // It is also checked before the proxy, deliberately. A parked name has no + // origin and no published pin, so handing it to a proxy whose entire job is + // to verify one would turn "this name is for sale" into a TLS error. if (result.status === "parked") { return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata"); } + // Every live name answers the local proxy, whatever the registry says its + // target is — that is the point. The proxy reads the SNI, checks the origin's + // key against the registry pin, and re-signs with a root this machine + // generated, which is the only way a stock client can be told the result: no + // CA will ever sign for a Moshpit name. + // + // Answering the origin instead is what left the proxy running on loopback + // with nothing ever routed to it, so every name arrived at a stock client as + // a self-signed certificate no matter what was installed. + if (proxyAddress) { + const forFamily = wantsV6 ? proxyAddress.v6 : proxyAddress.v4; + // A proxy that only speaks one family is NODATA for the other, not a + // fabricated address: answering ::1 for a v4-only listener is a connection + // refused that looks like the site is down. + return forFamily ? plan("address", { address: forFamily, proxied: true }) : plan("nodata"); + } + const address = targetAddress(result.target); if (address) return plan("address", { address }); @@ -940,6 +998,7 @@ export function createServer(options = {}) { // names it is authoritative for. upstreams = [], tldSet = null, + proxyAddress = null, forwardTimeoutMs = 3000, // Off by default: a loopback bridge has one client and rate limiting it is // pure cost. These matter when the socket is reachable by strangers, which @@ -1065,7 +1124,7 @@ export function createServer(options = {}) { if (policy) ({ exists } = policy); } else { const plan = await addressAnswer(query.name, { - ...options, wantsV6: query.type === TYPE_AAAA, + ...options, wantsV6: query.type === TYPE_AAAA, proxyAddress, }).catch(() => null); exists = Boolean(plan?.exists); if (plan?.kind === "records") { @@ -2012,6 +2071,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { verify = verifyResolution, bridgeStatus = daemonStatus, startBridge = startDaemon, + proxyReachableImpl = proxyReachable, stopBridge = stopDaemon, dropins = readDropins, manifestFile = manifestPath(), @@ -2150,6 +2210,35 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { : " this bridge has nothing to answer for and nothing to forward to"); } + // Proxy mode: answer every live name with the local pinned-TLS proxy rather + // than its origin, so a stock client gets a certificate it can verify. + const proxyIndex = rest.indexOf("--proxy"); + let proxyAddress = null; + if (proxyIndex >= 0) { + const given = rest[proxyIndex + 1]; + const host = given && !given.startsWith("-") ? given : null; + const candidates = host ? [host] : ["127.0.0.1", "::1"]; + const reachable = []; + for (const candidate of candidates) { + if (await proxyReachableImpl(candidate, PROXY_PORT)) reachable.push(candidate); + } + if (!reachable.length) { + // Refused rather than warned. With the mode on and nothing behind it, + // every Moshpit name on the machine resolves and then refuses the + // connection — a total outage that reads as "the sites are down". + out(`! nothing is listening on ${candidates.map((c) => `${c}:${PROXY_PORT}`).join(" or ")}`); + out(" --proxy points every live Moshpit name there, so turning it on now would"); + out(" break all of them at once rather than fix their certificates."); + out(" start moshpit-proxy first: https://github.com/profullstack/moshpit-proxy"); + return 1; + } + proxyAddress = { + v4: reachable.find((a) => isIP(a) === 4) || null, + v6: reachable.find((a) => isIP(a) === 6) || null, + }; + out(`proxying every live name to ${reachable.join(", ")}:${PROXY_PORT} — certificates are verified there`); + } + // The same two error codes the parking server above already explains, on // the port this command exists to bind. Without this they arrived as an // unhandled rejection — bin/moshcode calls main() with no top-level catch — @@ -2164,6 +2253,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { parkingAddress: park, upstreams, tldSet, + proxyAddress, onQuery: ({ name, address }) => out(` ${name} → ${address || "NXDOMAIN"}`), onError: (err) => out(`! resolver socket error — ${err?.message || err}`), }); diff --git a/test/dns-proxy-mode.test.mjs b/test/dns-proxy-mode.test.mjs new file mode 100644 index 0000000..ad3a4a1 --- /dev/null +++ b/test/dns-proxy-mode.test.mjs @@ -0,0 +1,191 @@ +/** + * Pointing every live name at the local proxy, so a stock client can verify one. + * + * No CA will ever sign for a Moshpit name, so the only way to hand `curl` a + * certificate it accepts is to terminate TLS locally: moshpit-proxy checks the + * origin's key against the registry pin and re-signs with a root this machine + * generated. That was already built, and nothing routed to it — the resolver + * answered the origin, so the proxy sat on loopback and every name arrived at a + * stock client as a self-signed certificate no matter what was installed. + * + * The mode is dangerous in exactly one direction, and these tests are mostly + * about that direction: with the proxy there, every name works; with nothing + * there, every name resolves and then refuses the connection, which reads as + * "all my sites are down" while `dig` looks perfectly healthy. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import dgram from "node:dgram"; + +import { addressAnswer, dnsCommand, proxyReachable, PROXY_PORT } from "../src/dns.mjs"; + +/** Hold a UDP port so the bind fails and `start` returns instead of serving. */ +function holdUdp() { + const socket = dgram.createSocket({ type: "udp4" }); + return new Promise((resolve) => { + socket.bind(0, "127.0.0.1", () => resolve({ + port: socket.address().port, + release: () => new Promise((done) => socket.close(done)), + })); + }); +} + +/** A registry answering one verdict for any name. */ +function registry({ target = "dev.profullstack.com", registered = true, records = [] } = {}) { + return { + fetchImpl: async (url) => ({ + ok: true, + json: async () => ({ + name_registered: registered, + target, + ...(url.includes("records=1") ? { records } : {}), + }), + }), + }; +} + +const PROXY = { v4: "127.0.0.1", v6: "::1" }; + +/* --------------------------------------------------------------- the routing */ + +test("every live name answers the proxy, whatever its target says", async () => { + // The point of the mode: the origin is the proxy's business, not the + // client's. A name pointed at a host, an address, or a published record all + // arrive at the same place. + for (const target of ["dev.profullstack.com", "203.0.113.7", "https://box.example.com"]) { + const plan = await addressAnswer("scrambled.eggs", { + ...registry({ target }), proxyAddress: PROXY, + }); + assert.equal(plan.kind, "address", target); + assert.equal(plan.address, "127.0.0.1", target); + assert.equal(plan.proxied, true); + } +}); + +test("the AAAA question gets the proxy's v6 address", async () => { + const plan = await addressAnswer("scrambled.eggs", { + ...registry(), proxyAddress: PROXY, wantsV6: true, + }); + assert.equal(plan.address, "::1"); +}); + +test("a proxy that speaks one family is NODATA for the other, not a fabricated address", async () => { + // Answering ::1 for a v4-only listener is a connection refused that looks + // like the site is down. + const plan = await addressAnswer("scrambled.eggs", { + ...registry(), proxyAddress: { v4: "127.0.0.1", v6: null }, wantsV6: true, + }); + assert.equal(plan.kind, "nodata"); + assert.equal(plan.address, null); + assert.equal(plan.exists, true, "the name is still here — this is NODATA, not NXDOMAIN"); +}); + +/* ------------------------------------------------ what the mode must not swallow */ + +test("a name nobody holds is still NXDOMAIN with the proxy on", async () => { + // Without this, every typo on the machine resolves to loopback and the proxy + // is asked to verify a pin for a name that does not exist. + const plan = await addressAnswer("scrambled.eggs", { + fetchImpl: async () => ({ ok: false, json: async () => ({}) }), + proxyAddress: PROXY, + }); + assert.equal(plan.kind, "nxdomain"); + assert.equal(plan.exists, false); +}); + +test("a parked name still reaches the parking page, not the proxy", async () => { + // A parked name has no origin and no published pin, so handing it to a proxy + // whose whole job is to verify one turns "this name is for sale" into a TLS + // error. + const plan = await addressAnswer("scrambled.eggs", { + ...registry({ target: null }), proxyAddress: PROXY, parkingAddress: "198.51.100.9", + }); + assert.equal(plan.address, "198.51.100.9"); + assert.notEqual(plan.proxied, true); +}); + +test("without the mode, nothing changes", async () => { + const plan = await addressAnswer("scrambled.eggs", { ...registry() }); + assert.equal(plan.kind, "chain"); + assert.equal(plan.cname, "dev.profullstack.com"); +}); + +/* ------------------------------------------------------------- the safety gate */ + +/** A fake connect() that succeeds or fails on demand. */ +function connector(reachable) { + return ({ host }) => { + const socket = new EventEmitter(); + socket.destroy = () => {}; + queueMicrotask(() => socket.emit(reachable.includes(host) ? "connect" : "error", new Error("ECONNREFUSED"))); + return socket; + }; +} + +test("reachability is what the gate actually measures", async () => { + assert.equal(await proxyReachable("127.0.0.1", PROXY_PORT, { connect: connector(["127.0.0.1"]) }), true); + assert.equal(await proxyReachable("127.0.0.1", PROXY_PORT, { connect: connector([]) }), false); +}); + +test("a connect that never resolves is unreachable, not a hang", async () => { + const stalls = () => { + const socket = new EventEmitter(); + socket.destroy = () => {}; + return socket; // never emits + }; + assert.equal(await proxyReachable("127.0.0.1", PROXY_PORT, { connect: stalls, timeoutMs: 50 }), false); +}); + +test("--proxy with nothing listening refuses to start", async () => { + // The whole reason this gate exists. Starting anyway would point every live + // name on the machine at a closed port. + const lines = []; + const code = await dnsCommand(["start", "--proxy", "--port", "15971"], (l) => lines.push(l), { + tlds: async () => ["eggs"], + proxyReachableImpl: async () => false, + }); + + assert.equal(code, 1); + const text = lines.join("\n"); + assert.match(text, /nothing is listening on/); + assert.match(text, /break all of them at once/, "the cost is named, not just the fact"); + assert.doesNotMatch(text, /moshpit resolver on/, "and it must not claim to have started"); +}); + +test("--proxy names the address it will send everything to", async () => { + const held = await holdUdp(); + try { + const lines = []; + const seen = []; + await dnsCommand(["start", "--proxy", "--port", String(held.port)], (l) => lines.push(l), { + tlds: async () => ["eggs"], + proxyReachableImpl: async (host) => { + seen.push(host); + return host === "127.0.0.1"; + }, + }); + + assert.deepEqual(seen, ["127.0.0.1", "::1"], "both families are probed before either is used"); + assert.match(lines.join("\n"), /proxying every live name to 127\.0\.0\.1:443/); + } finally { + await held.release(); + } +}); + +test("an explicit --proxy host is the only one probed", async () => { + const held = await holdUdp(); + try { + const seen = []; + await dnsCommand(["start", "--proxy", "10.0.0.5", "--port", String(held.port)], () => {}, { + tlds: async () => ["eggs"], + proxyReachableImpl: async (host) => { + seen.push(host); + return true; + }, + }); + assert.deepEqual(seen, ["10.0.0.5"]); + } finally { + await held.release(); + } +}); From 9602efefb56e432ed7ed319de23d195459b4d40d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 19:09:13 +0000 Subject: [PATCH 2/3] feat(templates): caddy-proxy puts a Moshpit name in front of a local service --- examples/templates/caddy-proxy/Caddyfile | 36 ++++++ examples/templates/caddy-proxy/README.md | 104 ++++++++++++++++++ .../caddy-proxy/deploy/moshcode-dns.service | 39 +++++++ examples/templates/caddy-proxy/template.json | 8 ++ 4 files changed, 187 insertions(+) create mode 100644 examples/templates/caddy-proxy/Caddyfile create mode 100644 examples/templates/caddy-proxy/README.md create mode 100644 examples/templates/caddy-proxy/deploy/moshcode-dns.service create mode 100644 examples/templates/caddy-proxy/template.json diff --git a/examples/templates/caddy-proxy/Caddyfile b/examples/templates/caddy-proxy/Caddyfile new file mode 100644 index 0000000..a7f54ff --- /dev/null +++ b/examples/templates/caddy-proxy/Caddyfile @@ -0,0 +1,36 @@ +# Caddy in front of a service already running on this box, published at a +# Moshpit name. +# +# The `http://` is required and is not a style choice. A Moshpit ending is not +# in the public DNS root, so no certificate authority will issue for it — leave +# the scheme off and Caddy will try to provision a certificate, fail, and never +# bring the site up. Everything served at a Moshpit name is plain HTTP. +# +# Nothing here resolves the name. The visitor's resolver did that; by the time a +# request arrives Caddy has only a Host header to match on, which is why the +# site address must be the name exactly as it is registered. + +http://{$MOSHPIT_NAME:foo.whatever} { + reverse_proxy {$APP_ADDR:127.0.0.1:8080} + + log { + output file /var/log/caddy/moshpit-service.log + } +} + +# Every subdomain too. This block answers only once the name publishes a +# wildcard record — DNS Records tab in the Pit, the `*.` option with an AAAA +# at this box — because until then `anything.foo.whatever` resolves to nothing +# and no request reaches Caddy to match. Uncomment both together. +# +# The app sees which subdomain was asked for in the Host header, and Caddy +# matches exactly one label deep: `api.foo.whatever` answers, +# `a.b.foo.whatever` does not. +# +# http://*.{$MOSHPIT_NAME:foo.whatever} { +# reverse_proxy {$APP_ADDR:127.0.0.1:8080} +# +# log { +# output file /var/log/caddy/moshpit-service.log +# } +# } diff --git a/examples/templates/caddy-proxy/README.md b/examples/templates/caddy-proxy/README.md new file mode 100644 index 0000000..7f66b80 --- /dev/null +++ b/examples/templates/caddy-proxy/README.md @@ -0,0 +1,104 @@ +# caddy-proxy + +A name in front of something that is already running. No app, no database, no +runtime to keep alive — Caddy answers the Moshpit name and hands every request +to a local service on `127.0.0.1:8080` (or wherever `APP_ADDR` says). + +This is the template for "I have a thing on this box, put a name on it": a +dev server, a dashboard, grafana, a game panel, anything that already listens +on loopback. + +## The part that surprises people + +Three machines' worth of concerns, and they fail independently: + +| | needs the resolver? | what it does | +|---|---|---| +| the box serving the name | **no** | Caddy matches a `Host` header, nothing more | +| the registry | — | holds the address the name points at | +| every visitor | **yes** | `sudo moshcode dns enable`, or the name resolves to nothing | + +Nothing on the server ever resolves its own name. That is why there is no DNS +software in this template. + +## Deploying + +1. **Point the name at the box.** In the Pit, set `points at` to its public + IPv6 address — bare, no scheme, no brackets, no port: + + ```sh + ip -6 addr show scope global | grep inet6 + ``` + + Pick the globally routable one. An `fd..`/`fc..` address is unique-local + (Tailscale and friends live there) and the registry refuses it, because a + name pointed at one resolves somewhere only you can reach. + +2. **Serve it.** The service stays bound to loopback — Caddy is its only + client, and binding it publicly publishes it on a port nothing + virtual-hosts. + + ```sh + export MOSHPIT_NAME=foo.whatever + export APP_ADDR=127.0.0.1:8080 # the default; change only if the service differs + sudo cp Caddyfile /etc/caddy/Caddyfile + sudo systemctl reload caddy + sudo ufw allow 80/tcp + ``` + +3. **Reach it,** on any machine that should see the name: + + ```sh + sudo moshcode dns enable + sudo cp deploy/moshcode-dns.service /etc/systemd/system/ # survives reboot + sudo systemctl enable --now moshcode-dns + ``` + +## Every subdomain at once + +One name covers one hostname. To answer `anything.foo.whatever` too — one +service per subdomain, or a wildcard tenant app — do both halves, in either +order, because neither works without the other: + +1. In the Pit's **DNS Records** tab, publish an **AAAA** record on the + `*.foo.whatever` option pointing at the same box. Until that exists the + subdomains resolve to nothing and no request ever reaches Caddy. +2. Uncomment the wildcard block at the bottom of the Caddyfile and reload. + Caddy matches exactly one label deep, and the app reads which subdomain was + asked for from the `Host` header. + +`foo.whatever` itself is not covered by a wildcard — keep the apex block (and +its own AAAA or `points at`) for that. This is how DNS wildcards work, not a +choice Caddy made. + +## Verifying, one layer at a time + +A failure at any layer looks identical in a browser, so do not start there. + +```sh +# Server only — no DNS involved. Proves Caddy, the firewall, and the service. +curl -6 -H "Host: foo.whatever" http://[YOUR:V6:ADDR]/ + +# Resolver only. Proves the registry and the bridge. +moshcode dns resolve foo.whatever + +# Both. +curl -6 http://foo.whatever/ +``` + +If the first works and the last does not, it is DNS. If the first fails, stop +looking at DNS. + +## Known limits + +- **No HTTPS, ever.** No CA will issue for an ending outside the DNS root. That + rules out secure cookies, service workers, and WebCrypto in the browser. The + `http://` in the Caddyfile is what stops Caddy trying and failing. +- **Only machines running the resolver can reach the name.** Not phones, not a + colleague who has not installed it, not webhooks. + `pit.moshcode.sh/n/foo.whatever` is the URL for people who installed nothing. +- **Subdomains are opt-in.** `foo.whatever` works out of the box; + `www.foo.whatever` works only with the wildcard record described above. +- **Port 80 only** on the resolver path. A DNS record carries an address and + has nowhere to put a port, which is why Caddy listens on 80 and the + `host:port` part lives here, not in the registry. diff --git a/examples/templates/caddy-proxy/deploy/moshcode-dns.service b/examples/templates/caddy-proxy/deploy/moshcode-dns.service new file mode 100644 index 0000000..30ba56e --- /dev/null +++ b/examples/templates/caddy-proxy/deploy/moshcode-dns.service @@ -0,0 +1,39 @@ +# The Moshpit resolver, kept running across reboots. +# +# `moshcode dns enable` sets up two halves: a systemd-resolved drop-in that +# routes Moshpit endings at the bridge, and the bridge process itself. The +# drop-in is a file and survives a reboot on its own. The process does not — +# so after a restart the routing still points at a port with nothing behind it, +# and every Moshpit name stops resolving with no obvious cause. This unit is +# the missing half. +# +# sudo cp deploy/moshcode-dns.service /etc/systemd/system/ +# sudo systemctl enable --now moshcode-dns +# +# Install this on machines that need to REACH Moshpit names. A box that only +# serves one does not need it — Caddy answers whatever Host header arrives and +# never resolves its own name. + +[Unit] +Description=Moshpit DNS bridge +After=network-online.target +Wants=network-online.target +Before=systemd-resolved.service + +[Service] +Type=simple +# Port 5354 is unprivileged, so this does not need root. The trade-off is that +# the parking responder cannot take port 80 and falls back to the public +# parking address — which only affects names that point nowhere yet. +ExecStart=/usr/bin/env moshcode dns start --port 5354 +Restart=always +RestartSec=2 + +DynamicUser=yes +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes + +[Install] +WantedBy=multi-user.target diff --git a/examples/templates/caddy-proxy/template.json b/examples/templates/caddy-proxy/template.json new file mode 100644 index 0000000..977a6cf --- /dev/null +++ b/examples/templates/caddy-proxy/template.json @@ -0,0 +1,8 @@ +{ + "name": "caddy-proxy", + "description": "Caddy proxying a Moshpit name to a service already running on the box — no app, no database, just the name in front", + "vars": { + "MOSHPIT_NAME": "the registered name to serve, e.g. foo.whatever", + "APP_ADDR": "where the local service listens, loopback only (default 127.0.0.1:8080)" + } +} From 069958c01d77a09e5c7112510c4b57646cac7875 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 5 Aug 2026 00:13:48 +0000 Subject: [PATCH 3/3] fix(dns): let the proxy reachability timeout hold the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout in proxyReachable() was unref'd, which defeats the one thing it exists to guarantee. A connect that stalls without keeping a handle alive left nothing holding the loop open, so the process reached an idle event loop with the probe still pending — node 22 reports that as a cancelled await rather than the `false` the caller needs, and it took the three tests that follow it down with it as cancelledByParent. It cannot outlive the probe: both settle paths already clear it. Co-Authored-By: Claude Opus 5 (1M context) --- src/dns.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dns.mjs b/src/dns.mjs index 330c5de..323508c 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -637,8 +637,13 @@ export function proxyReachable(address, port = 443, { connect = null, timeoutMs try { const net = connect || netConnect; socket = net({ host: address, port }); + // Deliberately not unref'd. This timer is the only thing that guarantees + // the promise settles at all, and an unref'd one does not hold the loop + // open — so a connect that stalls without keeping a handle alive let the + // process reach an idle event loop with this still pending, which node + // reports as a cancelled await rather than the `false` the caller needs. + // It cannot outlive the probe: both settle paths clear it. const timer = setTimeout(() => done(false), timeoutMs); - timer.unref?.(); socket.once("connect", () => { clearTimeout(timer); done(true); }); socket.once("error", () => { clearTimeout(timer); done(false); }); } catch {