diff --git a/package.json b/package.json index be9fe6f9..33dc58a5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cronstrue": "2.23.0", "date-fns": "2.29.3", "dayjs": "1.11.7", + "dns-caching": "^0.1.3", "email-templates": "12.0.1", "email-validator": "2.0.4", "express": "4.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1570be0..131b06c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: dayjs: specifier: 1.11.7 version: 1.11.7 + dns-caching: + specifier: ^0.1.3 + version: 0.1.3 email-templates: specifier: 12.0.1 version: 12.0.1(@babel/core@7.24.7)(encoding@0.1.13)(handlebars@4.7.8)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7) @@ -4869,6 +4872,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dns-caching@0.1.3: + resolution: {integrity: sha512-uLDVeq546NuvS5bx3uLSnz38yNoR90UPP7+jlyL93z9OYkVIIk+ky5I+5VowVQ/I54C/rfF7DiUXzJPivmk5mw==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -6751,6 +6757,10 @@ packages: resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} engines: {node: 20 || >=22} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -15705,6 +15715,10 @@ snapshots: dlv@1.1.3: {} + dns-caching@0.1.3: + dependencies: + lru-cache: 11.1.0 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -18045,6 +18059,8 @@ snapshots: lru-cache@11.0.2: {} + lru-cache@11.1.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 diff --git a/server/index.ts b/server/index.ts index e4d46b8a..ed933347 100644 --- a/server/index.ts +++ b/server/index.ts @@ -30,6 +30,7 @@ import { getClientIp } from '@supercharge/request-ip'; import axios from 'axios'; import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; +import { DnsCacheManager } from 'dns-caching'; import type { NextFunction, Request, Response } from 'express'; import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; @@ -80,6 +81,12 @@ app axios.defaults.httpsAgent = new https.Agent({ family: 4 }); } + // Add DNS caching + if (settings.network.dnsCache) { + const dnsCache = new DnsCacheManager({ logger }); + dnsCache.initialize(); + } + // Register HTTP proxy if (settings.network.proxy.enabled) { await createCustomProxyAgent(settings.network.proxy); diff --git a/server/utils/dnsCacheManager.ts b/server/utils/dnsCacheManager.ts deleted file mode 100644 index 9f326b4f..00000000 --- a/server/utils/dnsCacheManager.ts +++ /dev/null @@ -1,920 +0,0 @@ -import logger from '@server/logger'; -import { LRUCache } from 'lru-cache'; -import dns from 'node:dns'; - -type LookupCallback = ( - err: NodeJS.ErrnoException | null, - address: string | dns.LookupAddress[] | undefined, - family?: number -) => void; - -type LookupFunction = { - (hostname: string, callback: LookupCallback): void; - ( - hostname: string, - options: dns.LookupOptions, - callback: LookupCallback - ): void; - ( - hostname: string, - options: dns.LookupOneOptions, - callback: LookupCallback - ): void; - ( - hostname: string, - options: dns.LookupAllOptions, - callback: LookupCallback - ): void; - ( - hostname: string, - options: dns.LookupOptions | dns.LookupOneOptions | dns.LookupAllOptions, - callback: LookupCallback - ): void; - __promisify__: typeof dns.lookup.__promisify__; -} & typeof dns.lookup; - -interface DnsCache { - addresses: { ipv4: string[]; ipv6: string[] }; - activeAddress: string; - family: number; - timestamp: number; - ttl: number; - networkErrors?: number; - hits: number; - misses: number; -} - -interface CacheStats { - hits: number; - misses: number; - failures: number; - ipv4Fallbacks: number; -} - -class DnsCacheManager { - private cache: LRUCache; - private lookupAsync: typeof dns.promises.lookup; - private resolver: dns.promises.Resolver; - private stats: CacheStats = { - hits: 0, - misses: 0, - failures: 0, - ipv4Fallbacks: 0, - }; - private hardTtlMs: number; - private maxRetries: number; - private originalDnsLookup: typeof dns.lookup; - private originalPromisify: any; - - constructor(maxSize = 500, hardTtlMs = 300000, maxRetries = 3) { - this.originalDnsLookup = dns.lookup; - this.originalPromisify = this.originalDnsLookup.__promisify__; - - this.cache = new LRUCache({ - max: maxSize, - ttl: hardTtlMs, - }); - this.hardTtlMs = hardTtlMs; - this.lookupAsync = dns.promises.lookup; - this.resolver = new dns.promises.Resolver(); - this.maxRetries = maxRetries; - } - - public initialize(): void { - const wrappedLookup = (( - hostname: string, - options: - | number - | dns.LookupOneOptions - | dns.LookupOptions - | dns.LookupAllOptions, - callback: LookupCallback - ): void => { - if (typeof options === 'function') { - callback = options; - options = {}; - } - - this.lookup(hostname) - .then((result) => { - if ((options as dns.LookupOptions).all) { - const allAddresses: dns.LookupAddress[] = []; - - result.addresses.ipv4.forEach((addr) => { - allAddresses.push({ address: addr, family: 4 }); - }); - - result.addresses.ipv6.forEach((addr) => { - allAddresses.push({ address: addr, family: 6 }); - }); - - callback( - null, - allAddresses.length > 0 - ? allAddresses - : [{ address: result.activeAddress, family: result.family }] - ); - } else { - callback(null, result.activeAddress, result.family); - } - }) - .catch((error) => { - logger.warn( - `Cached DNS lookup failed for ${hostname}, falling back to native DNS: ${error.message}`, - { - label: 'DNSCache', - } - ); - - try { - this.originalDnsLookup( - hostname, - options as any, - (err, addr, fam) => { - if (!err && addr) { - const cacheEntry = { - addresses: { - ipv4: Array.isArray(addr) - ? addr - .filter((a) => !a.address.includes(':')) - .map((a) => a.address) - : typeof addr === 'string' && !addr.includes(':') - ? [addr] - : [], - ipv6: Array.isArray(addr) - ? addr - .filter((a) => a.address.includes(':')) - .map((a) => a.address) - : typeof addr === 'string' && addr.includes(':') - ? [addr] - : [], - }, - activeAddress: Array.isArray(addr) - ? addr[0]?.address || '' - : addr, - family: Array.isArray(addr) - ? addr[0]?.family || 4 - : fam || 4, - timestamp: Date.now(), - ttl: 60000, - networkErrors: 0, - hits: 0, - misses: 0, - }; - - this.updateCache(hostname, cacheEntry).catch(() => { - logger.debug( - `Failed to update DNS cache for ${hostname}: ${error.message}`, - { - label: 'DNSCache', - } - ); - }); - } - callback(err, addr, fam); - } - ); - return; - } catch (fallbackError) { - logger.error( - `Native DNS fallback also failed for ${hostname}: ${fallbackError.message}`, - { - label: 'DNSCache', - } - ); - } - - callback(error, undefined, undefined); - }); - }) as LookupFunction; - - (wrappedLookup as any).__promisify__ = async function ( - hostname: string, - options?: - | dns.LookupAllOptions - | dns.LookupOneOptions - | number - | dns.LookupOptions - ): Promise { - try { - const result = await this.lookup(hostname); - - if ( - options && - typeof options === 'object' && - 'all' in options && - options.all === true - ) { - const allAddresses: dns.LookupAddress[] = []; - - result.addresses.ipv4.forEach((addr: string) => { - allAddresses.push({ address: addr, family: 4 }); - }); - - result.addresses.ipv6.forEach((addr: string) => { - allAddresses.push({ address: addr, family: 6 }); - }); - - return allAddresses.length > 0 - ? allAddresses - : [{ address: result.activeAddress, family: result.family }]; - } - - return { address: result.activeAddress, family: result.family }; - } catch (error) { - if (this.originalPromisify) { - const nativeResult = await this.originalPromisify( - hostname, - options as any - ); - return nativeResult; - } - throw error; - } - }; - - dns.lookup = wrappedLookup; - } - - async lookup( - hostname: string, - retryCount = 0, - forceIpv4 = false - ): Promise { - if (hostname === 'localhost') { - return { - addresses: { - ipv4: ['127.0.0.1'], - ipv6: ['::1'], - }, - activeAddress: '127.0.0.1', - family: 4, - timestamp: Date.now(), - ttl: 0, - networkErrors: 0, - hits: 0, - misses: 0, - }; - } - - // force ipv4 if configured - const shouldForceIpv4 = forceIpv4; - - const cached = this.cache.get(hostname); - if (cached) { - const age = Date.now() - cached.timestamp; - const ttlRemaining = Math.max(0, cached.ttl - age); - - if (ttlRemaining > 0) { - if ( - shouldForceIpv4 && - cached.family === 6 && - cached.addresses.ipv4.length > 0 - ) { - const ipv4Address = cached.addresses.ipv4[0]; - logger.debug( - `Switching from IPv6 to IPv4 for ${hostname} in cypress testing`, - { - label: 'DNSCache', - oldAddress: cached.activeAddress, - newAddress: ipv4Address, - } - ); - - this.stats.ipv4Fallbacks++; - return { - ...cached, - activeAddress: ipv4Address, - family: 4, - }; - } - - cached.hits++; - this.stats.hits++; - return cached; - } - - // Soft expiration. Will use stale entry while refreshing - if (age < this.hardTtlMs) { - cached.misses++; - this.stats.misses++; - - // Background refresh - this.resolveWithTtl(hostname) - .then((result) => { - const preferredFamily = shouldForceIpv4 ? 4 : 6; - - const activeAddress = this.selectActiveAddress( - result.addresses, - preferredFamily - ); - const family = activeAddress.includes(':') ? 6 : 4; - - const existing = this.cache.get(hostname); - this.cache.set(hostname, { - addresses: result.addresses, - activeAddress, - family, - timestamp: Date.now(), - ttl: result.ttl, - networkErrors: 0, - hits: existing?.hits ?? 0, - misses: (existing?.misses ?? 0) + 1, - }); - }) - .catch((error) => { - logger.error( - `Failed to refresh DNS for ${hostname}: ${error.message}` - ); - }); - - return cached; - } - - // Hard expiration to remove stale entry - this.stats.misses++; - this.cache.delete(hostname); - } - - try { - const result = await this.resolveWithTtl(hostname); - - const preferredFamily = shouldForceIpv4 ? 4 : 6; - - const activeAddress = this.selectActiveAddress( - result.addresses, - preferredFamily - ); - const family = activeAddress.includes(':') ? 6 : 4; - - const existingMisses = this.cache.get(hostname)?.misses ?? 0; - - const dnsCache: DnsCache = { - addresses: result.addresses, - activeAddress, - family, - timestamp: Date.now(), - ttl: result.ttl, - networkErrors: 0, - hits: 0, - misses: existingMisses + 1, - }; - - this.cache.set(hostname, dnsCache); - - return dnsCache; - } catch (error) { - this.stats.failures++; - - if (retryCount < this.maxRetries) { - const backoff = Math.min(100 * Math.pow(2, retryCount), 2000); - logger.warn( - `DNS lookup failed for ${hostname}, retrying (${retryCount + 1}/${ - this.maxRetries - }) after ${backoff}ms`, - { - label: 'DNSCache', - error: error.message, - } - ); - - await new Promise((resolve) => setTimeout(resolve, backoff)); - - // If this is the last retry and was using IPv6 then force IPv4 - const shouldTryIpv4 = retryCount === this.maxRetries - 1 && !forceIpv4; - - return this.lookup(hostname, retryCount + 1, shouldTryIpv4); - } - - // If there is a stale entry, use it as last resort - const staleEntry = this.getStaleEntry(hostname); - if (staleEntry) { - logger.warn( - `Using expired DNS cache as fallback for ${hostname} after ${this.maxRetries} failed lookups`, - { - label: 'DNSCache', - activeAddress: staleEntry.activeAddress, - } - ); - - // If cypress testing and IPv4 addresses are available, use those instead - if ( - shouldForceIpv4 && - staleEntry.family === 6 && - staleEntry.addresses.ipv4.length > 0 - ) { - this.stats.ipv4Fallbacks++; - const ipv4Address = staleEntry.addresses.ipv4[0]; - logger.debug( - `Switching expired cache from IPv6 to IPv4 for ${hostname} in test mode`, - { - label: 'DNSCache', - oldAddress: staleEntry.activeAddress, - newAddress: ipv4Address, - } - ); - - return { - ...staleEntry, - activeAddress: ipv4Address, - family: 4, - timestamp: Date.now(), - ttl: 60000, - }; - } - - return { - ...staleEntry, - timestamp: Date.now(), - ttl: 60000, - }; - } - - throw new Error( - `DNS lookup failed for ${hostname} after ${this.maxRetries} retries: ${error.message}` - ); - } - } - - private selectActiveAddress( - addresses: { ipv4: string[]; ipv6: string[] }, - preferredFamily: number - ): string { - if (preferredFamily === 4) { - return addresses.ipv4.length > 0 - ? addresses.ipv4[0] - : addresses.ipv6.length > 0 - ? addresses.ipv6[0] - : '127.0.0.1'; - } else { - return addresses.ipv6.length > 0 - ? addresses.ipv6[0] - : addresses.ipv4.length > 0 - ? addresses.ipv4[0] - : '127.0.0.1'; - } - } - - private getStaleEntry(hostname: string): DnsCache | null { - const entry = (this.cache as any).store.get(hostname)?.value; - if (entry) { - if (!entry.addresses && entry.address) { - return { - addresses: { - ipv4: entry.family === 4 ? [entry.address] : [], - ipv6: entry.family === 6 ? [entry.address] : [], - }, - activeAddress: entry.address, - family: entry.family, - timestamp: entry.timestamp, - ttl: entry.ttl, - networkErrors: 0, - hits: entry.hits, - misses: entry.misses, - }; - } - return entry; - } - return null; - } - - private async resolveWithTtl( - hostname: string - ): Promise<{ addresses: { ipv4: string[]; ipv6: string[] }; ttl: number }> { - if ( - !this.resolver || - typeof this.resolver.resolve4 !== 'function' || - typeof this.resolver.resolve6 !== 'function' - ) { - throw new Error('Resolver is not initialized'); - } - - try { - const [ipv4Records, ipv6Records] = await Promise.allSettled([ - this.resolver.resolve4(hostname, { ttl: true }), - this.resolver.resolve6(hostname, { ttl: true }), - ]); - - const addresses = { - ipv4: [] as string[], - ipv6: [] as string[], - }; - - let minTtl = 300; - - if (ipv4Records.status === 'fulfilled' && ipv4Records.value.length > 0) { - addresses.ipv4 = ipv4Records.value.map((record) => record.address); - - // Find minimum TTL from IPv4 records - const ipv4MinTtl = Math.min( - ...ipv4Records.value.map((r) => r.ttl || 300) - ); - if (ipv4MinTtl > 0 && ipv4MinTtl < minTtl) { - minTtl = ipv4MinTtl; - } - } - - if (ipv6Records.status === 'fulfilled' && ipv6Records.value.length > 0) { - addresses.ipv6 = ipv6Records.value.map((record) => record.address); - - // Find minimum TTL from IPv6 records - const ipv6MinTtl = Math.min( - ...ipv6Records.value.map((r) => r.ttl || 300) - ); - if (ipv6MinTtl > 0 && ipv6MinTtl < minTtl) { - minTtl = ipv6MinTtl; - } - } - - if (addresses.ipv4.length === 0 && addresses.ipv6.length === 0) { - throw new Error(`No DNS records found for ${hostname}`); - } - - const ttlMs = minTtl * 1000; - - return { addresses, ttl: ttlMs }; - } catch (error) { - logger.error(`Failed to resolve ${hostname} with TTL: ${error.message}`, { - label: 'DNSCache', - }); - throw error; - } - } - - /** - * Updates the cache with an externally provided entry - * Used for updating cache from fallback DNS lookups - */ - async updateCache(hostname: string, entry: DnsCache): Promise { - if (!entry || !entry.activeAddress || !entry.addresses) { - throw new Error('Invalid cache entry provided'); - } - - const validatedEntry: DnsCache = { - addresses: { - ipv4: Array.isArray(entry.addresses.ipv4) ? entry.addresses.ipv4 : [], - ipv6: Array.isArray(entry.addresses.ipv6) ? entry.addresses.ipv6 : [], - }, - activeAddress: entry.activeAddress, - family: entry.family || (entry.activeAddress.includes(':') ? 6 : 4), - timestamp: entry.timestamp || Date.now(), - ttl: entry.ttl || 60000, - networkErrors: entry.networkErrors || 0, - hits: entry.hits || 0, - misses: entry.misses || 0, - }; - - if ( - validatedEntry.addresses.ipv4.length === 0 && - validatedEntry.addresses.ipv6.length === 0 - ) { - if (validatedEntry.activeAddress.includes(':')) { - validatedEntry.addresses.ipv6.push(validatedEntry.activeAddress); - } else { - validatedEntry.addresses.ipv4.push(validatedEntry.activeAddress); - } - } - - const existing = this.cache.get(hostname); - if (existing) { - const mergedEntry: DnsCache = { - addresses: { - ipv4: [ - ...new Set([ - ...existing.addresses.ipv4, - ...validatedEntry.addresses.ipv4, - ]), - ], - ipv6: [ - ...new Set([ - ...existing.addresses.ipv6, - ...validatedEntry.addresses.ipv6, - ]), - ], - }, - activeAddress: validatedEntry.activeAddress, - family: validatedEntry.family, - timestamp: validatedEntry.timestamp, - ttl: validatedEntry.ttl, - networkErrors: 0, - hits: 0, - misses: 0, - }; - - this.cache.set(hostname, mergedEntry); - logger.debug(`Updated DNS cache for ${hostname} with merged entry`, { - label: 'DNSCache', - addresses: { - ipv4: mergedEntry.addresses.ipv4.length, - ipv6: mergedEntry.addresses.ipv6.length, - }, - activeAddress: mergedEntry.activeAddress, - family: mergedEntry.family, - }); - } else { - this.cache.set(hostname, validatedEntry); - logger.debug(`Added new DNS cache entry for ${hostname}`, { - label: 'DNSCache', - addresses: { - ipv4: validatedEntry.addresses.ipv4.length, - ipv6: validatedEntry.addresses.ipv6.length, - }, - activeAddress: validatedEntry.activeAddress, - family: validatedEntry.family, - }); - } - - return Promise.resolve(); - } - - /** - * Fallback DNS lookup when cache fails - * Respects system DNS configuration - */ - async fallbackLookup(hostname: string): Promise { - logger.warn(`Performing fallback DNS lookup for ${hostname}`, { - label: 'DNSCache', - }); - - // Try different DNS resolution methods - const strategies = [ - this.tryNodeDefaultLookup.bind(this), - this.tryNodePromisesLookup.bind(this), - ]; - - let lastError: Error | null = null; - - for (const strategy of strategies) { - try { - const result = await strategy(hostname); - if ( - result && - (result.addresses.ipv4.length > 0 || result.addresses.ipv6.length > 0) - ) { - return result; - } - } catch (error) { - lastError = error; - logger.debug( - `Fallback strategy failed for ${hostname}: ${error.message}`, - { - label: 'DNSCache', - strategy: strategy.name, - } - ); - } - } - - throw ( - lastError || - new Error(`All DNS fallback strategies failed for ${hostname}`) - ); - } - - /** - * Attempt lookup using Node's default dns.lookup - */ - private async tryNodeDefaultLookup(hostname: string): Promise { - return new Promise((resolve, reject) => { - dns.lookup(hostname, { all: true }, (err, addresses) => { - if (err) { - reject(err); - return; - } - - if (!addresses || addresses.length === 0) { - reject(new Error('No addresses returned')); - return; - } - - const ipv4Addresses = addresses - .filter((a) => a.family === 4) - .map((a) => a.address); - - const ipv6Addresses = addresses - .filter((a) => a.family === 6) - .map((a) => a.address); - - let activeAddress: string; - let family: number; - - if (ipv6Addresses.length > 0) { - activeAddress = ipv6Addresses[0]; - family = 6; - } else if (ipv4Addresses.length > 0) { - activeAddress = ipv4Addresses[0]; - family = 4; - } else { - reject(new Error('No valid addresses found')); - return; - } - - resolve({ - addresses: { ipv4: ipv4Addresses, ipv6: ipv6Addresses }, - activeAddress, - family, - timestamp: Date.now(), - ttl: 60000, - networkErrors: 0, - hits: 0, - misses: 0, - }); - }); - }); - } - - /** - * Try lookup using Node's dns.promises API directly - * This uses a different internal implementation than dns.lookup - */ - private async tryNodePromisesLookup(hostname: string): Promise { - const resolver = new dns.promises.Resolver(); - - const [ipv4Results, ipv6Results] = await Promise.allSettled([ - resolver.resolve4(hostname).catch(() => []), - resolver.resolve6(hostname).catch(() => []), - ]); - - const ipv4Addresses = - ipv4Results.status === 'fulfilled' ? ipv4Results.value : []; - const ipv6Addresses = - ipv6Results.status === 'fulfilled' ? ipv6Results.value : []; - - if (ipv4Addresses.length === 0 && ipv6Addresses.length === 0) { - throw new Error('No addresses resolved'); - } - - let activeAddress: string; - let family: number; - - if (ipv6Addresses.length > 0) { - activeAddress = ipv6Addresses[0]; - family = 6; - } else { - activeAddress = ipv4Addresses[0]; - family = 4; - } - - return { - addresses: { ipv4: ipv4Addresses, ipv6: ipv6Addresses }, - activeAddress, - family, - timestamp: Date.now(), - ttl: 30000, - networkErrors: 0, - hits: 0, - misses: 0, - }; - } - - reportNetworkError(hostname: string) { - const entry = this.cache.get(hostname); - if (entry) { - if (!entry.addresses && (entry as any).address) { - const oldEntry = entry as any; - entry.addresses = { - ipv4: oldEntry.family === 4 ? [oldEntry.address] : [], - ipv6: oldEntry.family === 6 ? [oldEntry.address] : [], - }; - entry.activeAddress = oldEntry.address; - delete (entry as any).address; - } - - entry.networkErrors = (entry.networkErrors || 0) + 1; - - // If there are multiple network errors for this address and alternatives exist, then switch - if (entry.networkErrors > 2) { - if (entry.family === 6 && entry.addresses.ipv4.length > 0) { - logger.info( - `Switching ${hostname} from IPv6 to IPv4 after network errors`, - { - label: 'DNSCache', - oldAddress: entry.activeAddress, - newAddress: entry.addresses.ipv4[0], - errors: entry.networkErrors, - } - ); - - entry.activeAddress = entry.addresses.ipv4[0]; - entry.family = 4; - entry.networkErrors = 0; - } else if (entry.family === 4 && entry.addresses.ipv4.length > 1) { - const currentIndex = entry.addresses.ipv4.indexOf( - entry.activeAddress - ); - const nextIndex = (currentIndex + 1) % entry.addresses.ipv4.length; - - logger.info( - `Rotating to next IPv4 address for ${hostname} after network errors`, - { - label: 'DNSCache', - oldAddress: entry.activeAddress, - newAddress: entry.addresses.ipv4[nextIndex], - errors: entry.networkErrors, - } - ); - - entry.activeAddress = entry.addresses.ipv4[nextIndex]; - entry.networkErrors = 0; - } - } - } - } - - getStats() { - return { - size: this.cache.size, - maxSize: this.cache.max, - hits: this.stats.hits, - misses: this.stats.misses, - failures: this.stats.failures, - ipv4Fallbacks: this.stats.ipv4Fallbacks, - hitRate: this.stats.hits / (this.stats.hits + this.stats.misses || 1), - }; - } - - getCacheEntries() { - const entries: Record< - string, - { - addresses: { ipv4: number; ipv6: number }; - activeAddress: string; - family: number; - age: number; - ttl: number; - networkErrors?: number; - hits: number; - misses: number; - } - > = {}; - - for (const [hostname, data] of this.cache.entries()) { - const age = Date.now() - data.timestamp; - const ttl = Math.max(0, data.ttl - age); - - entries[hostname] = { - addresses: { - ipv4: data.addresses.ipv4.length, - ipv6: data.addresses.ipv6.length, - }, - activeAddress: data.activeAddress, - family: data.family, - age, - ttl, - networkErrors: data.networkErrors, - hits: data.hits, - misses: data.misses, - }; - } - - return entries; - } - - getCacheEntry(hostname: string) { - const entry = this.cache.get(hostname); - if (!entry) { - return null; - } - - return { - addresses: { - ipv4: entry.addresses.ipv4.length, - ipv6: entry.addresses.ipv6.length, - }, - activeAddress: entry.activeAddress, - family: entry.family, - age: Date.now() - entry.timestamp, - ttl: Math.max(0, entry.ttl - (Date.now() - entry.timestamp)), - networkErrors: entry.networkErrors, - }; - } - - clearHostname(hostname: string): void { - if (!hostname || hostname.length === 0) { - return; - } - - if (this.cache.has(hostname)) { - this.cache.delete(hostname); - logger.debug(`Cleared DNS cache entry for ${hostname}`, { - label: 'DNSCache', - }); - } - } - - clear(hostname?: string): void { - if (hostname && hostname.length > 0) { - this.clearHostname(hostname); - return; - } - - this.cache.clear(); - this.stats.hits = 0; - this.stats.misses = 0; - this.stats.failures = 0; - this.stats.ipv4Fallbacks = 0; - logger.debug('DNS cache cleared', { label: 'DNSCache' }); - } -} - -export const dnsCache = new DnsCacheManager();