Compare commits

...

25 Commits

Author SHA1 Message Date
Gauthier
a3feaba230 fix: remove old ipv4first setting 2025-05-07 16:55:20 +02:00
Gauthier
e90134e645 fix: remove FetchAPI-related code 2025-05-07 16:51:09 +02:00
fallenbagel
27b22531c1 refactor: removed useless condition when its always truthy 2025-05-07 16:47:47 +02:00
fallenbagel
85cf40ad10 refactor: remove console logs 2025-05-07 16:47:46 +02:00
fallenbagel
2f21551204 refactor: remove cypress testing options in dnsCacheManager 2025-05-07 16:47:46 +02:00
fallenbagel
e80f17ddcf refactor: use date-fns for formatting age and remove useless code 2025-05-07 16:47:45 +02:00
fallenbagel
00262f70db chore(i18n): extract translation keys 2025-05-07 16:47:44 +02:00
fallenbagel
4a9cc6ff06 feat(dnscache): global stats 2025-05-07 16:47:43 +02:00
fallenbagel
dfdfe38726 fix(dnscache): fix miss counter 2025-05-07 16:47:43 +02:00
fallenbagel
b0fa682bd8 refactor: clean up console logs 2025-05-07 16:47:42 +02:00
fallenbagel
f92c0b89e1 fix(dnscache): use entry specific hits and misses not global 2025-05-07 16:47:41 +02:00
fallenbagel
08282aac6f chore: ignore cypress/config/settings.json 2025-05-07 16:47:41 +02:00
fallenbagel
2bc2a4cf3d chore(cypresssettings): git ignore cypress json settings 2025-05-07 16:47:40 +02:00
fallenbagel
b0eedb16c2 style(cypress): run prettier 2025-05-07 16:47:37 +02:00
fallenbagel
f26cd7e9be feat(dnscache): dns cache entries are now flushable 2025-05-07 16:47:16 +02:00
fallenbagel
82dde0a246 test(cypress): fix cypress testing 2025-05-07 16:47:12 +02:00
fallenbagel
22cea5f23d chore(i18n): extract translation keys 2025-05-07 16:46:22 +02:00
fallenbagel
cdb926e325 feat: make dnsCache optional and enable-able through network settings 2025-05-07 16:46:07 +02:00
fallenbagel
905c52cbdc feat(networksettings): cache dns off by default 2025-05-07 16:45:16 +02:00
fallenbagel
ec028e919f feat: dns cache stats in jobs & cache page (and cleanup) 2025-05-07 16:45:14 +02:00
fallenbagel
3eb63c28cf fix: typos 2025-05-07 16:44:53 +02:00
fallenbagel
0782e671ab feat(dns): improve DNS cache with multi-strategy fallback system
- multiple DNS resolution strategie
- graceful fallbacks between IPv6 and IPv4 addresses
- network error reporting in fetch fix
- compatibility with cypress testing (I HOPE)
2025-05-07 16:44:48 +02:00
fallenbagel
2c3a98265b feat: dynamic ttl which is revalidated while using stale dns cache
This is done as tmdb ttl is very less like 40 seconds so to make sure
any issues wont be caused due to cached dns (previously we were caching
for 5 minutes no matter what ttl)
2025-05-07 16:44:23 +02:00
fallenbagel
45989dd2ab feat: simple implementation of dnscaching 2025-05-07 16:44:22 +02:00
fallenbagel
e324ae4450 feat(dns): implement dns caching 2025-05-07 16:44:17 +02:00
14 changed files with 1282 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ dist/
config/
CHANGELOG.md
pnpm-lock.yaml
cypress/config/settings.cypress.json
# assets
src/assets/

View File

@@ -21,5 +21,11 @@ module.exports = {
rangeEnd: 0, // default: Infinity
},
},
{
files: 'cypress/config/settings.cypress.json',
options: {
rangeEnd: 0,
},
},
],
};

View File

@@ -6,7 +6,6 @@
"apiKey": "testkey",
"applicationTitle": "Jellyseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
@@ -187,5 +186,22 @@
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
}
},
"network": {
"csrfProtection": false,
"trustProxy": false,
"forceIpv4First": false,
"dnsServers": "",
"proxy": {
"enabled": false,
"hostname": "",
"port": 8080,
"useSsl": false,
"user": "",
"password": "",
"bypassFilter": "",
"bypassLocalAddresses": true
},
"dnsCache": true
}
}

View File

@@ -2914,6 +2914,68 @@ paths:
imageCount:
type: number
example: 123
dnsCache:
type: object
properties:
stats:
type: object
properties:
size:
type: number
example: 1
maxSize:
type: number
example: 500
hits:
type: number
example: 19
misses:
type: number
example: 1
failures:
type: number
example: 0
ipv4Fallbacks:
type: number
example: 0
hitRate:
type: number
example: 0.95
entries:
type: array
additionalProperties:
type: object
properties:
addresses:
type: object
properties:
ipv4:
type: number
example: 1
ipv6:
type: number
example: 1
activeAddress:
type: string
example: 127.0.0.1
family:
type: number
example: 4
age:
type: number
example: 10
ttl:
type: number
example: 10
networkErrors:
type: number
example: 0
hits:
type: number
example: 1
misses:
type: number
example: 1
apiCaches:
type: array
items:
@@ -2953,6 +3015,21 @@ paths:
responses:
'204':
description: 'Flushed cache'
/settings/cache/dns/{dnsEntry}/flush:
post:
summary: Flush a specific DNS cache entry
description: Flushes a specific DNS cache entry
tags:
- settings
parameters:
- in: path
name: dnsEntry
required: true
schema:
type: string
responses:
'204':
description: 'Flushed dns cache'
/settings/logs:
get:
summary: Returns logs

View File

@@ -66,6 +66,7 @@
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"lru-cache": "^11.0.2",
"mime": "3",
"next": "^14.2.25",
"node-cache": "5.1.2",

9
pnpm-lock.yaml generated
View File

@@ -110,6 +110,9 @@ importers:
lodash:
specifier: 4.17.21
version: 4.17.21
lru-cache:
specifier: ^11.0.2
version: 11.0.2
mime:
specifier: '3'
version: 3.0.0
@@ -6584,6 +6587,10 @@ packages:
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
engines: {node: 14 || >=16.14}
lru-cache@11.0.2:
resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -17666,6 +17673,8 @@ snapshots:
lru-cache@10.2.2: {}
lru-cache@11.0.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1

View File

@@ -26,6 +26,7 @@ import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import { dnsCache } from '@server/utils/dnsCacheManager';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out';
@@ -39,6 +40,7 @@ import next from 'next';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
import dns from 'node:dns';
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
@@ -73,6 +75,11 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings);
// Add DNS caching
if (settings.network.dnsCache) {
dnsCache.initialize();
}
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);

View File

@@ -61,9 +61,39 @@ export interface CacheItem {
};
}
export interface DNSAddresses {
ipv4: number;
ipv6: number;
}
export interface DNSRecord {
addresses: DNSAddresses;
activeAddress: string;
family: number;
age: number;
ttl: number;
networkErrors: number;
hits: number;
misses: number;
}
export interface DNSStats {
size: number;
maxSize: number;
hits: number;
misses: number;
failures: number;
ipv4Fallbacks: number;
hitRate: number;
}
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
dnsCache: {
entries: Record<string, DNSRecord>;
stats: DNSStats;
};
}
export interface StatusResponse {

View File

@@ -142,6 +142,7 @@ export interface NetworkSettings {
csrfProtection: boolean;
trustProxy: boolean;
proxy: ProxySettings;
dnsCache: boolean;
}
interface PublicSettings {
@@ -555,6 +556,7 @@ class Settings {
bypassFilter: '',
bypassLocalAddresses: true,
},
dnsCache: false,
},
};
if (initialSettings) {

View File

@@ -28,6 +28,7 @@ import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { dnsCache } from '@server/utils/dnsCacheManager';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
@@ -755,12 +756,19 @@ settingsRoutes.get('/cache', async (_req, res) => {
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
const stats = dnsCache.getStats();
const entries = dnsCache.getCacheEntries();
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
dnsCache: {
stats,
entries,
},
});
});
@@ -778,6 +786,20 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
}
);
settingsRoutes.post<{ dnsEntry: string }>(
'/cache/dns/:dnsEntry/flush',
(req, res, next) => {
const dnsEntry = req.params.dnsEntry;
if (dnsCache) {
dnsCache.clear(dnsEntry);
return res.status(204).send();
}
next({ status: 404, message: 'Cache not found.' });
}
);
settingsRoutes.post(
'/initialize',
isAuthenticated(Permission.ADMIN),

View File

@@ -0,0 +1,920 @@
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();

View File

@@ -22,6 +22,7 @@ import type {
import type { JobId } from '@server/lib/settings';
import axios from 'axios';
import cronstrue from 'cronstrue/i18n';
import { formatDuration, intervalToDuration } from 'date-fns';
import { Fragment, useReducer, useState } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -55,6 +56,26 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
cacheksize: 'Key Size',
cachevsize: 'Value Size',
flushcache: 'Flush Cache',
dnsCache: 'DNS Cache',
dnsCacheDescription:
'Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.',
dnscacheflushed: '{hostname} dns cache flushed.',
dnscachename: 'Hostname',
dnscacheactiveaddress: 'Active Address',
dnscachehits: 'Hits',
dnscachemisses: 'Misses',
dnscacheage: 'Age',
dnscachenetworkerrors: 'Network Errors',
flushdnscache: 'Flush DNS Cache',
dnsCacheGlobalStats: 'Global DNS Cache Stats',
dnsCacheGlobalStatsDescription:
'These stats are aggregated across all DNS cache entries.',
size: 'Size',
hits: 'Hits',
misses: 'Misses',
failures: 'Failures',
ipv4Fallbacks: 'IPv4 Fallbacks',
hitRate: 'Hit Rate',
unknownJob: 'Unknown Job',
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
@@ -242,6 +263,18 @@ const SettingsJobs = () => {
cacheRevalidate();
};
const flushDnsCache = async (hostname: string) => {
await axios.post(`/api/v1/settings/cache/dns/${hostname}/flush`);
addToast(
intl.formatMessage(messages.dnscacheflushed, { hostname: hostname }),
{
appearance: 'success',
autoDismiss: true,
}
);
cacheRevalidate();
};
const scheduleJob = async () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
@@ -285,6 +318,18 @@ const SettingsJobs = () => {
}
};
const formatAge = (milliseconds: number): string => {
const duration = intervalToDuration({
start: 0,
end: milliseconds,
});
return formatDuration(duration, {
format: ['minutes', 'seconds'],
zero: false,
});
};
return (
<>
<PageTitle
@@ -567,6 +612,95 @@ const SettingsJobs = () => {
</Table.TBody>
</Table>
</div>
<div>
<h3 className="heading">{intl.formatMessage(messages.dnsCache)}</h3>
<p className="description">
{intl.formatMessage(messages.dnsCacheDescription)}
</p>
</div>
<div className="section">
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.dnscachename)}</Table.TH>
<Table.TH>
{intl.formatMessage(messages.dnscacheactiveaddress)}
</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscachehits)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscachemisses)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscacheage)}</Table.TH>
<Table.TH>
{intl.formatMessage(messages.dnscachenetworkerrors)}
</Table.TH>
<Table.TH></Table.TH>
</tr>
</thead>
<Table.TBody>
{Object.entries(cacheData?.dnsCache.entries || {}).map(
([hostname, data]) => (
<tr key={`cache-list-${hostname}`}>
<Table.TD>{hostname}</Table.TD>
<Table.TD>{data.activeAddress}</Table.TD>
<Table.TD>{intl.formatNumber(data.hits)}</Table.TD>
<Table.TD>{intl.formatNumber(data.misses)}</Table.TD>
<Table.TD>{formatAge(data.age)}</Table.TD>
<Table.TD>{intl.formatNumber(data.networkErrors)}</Table.TD>
<Table.TD alignText="right">
<Button
buttonType="danger"
onClick={() => flushDnsCache(hostname)}
>
<TrashIcon />
<span>{intl.formatMessage(messages.flushdnscache)}</span>
</Button>
</Table.TD>
</tr>
)
)}
</Table.TBody>
</Table>
</div>
<div>
<h3 className="heading">
{intl.formatMessage(messages.dnsCacheGlobalStats)}
</h3>
<p className="description">
{intl.formatMessage(messages.dnsCacheGlobalStatsDescription)}
</p>
</div>
<div className="section">
<Table>
<thead>
<tr>
{Object.entries(cacheData?.dnsCache.stats || {})
.filter(([statName]) => statName !== 'maxSize')
.map(([statName]) => (
<Table.TH key={`dns-stat-header-${statName}`}>
{messages[statName]
? intl.formatMessage(messages[statName])
: statName}
</Table.TH>
))}
</tr>
</thead>
<Table.TBody>
<tr>
{Object.entries(cacheData?.dnsCache.stats || {})
.filter(([statName]) => statName !== 'maxSize')
.map(([statName, statValue]) => (
<Table.TD key={`dns-stat-${statName}`}>
{statName === 'hitRate'
? intl.formatNumber(statValue, {
style: 'percent',
maximumFractionDigits: 2,
})
: intl.formatNumber(statValue)}
</Table.TD>
))}
</tr>
</Table.TBody>
</Table>
</div>
<div className="break-words">
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
<p className="description">

View File

@@ -42,6 +42,11 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
networkDisclaimer:
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
docs: 'documentation',
dnsCache: 'DNS Cache',
dnsCacheTip:
'Enable caching of DNS lookups to optimize performance and avoid making unnecessary API calls',
dnsCacheHoverTip:
'Do NOT enable this if you are experiencing issues with DNS lookups',
});
const SettingsNetwork = () => {
@@ -86,6 +91,7 @@ const SettingsNetwork = () => {
<Formik
initialValues={{
csrfProtection: data?.csrfProtection,
dnsCache: data?.dnsCache,
trustProxy: data?.trustProxy,
proxyEnabled: data?.proxy?.enabled,
proxyHostname: data?.proxy?.hostname,
@@ -103,6 +109,7 @@ const SettingsNetwork = () => {
await axios.post('/api/v1/settings/network', {
csrfProtection: values.csrfProtection,
trustProxy: values.trustProxy,
dnsCache: values.dnsCache,
proxy: {
enabled: values.proxyEnabled,
hostname: values.proxyHostname,
@@ -193,6 +200,33 @@ const SettingsNetwork = () => {
</Tooltip>
</div>
</div>
<div className="form-row">
<label htmlFor="dnsCache" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.dnsCache)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<SettingsBadge badgeType="experimental" className="mr-2" />
<span className="label-tip">
{intl.formatMessage(messages.dnsCacheTip)}
</span>
</label>
<div className="form-input-area">
<Tooltip
content={intl.formatMessage(messages.dnsCacheHoverTip)}
>
<Field
type="checkbox"
id="dnsCache"
name="dnsCache"
onChange={() => {
setFieldValue('dnsCache', !values.dnsCache);
}}
/>
</Tooltip>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">

View File

@@ -886,6 +886,17 @@
"components.Settings.SettingsJobsCache.cachevsize": "Value Size",
"components.Settings.SettingsJobsCache.canceljob": "Cancel Job",
"components.Settings.SettingsJobsCache.command": "Command",
"components.Settings.SettingsJobsCache.dnsCache": "DNS Cache",
"components.Settings.SettingsJobsCache.dnsCacheDescription": "Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.",
"components.Settings.SettingsJobsCache.dnsCacheGlobalStats": "Global DNS Cache Stats",
"components.Settings.SettingsJobsCache.dnsCacheGlobalStatsDescription": "These stats are aggregated across all DNS cache entries.",
"components.Settings.SettingsJobsCache.dnscacheactiveaddress": "Active Address",
"components.Settings.SettingsJobsCache.dnscacheage": "Age",
"components.Settings.SettingsJobsCache.dnscacheflushed": "{hostname} dns cache flushed.",
"components.Settings.SettingsJobsCache.dnscachehits": "Hits",
"components.Settings.SettingsJobsCache.dnscachemisses": "Misses",
"components.Settings.SettingsJobsCache.dnscachename": "Hostname",
"components.Settings.SettingsJobsCache.dnscachenetworkerrors": "Network Errors",
"components.Settings.SettingsJobsCache.download-sync": "Download Sync",
"components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset",
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
@@ -895,12 +906,17 @@
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
"components.Settings.SettingsJobsCache.failures": "Failures",
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
"components.Settings.SettingsJobsCache.flushdnscache": "Flush DNS Cache",
"components.Settings.SettingsJobsCache.hitRate": "Hit Rate",
"components.Settings.SettingsJobsCache.hits": "Hits",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
"components.Settings.SettingsJobsCache.imagecacheDescription": "When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.",
"components.Settings.SettingsJobsCache.imagecachecount": "Images Cached",
"components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size",
"components.Settings.SettingsJobsCache.ipv4Fallbacks": "IPv4 Fallbacks",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Jellyfin Recently Added Scan",
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Something went wrong while saving the job.",
@@ -912,6 +928,7 @@
"components.Settings.SettingsJobsCache.jobsandcache": "Jobs & Cache",
"components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.",
"components.Settings.SettingsJobsCache.jobtype": "Type",
"components.Settings.SettingsJobsCache.misses": "Misses",
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
@@ -921,6 +938,7 @@
"components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
"components.Settings.SettingsJobsCache.runnow": "Run Now",
"components.Settings.SettingsJobsCache.size": "Size",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
"components.Settings.SettingsJobsCache.usersavatars": "Users' Avatars",
@@ -981,6 +999,9 @@
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
"components.Settings.SettingsNetwork.dnsCache": "DNS Cache",
"components.Settings.SettingsNetwork.dnsCacheHoverTip": "Do NOT enable this if you are experiencing issues with DNS lookups",
"components.Settings.SettingsNetwork.dnsCacheTip": "Enable caching of DNS lookups to optimize performance and avoid making unnecessary API calls",
"components.Settings.SettingsNetwork.docs": "documentation",
"components.Settings.SettingsNetwork.network": "Network",
"components.Settings.SettingsNetwork.networkDisclaimer": "Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.",
@@ -1213,7 +1234,7 @@
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign in to your account",
"components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",