From c0df8be83237e323c2c9a5bd02507430a86a00cc Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Thu, 4 Jun 2026 23:24:29 +0800 Subject: [PATCH] =?UTF-8?q?perf(settings):=20=E6=96=B0=E5=A2=9ENO=5FPROXY?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=8E=92=E9=99=A4=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/basic/src/utils/util.request.test.ts | 124 ++++++++++- packages/core/basic/src/utils/util.request.ts | 202 +++++++++++++++--- .../src/system/settings/service/models.ts | 1 + .../settings/service/sys-settings-service.ts | 1 + .../locales/langs/en-US/certd/sys-settings.ts | 3 + .../locales/langs/zh-CN/certd/sys-settings.ts | 3 + .../src/store/settings/api.basic.ts | 1 + .../src/views/sys/settings/tabs/network.vue | 5 + 8 files changed, 311 insertions(+), 29 deletions(-) diff --git a/packages/core/basic/src/utils/util.request.test.ts b/packages/core/basic/src/utils/util.request.test.ts index cf66ef4cb..6617fe1b8 100644 --- a/packages/core/basic/src/utils/util.request.test.ts +++ b/packages/core/basic/src/utils/util.request.test.ts @@ -1,8 +1,9 @@ import { expect } from "chai"; -import { createAxiosService, HttpClient, setGlobalHeaders } from "./util.request.js"; +import { createAgent, createAxiosService, getGlobalAgents, HttpClient, isNoProxyMatched, setGlobalHeaders, setGlobalProxy } from "./util.request.js"; import { ILogger } from "./util.log.js"; const testLogger = { + debug() {}, info() {}, error() {}, } as unknown as ILogger; @@ -10,6 +11,9 @@ const testLogger = { describe("util.request", () => { afterEach(() => { setGlobalHeaders({}); + setGlobalProxy({}); + delete process.env.NO_PROXY; + delete process.env.no_proxy; }); it("should merge global headers without overriding request headers", async () => { @@ -50,4 +54,122 @@ describe("util.request", () => { request: "request", }); }); + + it("should set no_proxy environment variables", () => { + setGlobalProxy({ + httpProxy: "http://127.0.0.1:1080", + httpsProxy: "http://127.0.0.1:1080", + noProxy: "localhost,*.internal.example.com", + }); + + expect(process.env.NO_PROXY).to.equal("localhost,*.internal.example.com"); + expect(process.env.no_proxy).to.equal("localhost,*.internal.example.com"); + }); + + it("should normalize multiline no_proxy environment variables", () => { + setGlobalProxy({ + noProxy: "localhost\n127.0.0.1, 192.168.*\n*.internal.example.com", + }); + + expect(process.env.NO_PROXY).to.equal("localhost,127.0.0.1,192.168.*,*.internal.example.com"); + expect(process.env.no_proxy).to.equal("localhost,127.0.0.1,192.168.*,*.internal.example.com"); + }); + + it("should not change environment variables when creating agents", () => { + process.env.HTTP_PROXY = "http://old-http-proxy"; + process.env.HTTPS_PROXY = "http://old-https-proxy"; + process.env.NO_PROXY = "old.local"; + + createAgent({ + httpProxy: "http://127.0.0.1:1080", + httpsProxy: "http://127.0.0.1:1081", + }); + + expect(process.env.HTTP_PROXY).to.equal("http://old-http-proxy"); + expect(process.env.HTTPS_PROXY).to.equal("http://old-https-proxy"); + expect(process.env.NO_PROXY).to.equal("old.local"); + }); + + it("should bypass global proxy when request host matches no_proxy", async () => { + setGlobalProxy({ + httpProxy: "http://127.0.0.1:1080", + httpsProxy: "http://127.0.0.1:1080", + noProxy: "localhost,.internal.example.com", + }); + + const globalAgents = getGlobalAgents(); + const http = createAxiosService({ logger: testLogger }) as HttpClient; + const res = await http.request({ + url: "https://api.internal.example.com", + method: "get", + logReq: false, + logRes: false, + adapter: async config => { + return { + config, + data: { + usesGlobalHttpAgent: config.httpAgent === globalAgents.httpAgent, + usesGlobalHttpsAgent: config.httpsAgent === globalAgents.httpsAgent, + }, + headers: {}, + status: 200, + statusText: "OK", + }; + }, + }); + + expect(res).to.deep.equal({ + usesGlobalHttpAgent: false, + usesGlobalHttpsAgent: false, + }); + }); + + it("should bypass custom request proxy when request host matches no_proxy", async () => { + setGlobalProxy({ + noProxy: ".internal.example.com", + }); + + const http = createAxiosService({ logger: testLogger }) as HttpClient; + const res = await http.request({ + url: "https://api.internal.example.com", + method: "get", + httpProxy: "http://127.0.0.1:1080", + logReq: false, + logRes: false, + adapter: async config => { + return { + config, + data: { + httpAgent: config.httpAgent?.constructor?.name, + httpsAgent: config.httpsAgent?.constructor?.name, + }, + headers: {}, + status: 200, + statusText: "OK", + }; + }, + }); + + expect(res).to.deep.equal({ + httpAgent: "Agent", + httpsAgent: "Agent", + }); + }); + + it("should match no_proxy rules", () => { + expect(isNoProxyMatched("*", { hostname: "api.example.com", port: "" })).to.equal(true); + expect(isNoProxyMatched("api.example.com", { hostname: "api.example.com", port: "" })).to.equal(true); + expect(isNoProxyMatched("example.com", { hostname: "api.example.com", port: "" })).to.equal(true); + expect(isNoProxyMatched(".example.com", { hostname: "api.example.com", port: "" })).to.equal(true); + expect(isNoProxyMatched("*.example.com", { hostname: "api.example.com", port: "" })).to.equal(true); + expect(isNoProxyMatched("127.0.0.1", { hostname: "127.0.0.1", port: "" })).to.equal(true); + expect(isNoProxyMatched("192.168.*", { hostname: "192.168.1.10", port: "" })).to.equal(true); + expect(isNoProxyMatched("192.168.*", { hostname: "192.169.1.10", port: "" })).to.equal(false); + expect(isNoProxyMatched("[::1]", { hostname: "::1", port: "" })).to.equal(true); + expect(isNoProxyMatched("[::1]:8443", { hostname: "::1", port: "8443" })).to.equal(true); + expect(isNoProxyMatched("api.example.com:8443", { hostname: "api.example.com", port: "8443" })).to.equal(true); + expect(isNoProxyMatched("api.example.com:8443", { hostname: "api.example.com", port: "443" })).to.equal(false); + expect(isNoProxyMatched("127.0.0.1", { hostname: "127.0.0.2", port: "" })).to.equal(false); + expect(isNoProxyMatched(".example.com", { hostname: "example.org", port: "" })).to.equal(false); + }); }); diff --git a/packages/core/basic/src/utils/util.request.ts b/packages/core/basic/src/utils/util.request.ts index 5c3d81be3..93c800140 100644 --- a/packages/core/basic/src/utils/util.request.ts +++ b/packages/core/basic/src/utils/util.request.ts @@ -82,11 +82,24 @@ export class HttpError extends Error { export const HttpCommonError = HttpError; let defaultAgents = createAgent(); +const directAgents = createAgent(); +let defaultProxyOptions: GlobalProxyOptions = {}; let defaultHeaders: Record = {}; -export function setGlobalProxy(opts: { httpProxy?: string; httpsProxy?: string }) { +export type GlobalProxyOptions = { + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; +}; + +export function setGlobalProxy(opts: GlobalProxyOptions) { logger.info("setGlobalProxy:", opts); - defaultAgents = createAgent(opts); + defaultProxyOptions = { ...opts }; + defaultAgents = createAgent({ + httpProxy: opts.httpProxy, + httpsProxy: opts.httpsProxy, + }); + setProxyEnvironment(opts); } export function getGlobalAgents() { @@ -137,21 +150,25 @@ export function createAxiosService({ logger }: { logger: ILogger }) { if (config.timeout == null) { config.timeout = 15000; } - let agents = defaultAgents; - if (config.skipSslVerify || config.httpProxy) { - let rejectUnauthorized = true; + const bypassProxy = shouldBypassProxy(config, defaultProxyOptions.noProxy); + const useCustomProxy = !!config.httpProxy && !bypassProxy; + let agents = bypassProxy ? directAgents : defaultAgents; + if (bypassProxy) { + logger.info("命中no_proxy配置,跳过代理:", config.url); + } + if (config.skipSslVerify || useCustomProxy) { + const agentOptions: any = {}; if (config.skipSslVerify) { logger.info("忽略接口请求的SSL校验"); - rejectUnauthorized = false; + agentOptions.rejectUnauthorized = false; } - const proxy: any = {}; - if (config.httpProxy) { + if (useCustomProxy) { logger.info("使用自定义http代理:", config.httpProxy); - proxy.httpProxy = config.httpProxy; - proxy.httpsProxy = config.httpProxy; + agentOptions.httpProxy = config.httpProxy; + agentOptions.httpsProxy = config.httpProxy; } - agents = createAgent({ rejectUnauthorized, ...proxy } as any); + agents = createAgent(agentOptions); } delete config.skipSslVerify; @@ -354,7 +371,7 @@ export type CreateAgentOptions = { httpsProxy?: string; } & nodeHttp.AgentOptions; export function createAgent(opts: CreateAgentOptions = {}) { - opts = merge( + const { httpProxy, httpsProxy, ...agentOptions } = merge( { autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 1000, @@ -364,29 +381,19 @@ export function createAgent(opts: CreateAgentOptions = {}) { ); let httpAgent, httpsAgent; - const httpProxy = opts.httpProxy; if (httpProxy) { - process.env.HTTP_PROXY = httpProxy; - process.env.http_proxy = httpProxy; logger.info("use httpProxy:", httpProxy); - httpAgent = new HttpProxyAgent(httpProxy, opts as any); - merge(httpAgent.options, opts); + httpAgent = new HttpProxyAgent(httpProxy, agentOptions as any); + merge(httpAgent.options, agentOptions); } else { - process.env.HTTP_PROXY = ""; - process.env.http_proxy = ""; - httpAgent = new nodeHttp.Agent(opts); + httpAgent = new nodeHttp.Agent(agentOptions); } - const httpsProxy = opts.httpsProxy; if (httpsProxy) { - process.env.HTTPS_PROXY = httpsProxy; - process.env.https_proxy = httpsProxy; logger.info("use httpsProxy:", httpsProxy); - httpsAgent = new HttpsProxyAgent(httpsProxy, opts as any); - merge(httpsAgent.options, opts); + httpsAgent = new HttpsProxyAgent(httpsProxy, agentOptions as any); + merge(httpsAgent.options, agentOptions); } else { - process.env.HTTPS_PROXY = ""; - process.env.https_proxy = ""; - httpsAgent = new https.Agent(opts); + httpsAgent = new https.Agent(agentOptions); } return { httpAgent, @@ -394,6 +401,145 @@ export function createAgent(opts: CreateAgentOptions = {}) { }; } +function setProxyEnvironment(opts: GlobalProxyOptions = {}) { + setEnvValue("HTTP_PROXY", opts.httpProxy); + setEnvValue("http_proxy", opts.httpProxy); + setEnvValue("HTTPS_PROXY", opts.httpsProxy); + setEnvValue("https_proxy", opts.httpsProxy); + const noProxy = normalizeNoProxyText(opts.noProxy); + setEnvValue("NO_PROXY", noProxy); + setEnvValue("no_proxy", noProxy); +} + +function setEnvValue(key: string, value?: string) { + process.env[key] = value || ""; +} + +function shouldBypassProxy(config: AxiosRequestConfig, noProxy?: string) { + if (!noProxy) { + return false; + } + const target = getRequestTarget(config); + if (!target) { + return false; + } + return splitNoProxyRules(noProxy).some(item => isNoProxyMatched(item, target)); +} + +function getRequestTarget(config: AxiosRequestConfig) { + try { + const baseURL = config.baseURL || undefined; + const url = new URL(config.url || "", baseURL); + return { + hostname: normalizeHost(url.hostname), + port: url.port, + }; + } catch (e) { + return null; + } +} + +export function isNoProxyMatched(rule: string, target: { hostname: string; port: string }) { + if (rule === "*") { + return true; + } + + const normalizedRule = normalizeNoProxyRule(rule); + if (!normalizedRule.host) { + return false; + } + if (normalizedRule.port && normalizedRule.port !== target.port) { + return false; + } + + const host = normalizeHost(target.hostname); + if (normalizedRule.host.includes("*")) { + return wildcardHostMatched(normalizedRule.host, host); + } + if (normalizedRule.host.startsWith("*.")) { + const suffix = normalizedRule.host.substring(1); + return host.endsWith(suffix); + } + if (normalizedRule.host.startsWith(".")) { + return host === normalizedRule.host.substring(1) || host.endsWith(normalizedRule.host); + } + return host === normalizedRule.host || host.endsWith(`.${normalizedRule.host}`); +} + +function normalizeNoProxyRule(rule: string) { + let value = rule.trim().toLowerCase(); + if (value.includes("://")) { + try { + const url = new URL(value); + return { + host: normalizeHost(url.hostname), + port: url.port, + }; + } catch (e) { + return { + host: "", + port: "", + }; + } + } + + let port = ""; + if (value.startsWith("[")) { + const closeIndex = value.indexOf("]"); + const host = value.substring(1, closeIndex); + const rest = value.substring(closeIndex + 1); + if (rest.startsWith(":")) { + port = rest.substring(1); + } + return { + host: normalizeHost(host), + port, + }; + } + + const colonCount = (value.match(/:/g) || []).length; + const portIndex = value.lastIndexOf(":"); + if (colonCount === 1 && portIndex > -1) { + port = value.substring(portIndex + 1); + value = value.substring(0, portIndex); + } + return { + host: normalizeHost(value), + port, + }; +} + +function normalizeHost(host: string) { + let value = host.trim().toLowerCase(); + if (value.startsWith("[") && value.endsWith("]")) { + value = value.substring(1, value.length - 1); + } + return value; +} + +function wildcardHostMatched(rule: string, host: string) { + const pattern = rule.split("*").map(escapeRegExp).join(".*"); + return new RegExp(`^${pattern}$`).test(host); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeNoProxyText(noProxy?: string) { + return splitNoProxyRules(noProxy).join(","); +} + +function splitNoProxyRules(noProxy?: string) { + if (!noProxy) { + return []; + } + return noProxy + .split(/[,\s]+/) + .map(item => item.trim()) + .filter(Boolean); +} + export async function download(req: { http: HttpClient; config: HttpRequestConfig; savePath: string; logger: ILogger }) { const { http, config, savePath, logger } = req; return safePromise((resolve, reject) => { diff --git a/packages/libs/lib-server/src/system/settings/service/models.ts b/packages/libs/lib-server/src/system/settings/service/models.ts index 58ecf6434..13f701ac4 100644 --- a/packages/libs/lib-server/src/system/settings/service/models.ts +++ b/packages/libs/lib-server/src/system/settings/service/models.ts @@ -81,6 +81,7 @@ export class SysPrivateSettings extends BaseSettings { httpsProxy? = ''; httpProxy? = ''; + noProxy? = ''; commonHeaders?: string = ''; reverseProxies?: Record = {}; diff --git a/packages/libs/lib-server/src/system/settings/service/sys-settings-service.ts b/packages/libs/lib-server/src/system/settings/service/sys-settings-service.ts index 7fa835a9c..b76450e4d 100644 --- a/packages/libs/lib-server/src/system/settings/service/sys-settings-service.ts +++ b/packages/libs/lib-server/src/system/settings/service/sys-settings-service.ts @@ -165,6 +165,7 @@ export class SysSettingsService extends BaseService { const opts = { httpProxy: privateSetting.httpProxy, httpsProxy: privateSetting.httpsProxy, + noProxy: privateSetting.noProxy, }; setGlobalProxy(opts); setGlobalHeaders(this.parseKeyValueText(privateSetting.commonHeaders)); diff --git a/packages/ui/certd-client/src/locales/langs/en-US/certd/sys-settings.ts b/packages/ui/certd-client/src/locales/langs/en-US/certd/sys-settings.ts index cb0089c0f..5a9deec92 100644 --- a/packages/ui/certd-client/src/locales/langs/en-US/certd/sys-settings.ts +++ b/packages/ui/certd-client/src/locales/langs/en-US/certd/sys-settings.ts @@ -111,6 +111,9 @@ export default { httpsProxyPlaceholder: "http://192.168.1.2:18010/", saveThenTestTitle: "Save first, then click test", httpsProxyHelper: "Usually both proxies are the same, save first then test", + noProxy: "Proxy Bypass", + noProxyPlaceholder: "localhost,127.0.0.1,.example.com,192.168.*", + noProxyHelper: "Configure NO_PROXY. Separate entries with commas, spaces, or line breaks; matched requests bypass the proxy. \nExample: localhost,127.0.0.1,.example.com,192.168.*", dualStackNetwork: "Dual Stack Network", ipv4Priority: "IPv4 Priority", ipv6Priority: "IPv6 Priority", diff --git a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/sys-settings.ts b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/sys-settings.ts index 2d1f30028..22a3832cb 100644 --- a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/sys-settings.ts +++ b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/sys-settings.ts @@ -108,6 +108,9 @@ export default { httpsProxyPlaceholder: "http://192.168.1.2:18010/", saveThenTestTitle: "保存后,再点击测试", httpsProxyHelper: "一般这两个代理填一样的,保存后再测试", + noProxy: "代理排除", + noProxyPlaceholder: "localhost,127.0.0.1,.example.com,192.168.*", + noProxyHelper: "配置NO_PROXY,多个地址可用英文逗号、空格或换行分隔,命中的请求将不走代理\n例如:localhost,127.0.0.1,.example.com,192.168.*", dualStackNetwork: "双栈网络", ipv4Priority: "IPV4优先", ipv6Priority: "IPV6优先", diff --git a/packages/ui/certd-client/src/store/settings/api.basic.ts b/packages/ui/certd-client/src/store/settings/api.basic.ts index c6ada0f37..ffc5d2057 100644 --- a/packages/ui/certd-client/src/store/settings/api.basic.ts +++ b/packages/ui/certd-client/src/store/settings/api.basic.ts @@ -105,6 +105,7 @@ export type InviteSetting = { export type SysPrivateSetting = { httpProxy?: string; httpsProxy?: string; + noProxy?: string; commonHeaders?: string; reverseProxies?: any; dnsResultOrder?: string; diff --git a/packages/ui/certd-client/src/views/sys/settings/tabs/network.vue b/packages/ui/certd-client/src/views/sys/settings/tabs/network.vue index b9946267a..6fb163365 100644 --- a/packages/ui/certd-client/src/views/sys/settings/tabs/network.vue +++ b/packages/ui/certd-client/src/views/sys/settings/tabs/network.vue @@ -14,6 +14,11 @@
{{ t("certd.httpsProxyHelper") }}
+ + +
{{ t("certd.noProxyHelper") }}
+
+
{{ t("certd.sys.setting.environmentVarsHelper") }}