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)" + } +} diff --git a/src/dns.mjs b/src/dns.mjs index c7107aa..323508c 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,46 @@ 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 }); + // 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); + 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 +662,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 +1003,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 +1129,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 +2076,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { verify = verifyResolution, bridgeStatus = daemonStatus, startBridge = startDaemon, + proxyReachableImpl = proxyReachable, autoTrustImpl = createAutoTrust, stopBridge = stopDaemon, dropins = readDropins, @@ -2159,6 +2224,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 — @@ -2185,6 +2279,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { parkingAddress: park, upstreams, tldSet, + proxyAddress, onQuery: ({ name, address, forwarded }) => { out(` ${name} → ${address || "NXDOMAIN"}`); // Only a name that actually resolved to something of ours. A forwarded 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(); + } +}); diff --git a/test/templates.test.mjs b/test/templates.test.mjs index 169ec42..d45c244 100644 --- a/test/templates.test.mjs +++ b/test/templates.test.mjs @@ -46,7 +46,7 @@ test("template list --json prints the stable template metadata", async () => { assert.equal(code, 0); assert.ok(Array.isArray(templates)); - assert.deepEqual(templates.map((template) => template.name), ["bun-caddy-sqlite", "caddy-static"]); + assert.deepEqual(templates.map((template) => template.name), ["bun-caddy-sqlite", "caddy-proxy", "caddy-static"]); for (const template of templates) { assert.deepEqual(Object.keys(template), ["name", "description"]); assert.equal(typeof template.description, "string");