From ca838a00fa4acb0ccdfbac8be4cf7fde493346f7 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 31 Oct 2024 16:10:45 +0100 Subject: [PATCH] feat: add bypass list, bypass local addresses and username/password to proxy setting (#1059) * fix: use fs/promises for settings This PR switches from synchronous operations with the 'fs' module to asynchronous operations with the 'fs/promises' module. It also corrects a small error with hostname migration. * fix: add missing merge function of default and current config * feat: add bypass list, bypass local addresses and username/password to proxy setting This PR adds more options to the proxy setting, like username/password authentication, bypass list of domains and bypass local addresses. The UX is taken from *arrs. * fix: add error handling for proxy creating * fix: remove logs --- server/index.ts | 6 +- server/lib/settings/index.ts | 24 +- server/utils/customProxyAgent.ts | 111 +++++++++ server/utils/restartFlag.ts | 2 +- .../Settings/SettingsMain/index.tsx | 216 ++++++++++++++++-- 5 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 server/utils/customProxyAgent.ts diff --git a/server/index.ts b/server/index.ts index f37d7522c..cd65d566a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,6 +23,7 @@ import avatarproxy from '@server/routes/avatarproxy'; 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 restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import { TypeormStore } from 'connect-typeorm/out'; @@ -38,7 +39,6 @@ import dns from 'node:dns'; import net from 'node:net'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; -import { ProxyAgent, setGlobalDispatcher } from 'undici'; import YAML from 'yamljs'; if (process.env.forceIpv4First === 'true') { @@ -76,8 +76,8 @@ app restartFlag.initializeSettings(settings.main); // Register HTTP proxy - if (settings.main.httpProxy) { - setGlobalDispatcher(new ProxyAgent(settings.main.httpProxy)); + if (settings.main.proxy.enabled) { + await createCustomProxyAgent(settings.main.proxy); } // Migrate library types diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index d0e6166d0..29447f534 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -99,6 +99,17 @@ interface Quota { quotaDays?: number; } +export interface ProxySettings { + enabled: boolean; + hostname: string; + port: number; + useSsl: boolean; + user: string; + password: string; + bypassFilter: string; + bypassLocalAddresses: boolean; +} + export interface MainSettings { apiKey: string; applicationTitle: string; @@ -119,7 +130,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; locale: string; - httpProxy: string; + proxy: ProxySettings; } interface PublicSettings { @@ -326,7 +337,16 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, locale: 'en', - httpProxy: '', + proxy: { + enabled: false, + hostname: '', + port: 8080, + useSsl: false, + user: '', + password: '', + bypassFilter: '', + bypassLocalAddresses: true, + }, }, plex: { name: '', diff --git a/server/utils/customProxyAgent.ts b/server/utils/customProxyAgent.ts new file mode 100644 index 000000000..3b6223685 --- /dev/null +++ b/server/utils/customProxyAgent.ts @@ -0,0 +1,111 @@ +import type { ProxySettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { Dispatcher } from 'undici'; +import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; + +export default async function createCustomProxyAgent( + proxySettings: ProxySettings +) { + const defaultAgent = new Agent(); + + const skipUrl = (url: string) => { + const hostname = new URL(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) => { + const url = opts.origin?.toString(); + return url && skipUrl(url) + ? 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 proxyAgent = new ProxyAgent({ + uri: + (proxySettings.useSsl ? 'https://' : 'http://') + + proxySettings.hostname + + ':' + + proxySettings.port, + token, + interceptors: { + Client: [noProxyInterceptor], + }, + }); + + setGlobalDispatcher(proxyAgent); + } catch (e) { + logger.error('Failed to connect to the proxy: ' + e.message, { + label: 'Proxy', + }); + setGlobalDispatcher(defaultAgent); + return; + } + + try { + const res = await fetch('https://www.google.com', { method: 'HEAD' }); + if (res.ok) { + logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' }); + } else { + logger.error('Proxy responded, but with a non-OK status: ' + res.status, { + label: 'Proxy', + }); + setGlobalDispatcher(defaultAgent); + } + } 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') { + 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; +} diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts index bb5f011d5..18d03ea64 100644 --- a/server/utils/restartFlag.ts +++ b/server/utils/restartFlag.ts @@ -14,7 +14,7 @@ class RestartFlag { return ( this.settings.csrfProtection !== settings.csrfProtection || this.settings.trustProxy !== settings.trustProxy || - this.settings.httpProxy !== settings.httpProxy + this.settings.proxy.enabled !== settings.proxy.enabled ); } } diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index b4fdea783..2d1e0219f 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -55,8 +55,17 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', locale: 'Display Language', - httpProxy: 'HTTP Proxy', - httpProxyTip: 'Tooltip to write', + proxyEnabled: 'HTTP(S) Proxy', + proxyHostname: 'Proxy Hostname', + proxyPort: 'Proxy Port', + proxySsl: 'Use SSL For Proxy', + proxyUser: 'Proxy Username', + proxyPassword: 'Proxy Password', + proxyBypassFilter: 'Proxy Ignored Addresses', + proxyBypassFilterTip: + "Use ',' as a separator, and '*.' as a wildcard for subdomains", + proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses', + validationProxyPort: 'You must provide a valid port', }); const SettingsMain = () => { @@ -84,9 +93,12 @@ const SettingsMain = () => { intl.formatMessage(messages.validationApplicationUrlTrailingSlash), (value) => !value || !value.endsWith('/') ), - httpProxy: Yup.string().url( - intl.formatMessage(messages.validationApplicationUrl) - ), + proxyPort: Yup.number().when('proxyEnabled', { + is: (proxyEnabled: boolean) => proxyEnabled, + then: Yup.number().required( + intl.formatMessage(messages.validationProxyPort) + ), + }), }); const regenerate = async () => { @@ -142,7 +154,14 @@ const SettingsMain = () => { partialRequestsEnabled: data?.partialRequestsEnabled, trustProxy: data?.trustProxy, cacheImages: data?.cacheImages, - httpProxy: data?.httpProxy, + proxyEnabled: data?.proxy?.enabled, + proxyHostname: data?.proxy?.hostname, + proxyPort: data?.proxy?.port, + proxySsl: data?.proxy?.useSsl, + proxyUser: data?.proxy?.user, + proxyPassword: data?.proxy?.password, + proxyBypassFilter: data?.proxy?.bypassFilter, + proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses, }} enableReinitialize validationSchema={MainSettingsSchema} @@ -164,7 +183,16 @@ const SettingsMain = () => { partialRequestsEnabled: values.partialRequestsEnabled, trustProxy: values.trustProxy, cacheImages: values.cacheImages, - httpProxy: values.httpProxy, + proxy: { + enabled: values.proxyEnabled, + hostname: values.proxyHostname, + port: values.proxyPort, + useSsl: values.proxySsl, + user: values.proxyUser, + password: values.proxyPassword, + bypassFilter: values.proxyBypassFilter, + bypassLocalAddresses: values.proxyBypassLocalAddresses, + }, }), }); if (!res.ok) throw new Error(); @@ -445,27 +473,175 @@ const SettingsMain = () => {
-
+ {values.proxyEnabled && ( + <> +
+ +
+
+ +
+ {errors.proxyHostname && + touched.proxyHostname && + typeof errors.proxyHostname === 'string' && ( +
{errors.proxyHostname}
+ )} +
+
+
+ +
+
+ +
+ {errors.proxyPort && + touched.proxyPort && + typeof errors.proxyPort === 'string' && ( +
{errors.proxyPort}
+ )} +
+
+
+ +
+ { + setFieldValue('proxySsl', !values.proxySsl); + }} + /> +
+
+
+ +
+
+ +
+ {errors.proxyUser && + touched.proxyUser && + typeof errors.proxyUser === 'string' && ( +
{errors.proxyUser}
+ )} +
+
+
+ +
+
+ +
+ {errors.proxyPassword && + touched.proxyPassword && + typeof errors.proxyPassword === 'string' && ( +
{errors.proxyPassword}
+ )} +
+
+
+ +
+
+ +
+ {errors.proxyBypassFilter && + touched.proxyBypassFilter && + typeof errors.proxyBypassFilter === 'string' && ( +
+ {errors.proxyBypassFilter} +
+ )} +
+
+
+ +
+ { + setFieldValue( + 'proxyBypassLocalAddresses', + !values.proxyBypassLocalAddresses + ); + }} + /> +
+
+ + )}