Files
seerr/server/utils/customProxyAgent.ts
fallenbagel 84accb544a fix(proxy): proxy clients built before proxy init
Proxying for axios relied on the per-instance httpAgent/httpsAgent, which axios.create() snapshots
from axios.defaults at construction time. API clients constructed at import (such as
BaseScanner.tmdb, avalabilitySync.tmdb) are built before createCustomProxyAgent runs, so they
snapshotted no proxy agent and bypassed the proxy permanently, while clients created per-request
afterwards were proxied. This fix resolves the agent per request from a live module-level holder via
a stable interceptor that is registered on the default instance and every ExternalAPI client, so
construction order no longer matters. forceIpv4First has been moved to the same path logic as well.

fix #3193
2026-06-23 10:13:06 +08:00

189 lines
4.9 KiB
TypeScript

import type { ProxySettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { InternalAxiosRequestConfig } from 'axios';
import axios from 'axios';
import http from 'http';
import { HttpProxyAgent } from 'http-proxy-agent';
import https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type { Dispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
interface ProxyState {
httpAgent: HttpProxyAgent<string>;
httpsAgent: HttpsProxyAgent<string>;
skipUrl: (url: string | URL) => boolean;
}
let proxyState: ProxyState | null = null;
let ipv4Agents: { httpAgent: http.Agent; httpsAgent: https.Agent } | null =
null;
export function setForceIpv4First(enabled: boolean) {
ipv4Agents = enabled
? {
httpAgent: new http.Agent({ family: 4 }),
httpsAgent: new https.Agent({ family: 4 }),
}
: null;
}
export function proxyRequestInterceptor(
config: InternalAxiosRequestConfig
): InternalAxiosRequestConfig {
let url: URL | undefined;
try {
if (config.baseURL) {
url = new URL(config.url ?? '', config.baseURL);
} else if (config.url) {
url = new URL(config.url);
}
} catch {
url = undefined;
}
if (proxyState) {
if (url && proxyState.skipUrl(url)) {
config.httpAgent = ipv4Agents?.httpAgent ?? false;
config.httpsAgent = ipv4Agents?.httpsAgent ?? false;
} else {
config.httpAgent = proxyState.httpAgent;
config.httpsAgent = proxyState.httpsAgent;
}
config.proxy = false;
} else if (ipv4Agents) {
config.httpAgent = ipv4Agents.httpAgent;
config.httpsAgent = ipv4Agents.httpsAgent;
}
return config;
}
// default instance only, axios.create() clients register this themselves
axios.interceptors.request.use(proxyRequestInterceptor);
export default async function createCustomProxyAgent(
proxySettings: ProxySettings,
forceIpv4First?: boolean
) {
const defaultAgent = new Agent({
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
const skipUrl = (url: string | URL) => {
const hostname =
typeof url === 'string' ? new URL(url).hostname : url.hostname;
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
return true;
}
for (const address of proxySettings.bypassFilter.split(',')) {
const trimmedAddress = address.trim();
if (!trimmedAddress) {
continue;
}
if (trimmedAddress.startsWith('*')) {
const domain = trimmedAddress.slice(1);
if (hostname.endsWith(domain)) {
return true;
}
} else if (hostname === trimmedAddress) {
return true;
}
}
return false;
};
const noProxyInterceptor = (
dispatch: Dispatcher['dispatch']
): Dispatcher['dispatch'] => {
return (opts, handler) => {
return opts.origin && skipUrl(opts.origin)
? defaultAgent.dispatch(opts, handler)
: dispatch(opts, handler);
};
};
const token =
proxySettings.user && proxySettings.password
? `Basic ${Buffer.from(
`${proxySettings.user}:${proxySettings.password}`
).toString('base64')}`
: undefined;
try {
const proxyUrl = `${proxySettings.useSsl ? 'https' : 'http'}://${
proxySettings.hostname
}:${proxySettings.port}`;
const proxyAgent = new ProxyAgent({
uri: proxyUrl,
token,
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
const agentOptions = {
headers: token ? { 'proxy-authorization': token } : undefined,
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 5000,
scheduling: 'lifo' as const,
family: forceIpv4First ? 4 : undefined,
};
proxyState = {
httpAgent: new HttpProxyAgent(proxyUrl, agentOptions),
httpsAgent: new HttpsProxyAgent(proxyUrl, agentOptions),
skipUrl,
};
} catch (e) {
logger.error('Failed to connect to the proxy: ' + e.message, {
label: 'Proxy',
});
setGlobalDispatcher(defaultAgent);
proxyState = null;
return;
}
try {
await axios.head('https://www.google.com');
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
} catch (e) {
logger.error(
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
{ label: 'Proxy' }
);
setGlobalDispatcher(defaultAgent);
}
}
function isLocalAddress(hostname: string) {
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1'
) {
return true;
}
const privateIpRanges = [
/^10\./, // 10.x.x.x
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x
/^192\.168\./, // 192.168.x.x
];
if (privateIpRanges.some((regex) => regex.test(hostname))) {
return true;
}
return false;
}