Skip to content

Commit 3f3d2a3

Browse files
ralyodioclaude
andauthored
feat(doh): serve it, behind TLS that something else already terminates (#220)
* feat(doh): serve it, behind TLS that something else already terminates moshcode doh [--port N] run the resolver on loopback moshcode doh --nginx <name> print the reverse-proxy block for it TLS is deliberately not this process's job. A resolver that manages its own certificate goes down when that certificate expires, and every machine pointed at it loses DNS at once — not a Moshpit name, all of DNS. Renewal is a solved problem for exactly one process on a host and it is not this one. Which is why it binds loopback and says so on startup. A DoH endpoint reachable directly is an open resolver without the rate limits its proxy was going to apply, and scanners find those in hours. The client address comes from X-Forwarded-For, because behind a proxy every request arrives from 127.0.0.1 and limiting on the socket address would put every client in one bucket — one abusive source locking out everyone. That header is trustworthy exactly as far as the proxy is, which is the second reason this must never be exposed directly. The emitted nginx block carries the two lines people miss, both of which break something quietly: X-Forwarded-For, without which the rate limits are useless, and `gzip off`, because a DNS message is binary and some clients reject a gzipped application/dns-message outright. Bodies are refused at the cap rather than buffered to it. A client sending megabytes to a DNS endpoint is not a client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(doh): guards on by default, because an open resolver is found in hours The UDP bridge can default these off — it listens on loopback and has one client, where rate limiting is pure cost. This is meant to be reachable, and scanners find an unprotected open resolver within hours of it being published. So the safe configuration has to be the one you get by not thinking about it. 20 queries/second per client, burst 40 bans doubling from 60s, capped at a day answers capped at 1232 bytes Generous for a person, tight for a script: a browser does not produce 20 queries a second sustained and a scraper wants far more. 1232 is the payload size the DNS flag day settled on as safe across the internet, so nothing legitimate loses anything to the cap. Tunable with --rate, --burst, --ban-seconds and --max-response. Turning them off takes --no-guards, which is loud on startup: an unlimited open resolver is a decision and the caller has to have typed it. A junk value falls back to the default rather than disabling anything. `--rate banana` quietly becoming an unlimited resolver is the failure this avoids, and it is tested, along with a server built from the defaults actually refusing over the wire — a default that is read and then dropped on the floor is worse than no default, because it reads as safe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 96d6b8f commit 3f3d2a3

4 files changed

Lines changed: 374 additions & 0 deletions

File tree

bin/moshcode.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { consoleCommand } from "../src/console.mjs";
2727
import { dnsCommand } from "../src/dns.mjs";
2828
import { templateCommand } from "../src/templates.mjs";
2929
import { serveCommand } from "../src/serve.mjs";
30+
import { createDohServer, nginxDohSite, parseGuardArgs, DEFAULT_DOH_PORT, DOH_PATH } from "../src/doh-server.mjs";
3031
import { completionScript } from "../src/completion.mjs";
3132
import { CORE_CLI_COMMAND_NAMES } from "../src/cli-schema.mjs";
3233
import { moshcodeVersion } from "../src/ui.mjs";
@@ -156,6 +157,10 @@ usage:
156157
moshcode skill list [--json] show skills support + install status
157158
moshcode skill install <git-url> install a skill across every engine that
158159
supports it (claude/gemini)
160+
moshcode doh [--port N] run the DNS-over-HTTPS resolver (loopback;
161+
[--rate N] [--burst N] put TLS in front of it). Rate limits and
162+
[--ban-seconds N] [--no-guards] bans are ON by default.
163+
moshcode doh --nginx <name> print the reverse-proxy block for it
159164
moshcode site <name> [--install] install web-server config for a Moshpit
160165
[--reload] [--proxy PORT] name (nginx/Caddy does the serving, not
161166
[--root DIR] moshcode); shows the plan by default
@@ -387,6 +392,26 @@ async function main() {
387392
process.exitCode = (await dnsCommand(rest)) || 0;
388393
return;
389394
}
395+
if (cmd === "doh") {
396+
const nameAt = rest.indexOf("--nginx");
397+
if (nameAt >= 0) {
398+
console.log(nginxDohSite({ name: rest[nameAt + 1] || "dns.example", port: DEFAULT_DOH_PORT }));
399+
return;
400+
}
401+
const portAt = rest.indexOf("--port");
402+
const server = await createDohServer({
403+
port: portAt >= 0 ? Number(rest[portAt + 1]) : DEFAULT_DOH_PORT,
404+
...parseGuardArgs(rest),
405+
});
406+
console.log(`DoH resolver on ${server.url}`);
407+
console.log(server.guards.rateLimit
408+
? ` guards: ${server.guards.rateLimit.perSecond}/s per client (burst ${server.guards.rateLimit.burst}), `
409+
+ `bans double from ${Math.round(server.guards.ban.baseMs / 1000)}s, answers capped at ${server.guards.maxResponseBytes}B`
410+
: " ! guards OFF (--no-guards) — do not expose this without something else limiting it");
411+
console.log("TLS belongs to whatever holds 443 — see: moshcode doh --nginx <name>");
412+
console.log("this must not be reachable directly; it has no TLS and trusts X-Forwarded-For");
413+
return new Promise(() => {});
414+
}
390415
if (cmd === "site" || cmd === "serve") {
391416
process.exitCode = (await serveCommand(rest)) || 0;
392417
return;

src/cli-schema.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const CORE_CLI_COMMANDS = [
1515
{ name: "logout", description: "clear the logged-in account" },
1616
{ name: "console", description: "serve or connect to the browser terminal" },
1717
{ name: "dns", description: "manage DNS records" },
18+
{ name: "doh", description: "run the DNS-over-HTTPS resolver" },
1819
{ name: "site", description: "install web-server config for a Moshpit name" },
1920
{ name: "serve", description: "alias for site" },
2021
{ name: "template", description: "scaffold a stack for a Moshpit-hosted service" },

src/doh-server.mjs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// The HTTP half of the DoH resolver.
2+
//
3+
// Plain HTTP on loopback, with TLS terminated by whatever already holds 443 on
4+
// the box — nginx, Caddy, or a platform load balancer. That is not a shortcut:
5+
// a resolver that manages its own certificate is a resolver that goes down when
6+
// the certificate expires, and every machine pointed at it loses DNS at once.
7+
// Renewal is a solved problem for exactly one process on a host, and it is not
8+
// this one.
9+
//
10+
// Which means this must never bind a public address. A DoH endpoint reachable
11+
// directly is an open resolver without the rate limits its proxy was going to
12+
// apply, and scanners find those in hours.
13+
14+
import http from "node:http";
15+
import { createDohHandler, DNS_MESSAGE } from "./doh.mjs";
16+
import { discoverUpstreams, fetchTlds, DEFAULT_REGISTRY_BASE, parkingAddress } from "./dns.mjs";
17+
18+
export const DEFAULT_DOH_PORT = 8053;
19+
export const DOH_PATH = "/dns-query";
20+
21+
/**
22+
* Guards on by default here, unlike the UDP bridge.
23+
*
24+
* The bridge listens on loopback and has one client, where rate limiting is
25+
* pure cost. This is meant to be reachable, and an unprotected open resolver
26+
* is found by scanners within hours of being published — so the safe
27+
* configuration has to be the one you get by not thinking about it.
28+
*
29+
* The numbers are generous for a person and tight for a script: 20 queries a
30+
* second sustained is far more than a browser produces and far less than a
31+
* scraper wants.
32+
*/
33+
export const DEFAULT_GUARDS = {
34+
rateLimit: { perSecond: 20, burst: 40 },
35+
ban: { baseMs: 60_000, factor: 2, maxMs: 24 * 60 * 60 * 1000 },
36+
// Caps amplification. 1232 is the payload size the DNS flag day settled on
37+
// as safe across the internet, so nothing legitimate loses anything.
38+
maxResponseBytes: 1232,
39+
};
40+
41+
/**
42+
* Read guard settings off the command line.
43+
*
44+
* `--no-guards` exists for running behind something that already limits, and
45+
* is loud rather than silent: an unlimited open resolver is a decision, and
46+
* the caller has to have typed it.
47+
*/
48+
export function parseGuardArgs(args = []) {
49+
if (args.includes("--no-guards")) return { rateLimit: null, ban: null, maxResponseBytes: 0 };
50+
const num = (flag, fallback) => {
51+
const at = args.indexOf(flag);
52+
if (at < 0) return fallback;
53+
const value = Number(args[at + 1]);
54+
return Number.isFinite(value) && value >= 0 ? value : fallback;
55+
};
56+
return {
57+
rateLimit: {
58+
perSecond: num("--rate", DEFAULT_GUARDS.rateLimit.perSecond),
59+
burst: num("--burst", DEFAULT_GUARDS.rateLimit.burst),
60+
},
61+
ban: { ...DEFAULT_GUARDS.ban, baseMs: num("--ban-seconds", 60) * 1000 },
62+
maxResponseBytes: num("--max-response", DEFAULT_GUARDS.maxResponseBytes),
63+
};
64+
}
65+
66+
/** Read a request body, refusing anything implausible for a DNS message. */
67+
export function readBody(req, limit = 4096) {
68+
return new Promise((resolve, reject) => {
69+
const chunks = [];
70+
let size = 0;
71+
req.on("data", (chunk) => {
72+
size += chunk.length;
73+
// Hung up on rather than buffered: the cap is the point, and a client
74+
// sending megabytes to a DNS endpoint is not a client.
75+
if (size > limit) {
76+
reject(new Error("too large"));
77+
req.destroy();
78+
return;
79+
}
80+
chunks.push(chunk);
81+
});
82+
req.on("end", () => resolve(Buffer.concat(chunks)));
83+
req.on("error", reject);
84+
});
85+
}
86+
87+
/**
88+
* Who asked, as the proxy in front of us sees it.
89+
*
90+
* Behind a reverse proxy every request arrives from 127.0.0.1, so rate
91+
* limiting on the socket address would put every client in one bucket — one
92+
* abusive source would lock out everyone. The forwarded header is the only
93+
* client identity available, and it is trustworthy exactly as far as the proxy
94+
* is: fine when the proxy sets it, worthless if this is ever exposed directly,
95+
* which is the other reason it must not be.
96+
*/
97+
export function clientAddress(req, { trustProxy = true } = {}) {
98+
if (trustProxy) {
99+
const forwarded = req.headers?.["x-forwarded-for"];
100+
if (forwarded) return String(forwarded).split(",")[0].trim();
101+
}
102+
return req.socket?.remoteAddress || "";
103+
}
104+
105+
/** Mount the DoH handler on an http server. Returns { port, address, close }. */
106+
export async function createDohServer({
107+
port = DEFAULT_DOH_PORT,
108+
host = "127.0.0.1",
109+
registryBase = DEFAULT_REGISTRY_BASE,
110+
path = DOH_PATH,
111+
trustProxy = true,
112+
handler = null,
113+
onQuery = () => {},
114+
...guards
115+
} = {}) {
116+
const applied = { ...DEFAULT_GUARDS, ...guards };
117+
const handle = handler || createDohHandler({
118+
registryBase,
119+
upstreams: await discoverUpstreams(),
120+
tldSet: new Set(await fetchTlds({ registryBase }).catch(() => [])),
121+
parkingAddress: await parkingAddress().catch(() => null),
122+
onQuery,
123+
...guards,
124+
});
125+
126+
const server = http.createServer(async (req, res) => {
127+
const url = req.url || "/";
128+
if (!url.split("?")[0].endsWith(path)) {
129+
res.writeHead(404, { "content-type": "text/plain" });
130+
res.end("not here\n");
131+
return;
132+
}
133+
134+
let body = null;
135+
if (req.method === "POST") {
136+
try {
137+
body = await readBody(req);
138+
} catch {
139+
res.writeHead(413, { "content-type": "text/plain" });
140+
res.end("query too large\n");
141+
return;
142+
}
143+
}
144+
145+
const answer = await handle({
146+
method: req.method,
147+
url,
148+
body,
149+
address: clientAddress(req, { trustProxy }),
150+
}).catch(() => null);
151+
152+
if (!answer) {
153+
res.writeHead(500, { "content-type": "text/plain" });
154+
res.end("resolver error\n");
155+
return;
156+
}
157+
res.writeHead(answer.status, answer.headers);
158+
res.end(answer.body);
159+
});
160+
161+
return new Promise((resolve, reject) => {
162+
server.once("error", reject);
163+
server.listen(port, host, () => {
164+
const addr = server.address();
165+
resolve({
166+
port: addr.port,
167+
address: addr.address,
168+
url: `http://${addr.address}:${addr.port}${path}`,
169+
guards: applied,
170+
close: () => new Promise((done) => server.close(done)),
171+
});
172+
});
173+
});
174+
}
175+
176+
/**
177+
* The reverse-proxy block that terminates TLS in front of this.
178+
*
179+
* Emitted rather than described because the two lines people miss are the two
180+
* that matter: a DNS message is binary, so no charset and no gzip, and the
181+
* client's address has to be forwarded or every client shares a rate-limit
182+
* bucket.
183+
*/
184+
export function nginxDohSite({ name, port = DEFAULT_DOH_PORT, path = DOH_PATH }) {
185+
return [
186+
`# ${name} — DoH endpoint, written by \`moshcode doh --nginx\`.`,
187+
"#",
188+
"# TLS is terminated here on purpose. A resolver that manages its own",
189+
"# certificate goes down when that certificate expires, and every machine",
190+
"# pointed at it loses DNS at once.",
191+
"server {",
192+
"\tlisten 443 ssl;",
193+
"\tlisten [::]:443 ssl;",
194+
`\tserver_name ${name};`,
195+
"",
196+
`\t# certificates: certbot --nginx -d ${name}`,
197+
"",
198+
`\tlocation ${path} {`,
199+
`\t\tproxy_pass http://127.0.0.1:${port};`,
200+
"\t\tproxy_set_header Host $host;",
201+
"\t\t# Without this every client shares one rate-limit bucket, because",
202+
"\t\t# behind a proxy they all arrive from 127.0.0.1.",
203+
"\t\tproxy_set_header X-Forwarded-For $remote_addr;",
204+
"\t\t# A DNS message is binary. Compressing it wastes CPU and some",
205+
"\t\t# clients reject a gzipped application/dns-message outright.",
206+
"\t\tgzip off;",
207+
"\t}",
208+
"}",
209+
"",
210+
].join("\n");
211+
}
212+
213+
export { DNS_MESSAGE };

0 commit comments

Comments
 (0)