moshcode 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +580 -0
- package/bin/moshcode.mjs +674 -0
- package/bin/moshscript.mjs +29 -0
- package/examples/alive.mosh +6 -0
- package/examples/scripting-the-cli.mosh +21 -0
- package/examples/team-secrets.mosh +20 -0
- package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
- package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
- package/examples/templates/bun-caddy-sqlite/README.md +97 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
- package/examples/templates/bun-caddy-sqlite/package.json +15 -0
- package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
- package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
- package/examples/templates/bun-caddy-sqlite/template.json +10 -0
- package/examples/templates/caddy-proxy/Caddyfile +36 -0
- package/examples/templates/caddy-proxy/README.md +104 -0
- package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-proxy/template.json +8 -0
- package/examples/templates/caddy-static/Caddyfile +16 -0
- package/examples/templates/caddy-static/README.md +90 -0
- package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-static/site/index.html +11 -0
- package/examples/templates/caddy-static/template.json +8 -0
- package/install.sh +194 -0
- package/package.json +28 -0
- package/prd/0000-template.md +49 -0
- package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
- package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
- package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
- package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
- package/prd/0005-hosted-moshpit-resolver.md +192 -0
- package/prd/0006-help.md +359 -0
- package/prd/0007-profullstack-site-init.md +1183 -0
- package/prd/README.md +26 -0
- package/src/ads.mjs +58 -0
- package/src/auth.mjs +193 -0
- package/src/cli-schema.mjs +533 -0
- package/src/cli.mjs +118 -0
- package/src/commands.mjs +259 -0
- package/src/completion.mjs +594 -0
- package/src/console.mjs +244 -0
- package/src/dns-system.mjs +404 -0
- package/src/dns.mjs +2872 -0
- package/src/doh-server.mjs +256 -0
- package/src/doh.mjs +218 -0
- package/src/engines.mjs +385 -0
- package/src/escalate.mjs +85 -0
- package/src/help.mjs +443 -0
- package/src/integrations.mjs +265 -0
- package/src/mcp-catalog.mjs +50 -0
- package/src/mcp.mjs +155 -0
- package/src/mirror.mjs +187 -0
- package/src/notify.mjs +86 -0
- package/src/open-url.mjs +34 -0
- package/src/parking-http.mjs +65 -0
- package/src/pins.mjs +190 -0
- package/src/pit-url.mjs +13 -0
- package/src/prd.mjs +341 -0
- package/src/pty.mjs +176 -0
- package/src/pwd.mjs +103 -0
- package/src/registry.mjs +37 -0
- package/src/release-install.mjs +191 -0
- package/src/runtime.mjs +161 -0
- package/src/selfupdate.mjs +215 -0
- package/src/serve.mjs +502 -0
- package/src/skills.mjs +93 -0
- package/src/tabs.mjs +144 -0
- package/src/templates.mjs +456 -0
- package/src/tools.mjs +231 -0
- package/src/trade.mjs +137 -0
- package/src/trust.mjs +712 -0
- package/src/tui.mjs +736 -0
- package/src/ui.mjs +49 -0
- package/src/uninstall.mjs +113 -0
- package/src/upgrade.mjs +217 -0
|
@@ -0,0 +1,256 @@
|
|
|
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 {
|
|
17
|
+
discoverUpstreams, fetchTlds, parseDnsPort, DEFAULT_REGISTRY_BASE, parkingAddress,
|
|
18
|
+
} from "./dns.mjs";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_DOH_PORT = 8053;
|
|
21
|
+
export const DOH_PATH = "/dns-query";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Guards on by default here, unlike the UDP bridge.
|
|
25
|
+
*
|
|
26
|
+
* The bridge listens on loopback and has one client, where rate limiting is
|
|
27
|
+
* pure cost. This is meant to be reachable, and an unprotected open resolver
|
|
28
|
+
* is found by scanners within hours of being published — so the safe
|
|
29
|
+
* configuration has to be the one you get by not thinking about it.
|
|
30
|
+
*
|
|
31
|
+
* The numbers are generous for a person and tight for a script: 20 queries a
|
|
32
|
+
* second sustained is far more than a browser produces and far less than a
|
|
33
|
+
* scraper wants.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_GUARDS = {
|
|
36
|
+
rateLimit: { perSecond: 20, burst: 40 },
|
|
37
|
+
ban: { baseMs: 60_000, factor: 2, maxMs: 24 * 60 * 60 * 1000 },
|
|
38
|
+
// Caps amplification. 1232 is the payload size the DNS flag day settled on
|
|
39
|
+
// as safe across the internet, so nothing legitimate loses anything.
|
|
40
|
+
maxResponseBytes: 1232,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read guard settings off the command line.
|
|
45
|
+
*
|
|
46
|
+
* `--no-guards` exists for running behind something that already limits, and
|
|
47
|
+
* is loud rather than silent: an unlimited open resolver is a decision, and
|
|
48
|
+
* the caller has to have typed it.
|
|
49
|
+
*/
|
|
50
|
+
export function parseGuardArgs(args = []) {
|
|
51
|
+
if (args.includes("--no-guards")) return { rateLimit: null, ban: null, maxResponseBytes: 0 };
|
|
52
|
+
const num = (flag, fallback) => {
|
|
53
|
+
const at = args.indexOf(flag);
|
|
54
|
+
if (at < 0) return fallback;
|
|
55
|
+
const value = Number(args[at + 1]);
|
|
56
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
rateLimit: {
|
|
60
|
+
perSecond: num("--rate", DEFAULT_GUARDS.rateLimit.perSecond),
|
|
61
|
+
burst: num("--burst", DEFAULT_GUARDS.rateLimit.burst),
|
|
62
|
+
},
|
|
63
|
+
ban: { ...DEFAULT_GUARDS.ban, baseMs: num("--ban-seconds", 60) * 1000 },
|
|
64
|
+
maxResponseBytes: num("--max-response", DEFAULT_GUARDS.maxResponseBytes),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the listen port off the command line.
|
|
70
|
+
*
|
|
71
|
+
* Same rule as the bridge's ports, by reusing the same parser: `dns install
|
|
72
|
+
* --port 1e3` is refused, so `doh --port 1e3` refusing it is one rule to learn
|
|
73
|
+
* rather than two. Bare `Number()` disagrees with that parser in both
|
|
74
|
+
* directions — it takes `1e3` and `0`, which it should not, and turns `abc`
|
|
75
|
+
* and a missing value into NaN, which reaches `listen()` as a crash.
|
|
76
|
+
*
|
|
77
|
+
* Returns the raw text back on failure so the caller can quote what was typed.
|
|
78
|
+
*/
|
|
79
|
+
export function parseDohPort(args = [], fallback = DEFAULT_DOH_PORT) {
|
|
80
|
+
const at = args.indexOf("--port");
|
|
81
|
+
if (at < 0) return { ok: true, port: fallback };
|
|
82
|
+
const raw = args[at + 1];
|
|
83
|
+
const port = parseDnsPort(raw);
|
|
84
|
+
return port === null ? { ok: false, raw } : { ok: true, port };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read a request body, refusing anything implausible for a DNS message. */
|
|
88
|
+
export function readBody(req, limit = 4096) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const chunks = [];
|
|
91
|
+
let size = 0;
|
|
92
|
+
req.on("data", (chunk) => {
|
|
93
|
+
size += chunk.length;
|
|
94
|
+
// Hung up on rather than buffered: the cap is the point, and a client
|
|
95
|
+
// sending megabytes to a DNS endpoint is not a client.
|
|
96
|
+
if (size > limit) {
|
|
97
|
+
reject(new Error("too large"));
|
|
98
|
+
req.destroy();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
chunks.push(chunk);
|
|
102
|
+
});
|
|
103
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
104
|
+
req.on("error", reject);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Who asked, as the proxy in front of us sees it.
|
|
110
|
+
*
|
|
111
|
+
* Behind a reverse proxy every request arrives from 127.0.0.1, so rate
|
|
112
|
+
* limiting on the socket address would put every client in one bucket — one
|
|
113
|
+
* abusive source would lock out everyone. The forwarded header is the only
|
|
114
|
+
* client identity available, and it is trustworthy exactly as far as the proxy
|
|
115
|
+
* is: fine when the proxy sets it, worthless if this is ever exposed directly,
|
|
116
|
+
* which is the other reason it must not be.
|
|
117
|
+
*/
|
|
118
|
+
export function clientAddress(req, { trustProxy = true } = {}) {
|
|
119
|
+
if (trustProxy) {
|
|
120
|
+
const forwarded = req.headers?.["x-forwarded-for"];
|
|
121
|
+
if (forwarded) return String(forwarded).split(",")[0].trim();
|
|
122
|
+
}
|
|
123
|
+
return req.socket?.remoteAddress || "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Mount the DoH handler on an http server. Returns { port, address, close }. */
|
|
127
|
+
export async function createDohServer({
|
|
128
|
+
port = DEFAULT_DOH_PORT,
|
|
129
|
+
host = "127.0.0.1",
|
|
130
|
+
registryBase = DEFAULT_REGISTRY_BASE,
|
|
131
|
+
path = DOH_PATH,
|
|
132
|
+
trustProxy = true,
|
|
133
|
+
handler = null,
|
|
134
|
+
onQuery = () => {},
|
|
135
|
+
...guards
|
|
136
|
+
} = {}) {
|
|
137
|
+
const applied = { ...DEFAULT_GUARDS, ...guards };
|
|
138
|
+
const handle = handler || createDohHandler({
|
|
139
|
+
registryBase,
|
|
140
|
+
upstreams: await discoverUpstreams(),
|
|
141
|
+
tldSet: new Set(await fetchTlds({ registryBase }).catch(() => [])),
|
|
142
|
+
parkingAddress: await parkingAddress().catch(() => null),
|
|
143
|
+
onQuery,
|
|
144
|
+
...applied,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const server = http.createServer(async (req, res) => {
|
|
148
|
+
const url = req.url || "/";
|
|
149
|
+
if (!url.split("?")[0].endsWith(path)) {
|
|
150
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
151
|
+
res.end("not here\n");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let body = null;
|
|
156
|
+
if (req.method === "POST") {
|
|
157
|
+
try {
|
|
158
|
+
body = await readBody(req);
|
|
159
|
+
} catch {
|
|
160
|
+
res.writeHead(413, { "content-type": "text/plain" });
|
|
161
|
+
res.end("query too large\n");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const answer = await handle({
|
|
167
|
+
method: req.method,
|
|
168
|
+
url,
|
|
169
|
+
body,
|
|
170
|
+
address: clientAddress(req, { trustProxy }),
|
|
171
|
+
}).catch(() => null);
|
|
172
|
+
|
|
173
|
+
if (!answer) {
|
|
174
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
175
|
+
res.end("resolver error\n");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
res.writeHead(answer.status, answer.headers);
|
|
179
|
+
res.end(answer.body);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
server.once("error", reject);
|
|
184
|
+
server.listen(port, host, () => {
|
|
185
|
+
const addr = server.address();
|
|
186
|
+
resolve({
|
|
187
|
+
port: addr.port,
|
|
188
|
+
address: addr.address,
|
|
189
|
+
url: `http://${addr.address}:${addr.port}${path}`,
|
|
190
|
+
guards: applied,
|
|
191
|
+
close: () => new Promise((done) => server.close(done)),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The reverse-proxy block that terminates TLS in front of this.
|
|
199
|
+
*
|
|
200
|
+
* Emitted rather than described because the two lines people miss are the two
|
|
201
|
+
* that matter: a DNS message is binary, so no charset and no gzip, and the
|
|
202
|
+
* client's address has to be forwarded or every client shares a rate-limit
|
|
203
|
+
* bucket.
|
|
204
|
+
*
|
|
205
|
+
* Port 80, not 443. `listen 443 ssl` without an `ssl_certificate` is a config
|
|
206
|
+
* nginx refuses to load at all — "no ssl_certificate is defined for the listen
|
|
207
|
+
* ... ssl directive" — so emitting the TLS form first is unusable by
|
|
208
|
+
* construction: nginx will not start with it, and `certbot --nginx` needs a
|
|
209
|
+
* loadable vhost to find before it can issue the certificate that would make it
|
|
210
|
+
* loadable. That is a cycle with no entry point.
|
|
211
|
+
*
|
|
212
|
+
* So this emits the half that stands on its own. certbot rewrites it in place,
|
|
213
|
+
* adding the 443 listeners, the certificate paths, and the redirect — which is
|
|
214
|
+
* also what leaves renewal owned by certbot rather than by whoever pasted this.
|
|
215
|
+
*
|
|
216
|
+
* Pass `tls: true` for the already-certified form, for a host provisioned some
|
|
217
|
+
* other way.
|
|
218
|
+
*/
|
|
219
|
+
export function nginxDohSite({ name, port = DEFAULT_DOH_PORT, path = DOH_PATH, tls = false }) {
|
|
220
|
+
const listeners = tls
|
|
221
|
+
? ["\tlisten 443 ssl;", "\tlisten [::]:443 ssl;"]
|
|
222
|
+
: ["\tlisten 80;", "\tlisten [::]:80;"];
|
|
223
|
+
const certificate = tls
|
|
224
|
+
? [`\tssl_certificate /etc/letsencrypt/live/${name}/fullchain.pem;`,
|
|
225
|
+
`\tssl_certificate_key /etc/letsencrypt/live/${name}/privkey.pem;`]
|
|
226
|
+
: [`\t# TLS is not here yet. Install this, then: certbot --nginx -d ${name}`,
|
|
227
|
+
"\t# certbot adds the 443 listeners, the certificate, and the redirect,",
|
|
228
|
+
"\t# and owns the renewal afterwards."];
|
|
229
|
+
return [
|
|
230
|
+
`# ${name} — DoH endpoint, written by \`moshcode doh --nginx\`.`,
|
|
231
|
+
"#",
|
|
232
|
+
"# TLS is terminated here on purpose. A resolver that manages its own",
|
|
233
|
+
"# certificate goes down when that certificate expires, and every machine",
|
|
234
|
+
"# pointed at it loses DNS at once.",
|
|
235
|
+
"server {",
|
|
236
|
+
...listeners,
|
|
237
|
+
`\tserver_name ${name};`,
|
|
238
|
+
"",
|
|
239
|
+
...certificate,
|
|
240
|
+
"",
|
|
241
|
+
`\tlocation ${path} {`,
|
|
242
|
+
`\t\tproxy_pass http://127.0.0.1:${port};`,
|
|
243
|
+
"\t\tproxy_set_header Host $host;",
|
|
244
|
+
"\t\t# Without this every client shares one rate-limit bucket, because",
|
|
245
|
+
"\t\t# behind a proxy they all arrive from 127.0.0.1.",
|
|
246
|
+
"\t\tproxy_set_header X-Forwarded-For $remote_addr;",
|
|
247
|
+
"\t\t# A DNS message is binary. Compressing it wastes CPU and some",
|
|
248
|
+
"\t\t# clients reject a gzipped application/dns-message outright.",
|
|
249
|
+
"\t\tgzip off;",
|
|
250
|
+
"\t}",
|
|
251
|
+
"}",
|
|
252
|
+
"",
|
|
253
|
+
].join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export { DNS_MESSAGE };
|
package/src/doh.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// DNS over HTTPS for Moshpit names (RFC 8484).
|
|
2
|
+
//
|
|
3
|
+
// The same resolver, in an envelope browsers and phones already speak. That is
|
|
4
|
+
// the whole reason it exists: a browser with Secure DNS enabled never asks the
|
|
5
|
+
// system resolver, so a machine running the bridge perfectly still fails to
|
|
6
|
+
// open a Moshpit name — `curl` resolves it and the browser does not. Pointing
|
|
7
|
+
// that browser at a DoH endpoint which knows both namespaces resolves the name
|
|
8
|
+
// *through* DoH instead of being defeated by it.
|
|
9
|
+
//
|
|
10
|
+
// It also reaches what the bridge cannot: phones, routers, and anyone else's
|
|
11
|
+
// machine. No daemon, no root, one URL in a settings field.
|
|
12
|
+
//
|
|
13
|
+
// The privacy trade is real and does not disappear by being encrypted. DoH
|
|
14
|
+
// hides lookups from the network and hands them, in full, to whoever runs the
|
|
15
|
+
// endpoint. That is a move of trust, not a removal of it, and it is why this
|
|
16
|
+
// keeps no per-query record of who asked what.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
addressAnswer, answerRecords, buildChainResponse, buildRecordResponse, buildResponse,
|
|
20
|
+
capResponse, clientKey, createBanList, resolveChain,
|
|
21
|
+
createRateLimiter, forwardQuery, isOurs, answerPolicy, parseQuery, refusalReason,
|
|
22
|
+
RECORD_TYPES, TYPE_A, TYPE_AAAA, DEFAULT_TTL, UDP_SAFE_BYTES,
|
|
23
|
+
} from "./dns.mjs";
|
|
24
|
+
|
|
25
|
+
/** What RFC 8484 says both directions are. */
|
|
26
|
+
export const DNS_MESSAGE = "application/dns-message";
|
|
27
|
+
|
|
28
|
+
/** Answers are cacheable for as long as the record is, and no longer. */
|
|
29
|
+
const MAX_QUERY_BYTES = 4096;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decode the DNS message out of a DoH request.
|
|
33
|
+
*
|
|
34
|
+
* GET carries it base64url in `?dns=`, POST carries it as the body. Both are
|
|
35
|
+
* required by the spec and clients differ: browsers mostly POST, some resolvers
|
|
36
|
+
* and every curl example use GET.
|
|
37
|
+
*/
|
|
38
|
+
export function decodeRequest({ method = "GET", url = "/", body = null } = {}) {
|
|
39
|
+
if (method === "POST") {
|
|
40
|
+
if (!body?.length) return { ok: false, status: 400, error: "empty body" };
|
|
41
|
+
if (body.length > MAX_QUERY_BYTES) return { ok: false, status: 413, error: "query too large" };
|
|
42
|
+
return { ok: true, message: Buffer.from(body) };
|
|
43
|
+
}
|
|
44
|
+
if (method !== "GET") return { ok: false, status: 405, error: "use GET or POST" };
|
|
45
|
+
|
|
46
|
+
const at = String(url).indexOf("?");
|
|
47
|
+
const params = new URLSearchParams(at >= 0 ? String(url).slice(at + 1) : "");
|
|
48
|
+
const encoded = params.get("dns");
|
|
49
|
+
if (!encoded) return { ok: false, status: 400, error: "missing ?dns=" };
|
|
50
|
+
try {
|
|
51
|
+
const message = Buffer.from(encoded, "base64url");
|
|
52
|
+
if (!message.length) return { ok: false, status: 400, error: "empty query" };
|
|
53
|
+
if (message.length > MAX_QUERY_BYTES) return { ok: false, status: 413, error: "query too large" };
|
|
54
|
+
return { ok: true, message };
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, status: 400, error: "?dns= is not base64url" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How long a caching client may keep this answer.
|
|
62
|
+
*
|
|
63
|
+
* Bounded by the record's own TTL, which is short on purpose: a Moshpit name's
|
|
64
|
+
* target changes the moment its owner repoints it, and a stale answer is the
|
|
65
|
+
* one failure nobody can debug from the outside.
|
|
66
|
+
*/
|
|
67
|
+
export function cacheControl(ttl = DEFAULT_TTL) {
|
|
68
|
+
return `max-age=${Math.max(0, Math.floor(ttl))}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A REFUSED (rcode 5) answer echoing the question. */
|
|
72
|
+
export function refusedMessage(query, message) {
|
|
73
|
+
const header = Buffer.alloc(12);
|
|
74
|
+
header.writeUInt16BE(query.id, 0);
|
|
75
|
+
// QR=1, AA=1, RD echoed, rcode 5.
|
|
76
|
+
header.writeUInt16BE(0x8400 | (query.recursionDesired ? 0x0100 : 0) | 5, 2);
|
|
77
|
+
header.writeUInt16BE(1, 4);
|
|
78
|
+
return Buffer.concat([header, message.subarray(12, query.questionEnd)]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A DoH resolver over the existing bridge logic.
|
|
83
|
+
*
|
|
84
|
+
* Returns a handler taking a plain request shape and giving back a plain
|
|
85
|
+
* response shape, so it can be mounted on any server and tested without one.
|
|
86
|
+
*/
|
|
87
|
+
export function createDohHandler({
|
|
88
|
+
registryBase,
|
|
89
|
+
fetchImpl,
|
|
90
|
+
upstreams = [],
|
|
91
|
+
tldSet = null,
|
|
92
|
+
parkingAddress = null,
|
|
93
|
+
ttl = DEFAULT_TTL,
|
|
94
|
+
rateLimit = null,
|
|
95
|
+
ban = null,
|
|
96
|
+
maxResponseBytes = 0,
|
|
97
|
+
forwardTimeoutMs = 3000,
|
|
98
|
+
onQuery = () => {},
|
|
99
|
+
} = {}) {
|
|
100
|
+
const limiter = rateLimit ? createRateLimiter(rateLimit) : null;
|
|
101
|
+
const bans = ban ? createBanList(ban) : null;
|
|
102
|
+
|
|
103
|
+
return async function handle({ method = "GET", url = "/", body = null, address = "" } = {}) {
|
|
104
|
+
const decoded = decodeRequest({ method, url, body });
|
|
105
|
+
if (!decoded.ok) {
|
|
106
|
+
return { status: decoded.status, headers: { "content-type": "text/plain" }, body: decoded.error };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const query = parseQuery(decoded.message);
|
|
110
|
+
if (!query) {
|
|
111
|
+
return { status: 400, headers: { "content-type": "text/plain" }, body: "not a DNS query" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Grouped by /64 for v6, as on the UDP side: a single address is free to
|
|
115
|
+
// change, so per-address limits are defeated by incrementing it.
|
|
116
|
+
const source = clientKey(address);
|
|
117
|
+
const refused = () => ({
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: { "content-type": DNS_MESSAGE, "cache-control": "no-store" },
|
|
120
|
+
// A DNS-level REFUSED rather than an HTTP error: clients understand the
|
|
121
|
+
// former and retry elsewhere, where an HTTP 429 is frequently just an
|
|
122
|
+
// exception in a stack that expected a DNS message.
|
|
123
|
+
//
|
|
124
|
+
// Built here rather than through buildResponse, which only speaks the
|
|
125
|
+
// answer/NODATA/NXDOMAIN vocabulary. NXDOMAIN would be a lie with a
|
|
126
|
+
// cost: it says the name does not exist, which a resolver may cache and
|
|
127
|
+
// apply to every other type, so a rate-limited client would go on
|
|
128
|
+
// failing after the limit lifted.
|
|
129
|
+
body: refusedMessage(query, decoded.message),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (refusalReason(query) || bans?.banned(source)) return refused();
|
|
133
|
+
if (limiter && !limiter.allow(source)) {
|
|
134
|
+
bans?.strike(source);
|
|
135
|
+
return refused();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Not ours: relay to an upstream exactly as the UDP path does.
|
|
139
|
+
if (!isOurs(query.name, tldSet)) {
|
|
140
|
+
if (!upstreams.length) return refused();
|
|
141
|
+
let relayed = null;
|
|
142
|
+
for (const upstream of upstreams) {
|
|
143
|
+
relayed = await forwardQuery(decoded.message, upstream, { timeoutMs: forwardTimeoutMs });
|
|
144
|
+
if (relayed) break;
|
|
145
|
+
}
|
|
146
|
+
onQuery({ name: query.name, type: query.type, forwarded: true });
|
|
147
|
+
if (!relayed) {
|
|
148
|
+
return { status: 502, headers: { "content-type": "text/plain" }, body: "upstream did not answer" };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
status: 200,
|
|
152
|
+
headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) },
|
|
153
|
+
body: maxResponseBytes ? capResponse(relayed, query, maxResponseBytes) : relayed,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// CNAME, MX and TXT come from the record set; addresses still come from
|
|
158
|
+
// `target`. Same split as the UDP path, and it has to stay the same split:
|
|
159
|
+
// a name that resolves over the bridge and not over DoH is the failure mode
|
|
160
|
+
// this endpoint exists to remove.
|
|
161
|
+
const wanted = RECORD_TYPES.get(query.type);
|
|
162
|
+
if (wanted) {
|
|
163
|
+
const found = await answerRecords(query.name, { registryBase, fetchImpl, type: wanted })
|
|
164
|
+
.catch(() => ({ exists: false, records: [] }));
|
|
165
|
+
onQuery({ name: query.name, type: query.type, records: found.records.length });
|
|
166
|
+
return {
|
|
167
|
+
status: 200,
|
|
168
|
+
headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) },
|
|
169
|
+
body: buildRecordResponse(query, decoded.message, found.records, {
|
|
170
|
+
ttl, exists: found.exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
|
|
171
|
+
}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA;
|
|
176
|
+
if (!wantsAddress) {
|
|
177
|
+
const policy = await answerPolicy(query.name, {
|
|
178
|
+
registryBase, fetchImpl, parkingAddress, wantsAddress: false,
|
|
179
|
+
}).catch(() => ({ exists: false, address: null }));
|
|
180
|
+
onQuery({ name: query.name, type: query.type, address: null });
|
|
181
|
+
return {
|
|
182
|
+
status: 200,
|
|
183
|
+
headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) },
|
|
184
|
+
body: buildResponse(query, decoded.message, null, ttl, policy.exists),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The same plan the UDP path follows, for the same reason the split above
|
|
189
|
+
// has to stay a split: a published record or a hostname target must resolve
|
|
190
|
+
// identically here, or this endpoint reintroduces the gap it exists to close.
|
|
191
|
+
const plan = await addressAnswer(query.name, {
|
|
192
|
+
registryBase, fetchImpl, parkingAddress, wantsV6: query.type === TYPE_AAAA,
|
|
193
|
+
}).catch(() => ({ exists: false, kind: "nxdomain", records: [], address: null, cname: null }));
|
|
194
|
+
|
|
195
|
+
const answer = async () => {
|
|
196
|
+
if (plan.kind === "records") {
|
|
197
|
+
return buildRecordResponse(query, decoded.message, plan.records, {
|
|
198
|
+
ttl, exists: plan.exists, limit: maxResponseBytes || UDP_SAFE_BYTES,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (plan.kind === "chain") {
|
|
202
|
+
const addresses = await resolveChain(plan.cname, {
|
|
203
|
+
upstreams, wantsV6: query.type === TYPE_AAAA, timeoutMs: forwardTimeoutMs,
|
|
204
|
+
});
|
|
205
|
+
return buildChainResponse(query, decoded.message, { cname: plan.cname, addresses, ttl });
|
|
206
|
+
}
|
|
207
|
+
return buildResponse(query, decoded.message, plan.address, ttl, plan.exists);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const encoded = await answer();
|
|
211
|
+
onQuery({ name: query.name, type: query.type, address: plan.address || plan.cname || null });
|
|
212
|
+
return {
|
|
213
|
+
status: 200,
|
|
214
|
+
headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) },
|
|
215
|
+
body: encoded,
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
}
|