Skip to content

Instantly share code, notes, and snippets.

@jetsanix
Created August 11, 2025 20:40
Show Gist options
  • Save jetsanix/aa2f31fa933492c562e3f0fd16b363ec to your computer and use it in GitHub Desktop.
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
/*
* 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