Created
August 11, 2025 20:40
-
-
Save jetsanix/aa2f31fa933492c562e3f0fd16b363ec to your computer and use it in GitHub Desktop.
A DNS-over-HTTPS (DoH) reverse proxy, with special handling for Ethereum (.eth) and Solana (.sol) domains for NGINX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * NJS script to implement a DNS‑over‑HTTPS (DoH) reverse proxy with | |
| * special handling for Ethereum (.eth) and Solana (.sol) domains. | |
| * | |
| * When a request hits `/dns‑query` the `handle()` function determines | |
| * the queried name and record type. Standard RFC 8484 GET requests | |
| * include a `?dns=` parameter containing a Base64Url encoded DNS | |
| * message, while the JSON API uses `?name=` and `?type=` parameters | |
| * for textual queries. POST requests carry a raw DNS message with | |
| * `Content‑Type: application/dns‑message`【742873960042857†L96-L108】. The | |
| * request body can be accessed via `r.requestBuffer` because | |
| * `r.requestBody` has been removed and replaced by `r.requestBuffer` | |
| * and `r.requestText`【835753156590986†L259-L267】. | |
| * | |
| * For `.eth` names we forward the query to `https://dns.eth.limo/dns-query`. That | |
| * resolver only supports TXT lookups. For `.sol` names we obtain | |
| * an IPNS record using Bonfida’s SNS SDK proxy (see example output | |
| * 【663671744350547†L0-L3】) and then respond with either a JSON DNS response | |
| * or a binary DNS packet containing a TXT record with a | |
| * `dnslink=/ipns/…` value. All other queries are forwarded to | |
| * Cloudflare’s DoH resolver at `https://cloudflare-dns.com/dns-query`. | |
| */ | |
| /* Base64Url decoding helper. RFC 8484 uses URL safe base64 for GET | |
| * requests; padding may be omitted. This function normalises the | |
| * string and decodes it into a Buffer. */ | |
| function base64UrlDecode(str) { | |
| // Replace URL safe characters | |
| str = str.replace(/-/g, "+").replace(/_/g, "/"); | |
| // Pad to multiple of 4 | |
| while (str.length % 4) { | |
| str += "="; | |
| } | |
| return Buffer.from(str, "base64"); | |
| } | |
| /* Parse the first question from a DNS message. Returns an object | |
| * containing the name, type and the offset to the end of the question | |
| * (start of the answer section). If the packet is malformed the | |
| * function returns null. */ | |
| function parseDNSQuestion(buf) { | |
| // DNS header is 12 bytes | |
| if (buf.length < 12) { | |
| return null; | |
| } | |
| let offset = 12; | |
| const labels = []; | |
| while (offset < buf.length) { | |
| const len = buf[offset]; | |
| offset++; | |
| if (len === undefined) { | |
| return null; | |
| } | |
| if (len === 0) { | |
| // End of name | |
| break; | |
| } | |
| if (offset + len > buf.length) { | |
| return null; | |
| } | |
| const label = buf.slice(offset, offset + len).toString(); | |
| labels.push(label); | |
| offset += len; | |
| } | |
| if (labels.length === 0) { | |
| return null; | |
| } | |
| // Ensure there is space for QTYPE and QCLASS | |
| if (offset + 4 > buf.length) { | |
| return null; | |
| } | |
| const name = labels.join("."); | |
| const qtype = buf.readUInt16BE(offset); | |
| const qEnd = offset + 4; | |
| return { name: name, type: qtype, qEnd: qEnd }; | |
| } | |
| /* Construct a DNS response for a TXT record. The response copies | |
| * the ID and RD flag from the request and sets QR=1 (response). | |
| * Answer count is always one. The answer uses name compression | |
| * via a pointer to the question section (0xc00c) and encodes the | |
| * supplied TXT value as a single character string. TTL is fixed at | |
| * 60 seconds. */ | |
| function createDnsResponse(reqBuf, txtValue) { | |
| const q = parseDNSQuestion(reqBuf); | |
| if (!q) { | |
| return null; | |
| } | |
| // Build response header | |
| const header = Buffer.alloc(12); | |
| // Copy transaction ID | |
| header.writeUInt16BE(reqBuf.readUInt16BE(0), 0); | |
| // Copy RD flag from request; set QR=1 (bit 15) | |
| const reqFlags = reqBuf.readUInt16BE(2); | |
| let respFlags = 0x8000; // QR=1, all other bits zero | |
| if (reqFlags & 0x0100) { | |
| respFlags |= 0x0100; // propagate RD | |
| } | |
| header.writeUInt16BE(respFlags, 2); | |
| // QDCOUNT = 1, ANCOUNT = 1, NSCOUNT=0, ARCOUNT=0 | |
| header.writeUInt16BE(1, 4); | |
| header.writeUInt16BE(1, 6); | |
| header.writeUInt16BE(0, 8); | |
| header.writeUInt16BE(0, 10); | |
| // Copy question section directly from request (excluding header) | |
| const question = reqBuf.slice(12, q.qEnd); | |
| // Build answer | |
| const txtBuf = Buffer.from(txtValue); | |
| const rdlen = txtBuf.length + 1; // one byte for length | |
| const answer = Buffer.alloc(2 + 2 + 2 + 4 + 2 + 1 + txtBuf.length); | |
| let off = 0; | |
| // Name pointer to offset 12 (0xC00C) | |
| answer[off++] = 0xc0; | |
| answer[off++] = 0x0c; | |
| // Type TXT (16) | |
| answer.writeUInt16BE(0x0010, off); | |
| off += 2; | |
| // Class IN (1) | |
| answer.writeUInt16BE(0x0001, off); | |
| off += 2; | |
| // TTL (60 seconds) | |
| answer.writeUInt32BE(60, off); | |
| off += 4; | |
| // RDLENGTH | |
| answer.writeUInt16BE(rdlen, off); | |
| off += 2; | |
| // TXT length and data | |
| answer[off++] = txtBuf.length; | |
| txtBuf.copy(answer, off); | |
| // Concatenate to form complete response | |
| return Buffer.concat([header, question, answer]); | |
| } | |
| /* Forward a query to Cloudflare’s public DoH resolver. Supports both | |
| * GET (including RFC 8484 and JSON API) and POST. The raw query | |
| * string is accessed via `$args` so that unrecognised parameters are | |
| * preserved. */ | |
| async function forwardToDefault(r, method, dnsBuf) { | |
| const upstream = "https://cloudflare-dns.com/dns-query"; | |
| let url; | |
| let options; | |
| if (method === "GET") { | |
| const args = r.variables.args; | |
| url = upstream + (args ? "?" + args : ""); | |
| options = { method: "GET", headers: {}, verify: false }; | |
| const accept = r.headersIn["accept"]; | |
| if (accept) options.headers["Accept"] = accept; | |
| } else { | |
| url = upstream; | |
| options = { | |
| method: "POST", | |
| body: dnsBuf, | |
| headers: { "Content-Type": "application/dns-message" }, | |
| verify: false, | |
| }; | |
| const accept = r.headersIn["accept"]; | |
| if (accept) options.headers["Accept"] = accept; | |
| } | |
| const res = await ngx.fetch(url, options); | |
| const body = await res.arrayBuffer(); | |
| const ct = res.headers.get("Content-Type") || ""; | |
| r.headersOut["Content-Type"] = ct; | |
| r.return(res.status, Buffer.from(body)); | |
| } | |
| /* Forward a query to the Ethereum Name Service DoH resolver. ENS | |
| * supports only TXT queries. GET requests are converted into | |
| * `name` and `type` parameters. POST requests simply proxy the | |
| * binary DNS message. */ | |
| async function forwardToEth(r, method, name, dnsBuf) { | |
| const upstream = "https://dns.eth.limo/dns-query"; | |
| let url; | |
| let options; | |
| if (method === "GET") { | |
| // Always request TXT | |
| url = `${upstream}?name=${encodeURIComponent(name)}&type=TXT`; | |
| options = { method: "GET", headers: {}, verify: false }; | |
| const accept = r.headersIn["accept"]; | |
| if (accept) options.headers["Accept"] = accept; | |
| } else { | |
| url = upstream; | |
| options = { | |
| method: "POST", | |
| body: dnsBuf, | |
| headers: { "Content-Type": "application/dns-message" }, | |
| verify: false, | |
| }; | |
| const accept = r.headersIn["accept"]; | |
| if (accept) options.headers["Accept"] = accept; | |
| } | |
| const res = await ngx.fetch(url, options); | |
| const body = await res.arrayBuffer(); | |
| const ct = res.headers.get("Content-Type") || ""; | |
| r.headersOut["Content-Type"] = ct; | |
| r.return(res.status, Buffer.from(body)); | |
| } | |
| /* Handle a `.sol` query. The domain portion without the `.sol` suffix | |
| * is used to look up the IPNS record via Bonfida’s SNS SDK proxy. | |
| * If the client requested the binary RFC 8484 API (POST or accepts | |
| * `application/dns-message`), the result is encoded into a DNS | |
| * message using createDnsResponse(); otherwise a JSON response is | |
| * built. */ | |
| async function handleSol(r, method, name, dnsBuf) { | |
| const base = name.replace(/_dnslink\./, "").replace(/\.sol$/, ""); | |
| const apiUrl = `https://sns-sdk-proxy.bonfida.workers.dev/record-v2/${base}/IPNS`; | |
| // Fetch IPNS record | |
| const apiRes = await ngx.fetch(apiUrl, { method: "GET", verify: false }); | |
| let ipns = ""; | |
| try { | |
| const json = await apiRes.json(); | |
| if (json && json.result && json.result.deserialized) { | |
| ipns = json.result.deserialized; | |
| } | |
| } catch (e) { | |
| ipns = ""; | |
| } | |
| // Strip scheme | |
| const cid = ipns.replace(/^ipns:\/\//, ""); | |
| const dnslink = `dnslink=/ipns/${cid}`; | |
| const accept = r.headersIn["accept"] || ""; | |
| if (method === "POST" || accept.includes("application/dns-message")) { | |
| const respBuf = createDnsResponse(dnsBuf, dnslink); | |
| if (!respBuf) { | |
| return r.return(500, "Failed to build DNS response"); | |
| } | |
| r.headersOut["Content-Type"] = "application/dns-message"; | |
| r.return(200, respBuf); | |
| } else { | |
| const jsonResp = { | |
| Status: 0, | |
| TC: false, | |
| RD: true, | |
| RA: false, | |
| AD: false, | |
| CD: false, | |
| Question: [{ name: name + ".", type: 16 }], | |
| Answer: [{ name: name, type: 16, TTL: 60, data: dnslink }], | |
| Authority: [], | |
| Additional: [], | |
| }; | |
| r.headersOut["Content-Type"] = "application/dns-json"; | |
| r.return(200, JSON.stringify(jsonResp, null, 2) + "\n"); | |
| } | |
| } | |
| /* Main content handler. Determines the domain and record type, then | |
| * routes the query appropriately. */ | |
| async function handle(r) { | |
| const method = r.method; | |
| let name; | |
| let qtype; | |
| let dnsBuf; | |
| // Determine name and type based on GET or POST | |
| if (method === "GET") { | |
| if (r.args.dns) { | |
| // RFC 8484 GET: decode Base64Url DNS packet | |
| dnsBuf = base64UrlDecode(r.args.dns); | |
| const q = parseDNSQuestion(dnsBuf); | |
| if (!q) { | |
| return r.return(400, "Malformed DNS message"); | |
| } | |
| name = q.name; | |
| qtype = q.type; | |
| } else if (r.args.name) { | |
| // JSON API: use name/type parameters | |
| name = r.args.name; | |
| qtype = parseInt(r.args.type || "1", 10) || 1; | |
| } else { | |
| return r.return(400, "Missing name"); | |
| } | |
| } else if (method === "POST") { | |
| // Body must be present in memory; client_body_buffer_size limits apply | |
| dnsBuf = r.requestBuffer; | |
| if (!dnsBuf) { | |
| return r.return(400, "Missing request body"); | |
| } | |
| const q = parseDNSQuestion(dnsBuf); | |
| if (!q) { | |
| return r.return(400, "Malformed DNS message"); | |
| } | |
| name = q.name; | |
| qtype = q.type; | |
| } else { | |
| return r.return(405, "Method Not Allowed"); | |
| } | |
| // Remove trailing dot if present | |
| name = name.replace(/\.$/, ""); | |
| // Route based on suffix | |
| if (name.endsWith(".eth")) { | |
| await forwardToEth(r, method, name, dnsBuf); | |
| return; | |
| } | |
| if (name.endsWith(".sol")) { | |
| await handleSol(r, method, name, dnsBuf); | |
| return; | |
| } | |
| // All other domains | |
| await forwardToDefault(r, method, dnsBuf); | |
| } | |
| export default { handle }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment