Files
archy/docker/dojobay/server/dns.ts
T

183 lines
8.6 KiB
TypeScript

// =============================================================================
// TXT record lookups over Tor, for verified operator domains.
//
// A Tor-only instance has no ordinary path to a TXT record: Tor's SOCKS
// interface resolves names but cannot fetch arbitrary record types. So we ask
// public DNS-over-HTTPS resolvers, tunnelling the HTTPS through the same SOCKS
// proxy the probes use.
//
// Two deliberate choices, because a resolver's answer decides whether a
// verified badge appears and a lying resolver could mint one:
//
// 1. Several independent resolvers are queried and a fixed number must agree
// before a domain is treated as verified (DOH_AGREEMENT, default 2).
// 2. "Could not reach enough resolvers" is reported as INCONCLUSIVE, never as
// a failure, so a Tor hiccup cannot strip a badge from an honest operator.
//
// The HTTPS-over-Tor fetch here duplicates a little of updates.mjs on purpose:
// that module is on the self-update path, which has never been exercised on real
// hardware, and refactoring it to share code is not a risk worth taking for a
// feature that only reads DNS.
// =============================================================================
import tls from "node:tls";
import { socks5Connect } from "../scripts/update.mjs";
import type { ProbeCfg } from "../types.js";
/** Transport settings a lookup needs; the caller may supply a subset. */
type LookupCfg = Partial<ProbeCfg> & {
/**
* An extra certificate authority to trust for this lookup, and nothing else.
*
* Exists so the self-test can run the whole path — SOCKS, TLS, HTTP, DoH JSON
* — against a mock resolver holding a self-signed certificate, WITHOUT
* reaching for NODE_TLS_REJECT_UNAUTHORIZED, which switches validation off
* for the entire process and every other connection made while it is set.
* Certificate validation stays on here; the test simply supplies the anchor
* that makes its own certificate valid.
*/
tlsCa?: string | Buffer | Array<string | Buffer>;
};
interface ResolverAnswer { host: string; records: string[] }
export interface TxtLookup {
records: string[];
answered: number;
byResolver: ResolverAnswer[];
errors: string[];
}
export interface TxtAgreement {
ok: boolean;
/** True when too few resolvers replied to draw any conclusion. */
inconclusive: boolean;
/** Absent when the lookup threw before any resolver could be counted. */
answered?: number;
agreed?: number;
error?: string;
}
// Resolvers use different JSON paths but the same response shape.
const RESOLVERS: { host: string; path: string }[] = [
{ host: "cloudflare-dns.com", path: "/dns-query" },
{ host: "dns.quad9.net", path: "/dns-query" },
{ host: "dns.google", path: "/resolve" },
];
export const DOH_AGREEMENT = Math.max(1, +(process.env.DOH_AGREEMENT || 2));
const MAX_BODY = 64 * 1024; // a TXT answer is tiny; cap the read
function resolverList(): { host: string; path: string }[] {
const only = (process.env.DOH_RESOLVERS || "").trim();
if (!only) return RESOLVERS;
const wanted = only.split(",").map((s) => s.trim()).filter(Boolean);
return RESOLVERS.filter((r) => wanted.includes(r.host));
}
// One HTTPS GET through the Tor SOCKS proxy, returning the response body as
// text. Deliberately minimal: no redirects (a resolver that redirects is not
// one we want), and a hard body cap.
async function httpsGetOverTor(host: string, path: string,
{ proxyHost, proxyPort, timeoutMs = 20000, tlsCa }: LookupCfg): Promise<string> {
const raw = await socks5Connect(proxyHost, proxyPort, host, 443, timeoutMs);
return new Promise<string>((resolve, reject) => {
let done = false;
const finish = (fn: (a?: any) => void, arg?: any) => { if (!done) { done = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(arg); } };
const timer = setTimeout(() => finish(reject, new Error("timeout")), timeoutMs);
const socket = tls.connect({ socket: raw, servername: host, ...(tlsCa ? { ca: tlsCa } : {}) }, () => {
socket.write(
`GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-domain-check\r\n` +
`Accept: application/dns-json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n`);
});
const chunks: Buffer[] = [];
let size = 0;
socket.on("data", (d: Buffer) => {
size += d.length;
if (size > MAX_BODY) return finish(reject, new Error("response too large"));
chunks.push(d);
});
socket.on("error", (e: Error) => finish(reject, e));
socket.on("close", () => {
if (done) return;
try {
const all = Buffer.concat(chunks);
const headEnd = all.indexOf("\r\n\r\n");
if (headEnd < 0) return finish(reject, new Error("malformed reply"));
const headText = all.subarray(0, headEnd).toString("latin1");
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
if (!m) return finish(reject, new Error("malformed reply"));
if (+m[1] !== 200) return finish(reject, new Error("HTTP " + m[1]));
let body = all.subarray(headEnd + 4);
if (/transfer-encoding:\s*chunked/i.test(headText)) {
const parts: Buffer[] = []; let p = 0;
for (;;) {
const nl = body.indexOf("\r\n", p);
if (nl < 0) break;
const n = parseInt(body.subarray(p, nl).toString("latin1"), 16);
if (!n) break;
parts.push(body.subarray(nl + 2, nl + 2 + n));
p = nl + 2 + n + 2;
}
body = Buffer.concat(parts);
}
finish(resolve, body.toString("utf8"));
} catch (e) { finish(reject, e); }
});
});
}
// A DoH JSON answer gives TXT data as a quoted string, and a long record as
// several quoted strings that must be concatenated. Normalise both to one line.
export function parseTxtAnswer(json: string): string[] | null {
let doc: any;
try { doc = JSON.parse(json); } catch { return null; }
if (typeof doc !== "object" || doc === null) return null;
if (doc.Status === 3) return []; // NXDOMAIN: no records
if (doc.Status !== 0) return null; // SERVFAIL etc: no answer
const answers = Array.isArray(doc.Answer) ? doc.Answer : [];
return answers
.filter((a: any) => a && (a.type === 16 || a.type === undefined))
.map((a: any) => String(a.data || ""))
.map((d: string) => (d.includes('"') ? (d.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)).join("") : d))
.map((d: string) => d.trim())
.filter(Boolean);
}
// Look up TXT records for `name` across the resolvers. Returns
// { records, answered, byResolver, errors } where `records` is the set of
// records seen and `answered` counts resolvers that gave a usable answer.
export async function lookupTxt(name: string, cfg: LookupCfg = {}): Promise<TxtLookup> {
const resolvers = resolverList();
const q = `?name=${encodeURIComponent(name)}&type=TXT`;
const results = await Promise.allSettled(resolvers.map(async (r) => {
const body = await httpsGetOverTor(r.host, r.path + q, cfg);
const recs = parseTxtAnswer(body);
if (recs === null) throw new Error("resolver returned no usable answer");
return { host: r.host, records: recs };
}));
const byResolver: ResolverAnswer[] = [];
const errors: string[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i]; // a local, so the union narrows
if (r.status === "fulfilled") byResolver.push(r.value);
else errors.push(`${resolvers[i].host}: ${r.reason?.message || "failed"}`);
}
const records = [...new Set(byResolver.flatMap((r) => r.records))];
return { records, answered: byResolver.length, byResolver, errors };
}
// Do at least DOH_AGREEMENT resolvers see a record satisfying `predicate`?
// Distinguishes "not there" from "we could not tell".
export async function txtRecordAgreed(name: string, predicate: (r: string) => boolean,
cfg: LookupCfg = {}): Promise<TxtAgreement> {
const { answered, byResolver, errors, records } = await lookupTxt(name, cfg);
if (answered < DOH_AGREEMENT) {
return { ok: false, inconclusive: true, answered, agreed: 0,
error: `only ${answered} of ${DOH_AGREEMENT} required resolvers answered (${errors.join("; ") || "no detail"})` };
}
const agreed = byResolver.filter((r) => r.records.some(predicate)).length;
if (agreed >= DOH_AGREEMENT) return { ok: true, inconclusive: false, answered, agreed };
return { ok: false, inconclusive: false, answered, agreed,
error: records.length
? `${agreed} of ${DOH_AGREEMENT} required resolvers saw a matching record; ${records.length} TXT record(s) present but not matching`
: `no TXT record found at ${name}` };
}