refactor: use our own dns-caching package instead

This commit is contained in:
Gauthier
2025-06-19 11:46:37 +02:00
committed by gauthier-th
parent e45a6c530b
commit 1eec1823d5
4 changed files with 24 additions and 920 deletions

View File

@@ -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",

16
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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);

View File

@@ -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<string, DnsCache>;
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<string, DnsCache>({
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<dns.LookupAddress[] | { address: string; family: number }> {
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<DnsCache> {
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<void> {
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<DnsCache> {
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<DnsCache> {
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<DnsCache> {
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();