diff --git a/server/utils/dnsCacheManager.ts b/server/utils/dnsCacheManager.ts index 70138b3f..22c2ec8a 100644 --- a/server/utils/dnsCacheManager.ts +++ b/server/utils/dnsCacheManager.ts @@ -1,3 +1,4 @@ +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { LRUCache } from 'lru-cache'; import dns from 'node:dns'; @@ -6,6 +7,7 @@ interface DnsCache { address: string; family: number; timestamp: number; + ttl: number; } interface CacheStats { @@ -16,50 +18,187 @@ interface CacheStats { class DnsCacheManager { private cache: LRUCache; private lookupAsync: typeof dns.promises.lookup; + private resolver: dns.promises.Resolver; private stats: CacheStats = { hits: 0, misses: 0, }; + private hardTtlMs: number; - constructor(ttlMs = 300000) { - this.cache = new LRUCache({ max: 500, ttl: ttlMs }); + constructor(maxSize = 500, hardTtlMs = 300000) { + this.cache = new LRUCache({ + max: maxSize, + ttl: hardTtlMs, + }); + this.hardTtlMs = hardTtlMs; this.lookupAsync = dns.promises.lookup; + this.resolver = new dns.promises.Resolver(); } async lookup(hostname: string): Promise { + // Ignore for localhost + if (hostname === 'localhost') { + return { + address: '127.0.0.1', + family: 4, + timestamp: Date.now(), + ttl: 0, + }; + } + const cached = this.cache.get(hostname); if (cached) { - this.stats.hits++; - logger.debug(`DNS cache hit for ${hostname}`, { - label: 'DNSCache', - address: cached.address, - family: cached.family, - age: Date.now() - cached.timestamp, - }); - return cached; + const age = Date.now() - cached.timestamp; + const ttlRemaining = Math.max(0, cached.ttl - age); + + if (ttlRemaining > 0) { + this.stats.hits++; + logger.debug(`DNS cache hit for ${hostname}`, { + label: 'DNSCache', + address: cached.address, + family: cached.family, + age, + ttlRemaining, + }); + return cached; + } + + // soft expiration using stale entry while refreshing + if (age < this.hardTtlMs) { + this.stats.hits++; + logger.debug(`Using stale DNS cache for ${hostname}`, { + label: 'DNSCache', + address: cached.address, + family: cached.family, + age, + ttlRemaining, + }); + + // revalidation + this.resolveWithTtl(hostname) + .then((result) => { + this.cache.set(hostname, { + address: result.address, + family: result.family, + timestamp: Date.now(), + ttl: result.ttl, + }); + logger.debug(`DNS cache refreshed for ${hostname}`, { + label: 'DNSCache', + address: result.address, + family: result.family, + ttl: result.ttl, + }); + }) + .catch((error) => { + logger.error( + `Failed to refresh DNS for ${hostname}: ${error.message}` + ); + }); + + return cached; + } + + // hard expiration: remove stale entry + this.cache.delete(hostname); } this.stats.misses++; try { - const result = await this.lookupAsync(hostname); + const result = await this.resolveWithTtl(hostname); + const dnsCache: DnsCache = { address: result.address, family: result.family, timestamp: Date.now(), + ttl: result.ttl, }; - this.cache.set(hostname, dnsCache); + this.cache.set(hostname, dnsCache, { ttl: this.hardTtlMs }); logger.debug(`DNS cache miss for ${hostname}, cached new result`, { label: 'DNSCache', address: dnsCache.address, family: dnsCache.family, + ttl: result.ttl, }); + return dnsCache; } catch (error) { throw new Error(`DNS lookup failed for ${hostname}: ${error.message}`); } } + private async resolveWithTtl( + hostname: string + ): Promise<{ address: string; family: number; 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 }), + ]); + + let record: { address: string; ttl: number } | null = null; + let family = 4; + + const settings = getSettings(); + const preferIpv6 = settings.main.forceIpv4First ? false : true; + + if (preferIpv6) { + if ( + ipv6Records.status === 'fulfilled' && + ipv6Records.value.length > 0 + ) { + record = ipv6Records.value[0]; + family = 6; + } else if ( + ipv4Records.status === 'fulfilled' && + ipv4Records.value.length > 0 + ) { + record = ipv4Records.value[0]; + family = 4; + } + } else { + if ( + ipv4Records.status === 'fulfilled' && + ipv4Records.value.length > 0 + ) { + record = ipv4Records.value[0]; + family = 4; + } else if ( + ipv6Records.status === 'fulfilled' && + ipv6Records.value.length > 0 + ) { + record = ipv6Records.value[0]; + family = 6; + } + } + + if (!record) { + throw new Error('No DNS records found for ${hostname'); + } + + const ttl = record.ttl > 0 ? record.ttl * 1000 : 30000; + logger.debug( + `Resolved ${hostname} with TTL: ${record.ttl} (Original), ${ttl} (Applied)` + ); + + return { address: record.address, family, ttl }; + } catch (error) { + logger.error(`Failed to resolve ${hostname} with TTL: ${error.message}`, { + label: 'DNSCache', + }); + throw error; + } + } + getStats() { const entries = [...this.cache.entries()]; return { @@ -84,7 +223,7 @@ class DnsCacheManager { for (const [hostname, data] of this.cache.entries()) { const age = Date.now() - data.timestamp; - const ttl = (this.cache.ttl ?? 300000) - age; + const ttl = Math.max(0, data.ttl - age); entries[hostname] = { address: data.address,