353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
const dgram = require('dgram');
|
|
const dnsPacket = require('dns-packet');
|
|
const { getHashForDomain } = require('../core/core');
|
|
const state = require('../infrastructure/state');
|
|
const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logger');
|
|
const { createInterfaceForDomain } = require('./virtual_interfaces');
|
|
const { startHolesailClient } = require('./holesail');
|
|
const { dnsPool } = require('./dns_pool');
|
|
const { trackDNSQuery, trackDNSQueryWithTiming } = require('../maintenance/metrics');
|
|
const { getCircuitBreaker } = require('../infrastructure/circuit_breaker');
|
|
const { secondsToMs } = require('../infrastructure/utils');
|
|
const dnsServer = dgram.createSocket('udp4');
|
|
|
|
// Initialize state for domains with both
|
|
if (!state.domainsWithBoth) state.domainsWithBoth = new Set();
|
|
if (!state.publicIpForDomain) state.publicIpForDomain = {};
|
|
if (!state.versionPreferences) state.versionPreferences = new Map();
|
|
|
|
// DNS resolution cache with TTL
|
|
const dnsCache = new Map(); // domain -> { p2pHash: string|null, publicIP: string|null, timestamp: number, ttl: number }
|
|
const DEFAULT_CACHE_TTL = secondsToMs(30); // 30 seconds default TTL
|
|
const MAX_DNS_CACHE_SIZE = parseInt(process.env.MAX_DNS_CACHE_SIZE || '5000', 10); // Default 5000 entries
|
|
|
|
function getCachedDNSResolution(domain) {
|
|
const cached = dnsCache.get(domain);
|
|
if (!cached) return null;
|
|
|
|
const now = Date.now();
|
|
if (now - cached.timestamp > cached.ttl) {
|
|
dnsCache.delete(domain);
|
|
return null;
|
|
}
|
|
|
|
return cached;
|
|
}
|
|
|
|
function setCachedDNSResolution(domain, p2pHash, publicIP, ttl = DEFAULT_CACHE_TTL) {
|
|
// If cache is at limit, remove oldest entries (simple FIFO - remove first key)
|
|
if (dnsCache.size >= MAX_DNS_CACHE_SIZE) {
|
|
const firstKey = dnsCache.keys().next().value;
|
|
if (firstKey) {
|
|
dnsCache.delete(firstKey);
|
|
logDebug('DNS', `Removed oldest DNS entry from cache (${firstKey}) to maintain size limit`);
|
|
}
|
|
}
|
|
dnsCache.set(domain, {
|
|
p2pHash,
|
|
publicIP,
|
|
timestamp: Date.now(),
|
|
ttl
|
|
});
|
|
}
|
|
|
|
// Cleanup old cache entries periodically
|
|
let dnsCacheCleanupInterval = null;
|
|
|
|
function startDNSCacheCleanup() {
|
|
if (dnsCacheCleanupInterval) {
|
|
clearInterval(dnsCacheCleanupInterval);
|
|
}
|
|
dnsCacheCleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
let cleanedCount = 0;
|
|
for (const [domain, cached] of dnsCache.entries()) {
|
|
if (now - cached.timestamp > cached.ttl) {
|
|
dnsCache.delete(domain);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
// If cache is still too large after TTL cleanup, remove oldest entries
|
|
if (dnsCache.size > MAX_DNS_CACHE_SIZE) {
|
|
const entriesToRemove = dnsCache.size - MAX_DNS_CACHE_SIZE;
|
|
const keysToRemove = Array.from(dnsCache.keys()).slice(0, entriesToRemove);
|
|
for (const key of keysToRemove) {
|
|
dnsCache.delete(key);
|
|
cleanedCount++;
|
|
}
|
|
logDebug('DNS', `Removed ${entriesToRemove} oldest DNS entries to maintain size limit`);
|
|
}
|
|
if (cleanedCount > 0) {
|
|
logDebug('DNS', `DNS cache cleanup: removed ${cleanedCount} entries, ${dnsCache.size} remaining`);
|
|
}
|
|
}, secondsToMs(60)); // Cleanup every minute
|
|
}
|
|
|
|
function stopDNSCacheCleanup() {
|
|
if (dnsCacheCleanupInterval) {
|
|
clearInterval(dnsCacheCleanupInterval);
|
|
dnsCacheCleanupInterval = null;
|
|
}
|
|
}
|
|
|
|
// Start cleanup on module load
|
|
startDNSCacheCleanup();
|
|
|
|
// Circuit breaker for public DNS queries
|
|
const publicDNSCircuitBreaker = getCircuitBreaker('public-dns', {
|
|
failureThreshold: 5,
|
|
resetTimeout: secondsToMs(30)
|
|
});
|
|
|
|
// Check public DNS for fallback resolution using connection pool
|
|
async function checkPublicDNS(query) {
|
|
try {
|
|
const response = await publicDNSCircuitBreaker.execute(async () => {
|
|
return await dnsPool.query(query);
|
|
}, 'public-dns');
|
|
|
|
if (response && response.answers && response.answers.length > 0) {
|
|
trackDNSQuery('public');
|
|
} else {
|
|
trackDNSQuery('failure');
|
|
}
|
|
return response;
|
|
} catch (err) {
|
|
// Circuit breaker is open or query failed
|
|
logWarn('DNS', `Public DNS query failed (circuit breaker may be open): ${err.message}`);
|
|
trackDNSQuery('failure');
|
|
// Return null to indicate failure, allow graceful degradation
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Handle incoming DNS messages
|
|
async function handleDnsMessage(msg, rinfo) {
|
|
const query = dnsPacket.decode(msg);
|
|
const domain = query.questions[0].name.toLowerCase();
|
|
const type = query.questions[0].type;
|
|
logInfo('DNS', `DNS query received: Domain = ${domain}, Type = ${type}`);
|
|
|
|
// Check local DNS records first
|
|
const localAnswers = (state.localDnsRecords || []).filter(r => r.name.toLowerCase() === domain && r.type === type);
|
|
if (localAnswers.length > 0) {
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
questions: query.questions,
|
|
answers: localAnswers
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
return;
|
|
}
|
|
|
|
// Handle internal domains
|
|
let isInternal = false;
|
|
try {
|
|
const { getInternalDomains } = require('../plugins/plugin-handler');
|
|
const internalDomains = await getInternalDomains();
|
|
isInternal = internalDomains.includes(domain);
|
|
} catch (err) {
|
|
// Fallback: only p2ns.admin is internal
|
|
isInternal = domain === 'p2ns.admin';
|
|
}
|
|
if (isInternal) {
|
|
const localIP = '127.0.0.1';
|
|
state.domainToIPMap[domain] = localIP;
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
questions: query.questions,
|
|
answers: [{
|
|
type: 'A',
|
|
name: domain,
|
|
ttl: 1,
|
|
data: localIP
|
|
}]
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
return;
|
|
}
|
|
|
|
if (!state.dnsPass) {
|
|
logWarn('DNS', 'dnsPass not initialized, handling as public DNS');
|
|
const publicDNSResponse = await checkPublicDNS(query);
|
|
// Graceful degradation: if public DNS fails, return empty response instead of error
|
|
if (publicDNSResponse) {
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
flags: publicDNSResponse.flags,
|
|
questions: query.questions,
|
|
answers: publicDNSResponse.answers.map(answer => ({
|
|
...answer,
|
|
ttl: 1 // Force TTL to 1 second
|
|
})),
|
|
authorities: publicDNSResponse.authorities || [],
|
|
additionals: publicDNSResponse.additionals || []
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
} else {
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
questions: query.questions,
|
|
answers: []
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check cache first
|
|
const cached = getCachedDNSResolution(domain);
|
|
let hash = null;
|
|
let p2pRecord = null;
|
|
let publicDNSResponse = null;
|
|
|
|
if (cached) {
|
|
logDebug('DNS', `Using cached DNS resolution for ${domain}`);
|
|
hash = cached.p2pHash;
|
|
if (hash) {
|
|
p2pRecord = { hash };
|
|
}
|
|
// For public DNS, we still need to query if not cached or if we need full response
|
|
// But we can use cached publicIP if available
|
|
if (cached.publicIP) {
|
|
state.publicIpForDomain[domain] = cached.publicIP;
|
|
}
|
|
} else {
|
|
// Not cached, perform lookups
|
|
hash = await getHashForDomain(domain);
|
|
logDebug('DNS', `Resolved hash for ${domain}: ${hash || 'null'}`);
|
|
if (hash) {
|
|
p2pRecord = { hash };
|
|
}
|
|
publicDNSResponse = await checkPublicDNS(query);
|
|
|
|
// Cache the results
|
|
let publicIP = null;
|
|
if (publicDNSResponse && publicDNSResponse.answers.length > 0) {
|
|
publicIP = publicDNSResponse.answers.find(a => a.type === 'A')?.data;
|
|
}
|
|
setCachedDNSResolution(domain, hash, publicIP, DEFAULT_CACHE_TTL);
|
|
}
|
|
|
|
// If we used cache but need public DNS response, query it
|
|
if (cached && !publicDNSResponse) {
|
|
publicDNSResponse = await checkPublicDNS(query);
|
|
// Update cache with fresh public IP
|
|
if (publicDNSResponse && publicDNSResponse.answers.length > 0) {
|
|
const publicIP = publicDNSResponse.answers.find(a => a.type === 'A')?.data;
|
|
if (publicIP) {
|
|
setCachedDNSResolution(domain, hash, publicIP, DEFAULT_CACHE_TTL);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if domain has both P2P and public records
|
|
let publicIP = null;
|
|
if (publicDNSResponse && publicDNSResponse.answers.length > 0) {
|
|
publicIP = publicDNSResponse.answers.find(a => a.type === 'A')?.data;
|
|
if (publicIP) {
|
|
state.publicIpForDomain[domain] = publicIP;
|
|
if (p2pRecord) {
|
|
state.domainsWithBoth.add(domain);
|
|
logInfo('DNS', `Domain ${domain} has both P2P and public records.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check user preference for version
|
|
const version = state.versionPreferences.get(domain);
|
|
|
|
if (p2pRecord && (!state.domainsWithBoth.has(domain) || version !== 'public')) {
|
|
const startTime = Date.now();
|
|
const localIP = state.domainToIPMap[domain] || await createInterfaceForDomain(domain);
|
|
if (!localIP) {
|
|
logError('DNS', `Failed to assign IP for ${domain}`);
|
|
trackDNSQuery('failure');
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
questions: query.questions,
|
|
answers: []
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
return;
|
|
}
|
|
await startHolesailClient(domain, p2pRecord.hash, localIP, state.internalPort);
|
|
const responseTime = Date.now() - startTime;
|
|
trackDNSQueryWithTiming('p2p', domain, responseTime);
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
questions: query.questions,
|
|
answers: [{
|
|
type: 'A',
|
|
name: domain,
|
|
ttl: 1,
|
|
data: localIP
|
|
}]
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
} else if (publicDNSResponse && publicDNSResponse.answers.length > 0 && (!p2pRecord || version === 'public')) {
|
|
trackDNSQueryWithTiming('public', domain, null); // Response time tracked in checkPublicDNS
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
flags: publicDNSResponse.flags,
|
|
questions: query.questions,
|
|
answers: publicDNSResponse.answers.map(answer => ({
|
|
...answer,
|
|
ttl: 1 // Force TTL to 1 second
|
|
})),
|
|
authorities: publicDNSResponse.authorities || [],
|
|
additionals: publicDNSResponse.additionals || []
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
} else {
|
|
trackDNSQueryWithTiming('failure', domain, null);
|
|
logWarn('DNS', `No P2P or public DNS records found for ${domain}`);
|
|
const response = dnsPacket.encode({
|
|
type: 'response',
|
|
id: query.id,
|
|
questions: query.questions,
|
|
answers: []
|
|
});
|
|
dnsServer.send(response, rinfo.port, rinfo.address);
|
|
}
|
|
}
|
|
|
|
// Bind DNS server to port
|
|
function bindDnsServer() {
|
|
if (process.env.DISABLE_DNS_SERVER === 'true') {
|
|
logInfo('DNS', 'DNS server disabled via DISABLE_DNS_SERVER=true');
|
|
return;
|
|
}
|
|
dnsServer.on('listening', () => {
|
|
logInfo('DNS', `DNS Server running on port ${process.env.DNS_PORT || 53}, bound to 0.0.0.0`);
|
|
});
|
|
dnsServer.on('error', (err) => {
|
|
logError('DNS', `Failed to bind DNS server to port ${process.env.DNS_PORT || 53}: ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
dnsServer.on('message', handleDnsMessage);
|
|
dnsServer.bind(process.env.DNS_PORT || 53, '0.0.0.0');
|
|
}
|
|
|
|
// Close DNS server and remove listeners
|
|
function closeDnsServer() {
|
|
if (process.env.DISABLE_DNS_SERVER === 'true') {
|
|
return;
|
|
}
|
|
try {
|
|
dnsServer.removeAllListeners();
|
|
dnsServer.close();
|
|
logInfo('DNS', 'DNS server closed and listeners removed');
|
|
} catch (err) {
|
|
logError('DNS', `Error closing DNS server: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
module.exports = { dnsServer, handleDnsMessage, bindDnsServer, closeDnsServer, stopDNSCacheCleanup }; |