refactor(proxy-node): 统一代理解析,全面支持 tunnel 模式

新增 resolve_ops_proxy_config 合并 proxy 和 tunnel_node_id 的解析,
避免各架构重复调用 _resolve_effective_node。所有架构连接器
(anyrouter/nekocode/sub2api/yescode) 和 HTTPClientPool 均适配
tunnel 模式,通过 TunnelTransport 替代传统代理。
This commit is contained in:
fawney19
2026-02-26 12:25:23 +08:00
parent 560345a889
commit 27cef96789
9 changed files with 101 additions and 51 deletions

View File

@@ -204,6 +204,13 @@ class HTTPClientPool:
if not proxy_config: if not proxy_config:
proxy_config = get_system_proxy_config() proxy_config = get_system_proxy_config()
# tunnel 模式检查tunnel 节点走专用的 TunnelTransport 客户端
from src.services.proxy_node.resolver import resolve_delegate_config
delegate_cfg = resolve_delegate_config(proxy_config)
if delegate_cfg and delegate_cfg.get("tunnel"):
return await cls._get_tunnel_client(delegate_cfg["node_id"])
cache_key = compute_proxy_cache_key(proxy_config) cache_key = compute_proxy_cache_key(proxy_config)
# 无代理时返回默认客户端 # 无代理时返回默认客户端

View File

@@ -188,7 +188,10 @@ def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
async def _get_acw_cookie( async def _get_acw_cookie(
base_url: str, timeout: float = 10, proxy: str | httpx.Proxy | None = None base_url: str,
timeout: float = 10,
proxy: str | httpx.Proxy | None = None,
tunnel_node_id: str | None = None,
) -> str | None: ) -> str | None:
""" """
获取 acw_sc__v2 Cookie 获取 acw_sc__v2 Cookie
@@ -199,6 +202,7 @@ async def _get_acw_cookie(
base_url: 目标站点 URL base_url: 目标站点 URL
timeout: 请求超时时间 timeout: 请求超时时间
proxy: 代理地址 proxy: 代理地址
tunnel_node_id: tunnel 模式节点 ID优先于 proxy
Returns: Returns:
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
@@ -209,7 +213,11 @@ async def _get_acw_cookie(
"timeout": timeout, "timeout": timeout,
"verify": get_ssl_context(), "verify": get_ssl_context(),
} }
if proxy: if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=timeout)
elif proxy:
client_kwargs["proxy"] = proxy client_kwargs["proxy"] = proxy
logger.debug(f"获取 acw_sc__v2 Cookie 使用代理: {proxy}") logger.debug(f"获取 acw_sc__v2 Cookie 使用代理: {proxy}")
@@ -273,7 +281,9 @@ class AnyrouterConnector(ProviderConnector):
self._user_id, _ = _parse_session_user_id(session_cookie) self._user_id, _ = _parse_session_user_id(session_cookie)
# 尝试获取反爬 Cookie使用配置中的代理 # 尝试获取反爬 Cookie使用配置中的代理
self._acw_cookie = await _get_acw_cookie(self.base_url, proxy=self._proxy) self._acw_cookie = await _get_acw_cookie(
self.base_url, proxy=self._proxy, tunnel_node_id=self._tunnel_node_id
)
self._set_connected() self._set_connected()
return True return True
@@ -406,11 +416,11 @@ class AnyrouterArchitecture(ProviderArchitecture):
Returns: Returns:
包含 acw_cookie 的配置 包含 acw_cookie 的配置
""" """
# 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL # 从 config 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config) proxy, tunnel_node_id = resolve_ops_proxy_config(config)
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy) acw_cookie = await _get_acw_cookie(base_url, proxy=proxy, tunnel_node_id=tunnel_node_id)
if acw_cookie: if acw_cookie:
return {"acw_cookie": acw_cookie} return {"acw_cookie": acw_cookie}
return {} return {}

View File

@@ -50,11 +50,12 @@ class ProviderConnector(ABC):
self._expires_at: datetime | None = None self._expires_at: datetime | None = None
self._last_error: str | None = None self._last_error: str | None = None
# 代理配置(支持 proxy_node_id 和旧的 proxy URL # 代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy, resolve_ops_tunnel_node_id from src.services.proxy_node.resolver import resolve_ops_proxy_config
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config) self._proxy: str | httpx.Proxy | None
self._tunnel_node_id: str | None = resolve_ops_tunnel_node_id(self.config) self._tunnel_node_id: str | None
self._proxy, self._tunnel_node_id = resolve_ops_proxy_config(self.config)
# HTTP 客户端配置 # HTTP 客户端配置
self._timeout = self.config.get("timeout", 30) self._timeout = self.config.get("timeout", 30)

View File

@@ -193,10 +193,14 @@ class NekoCodeArchitecture(ProviderArchitecture):
"timeout": 10, "timeout": 10,
"verify": get_ssl_context(), "verify": get_ssl_context(),
} }
from src.services.proxy_node.resolver import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config) proxy, tunnel_node_id = resolve_ops_proxy_config(config)
if proxy: if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy client_kwargs["proxy"] = proxy
async with httpx.AsyncClient(**client_kwargs) as client: async with httpx.AsyncClient(**client_kwargs) as client:

View File

@@ -99,12 +99,17 @@ class _Sub2ApiTokenMixin:
base_url: str base_url: str
_timeout: int | float _timeout: int | float
_proxy: str | httpx.Proxy | None _proxy: str | httpx.Proxy | None
_tunnel_node_id: str | None
@asynccontextmanager @asynccontextmanager
async def _get_raw_client(self) -> AsyncIterator[httpx.AsyncClient]: async def _get_raw_client(self) -> AsyncIterator[httpx.AsyncClient]:
"""获取不带 auth hook 的裸 HTTP 客户端(用于登录/刷新 token""" """获取不带 auth hook 的裸 HTTP 客户端(用于登录/刷新 token"""
transport = None transport = None
if self._proxy: if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
transport = TunnelTransport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy) transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient( async with httpx.AsyncClient(
base_url=self.base_url, base_url=self.base_url,
@@ -474,15 +479,19 @@ class Sub2ApiArchitecture(ProviderArchitecture):
""" """
base_url = base_url.rstrip("/") base_url = base_url.rstrip("/")
from src.services.proxy_node.resolver import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config) proxy, tunnel_node_id = resolve_ops_proxy_config(config)
client_kwargs: dict[str, Any] = { client_kwargs: dict[str, Any] = {
"base_url": base_url, "base_url": base_url,
"timeout": 30.0, "timeout": 30.0,
"verify": get_ssl_context(), "verify": get_ssl_context(),
} }
if proxy: if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=30.0)
elif proxy:
client_kwargs["proxy"] = proxy client_kwargs["proxy"] = proxy
email = credentials.get("email", "").strip() email = credentials.get("email", "").strip()

View File

@@ -232,10 +232,10 @@ class YesCodeArchitecture(ProviderArchitecture):
cookie_header = _build_cookie_header(cookie_input) cookie_header = _build_cookie_header(cookie_input)
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL # 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config) proxy, tunnel_node_id = resolve_ops_proxy_config(config)
try: try:
# 构建 client 参数 # 构建 client 参数
@@ -244,7 +244,11 @@ class YesCodeArchitecture(ProviderArchitecture):
"timeout": 10.0, "timeout": 10.0,
"verify": get_ssl_context(), "verify": get_ssl_context(),
} }
if proxy: if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy client_kwargs["proxy"] = proxy
# 创建临时 client 获取合并数据 # 创建临时 client 获取合并数据

View File

@@ -1034,10 +1034,9 @@ class ProviderOpsService:
) )
# 获取代理配置(支持 proxy_node_id、tunnel 模式和旧的 proxy URL # 获取代理配置(支持 proxy_node_id、tunnel 模式和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy, resolve_ops_tunnel_node_id from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config) proxy, tunnel_node_id = resolve_ops_proxy_config(config)
tunnel_node_id = resolve_ops_tunnel_node_id(config)
try: try:
# 构建 httpx client 参数 # 构建 httpx client 参数

View File

@@ -14,6 +14,7 @@ from .resolver import (
make_proxy_param, make_proxy_param,
resolve_delegate_config, resolve_delegate_config,
resolve_ops_proxy, resolve_ops_proxy,
resolve_ops_proxy_config,
resolve_ops_tunnel_node_id, resolve_ops_tunnel_node_id,
resolve_proxy_info, resolve_proxy_info,
) )
@@ -36,6 +37,7 @@ __all__ = [
"invalidate_system_proxy_cache", "invalidate_system_proxy_cache",
"resolve_delegate_config", "resolve_delegate_config",
"resolve_ops_proxy", "resolve_ops_proxy",
"resolve_ops_proxy_config",
"resolve_ops_tunnel_node_id", "resolve_ops_tunnel_node_id",
"resolve_proxy_info", "resolve_proxy_info",
] ]

View File

@@ -216,63 +216,67 @@ def _resolve_effective_node(
return None, None return None, None
def resolve_ops_proxy( def resolve_ops_proxy_config(
connector_config: dict[str, Any] | None, connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None: ) -> tuple[str | httpx.Proxy | None, str | None]:
""" """
ops connector.config 中解析代理参数(含系统默认回退) 一次解析 ops connector 的代理参数和 tunnel 节点 ID
合并 resolve_ops_proxy + resolve_ops_tunnel_node_id避免重复调用
_resolve_effective_node。两个返回值互斥tunnel 模式时 proxy 为 None
非 tunnel 模式时 tunnel_node_id 为 None。
优先级: 优先级:
1. connector_config.proxy_node_id新格式 1. connector_config.proxy_node_id新格式
2. connector_config.proxy旧格式 URL 字符串) 2. connector_config.proxy旧格式 URL 字符串)
3. 系统默认代理节点 3. 系统默认代理节点
tunnel 模式节点不返回代理 URL由 resolve_ops_tunnel_node_id 处理)。
Args:
connector_config: connector 的 config 字典
Returns: Returns:
httpx 可接受的代理参数str 或 httpx.Proxy或 None (proxy, tunnel_node_id)
""" """
from .tunnel_transport import is_tunnel_node from .tunnel_transport import is_tunnel_node
node_id, node_info = _resolve_effective_node(connector_config) node_id, node_info = _resolve_effective_node(connector_config)
if node_id and node_info: if node_id and node_info:
if is_tunnel_node(node_info): if is_tunnel_node(node_info):
return None # tunnel 模式不使用 proxy URL return None, node_id
try: try:
url = build_proxy_url({"node_id": node_id, "enabled": True}) url = build_proxy_url({"node_id": node_id, "enabled": True})
return make_proxy_param(url) return make_proxy_param(url), None
except Exception as exc: except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc) logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None return None, None
# 旧格式:直接返回 proxy URL 字符串 # 旧格式:直接返回 proxy URL 字符串
if connector_config: if connector_config:
proxy = connector_config.get("proxy") proxy = connector_config.get("proxy")
if isinstance(proxy, str) and proxy.strip(): if isinstance(proxy, str) and proxy.strip():
return proxy return proxy, None
return None return None, None
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
"""从 ops connector.config 中解析代理参数(含系统默认回退)
tunnel 模式节点不返回代理 URL。
如需同时获取 tunnel_node_id请使用 resolve_ops_proxy_config 避免重复解析。
"""
proxy, _ = resolve_ops_proxy_config(connector_config)
return proxy
def resolve_ops_tunnel_node_id( def resolve_ops_tunnel_node_id(
connector_config: dict[str, Any] | None, connector_config: dict[str, Any] | None,
) -> str | None: ) -> str | None:
"""解析 ops connector 的 tunnel 节点 ID
如需同时获取 proxy请使用 resolve_ops_proxy_config 避免重复解析。
""" """
解析 ops connector 的 tunnel 节点 ID _, tunnel_node_id = resolve_ops_proxy_config(connector_config)
return tunnel_node_id
如果配置的代理节点是 tunnel 模式且已连接,返回 node_id。
否则返回 None含系统默认代理回退
"""
from .tunnel_transport import is_tunnel_node
node_id, node_info = _resolve_effective_node(connector_config)
if node_id and node_info and is_tunnel_node(node_info):
return node_id
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -353,6 +357,16 @@ def build_proxy_client_kwargs(
verify = get_ssl_context() verify = get_ssl_context()
kwargs: dict[str, Any] = {"timeout": timeout, "verify": verify, **extra} kwargs: dict[str, Any] = {"timeout": timeout, "verify": verify, **extra}
# tunnel 模式优先:当代理节点为 tunnel 模式时,使用 TunnelTransport
delegate_cfg = resolve_delegate_config(proxy_config)
if delegate_cfg and delegate_cfg.get("tunnel"):
from src.services.proxy_node.tunnel_transport import TunnelTransport
timeout_secs = timeout if isinstance(timeout, (int, float)) else 60.0
kwargs["transport"] = TunnelTransport(delegate_cfg["node_id"], timeout=timeout_secs)
return kwargs
proxy_param = resolve_proxy_param(proxy_config) proxy_param = resolve_proxy_param(proxy_config)
if proxy_param: if proxy_param:
kwargs["proxy"] = proxy_param kwargs["proxy"] = proxy_param