Files
pangolin/server/private/lib/dns/server.ts
T
2026-09-09 11:17:53 -04:00

1396 lines
51 KiB
TypeScript

/*
* 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<string, Promise<unknown>> = 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<string> = 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<void> {
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 <name>/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<string | null> {
const cacheKey = `authzone:${queryName}`;
const cached = this.authoritativeDomainCache.get<string | null>(
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<boolean> {
const cacheKey = `exists:${queryName}`;
const cached = this.cache.get<boolean>(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<dns.Answer | null> {
const cacheKey = `soa:${zone}`;
const cached = this.soaCache.get<dns.Answer>(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<DNSRecord[]> {
const cacheKey = `resourceRecords:${name}:${queryType}`;
// Check cache first
const cached = this.cache.get<DNSRecord[]>(cacheKey);
if (cached) {
logger.debug(`Cache hit for ${cacheKey}`);
return cached;
}
return this.withInFlightDedup<DNSRecord[]>(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<T extends { exitNodeId: number }>(
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<DNSRecord[]> {
const cacheKey = `loginPageRecords:${name}:${queryType}`;
// Check cache first
const cached = this.cache.get<DNSRecord[]>(cacheKey);
if (cached) {
logger.debug(`Cache hit for ${cacheKey}`);
return cached;
}
return this.withInFlightDedup<DNSRecord[]>(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<T>(
key: string,
lookupFn: () => Promise<T>
): Promise<T> {
const existing = this.inFlightLookups.get(key) as
Promise<T> | 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<unknown>);
return lookupPromise;
}
// Add this new method to get domain by domain ID
private async getDomainByDomainId(
domainId: string
): Promise<string | null> {
const cacheKey = `domain:${domainId}`;
const cached = this.cache.get<string | null>(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<void> {
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<void> {
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<void> {
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<DNSRecord[]> {
// Remove any trailing dot for FQDN
const domain = name.endsWith(".") ? name.slice(0, -1) : name;
const cacheKey = `acme:${domain}`;
const cached = this.cache.get<DNSRecord[]>(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.<domain> 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<string | null> {
const cacheKey = `site-subnet:${newtId}`;
const cached = this.cache.get<string | null>(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<string[]> {
const cacheKey = `ns:${zone}`;
const cached = this.cache.get<string[]>(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<DNSRecord[]> {
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<string[]> {
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;
}
}