From b7ce28b0a6e6ce850f85f342d9f355c736016c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Tue, 18 Aug 2026 00:11:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=AD=A3=E5=90=91=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81(=E4=BC=81=E4=B8=9A=E7=A7=81?= =?UTF-8?q?=E6=9C=89=E5=8C=96=E5=A4=96=E7=BD=91=E5=87=BA=E5=8F=A3),?= =?UTF-8?q?=E5=86=85=E7=BD=91=E5=9C=B0=E5=9D=80=E8=87=AA=E5=8A=A8=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app/net_proxy.py 统一收口:显式配置(.env HTTP_PROXY/HTTPS_PROXY/ NO_PROXY)> 进程环境变量;私网 IP/localhost/无点主机名恒直连, no_proxy 支持域名后缀(.corp.example)、精确主机与 * - 启动期 apply_proxy_env 把显式配置收敛进 os.environ(大小写双写), httpx(trust_env)/websockets(getproxies)/pip 全栈生效——企微 WS SDK 无代理参数也由此覆盖;飞书 lark SDK 走原生 proxy_url/trust_env_proxy - httpx 主路径(模型 OpenAI/Anthropic/Gemini、工具、MCP、微信/钉钉/ 飞书 API)按目标 URL 显式注入 proxy 或无代理 mounts(强制直连), 内网模型服务绝不被代理出去 - 沙箱 env 白名单放行代理变量,allowlist 网络策略自动并入代理主机 - .env.example 增代理与 PyPI 镜像条目;README 中英加部署说明 - 测试 9 例:私网自动绕过/后缀通配/显式优先/绕过 mounts/环境收敛/ 未配置不覆盖既有环境/allowlist 代理主机 --- README.md | 14 ++ README.zh.md | 14 ++ backend/.env.example | 12 ++ backend/app/api/tools.py | 3 +- backend/app/channels/adapters/dingtalk.py | 7 +- backend/app/channels/adapters/feishu.py | 7 +- backend/app/channels/adapters/wechat.py | 4 +- backend/app/channels/feishu_runtime.py | 9 +- backend/app/channels/schema.py | 2 +- backend/app/config.py | 6 + backend/app/harness/command.py | 10 ++ backend/app/llm/client.py | 20 ++- backend/app/main.py | 3 + backend/app/net_proxy.py | 152 ++++++++++++++++++++++ backend/app/tools/mcp_client.py | 11 +- backend/tests/test_net_proxy.py | 124 ++++++++++++++++++ 16 files changed, 385 insertions(+), 13 deletions(-) create mode 100644 backend/app/net_proxy.py create mode 100644 backend/tests/test_net_proxy.py diff --git a/README.md b/README.md index eb30506f5..85e17122f 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,20 @@ Digital employees can serve users directly over IM channels: users chat with emp - Channel credentials (bot tokens/secrets) are stored Fernet-encrypted and never returned by any API; - Binding management is restricted to admins or the binding creator; mounting an employee exposes it to all users of that channel — grant with care. +## Forward Proxy (enterprise private deployments) + +When outbound traffic must go through a forward proxy, set three variables in `backend/.env` — model calls, tools/MCP, channel connections (WeChat/WeCom/Feishu/DingTalk), sandboxed skill code, and pip installs all honor them: + +```dotenv +HTTP_PROXY="http://proxy.corp.example:8080" +HTTPS_PROXY="http://proxy.corp.example:8080" +NO_PROXY=".corp.example" # bypass list: domain suffix / exact host / *; private IPs and localhost always connect directly +``` + +- **Internal model services are never proxied**: private ranges (10./172.16./192.168./127.) and single-label hosts connect directly; an internal domain suffix (e.g. `.corp.example`) covers all subdomains with one entry; +- Explicit configuration wins over process environment variables; when unset, env vars are honored as usual (httpx trust_env, websockets, pip); +- With the sandbox network policy in allowlist mode, the proxy host is added to the allowed domains automatically. + ## Project Structure ```text diff --git a/README.zh.md b/README.zh.md index 0d1f57cab..d3dd7d97c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -262,6 +262,20 @@ curl.exe http://127.0.0.1:5173/api/health 外部业务系统可以通过员工级 API Key 调用数字员工、持续会话、Harness v2 Run、SOP、知识、技能、工具和定时任务。完整的鉴权边界、接口清单、SSE、Webhook 与调用示例见 [数字员工开放 API v1](docs/open-api-v1.md)。 +## 正向代理(企业私有化部署) + +企业内网访问外网需要正向代理时,在 `backend/.env` 配置三项即可,全栈(模型调用、工具/MCP、微信/企微/飞书/钉钉渠道、沙箱内技能代码、pip 依赖安装)自动生效: + +```dotenv +HTTP_PROXY="http://proxy.corp.example:8080" +HTTPS_PROXY="http://proxy.corp.example:8080" +NO_PROXY=".corp.example" # 绕过名单:域名后缀/精确主机/*;内网 IP 与 localhost 恒自动直连,无需列入 +``` + +- **内网模型服务不会被代理出去**:`10./172.16./192.168./127.` 私网地址与无点主机名恒直连;内网域名只写一次后缀(如 `.corp.example`)即覆盖全部子域名; +- 显式配置优先于系统环境变量;未配置时沿用进程环境变量(httpx trust_env、websockets、pip 均识别); +- 沙箱网络策略为 allowlist 时,代理主机会自动并入允许域。 + ## 项目结构 ```text diff --git a/backend/.env.example b/backend/.env.example index 4f2c369d6..461ab2674 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,6 +22,18 @@ GENERAL_SKILL_RUNTIME_PYTHON="" GENERAL_SKILL_RUNTIME_VENV="" GENERAL_SKILL_RUNTIME_PACKAGES="requests,httpx" GENERAL_SKILL_RUNTIME_AUTO_INSTALL="true" +# 通用技能运行时依赖安装:企业内网 PyPI 镜像源地址(留空用官方源) +GENERAL_SKILL_PIP_INDEX_URL="" +# 单次 pip 安装超时(秒) +GENERAL_SKILL_PIP_TIMEOUT_SECONDS="180" +# 是否允许联网安装依赖(锁定/离线环境设为 false) +GENERAL_SKILL_NETWORK_INSTALL="true" +# 正向代理(企业私有化外网出口,留空不走代理):配置后模型/工具/渠道/pip 全栈生效 +HTTP_PROXY="" +HTTPS_PROXY="" +# 代理绕过名单(逗号分隔):精确主机、.域名后缀、*.域名后缀 或 * 全部直连; +# 内网 IP(10./172.16./192.168. 等)与 localhost 无需列入,恒自动直连 +NO_PROXY="" CHANNEL_SECRET="" STAFFDECK_ROLE="all" WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com" diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 52498fdcd..682e6957b 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -73,6 +73,7 @@ ToolTestRequest, ToolUpdateRequest, ) +from app.net_proxy import httpx_proxy_kwargs router = APIRouter(prefix="/api/enterprise/tools", tags=["enterprise:tools"]) mcp_router = APIRouter(prefix="/api/enterprise/mcp-servers", tags=["enterprise:mcp-servers"]) @@ -281,7 +282,7 @@ def probe_tool( ) timeout_seconds = _probe_timeout_seconds(request) try: - with httpx.Client(timeout=timeout_seconds) as client: + with httpx.Client(timeout=timeout_seconds, **httpx_proxy_kwargs(url)) as client: if request.method.upper() == "GET": request_url, request_kwargs = prepare_get_request(url, request.sample_arguments) response = client.request( diff --git a/backend/app/channels/adapters/dingtalk.py b/backend/app/channels/adapters/dingtalk.py index aaebe4d15..5911e422f 100644 --- a/backend/app/channels/adapters/dingtalk.py +++ b/backend/app/channels/adapters/dingtalk.py @@ -33,6 +33,7 @@ from app.config import get_settings from app.db import engine from app.db.models import ChannelBinding +from app.net_proxy import httpx_proxy_kwargs logger = logging.getLogger(__name__) @@ -72,7 +73,7 @@ class DingTalkTokenProvider: """按绑定缓存 access token;sessionWebhook 出站不需要它,服务端 API 才需要。""" def __init__(self, *, client_factory: Callable[[], httpx.Client] | None = None): - self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0)) + self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://api.dingtalk.com"))) self._cache: dict[tuple[str, str, int], tuple[str, float]] = {} self._lock = threading.Lock() self._key_locks: dict[tuple[str, str, int], threading.Lock] = {} @@ -301,7 +302,7 @@ def validate_dingtalk_credentials( client_secret = client_secret.strip() if not client_id or not client_secret: raise DingTalkPermanentError("钉钉 Client ID 与 Client Secret 均不能为空") - factory = client_factory or (lambda: httpx.Client(timeout=10.0)) + factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://api.dingtalk.com"))) body = { "clientId": client_id, "clientSecret": client_secret, @@ -373,7 +374,7 @@ def __init__( client_factory: Callable[[], httpx.Client] | None = None, token_provider: DingTalkTokenProvider | None = None, ): - self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0)) + self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0, **httpx_proxy_kwargs("https://api.dingtalk.com"))) self._tokens = token_provider or DingTalkTokenProvider(client_factory=self._client_factory) def normalize(self, raw: dict[str, Any]) -> ChannelInbound | None: diff --git a/backend/app/channels/adapters/feishu.py b/backend/app/channels/adapters/feishu.py index 99c15627c..80dd903fa 100644 --- a/backend/app/channels/adapters/feishu.py +++ b/backend/app/channels/adapters/feishu.py @@ -24,6 +24,7 @@ ) from app.config import get_settings from app.db.models import ChannelBinding +from app.net_proxy import httpx_proxy_kwargs FEISHU_API_BASE = "https://open.feishu.cn/open-apis" TOKEN_REFRESH_SKEW_SECONDS = 300 @@ -51,7 +52,7 @@ class FeishuTransientError(FeishuSendError): class FeishuTokenProvider: def __init__(self, *, client_factory: Callable[[], httpx.Client] | None = None): - self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0)) + self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://open.feishu.cn"))) self._cache: dict[tuple[str, str, int], tuple[str, float]] = {} self._lock = threading.Lock() self._key_locks: dict[tuple[str, str, int], threading.Lock] = {} @@ -126,7 +127,7 @@ def validate_feishu_credentials( *, client_factory: Callable[[], httpx.Client] | None = None, ) -> dict[str, str]: - factory = client_factory or (lambda: httpx.Client(timeout=10.0)) + factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://open.feishu.cn"))) try: with factory() as client: token_response = client.post( @@ -175,7 +176,7 @@ def __init__( token_provider: FeishuTokenProvider | None = None, client_factory: Callable[[], httpx.Client] | None = None, ): - self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0)) + self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0, **httpx_proxy_kwargs("https://open.feishu.cn"))) self._tokens = token_provider or FeishuTokenProvider(client_factory=self._client_factory) def normalize(self, raw: dict[str, Any]): diff --git a/backend/app/channels/adapters/wechat.py b/backend/app/channels/adapters/wechat.py index 2565d4a0a..3ec0b0c9f 100644 --- a/backend/app/channels/adapters/wechat.py +++ b/backend/app/channels/adapters/wechat.py @@ -38,6 +38,7 @@ from app.config import get_settings from app.db import engine from app.db.models import ChannelBinding, utc_now +from app.net_proxy import httpx_proxy_kwargs logger = logging.getLogger(__name__) @@ -102,6 +103,7 @@ async def _download_wechat_cdn_httpx(url: str) -> tuple[bytes, str]: verify=certifi.where(), http2=False, timeout=15.0, + **httpx_proxy_kwargs(url), ) as client, client.stream("GET", url) as response: response.raise_for_status() content_type = response.headers.get("content-type", "") @@ -320,7 +322,7 @@ def __init__( ): self.base_url = base_url.rstrip("/") self.bot_token = bot_token - self._client = httpx.Client(transport=transport) + self._client = httpx.Client(transport=transport, **httpx_proxy_kwargs(base_url)) @classmethod def for_binding(cls, binding: ChannelBinding) -> WeChatClient: diff --git a/backend/app/channels/feishu_runtime.py b/backend/app/channels/feishu_runtime.py index 8e5d6a6e1..92972b09e 100644 --- a/backend/app/channels/feishu_runtime.py +++ b/backend/app/channels/feishu_runtime.py @@ -353,7 +353,14 @@ async def _disconnect_and_reconnect(self, *, expected_conn=None): control.emit("DISCONNECTED") return await super()._disconnect_and_reconnect(expected_conn=expected_conn) - client = ProductionClient(app_id, app_secret, event_handler=dispatcher) + # 正向代理:显式配置走 proxy_url,否则 trust_env_proxy=True 读环境变量(含 NO_PROXY 绕过) + from app.net_proxy import proxy_for_url + + feishu_proxy = proxy_for_url("https://open.feishu.cn") + client_kwargs = ( + {"proxy_url": feishu_proxy} if feishu_proxy else {"trust_env_proxy": True} + ) + client = ProductionClient(app_id, app_secret, event_handler=dispatcher, **client_kwargs) def request_stop() -> None: async def shutdown() -> None: diff --git a/backend/app/channels/schema.py b/backend/app/channels/schema.py index c1bdd67ad..be4f3479f 100644 --- a/backend/app/channels/schema.py +++ b/backend/app/channels/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel from sqlmodel import Session, select diff --git a/backend/app/config.py b/backend/app/config.py index 5b1231b4d..0593438c5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,6 +34,12 @@ class Settings(BaseSettings): general_skill_pip_index_url: str = "" general_skill_pip_timeout_seconds: int = 180 general_skill_network_install: bool = True + # 正向代理(企业私有化外网出口):显式配置后启动期收敛进进程环境, + # httpx/websockets/pip 全栈生效;内网地址(私网 IP/localhost)恒直连 + http_proxy: str = "" + https_proxy: str = "" + # 绕过名单(逗号分隔):支持 精确主机 / .域名后缀 / *.域名后缀 / * 全部直连 + no_proxy: str = "" channel_secret: str = "" staffdeck_role: str = "all" wechat_ilink_base_url: str = "https://ilinkai.weixin.qq.com" diff --git a/backend/app/harness/command.py b/backend/app/harness/command.py index 951181c1f..915c97152 100644 --- a/backend/app/harness/command.py +++ b/backend/app/harness/command.py @@ -530,6 +530,12 @@ def _write_srt_settings( domains: list[str] = [] elif network_mode == "allowlist": domains = [item.strip() for item in allowed_domains if item.strip()] + # 配置正向代理后,沙箱内外网流量须经代理:代理主机自动并入允许域 + from app.net_proxy import proxy_host_for_allowlist + + proxy_host = proxy_host_for_allowlist() + if proxy_host and proxy_host not in domains: + domains.append(proxy_host) else: if not _srt_supports_allow_all(): raise HarnessExecutionError( @@ -804,6 +810,8 @@ def _bubblewrap_argv( if key in { "ARGUMENTS", "QUERY", "SKILL_WORKSPACE", "ARTIFACT_DIR", "SKILL_SLUG", "SKILL_NAME", "USER_ID", "SKILL_FILES_JSON", "SSL_CERT_FILE", "PIP_CERT", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "no_proxy", } } for key, value in allowed_env.items(): @@ -844,6 +852,8 @@ def _managed_process_environment(env: dict[str, str] | None) -> dict[str, str]: "PATH", "HOME", "PWD", "TMPDIR", "LANG", "LC_ALL", "ARGUMENTS", "QUERY", "SKILL_WORKSPACE", "ARTIFACT_DIR", "SKILL_SLUG", "SKILL_NAME", "USER_ID", "SKILL_FILES_JSON", "SSL_CERT_FILE", "PIP_CERT", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "no_proxy", } } return {**baseline, **allowed} diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 714f71e88..71878a1bf 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -17,6 +17,7 @@ from app.config import get_settings from app.db.models import ModelConfig from app.llm.model_protocols import ModelApiProtocol +from app.net_proxy import httpx_proxy_kwargs from app.llm.output_policy import ( operation_empty_response_retries, operation_output_tokens, @@ -90,6 +91,14 @@ def public_detail(self) -> dict[str, Any]: REASONING_TOKEN_ESCALATION_CEILING = 32768 +def _maybe_proxied_http_client(base_url: str, timeout_seconds: float) -> httpx.Client | None: + """显式代理配置(或绕过名单强制直连)时,构造注入好的 http_client;否则 None(SDK 默认 trust_env)。""" + kwargs = httpx_proxy_kwargs(base_url) + if not kwargs: + return None + return httpx.Client(timeout=timeout_seconds, **kwargs) + + def _escalate_reasoning_token_budget(current_max_tokens: int) -> int: """Double the token budget for the next retry, but never below the ceiling. @@ -125,11 +134,15 @@ def __init__(self, model_config: ModelConfig): or DEFAULT_MODEL_API_TIMEOUT_SECONDS ) self.base_url = str(model_config.base_url or "") + # 正向代理:显式配置时按目标 base_url 注入(绕过名单/私网自动直连), + # 未配置时 SDK 走 trust_env 读环境变量(HTTP_PROXY/NO_PROXY) + proxied_http_client = _maybe_proxied_http_client(self.base_url, self.timeout_seconds) if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS: self.client = OpenAI( api_key=api_key, base_url=self.base_url, timeout=self.timeout_seconds, + **({"http_client": proxied_http_client} if proxied_http_client else {}), ) self.driver = ChatCompletionsDriver(self.client) elif protocol is ModelApiProtocol.OPENAI_RESPONSES: @@ -137,6 +150,7 @@ def __init__(self, model_config: ModelConfig): api_key=api_key, base_url=self.base_url, timeout=self.timeout_seconds, + **({"http_client": proxied_http_client} if proxied_http_client else {}), ) self.driver = OpenAIResponsesDriver(self.client) elif protocol is ModelApiProtocol.ANTHROPIC_MESSAGES: @@ -145,6 +159,8 @@ def __init__(self, model_config: ModelConfig): "timeout": self.timeout_seconds, "max_retries": 0, } + if proxied_http_client: + kwargs["http_client"] = proxied_http_client if self.base_url: # Anthropic's SDK always appends /v1/messages. If an operator # already configured a /v1 API root, remove only that suffix @@ -160,7 +176,9 @@ def __init__(self, model_config: ModelConfig): self.client = Anthropic(**kwargs) self.driver = AnthropicMessagesDriver(self.client) elif protocol is ModelApiProtocol.GEMINI_GENERATE_CONTENT: - self.client = httpx.Client(timeout=self.timeout_seconds) + self.client = httpx.Client( + timeout=self.timeout_seconds, **httpx_proxy_kwargs(self.base_url) + ) self.driver = GeminiGenerateContentDriver( self.client, self.base_url, diff --git a/backend/app/main.py b/backend/app/main.py index 9c3f09112..b5c7a8989 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,6 +33,7 @@ from app.config import get_settings from app.db import engine, init_db from app.db.seed import seed_demo_data +from app.net_proxy import apply_proxy_env from app.public_api import create_public_api_app from app.public_api.jobs import cleanup_public_api_records, recover_public_jobs from app.public_api.maintenance import start_public_api_maintenance, stop_public_api_maintenance @@ -66,6 +67,8 @@ def on_startup() -> None: acquire_runtime_instance_lock() try: + # 正向代理配置先收敛进进程环境,后续所有网络栈(httpx/WS/pip)按其生效 + apply_proxy_env() start_async_jobs() init_db() with Session(engine) as db: diff --git a/backend/app/net_proxy.py b/backend/app/net_proxy.py new file mode 100644 index 000000000..0daa3d8e9 --- /dev/null +++ b/backend/app/net_proxy.py @@ -0,0 +1,152 @@ +"""正向代理(企业私有化外网出口)统一收口。 + +取值优先级:no_proxy 命中/私网自动绕过 > 显式配置(.env/settings)> 进程环境变量。 + +- 显式配置生效时,启动期收敛进 os.environ(HTTP_PROXY/HTTPS_PROXY/NO_PROXY, + 大小写双写),httpx(trust_env)、websockets(getproxies/proxy_bypass)、pip + 等栈自动生效——企微 WS 这类不支持显式代理参数的 SDK 也因此被覆盖; +- httpx 主路径另外显式注入(按目标 URL 先查绕过名单),保证"显式配置优先于 + 环境变量"的语义在打包版里也可靠; +- 私网地址(localhost/无点主机名/10·172.16-31·192.168·169.254/::1)恒直连, + 内网模型服务绝不被代理出去。 +""" + +from __future__ import annotations + +import ipaddress +import logging +import os +from typing import Any +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +_PRIVATE_NETWORKS = tuple( + ipaddress.ip_network(net) + for net in ( + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "::1/128", + ) +) + +_PROXY_ENV_KEYS = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY") + + +def _settings_proxy(kind: str) -> str: + """settings 里的显式代理配置(kind: http/https/no_proxy);未配置返回 ""。""" + from app.config import get_settings + + settings = get_settings() + return str(getattr(settings, kind, "") or "").strip() + + +def no_proxy_entries() -> list[str]: + """显式配置的绕过名单(逗号分隔,域名后缀/主机名/IP 段,*=全部直连)。""" + raw = _settings_proxy("no_proxy") + return [item.strip().lower() for item in raw.split(",") if item.strip()] + + +def is_private_host(host: str) -> bool: + """私网地址恒直连:localhost、无点主机名、RFC1918/环回/链路本地 IP。""" + host = (host or "").strip().strip("[]").lower() + if not host: + return True + if host == "localhost" or "." not in host: + return True + try: + address = ipaddress.ip_address(host) + except ValueError: + return False # 域名:靠 no_proxy 名单匹配 + return any(address in network for network in _PRIVATE_NETWORKS) + + +def should_bypass_proxy(host: str) -> bool: + """是否绕过代理:私网自动绕过 + no_proxy 名单(后缀/精确/*)命中。""" + host = (host or "").strip().lower() + if not host: + return True + if is_private_host(host): + return True + for entry in no_proxy_entries(): + if entry == "*": + return True + if entry.startswith("*."): # *.corp.internal 形态 + suffix = entry[1:] # .corp.internal + if host.endswith(suffix) or host == suffix[1:]: + return True + continue + if entry.startswith("."): # .corp.internal 后缀形态 + if host.endswith(entry) or host == entry[1:]: + return True + continue + if host == entry or host.endswith("." + entry): + return True + return False + + +def proxy_for_url(url: str) -> str | None: + """按目标 URL 决定显式代理:绕过名单命中返回 None,未配置显式代理也返回 None + (此时由 httpx trust_env / websockets getproxies 读环境变量兜底)。""" + parsed = urlparse(url if "://" in url else f"https://{url}") + host = parsed.hostname or "" + if should_bypass_proxy(host): + return None + if parsed.scheme == "https": + proxy = _settings_proxy("https_proxy") or _settings_proxy("http_proxy") + else: + proxy = _settings_proxy("http_proxy") or _settings_proxy("https_proxy") + return proxy or None + + +def httpx_proxy_kwargs(url: str) -> dict[str, Any]: + """httpx 客户端构造参数: + + - 绕过名单/私网命中:返回精确主机的无代理 mounts(强制直连,不读环境代理; + 其余 trust_env 行为如 SSL_CERT_FILE 保留); + - 显式配置代理:返回 {"proxy": ...}(显式配置优先于环境变量); + - 均未配置:返回 {}(trust_env 读环境变量,NO_PROXY 环境语义兜底)。 + """ + import httpx + + parsed = urlparse(url if "://" in url else f"https://{url}") + host = (parsed.hostname or "").lower() + if should_bypass_proxy(host): + return {"mounts": {f"all://{host}": httpx.HTTPTransport()}} + proxy = proxy_for_url(url) + return {"proxy": proxy} if proxy else {} + + +def apply_proxy_env() -> None: + """启动期把显式代理配置收敛进进程环境(大小写双写)。 + + 仅覆盖显式配置的键;未配置时不动既有环境变量(系统级代理照常工作)。 + 覆盖后:httpx trust_env、websockets proxy_bypass/getproxies、pip 等全部生效。 + """ + http_proxy = _settings_proxy("http_proxy") + https_proxy = _settings_proxy("https_proxy") + no_proxy = _settings_proxy("no_proxy") + applied: list[str] = [] + for key, value in ( + ("HTTP_PROXY", http_proxy), + ("HTTPS_PROXY", https_proxy or http_proxy), + ("NO_PROXY", no_proxy), + ): + if not value: + continue + os.environ[key] = value + os.environ[key.lower()] = value + applied.append(key) + if applied: + logger.info("正向代理已生效(%s);no_proxy 名单:%s", ",".join(applied), no_proxy or "空") + + +def proxy_host_for_allowlist() -> str: + """代理主机名:沙箱 allowlist 网络模式下需要并入允许域。""" + proxy = _settings_proxy("https_proxy") or _settings_proxy("http_proxy") + if not proxy: + return "" + return (urlparse(proxy if "://" in proxy else f"http://{proxy}").hostname or "").lower() diff --git a/backend/app/tools/mcp_client.py b/backend/app/tools/mcp_client.py index 3d36daf91..06c3809e7 100644 --- a/backend/app/tools/mcp_client.py +++ b/backend/app/tools/mcp_client.py @@ -14,6 +14,7 @@ import httpx from app.security.managed_subprocess import ManagedProcess, ManagedProcessError +from app.net_proxy import httpx_proxy_kwargs from app.tools.mcp_builtin import ( BuiltinMCPError, builtin_mcp_tool_definitions, @@ -538,7 +539,10 @@ def __init__(self, config: dict[str, Any], timeout_seconds: float) -> None: self._session_id: str | None = None def __enter__(self) -> "_HttpSession": - self._client = httpx.Client(timeout=self.timeout_seconds) + self._client = httpx.Client( + timeout=self.timeout_seconds, + **httpx_proxy_kwargs(str(self.config.get("url") or self.config.get("endpoint") or "")), + ) return self def __exit__(self, *exc: Any) -> None: @@ -631,7 +635,10 @@ def __init__(self, config: dict[str, Any], timeout_seconds: float) -> None: self._next_id = 0 def __enter__(self) -> "_SseSession": - self._client = httpx.Client(timeout=httpx.Timeout(self.timeout_seconds, read=None)) + self._client = httpx.Client( + timeout=httpx.Timeout(self.timeout_seconds, read=None), + **httpx_proxy_kwargs(str(self.config.get("url") or self.config.get("endpoint") or "")), + ) url = str(self.config.get("url") or self.config.get("endpoint") or "").strip() if not url: raise MCPClientError("SSE MCP 连接缺少 url/endpoint。") diff --git a/backend/tests/test_net_proxy.py b/backend/tests/test_net_proxy.py new file mode 100644 index 000000000..10d988064 --- /dev/null +++ b/backend/tests/test_net_proxy.py @@ -0,0 +1,124 @@ +"""正向代理收口:绕过判定、后缀通配、私网自动直连、httpx 注入、环境收敛。""" + +import os + +import pytest + +import app.net_proxy as net_proxy + + +@pytest.fixture(autouse=True) +def _clean_proxy_env(monkeypatch): + """每个用例前后都还原代理环境变量(apply_proxy_env 直接写 os.environ,须防泄漏)。""" + keys = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy") + saved = {key: os.environ.get(key) for key in keys} + for key in keys: + monkeypatch.delenv(key, raising=False) + yield + for key in keys: + if saved[key] is None: + os.environ.pop(key, None) + else: + os.environ[key] = saved[key] + + +def _settings(monkeypatch, *, http_proxy="", https_proxy="", no_proxy=""): + from types import SimpleNamespace + + monkeypatch.setattr( + "app.config.get_settings", + lambda: SimpleNamespace( + http_proxy=http_proxy, https_proxy=https_proxy, no_proxy=no_proxy + ), + ) + + +def test_private_hosts_auto_bypass_without_config(monkeypatch) -> None: + _settings(monkeypatch, http_proxy="http://proxy.corp:8080") + for host in ("localhost", "127.0.0.1", "10.1.2.3", "192.168.1.10", "172.16.5.5", "::1", "nas"): + assert net_proxy.should_bypass_proxy(host) is True, host + assert net_proxy.should_bypass_proxy("open.feishu.cn") is False + + +def test_no_proxy_suffix_and_wildcard_matching(monkeypatch) -> None: + _settings( + monkeypatch, + http_proxy="http://proxy.corp:8080", + no_proxy=".corp.internal, mirror.example.com, *.partner.cn", + ) + assert net_proxy.should_bypass_proxy("llm-center.corp.internal") is True + assert net_proxy.should_bypass_proxy("corp.internal") is True + assert net_proxy.should_bypass_proxy("mirror.example.com") is True + assert net_proxy.should_bypass_proxy("a.partner.cn") is True + # *. 与 . 后缀形态都含裸域(更直觉,少踩坑) + assert net_proxy.should_bypass_proxy("partner.cn") is True + assert net_proxy.should_bypass_proxy("evilcorp.internal") is False + assert net_proxy.should_bypass_proxy("api.weixin.qq.com") is False + + +def test_no_proxy_star_bypasses_everything(monkeypatch) -> None: + _settings(monkeypatch, http_proxy="http://proxy.corp:8080", no_proxy="*") + assert net_proxy.proxy_for_url("https://api.weixin.qq.com") is None + + +def test_proxy_for_url_prefers_explicit_and_bypasses(monkeypatch) -> None: + _settings( + monkeypatch, + http_proxy="http://proxy.corp:8080", + https_proxy="http://proxy.corp:8443", + no_proxy=".corp.internal", + ) + assert net_proxy.proxy_for_url("https://api.weixin.qq.com") == "http://proxy.corp:8443" + assert net_proxy.proxy_for_url("http://ilinkai.weixin.qq.com") == "http://proxy.corp:8080" + # 内网模型服务:命中后缀名单,直连 + assert net_proxy.proxy_for_url("https://llm-center.corp.internal/v1") is None + # 私网 IP:不配名单也直连 + assert net_proxy.proxy_for_url("http://10.0.0.8:8080/v1") is None + + +def test_proxy_for_url_unset_returns_none_for_env_fallback(monkeypatch) -> None: + _settings(monkeypatch) + assert net_proxy.proxy_for_url("https://api.weixin.qq.com") is None + + +def test_httpx_proxy_kwargs_forms(monkeypatch) -> None: + _settings(monkeypatch, http_proxy="http://proxy.corp:8080", no_proxy=".corp.internal") + # 显式代理 + kwargs = net_proxy.httpx_proxy_kwargs("https://api.weixin.qq.com") + assert kwargs == {"proxy": "http://proxy.corp:8080"} + # 绕过:精确主机无代理 mounts(强制直连) + bypass = net_proxy.httpx_proxy_kwargs("https://llm-center.corp.internal") + assert "proxy" not in bypass + assert "all://llm-center.corp.internal" in bypass["mounts"] + # 未配置:空(trust_env 接管) + _settings(monkeypatch) + assert net_proxy.httpx_proxy_kwargs("https://api.weixin.qq.com") == {} + + +def test_apply_proxy_env_converges_configured_keys(monkeypatch) -> None: + _settings( + monkeypatch, + http_proxy="http://proxy.corp:8080", + https_proxy="http://proxy.corp:8443", + no_proxy=".corp.internal", + ) + net_proxy.apply_proxy_env() + assert os.environ["HTTP_PROXY"] == "http://proxy.corp:8080" + assert os.environ["http_proxy"] == "http://proxy.corp:8080" + assert os.environ["HTTPS_PROXY"] == "http://proxy.corp:8443" + assert os.environ["NO_PROXY"] == ".corp.internal" + assert os.environ["no_proxy"] == ".corp.internal" + + +def test_apply_proxy_env_unset_keeps_existing_env(monkeypatch) -> None: + monkeypatch.setenv("HTTP_PROXY", "http://existing:3128") + _settings(monkeypatch) # 未配置 + net_proxy.apply_proxy_env() + assert os.environ["HTTP_PROXY"] == "http://existing:3128" + + +def test_proxy_host_for_allowlist(monkeypatch) -> None: + _settings(monkeypatch, https_proxy="http://proxy.corp:8443") + assert net_proxy.proxy_host_for_allowlist() == "proxy.corp" + _settings(monkeypatch) + assert net_proxy.proxy_host_for_allowlist() == "" From 00c64d98a2bf09fbfdd951ac9ebc86d9ccfc05d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Thu, 20 Aug 2026 00:13:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20A2A=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=AD=A3=E5=90=91=E4=BB=A3=E7=90=86(?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E6=94=B9=20A2AClient=20=E5=90=8E=E5=9C=A8?= =?UTF-8?q?=E6=9C=AC=E7=B1=BB=E8=A1=A5=E9=BD=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/tools/a2a_client.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/app/tools/a2a_client.py b/backend/app/tools/a2a_client.py index 241afa43f..b2f0deca4 100644 --- a/backend/app/tools/a2a_client.py +++ b/backend/app/tools/a2a_client.py @@ -10,6 +10,8 @@ from urllib.parse import urlsplit, urlunsplit import httpx + +from app.net_proxy import httpx_proxy_kwargs from sqlmodel import Session, select from app.config import get_settings @@ -217,7 +219,9 @@ def _discover_agent(self) -> None: return card_url = self._agent_card_url() try: - with httpx.Client(timeout=min(self.timeout_seconds, 15.0)) as client: + with httpx.Client( + timeout=min(self.timeout_seconds, 15.0), **httpx_proxy_kwargs(self.endpoint_url) + ) as client: response = client.get(card_url, headers=self.headers) response.raise_for_status() card = response.json() @@ -346,7 +350,7 @@ def _stream_method( last: dict[str, Any] | None = None accumulated_task: dict[str, Any] | None = None timeout = max(deadline - time.monotonic(), 0.1) - with httpx.Client(timeout=timeout) as client: + with httpx.Client(timeout=timeout, **httpx_proxy_kwargs(self.endpoint_url)) as client: with client.stream("POST", self.endpoint_url, headers=headers, json=payload) as response: response.raise_for_status() for event_id, data in _iter_sse(response.iter_lines()): @@ -379,7 +383,7 @@ def _stream_method( def _rpc(self, method: str, params: dict[str, Any], *, deadline: float) -> dict[str, Any]: timeout = max(deadline - time.monotonic(), 0.1) - with httpx.Client(timeout=timeout) as client: + with httpx.Client(timeout=timeout, **httpx_proxy_kwargs(self.endpoint_url)) as client: response = client.post( self.endpoint_url, headers=self.headers,