/* * This file is part of a proprietary work. * * Copyright (c) 2025-2026 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. * You may not use this file except in compliance with the License. * Unauthorized use, copying, modification, or distribution is strictly prohibited. * * This file is not licensed under the AGPLv3. */ import * as dgram from "dgram"; import * as dns from "dns-packet"; import NodeCache from "node-cache"; import { createHash } from "crypto"; import { eq, and, gt, or, inArray, desc } from "drizzle-orm"; import { db, primaryDb, exitNodes, loginPage, newts, resources, sites, targets, dnsChallenge, domains } from "@server/db"; import { privateConfig as config } from "#private/lib/config"; import * as dnsResolver from "dns"; import logger from "@server/logger"; import { listExitNodes } from "../exitNodes"; import { rateLimitService } from "../rateLimit"; type DNSRecord = { id: number; zone: string; name: string; type: string; value: string; ttl: number; priority: number; enabled: boolean; }; export class AuthoritativeDNSServer { private cache: NodeCache; private server: dgram.Socket; private port: number; private inFlightLookups: Map> = new Map(); // In-memory set of all base domains we are authoritative for. // Loaded at startup and refreshed every 60s so findAuthoritativeZone // never hits the database once the set is warm. private allDomains: Set = new Set(); private domainRefreshInterval: NodeJS.Timeout | null = null; // Cache for per-queryName zone resolution and SOA records private authoritativeDomainCache: NodeCache = new NodeCache({ stdTTL: 300, checkperiod: 60 }); private soaCache: NodeCache = new NodeCache({ stdTTL: 300, checkperiod: 60 }); constructor(port: number, cacheOptions: NodeCache.Options = {}) { // Initialize cache with default TTL of 5 minutes this.cache = new NodeCache({ stdTTL: 300, checkperiod: 60, ...cacheOptions }); this.port = port; this.server = dgram.createSocket("udp4"); this.setupServer(); } private setupServer(): void { this.server.on( "message", async (msg: Buffer, rinfo: dgram.RemoteInfo) => { try { await this.handleQuery(msg, rinfo); } catch (error) { logger.error("Error handling DNS query:", error); } } ); this.server.on("error", (err: Error) => { logger.error("DNS server error:", err); }); this.server.on("listening", () => { const address = this.server.address(); logger.info( `DNS server listening on ${address?.address}:${address?.port}` ); }); } private async handleQuery( msg: Buffer, rinfo: dgram.RemoteInfo ): Promise { let packet: dns.Packet; try { packet = dns.decode(msg); } catch (error) { logger.error("Failed to decode DNS packet:", error); return; } if (!packet.questions || packet.questions.length === 0) { return; } const question = packet.questions[0]; const dnsRateLimit = config.getRawConfig().dns!.rate_limit; if (dnsRateLimit.enabled) { const rateLimitResult = await rateLimitService.checkRateLimit( rinfo.address, question.type, dnsRateLimit.max_requests, dnsRateLimit.max_requests_per_query_type, dnsRateLimit.window_ms ); if (rateLimitResult.isLimited) { logger.warn( `DNS rate limit exceeded for ${rinfo.address} (${question.type}). Reason=${rateLimitResult.reason}, totalHits=${rateLimitResult.totalHits}` ); // REFUSED (rcode=5) indicates a policy refusal by this nameserver. this.sendResponse(packet, [], rinfo, false, 5, []); return; } } let queryName = question.name.toLowerCase(); const originalQueryName = queryName; const queryType = question.type; let baseDomain: string | null; logger.info( `DNS Query: ${queryName} ${queryType} from ${rinfo.address}:${rinfo.port}` ); // TODO: DO WE NEED SOME PROTECTION HERE TO PREVENT ABUSIVE QUERIES? (e.g., rate limiting, query validation, etc.) // Special handling for site subnet lookups: {niceId}.{site_extension} const siteExtension = config.getRawConfig().dns!.site_extension; const siteSubnetSuffix = `.${siteExtension}`; const normalizedQueryName = originalQueryName.replace(/\.$/, ""); if ( normalizedQueryName.endsWith(siteSubnetSuffix) && !normalizedQueryName .slice(0, -siteSubnetSuffix.length) .includes(".") ) { const newtId = normalizedQueryName.slice( 0, -siteSubnetSuffix.length ); if (newtId) { logger.info(`Site subnet lookup for newtId: ${newtId}`); const subnetIp = await this.getSiteSubnetIpByNewtId(newtId); if (subnetIp && queryType === "A") { const answer: dns.Answer = { name: originalQueryName, type: "A", ttl: 60, data: subnetIp }; this.sendResponse(packet, [answer], rinfo, true, 0, []); } else { logger.info(`No site found for newtId: ${newtId}`); this.sendResponse(packet, [], rinfo, true, 3, []); } return; } } // Special handling for cname.pangolin.net domains const cnameExtension = config.getRawConfig().dns!.cname_extension; const cnameAlternateExtensions = config.getRawConfig().dns!.cname_alternate_extensions ?? []; const nameserverName = config.getRawConfig().dns!.nameserver_name; const matchedCnameAlternateExtension = cnameAlternateExtensions.find( (ext: string) => originalQueryName.endsWith(`.${ext}`) ); const matchesNameserverName = originalQueryName.endsWith( `.${nameserverName}.` ); if ( originalQueryName.endsWith(`.${cnameExtension}`) || matchedCnameAlternateExtension !== undefined || matchesNameserverName ) { // The zone this query actually arrived under (used below for the // SOA/NS answers), as opposed to `actualDomain`, which is an // unrelated customer domain this synthetic CNAME name happens to // resolve to. const cnameZone = matchedCnameAlternateExtension ?? (matchesNameserverName ? nameserverName : cnameExtension); // Extract domainId using config values let domainId = queryName.replace( new RegExp(`\\.${cnameExtension}\\.?$`), "" ); for (const ext of cnameAlternateExtensions) { domainId = domainId.replace(new RegExp(`\\.${ext}\\.?$`), ""); } domainId = domainId .replace(new RegExp(`\\.${nameserverName}\\.?$`), "") .replace(/^_acme-challenge\./, ""); logger.info(`CNAME domain request for domain ID: ${domainId}`); const actualDomain = await this.getDomainByDomainId(domainId); if (!actualDomain) { logger.info(`NXDOMAIN for CNAME domain ID: ${domainId}`); this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative for unknown CNAME domain return; } queryName = actualDomain; // Use the actual domain for resource-record lookups // IMPORTANT: baseDomain must stay a real ancestor of // originalQueryName (cnameZone), NOT actualDomain. SOA/NS // answers below use `name: baseDomain` as the RRset owner, and // if that owner isn't provably related to the queried name, // resolvers reject the response as bogus (observed: 8.8.8.8 // returning SERVFAIL with EDE "Unexpected /soa in // received ANSWER") instead of accepting it - which broke // Let's Encrypt's DNS-01 validation here, not stale caching. baseDomain = cnameZone; } else { // Find authoritative zone for this query baseDomain = await this.findAuthoritativeZone(queryName); if (!baseDomain) { // Before giving up, check if there are static records for this name const staticRecords = this.getStaticRecords( queryName, queryType ); if (staticRecords.length > 0) { logger.info( `Serving ${staticRecords.length} static record(s) for ${queryName}` ); const answers = this.convertRecordsToAnswers( staticRecords, originalQueryName, queryType ); this.sendResponse(packet, answers, rinfo, true, 0, []); return; } logger.info( `NXDOMAIN: Not authoritative for zone: ${queryName}` ); this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative return; } } // Special handling for _acme-challenge CNAME requests if ( (queryType === "TXT" || queryType === "CNAME") && originalQueryName.startsWith("_acme-challenge") ) { logger.info(`Handling ACME challenge for ${queryName}`); const txtRecords = await this.getAcmeChallengeTXT( queryName.replace("_acme-challenge.", "") ); if (txtRecords.length > 0) { logger.info(`Serving ACME challenge TXT for ${queryName}`); const answers = this.convertRecordsToAnswers( txtRecords, originalQueryName, "TXT" ); this.sendResponse(packet, answers, rinfo, true, 0, []); } else { // just return no data on the acme challenge not an soa record logger.info(`No ACME challenge TXT found for ${queryName}`); this.sendResponse( packet, [], rinfo, true, 0, [] // No SOA record for ACME challenges ); } return; } // If its a CAA query then respond with letsencrypt if (queryType === "CAA") { logger.info(`Handling CAA query for ${queryName}`); const caaRecord: dns.Answer = { name: queryName, type: "CAA", ttl: 300, data: { flags: 0, tag: "issue", value: "letsencrypt.org" } }; this.sendResponse(packet, [caaRecord], rinfo, true, 0, []); return; } // Handle NS queries if (queryType === "NS") { logger.info(`Handling NS query for ${queryName}`); const nsRecords = await this.getNSRecords(baseDomain); if (nsRecords.length > 0) { const answers = nsRecords.map((record) => ({ name: queryName, type: "NS" as const, ttl: 300, data: record })); this.sendResponse(packet, answers, rinfo, true, 0, []); return; } } // Handle SOA queries if (queryType === "SOA") { logger.info(`Handling SOA query for ${queryName}`); const soaRecord = await this.getSOARecord(baseDomain); if (soaRecord) { // For SOA queries, we need to return the SOA record for the zone we're authoritative for // The query might be for a subdomain, but we return the SOA for the zone const soaAnswer = { ...soaRecord, name: baseDomain // SOA record name should be the zone name }; this.sendResponse(packet, [soaAnswer], rinfo, true, 0, []); return; } } // Check for static records from config (e.g. domain verification TXT/CNAME) const staticRecords = this.getStaticRecords(queryName, queryType); if (staticRecords.length > 0) { const answers = this.convertRecordsToAnswers( staticRecords, originalQueryName, queryType ); logger.info( `Returning ${answers.length} static config record(s) for ${originalQueryName} (${queryType}): ${JSON.stringify(answers)}` ); this.sendResponse(packet, answers, rinfo, true, 0, []); return; } // Get records from cache or database const records = await this.getResourceRecordsByFullDomain( queryName, queryType ); if (records.length > 0) { // Normal answer const answers = this.convertRecordsToAnswers( records, originalQueryName, queryType ); logger.info( `Returning ${ answers.length } answers for ${originalQueryName} (${queryType}): ${JSON.stringify( answers )}` ); this.sendResponse(packet, answers, rinfo, true, 0, []); return; } const loginPageRecords = await this.getLoginPageRecordsByFullDomain( queryName, queryType ); if (loginPageRecords.length > 0) { // Normal answer const answers = this.convertRecordsToAnswers( loginPageRecords, originalQueryName, queryType ); logger.info( `Returning ${ answers.length } answers for ${originalQueryName} (${queryType}): ${JSON.stringify( answers )}` ); this.sendResponse(packet, answers, rinfo, true, 0, []); return; } // Check if the domain exists (for NODATA vs NXDOMAIN) const [domainExists, soaRecord] = await Promise.all([ this.domainExists(queryName, baseDomain!), this.getSOARecord(baseDomain!) ]); if (!domainExists) { // NXDOMAIN: Name does not exist logger.info(`NXDOMAIN for ${queryName}`); this.sendResponse( packet, [], rinfo, true, 3, soaRecord ? [soaRecord] : [] ); return; } else { // NOERROR/NODATA: Domain exists, but no record of requested type logger.info(`NODATA (NOERROR) for ${queryName} type ${queryType}`); this.sendResponse( packet, [], rinfo, true, 0, soaRecord ? [soaRecord] : [] ); return; } } // Find the base domain for which we are authoritative private async findAuthoritativeZone( queryName: string ): Promise { const cacheKey = `authzone:${queryName}`; const cached = this.authoritativeDomainCache.get( cacheKey ); if (cached !== undefined) { return cached; } const labels = queryName.replace(/\.$/, "").split("."); // Fast path: O(1) in-memory Set lookup — no DB or network I/O if (this.allDomains.size > 0) { for (let i = 0; i < labels.length; i++) { const candidate = labels.slice(i).join("."); if (this.allDomains.has(candidate)) { this.authoritativeDomainCache.set(cacheKey, candidate); return candidate; } } this.authoritativeDomainCache.set(cacheKey, null); return null; } // Fallback: DB query used only before the first domain load completes at startup try { logger.info( `Domain set not yet loaded, querying database for ${queryName}` ); const candidates = labels.map((_, i) => labels.slice(i).join(".")); const rows = await db .select({ baseDomain: domains.baseDomain }) .from(domains) .where(inArray(domains.baseDomain, candidates)); if (rows.length === 0) { this.authoritativeDomainCache.set(cacheKey, null); return null; } const best = rows.reduce((a, b) => a.baseDomain.length > b.baseDomain.length ? a : b ); this.authoritativeDomainCache.set(cacheKey, best.baseDomain); return best.baseDomain; } catch (error) { logger.error("Error querying for authoritative zone:", error); return null; } } // Check if the domain exists (for NODATA/NXDOMAIN distinction) private async domainExists( queryName: string, baseDomain: string ): Promise { const cacheKey = `exists:${queryName}`; const cached = this.cache.get(cacheKey); if (cached !== undefined) return cached; try { const normalizedQuery = queryName.replace(/\.$/, ""); // If queryName is the zone apex we already know it exists — no DB query needed if (normalizedQuery === baseDomain) { this.cache.set(cacheKey, true, 60); return true; } // Check resources table (exact or wildcard match) const wildcardPattern = this.getWildcardPattern(normalizedQuery); const existsCondition = wildcardPattern ? or( eq(resources.fullDomain, queryName), eq(resources.fullDomain, wildcardPattern) )! : eq(resources.fullDomain, queryName); const [row] = await db .select({ fullDomain: resources.fullDomain }) .from(resources) .where(existsCondition) .limit(1); const exists = !!row; this.cache.set(cacheKey, exists, 60); return exists; } catch { return false; } } // Get SOA record for a zone private async getSOARecord(zone: string): Promise { const cacheKey = `soa:${zone}`; const cached = this.soaCache.get(cacheKey); if (cached) return cached; // For demonstration, construct a default SOA record // In production, fetch from DB if you store SOA records const soa: dns.Answer = { name: zone, type: "SOA", ttl: 300, data: { mname: config.getRawConfig().dns!.nameserver_name, rname: `dns-admin.${config.getRawConfig().dns!.nameserver_name}`, serial: Math.floor(Date.now() / 1000), refresh: 3600, retry: 1800, expire: 604800, minimum: 86400 } }; this.soaCache.set(cacheKey, soa); return soa; } private async getResourceRecordsByFullDomain( name: string, queryType: dns.RecordType ): Promise { const cacheKey = `resourceRecords:${name}:${queryType}`; // Check cache first const cached = this.cache.get(cacheKey); if (cached) { logger.debug(`Cache hit for ${cacheKey}`); return cached; } return this.withInFlightDedup(cacheKey, async () => { logger.debug(`Cache miss for ${cacheKey}, querying database`); try { const wildcardPattern = this.getWildcardPattern(name); const domainCondition = wildcardPattern ? or( eq(resources.fullDomain, name), eq(resources.fullDomain, wildcardPattern) )! : eq(resources.fullDomain, name); // LEFT JOINs so we always get the resource row (and its orgId) even // when the resource has no targets/sites/exitNodes assigned yet. const resourceRows = await db .select({ endpoint: exitNodes.endpoint, online: exitNodes.online, orgId: resources.orgId, siteType: sites.type, enabled: resources.enabled }) .from(resources) .leftJoin( targets, eq(resources.resourceId, targets.resourceId) ) .leftJoin(sites, eq(targets.siteId, sites.siteId)) .leftJoin( exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId) ) .where(domainCondition); if (resourceRows.length === 0) { // Resource doesn't exist at all — also pre-populate the domainExists // cache so the subsequent domainExists() call hits memory, not the DB. logger.debug(`No resource found for domain: ${name}`); this.cache.set(`exists:${name}`, false, 60); this.cache.set(cacheKey, [], 60); return []; } if (!resourceRows[0].enabled) { // Resource exists but is disabled — the domain still exists (NODATA, // not NXDOMAIN), it just shouldn't resolve to anything right now. logger.info( `Resource for domain ${name} is disabled; not returning DNS records` ); this.cache.set(cacheKey, [], 60); return []; } // Exclude exit nodes that are offline — an assigned but offline exit // node falls through to the random-online-exit-node selection below, // same as a resource with no exit node assigned at all. const resourceExitNodes = resourceRows.filter( (r) => r.endpoint !== null && r.online === true ) as { endpoint: string; orgId: string }[]; if (resourceExitNodes.length === 0) { // "local" sites route directly to the target IP on the box running // that site's own exit node/Traefik instance — they don't tunnel // through an exit node. If every site behind this resource is local, // a randomly picked exit node wouldn't have a route to the target, // so don't fall back to one; just serve no records. const siteRows = resourceRows.filter( (r) => r.siteType !== null ); const allSitesLocal = siteRows.length > 0 && siteRows.every((r) => r.siteType === "local"); if (allSitesLocal) { logger.info( "All sites for resource are type 'local' with no online exit node; not choosing a random exit node." ); this.cache.set(cacheKey, [], 10); return []; } logger.info( "No online exit nodes found for resource. Choosing a deterministic exit node." ); // TODO: EVENTUALLY WE SHOULD PICK THE CLOSEST EXIT NODE TO THE USER BASED ON LATENCY const orgId = resourceRows[0].orgId; const exitNodesList = await listExitNodes(orgId, true); if (!exitNodesList || exitNodesList.length === 0) { logger.warn(`No exit nodes found for orgId: ${orgId}`); this.cache.set(cacheKey, [], 10); return []; } // Prefer exit nodes in the same region as this DNS server, if any exist. const dnsRegion = config.getRawConfig().app.region; const regionExitNodes = dnsRegion ? exitNodesList.filter( (node) => node.region === dnsRegion ) : []; const candidateExitNodes = regionExitNodes.length > 0 ? regionExitNodes : exitNodesList; // Deterministically pick a node based on the queried name so // repeated queries for the same resource keep resolving to the // same exit node instead of changing on every cache miss. const selectedExitNode = this.selectDeterministicExitNode( name, candidateExitNodes ); const ipRecords = await this.createRecordsFromEndpoint( selectedExitNode.endpoint, name, queryType ); this.cache.set(cacheKey, ipRecords); return ipRecords; } let results: DNSRecord[] = []; for (const exitNode of resourceExitNodes) { const endpoint = exitNode.endpoint; logger.info( `Processing endpoint: ${endpoint} for query type: ${queryType}` ); // TODO: EVENTUALLY WE SHOULD PICK THE CLOSET EXIT NODE TO THE USER BASED ON LATENCY // Check if endpoint is an IP address const ipRecords = await this.createRecordsFromEndpoint( endpoint, name, queryType ); results.push(...ipRecords); } // Cache the results this.cache.set(cacheKey, results); logger.info(`Cached ${results.length} records for ${cacheKey}`); return results; } catch (error) { logger.error("Database query error:", error); return []; } }); } // Deterministically picks a node for `name` out of `nodes` by hashing the // queried name, so the same resource keeps resolving to the same exit // node across cache misses and across independently-caching DNS server // processes, instead of a fresh random pick every time. private selectDeterministicExitNode( name: string, nodes: T[] ): T { // Sort by id so the hash-to-index mapping doesn't depend on the order // rows happen to come back from the database. const sorted = [...nodes].sort((a, b) => a.exitNodeId - b.exitNodeId); const hash = createHash("sha1").update(name).digest(); const index = hash.readUInt32BE(0) % sorted.length; return sorted[index]; } private async getLoginPageRecordsByFullDomain( name: string, queryType: dns.RecordType ): Promise { const cacheKey = `loginPageRecords:${name}:${queryType}`; // Check cache first const cached = this.cache.get(cacheKey); if (cached) { logger.debug(`Cache hit for ${cacheKey}`); return cached; } return this.withInFlightDedup(cacheKey, async () => { logger.debug(`Cache miss for ${cacheKey}, querying database`); try { // Query database const loginPageExitNodes = await db .select({ endpoint: exitNodes.endpoint }) .from(loginPage) .innerJoin( exitNodes, eq(loginPage.exitNodeId, exitNodes.exitNodeId) ) .where(eq(loginPage.fullDomain, name)); if (loginPageExitNodes.length === 0) { logger.info("No exit nodes found for resource."); this.cache.set(cacheKey, [], 60); return []; } let results: DNSRecord[] = []; for (const exitNode of loginPageExitNodes) { const endpoint = exitNode.endpoint; logger.info( `Processing endpoint: ${endpoint} for query type: ${queryType}` ); // TODO: EVENTUALLY WE SHOULD PICK THE CLOSET EXIT NODE TO THE USER BASED ON LATENCY // Check if endpoint is an IP address const ipRecords = await this.createRecordsFromEndpoint( endpoint, name, queryType ); results.push(...ipRecords); } // Cache the results this.cache.set(cacheKey, results); logger.info(`Cached ${results.length} records for ${cacheKey}`); return results; } catch (error) { logger.error("Database query error:", error); return []; } }); } private async withInFlightDedup( key: string, lookupFn: () => Promise ): Promise { const existing = this.inFlightLookups.get(key) as Promise | undefined; if (existing) { logger.debug(`Joining in-flight lookup for ${key}`); return existing; } const lookupPromise = (async () => { try { return await lookupFn(); } finally { this.inFlightLookups.delete(key); } })(); this.inFlightLookups.set(key, lookupPromise as Promise); return lookupPromise; } // Add this new method to get domain by domain ID private async getDomainByDomainId( domainId: string ): Promise { const cacheKey = `domain:${domainId}`; const cached = this.cache.get(cacheKey); if (cached !== undefined) { logger.info(`Domain cache hit for ${domainId}: ${cached}`); return cached; } try { const [domain] = await db .select({ baseDomain: domains.baseDomain }) .from(domains) .where(eq(domains.domainId, domainId)) .limit(1); const result = domain ? domain.baseDomain : null; this.cache.set(cacheKey, result, 300); // Cache for 5 minutes logger.info(`Domain cache miss for ${domainId}, found: ${result}`); return result; } catch (error) { logger.error("Error querying domain by ID:", error); return null; } } private getStaticRecords( queryName: string, queryType: dns.RecordType ): DNSRecord[] { const dnsConfig = config.getRawConfig().dns; if (!dnsConfig?.static_records?.length) { return []; } const normalizedQuery = queryName.replace(/\.$/, "").toLowerCase(); return dnsConfig.static_records .filter((record) => { const normalizedDomain = record.domain .replace(/\.$/, "") .toLowerCase(); return ( normalizedDomain === normalizedQuery && record.type === queryType ); }) .map((record, index) => ({ id: -(index + 1), // negative IDs to avoid collision with DB records zone: normalizedQuery, name: normalizedQuery, type: record.type, value: record.value, ttl: record.ttl, priority: 0, enabled: true })); } private convertRecordsToAnswers( records: DNSRecord[], queryName: string, queryType: dns.RecordType ): dns.Answer[] { return records.map((record) => { const answer: any = { name: queryName, type: record.type, ttl: record.ttl, data: this.formatRecordData(record, queryType) }; return answer; }); } private formatRecordData( record: DNSRecord, queryType: dns.RecordType ): any { switch (queryType) { case "A": return record.value; // IPv4 address as string case "AAAA": return record.value; // IPv6 address as string case "CNAME": return record.value; // Canonical name as string case "TXT": // TXT records can have multiple strings try { const parsed = JSON.parse(record.value); return Array.isArray(parsed) ? parsed : [record.value]; } catch { return [record.value]; } case "MX": // MX records need priority and exchange return { priority: record.priority, exchange: record.value }; case "NS": return record.value; // Name server as string case "SOA": try { return JSON.parse(record.value); } catch { return { mname: record.value, rname: "admin." + record.zone, serial: Math.floor(Date.now() / 1000), refresh: 3600, retry: 1800, expire: 604800, minimum: 86400 }; } default: return record.value; } } private sendResponse( originalPacket: dns.Packet, answers: dns.Answer[], rinfo: dgram.RemoteInfo, authoritative: boolean = true, rcode: number = 0, authorities: dns.Answer[] = [] ): void { const response: dns.Packet = { id: originalPacket.id, type: "response", flags: dns.RECURSION_DESIRED, questions: originalPacket.questions, answers: answers, authorities: authorities, additionals: [] }; // Set authoritative flag if this is an authoritative response if (authoritative) { response.flags = (response.flags ?? 0) | dns.AUTHORITATIVE_ANSWER; } // Set rcode in the flags field (lower 4 bits) while preserving other flags response.flags = (response.flags ?? 0) & ~0xf; // Clear lower 4 bits response.flags |= rcode & 0xf; // Set rcode try { const responseBuffer = dns.encode(response); this.server.send( responseBuffer, rinfo.port, rinfo.address, (err) => { if (err) { logger.error("Error sending DNS response:", err); } } ); } catch (error) { logger.error("Error encoding DNS response:", error); } } private async loadAllDomains(): Promise { try { const rows = await db .select({ baseDomain: domains.baseDomain }) .from(domains); this.allDomains = new Set(rows.map((r) => r.baseDomain)); logger.info( `Loaded ${this.allDomains.size} authoritative domains into memory` ); } catch (error) { logger.error( "Failed to load authoritative domains, will retry next interval:", error ); } } public async start(): Promise { await this.loadAllDomains(); this.domainRefreshInterval = setInterval(() => { this.loadAllDomains().catch((err) => logger.error("Domain refresh failed:", err) ); }, 60_000); return new Promise((resolve, reject) => { this.server.bind(this.port, (err?: Error) => { if (err) { reject(err); } else { resolve(); } }); }); } public async stop(): Promise { if (this.domainRefreshInterval) { clearInterval(this.domainRefreshInterval); } return new Promise((resolve) => { this.server.close(() => { logger.info("DNS server stopped"); resolve(); }); }); } public getCacheStats(): NodeCache.Stats { return this.cache.getStats(); } public clearCache(): void { this.cache.flushAll(); logger.info("DNS cache cleared"); } // Add this new method for ACME challenge TXT lookup private async getAcmeChallengeTXT(name: string): Promise { // Remove any trailing dot for FQDN const domain = name.endsWith(".") ? name.slice(0, -1) : name; const cacheKey = `acme:${domain}`; const cached = this.cache.get(cacheKey); if (cached !== undefined) { logger.info(`ACME TXT cache hit for ${domain}`); return cached; } try { // Use primaryDb, not db (which may be routed to a lagging read // replica) - these rows are inserted moments before this lookup // runs, and a stale replica read here causes the ACME client's // own DNS pre-check to see no TXT record and fail the order. // Ordered newest-first (not just limited to one row) so that if // more than one non-expired, uncompleted challenge exists for // this domain at once - e.g. an overlapping/duplicate order - // we can log the collision instead of silently picking whichever // row Postgres happens to return first, which was previously // non-deterministic and could serve a stale/wrong TXT value. const challenges = await primaryDb .select({ id: dnsChallenge.dnsChallengeId, domain: dnsChallenge.domain, token: dnsChallenge.token, keyAuthorization: dnsChallenge.keyAuthorization, expiresAt: dnsChallenge.expiresAt }) .from(dnsChallenge) .where( and( eq(dnsChallenge.domain, domain), eq(dnsChallenge.completed, false), gt( dnsChallenge.expiresAt, Math.floor(Date.now() / 1000) ) ) ) .orderBy(desc(dnsChallenge.dnsChallengeId)); if (challenges.length === 0) { // Keep the negative-cache TTL short: challenges are inserted // right before verification starts, so caching a "not found" // result for too long can outlive that insert and make a // just-created challenge look absent for several more seconds. this.cache.set(cacheKey, [], 1); return []; } // A wildcard order challenges both `domain` and `*.domain`, and // both authorizations' identifier value is the bare `domain` - // so a wildcard cert legitimately has two live, uncompleted // challenge rows here at once, each needing its own TXT value // published at the same _acme-challenge. name (DNS // allows multiple TXT records per name; the CA's DNS-01 check // just looks for its expected value among whatever comes back). // Serving only the newest silently drops the other // authorization's value, which makes that authorization fail // DNS-01 validation forever - so every non-expired, uncompleted // challenge for this domain must be served together. const result: DNSRecord[] = challenges.map((c) => ({ id: c.id, zone: c.domain, name: c.domain, type: "TXT", value: c.keyAuthorization, // Kept short deliberately: a downstream resolver that // caches (or serve-stales) this answer for the full 60s // can hand Let's Encrypt a value from an already-replaced // challenge, since a validation attempt only needs this // record for a few seconds. Shrinking the window a // caching layer can serve a wrong answer during matters // more here than the extra query volume costs. ttl: 5, priority: 0, enabled: true })); logger.info( `Serving ${result.length} ACME challenge TXT record(s) for ${domain}: ${challenges .map((c) => `id ${c.id}/token ${c.token}`) .join(", ")}` ); this.cache.set(cacheKey, result, 3); return result; } catch (error) { logger.error("Error querying ACME challenge TXT:", error); return []; } } private async getSiteSubnetIpByNewtId( newtId: string ): Promise { const cacheKey = `site-subnet:${newtId}`; const cached = this.cache.get(cacheKey); if (cached !== undefined) { return cached; } try { const [site] = await db .select({ subnet: sites.exitNodeSubnet }) .from(sites) .innerJoin(newts, eq(newts.siteId, sites.siteId)) .where(eq(newts.newtId, newtId)) .limit(1); if (!site?.subnet) { this.cache.set(cacheKey, null, 60); return null; } // Strip CIDR prefix notation (e.g. "100.64.0.1/24" -> "100.64.0.1") const ip = site.subnet.split("/")[0]; this.cache.set(cacheKey, ip, 60); return ip; } catch (error) { logger.error("Error querying site subnet by niceId:", error); return null; } } // Add this new method to get NS records private async getNSRecords(zone: string | null): Promise { const cacheKey = `ns:${zone}`; const cached = this.cache.get(cacheKey); if (cached) { logger.info(`NS cache hit for ${zone}`); return cached; } try { // Return the configured nameserver(s) const nameservers = [ config.getRawConfig().dns!.nameserver_name, ...config.getRawConfig().dns!.alternate_nameservers ]; this.cache.set(cacheKey, nameservers, 300); // Cache for 5 minutes logger.info(`NS cache miss for ${zone}, returning: ${nameservers}`); return nameservers; } catch (error) { logger.error("Error getting NS records:", error); return []; } } private async createRecordsFromEndpoint( endpoint: string, name: string, queryType: dns.RecordType ): Promise { const results: DNSRecord[] = []; // First, check if endpoint is already an IP address const ipv4Record = this.createRecordFromIp(endpoint, name, "A"); const ipv6Record = this.createRecordFromIp(endpoint, name, "AAAA"); if (ipv4Record && queryType === "A") { logger.info(`Endpoint ${endpoint} is IPv4, returning A record`); results.push(ipv4Record); return results; } if (ipv6Record && queryType === "AAAA") { logger.info(`Endpoint ${endpoint} is IPv6, returning AAAA record`); results.push(ipv6Record); return results; } // If not an IP, try to resolve as DNS name if (!ipv4Record && !ipv6Record) { // Basic validation for DNS name if (this.isValidDNSName(endpoint)) { try { logger.info( `Attempting to resolve DNS name: ${endpoint} for type ${queryType}` ); const resolvedIPs = await this.resolveDNSName( endpoint, queryType ); for (const ip of resolvedIPs) { const record = this.createRecordFromIp( ip, name, queryType ); if (record) { results.push(record); } } } catch (error) { logger.error( `Failed to resolve DNS name ${endpoint}:`, error ); } } else { logger.warn(`Invalid DNS name or IP format: ${endpoint}`); } } return results; } private isValidDNSName(hostname: string): boolean { // Basic DNS name validation if (!hostname || hostname.length === 0 || hostname.length > 253) { return false; } // Check for valid characters and structure const dnsNameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return dnsNameRegex.test(hostname); } private async resolveDNSName( hostname: string, queryType: dns.RecordType ): Promise { return new Promise((resolve) => { const results: string[] = []; const timeout = setTimeout(() => { logger.warn(`DNS resolution timeout for ${hostname}`); resolve(results); }, 5000); // 5 second timeout if (queryType === "A") { dnsResolver.resolve4( hostname, (err: any, addresses: string[]) => { clearTimeout(timeout); if (!err && addresses && Array.isArray(addresses)) { logger.info( `Resolved ${hostname} to IPv4: ${addresses.join( ", " )}` ); results.push(...addresses); } else if (err) { logger.warn( `Failed to resolve ${hostname} to IPv4: ${err.message}` ); } resolve(results); } ); } else if (queryType === "AAAA") { dnsResolver.resolve6( hostname, (err: any, addresses: string[]) => { clearTimeout(timeout); if (!err && addresses && Array.isArray(addresses)) { logger.info( `Resolved ${hostname} to IPv6: ${addresses.join( ", " )}` ); results.push(...addresses); } else if (err) { logger.warn( `Failed to resolve ${hostname} to IPv6: ${err.message}` ); } resolve(results); } ); } else { clearTimeout(timeout); resolve(results); } }); } private getWildcardPattern(name: string): string | null { const normalized = name.replace(/\.$/, ""); const dotIndex = normalized.indexOf("."); if (dotIndex === -1) return null; return `*.${normalized.slice(dotIndex + 1)}`; } private createRecordFromIp( ip: string, name: string, recordType: dns.RecordType ): DNSRecord | null { const isIPv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test( ip ); const isIPv6 = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*$/.test( ip ); if (isIPv4 && recordType === "A") { return { id: 0, zone: name, name: name, type: "A", value: ip, ttl: 300, priority: 0, enabled: true }; } else if (isIPv6 && recordType === "AAAA") { return { id: 0, zone: name, name: name, type: "AAAA", value: ip, ttl: 300, priority: 0, enabled: true }; } return null; } }