feat: aether-proxy TLS 双栈支持与自签名证书自动生成

- aether-proxy 新增 TLS 模块:自签名证书生成、TLS acceptor 构建、证书 SHA-256 指纹计算
- 代理服务器支持 HTTP+TLS 双栈模式,通过 peek 首字节区分 TLS ClientHello 与普通 HTTP
- 注册与心跳上报 tls_enabled 和 tls_cert_fingerprint 字段
- Python 侧 httpx 代理适配:TLS 代理使用 httpx.Proxy + CERT_NONE ssl_context
- ProxyNode 模型新增 tls_enabled/tls_cert_fingerprint 字段及对应迁移
This commit is contained in:
fawney19
2026-02-07 23:18:58 +08:00
parent 10bd14c223
commit 5384ffd403
17 changed files with 588 additions and 79 deletions

View File

@@ -50,6 +50,8 @@ def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"avg_latency_ms": node.avg_latency_ms,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
"remote_config": node.remote_config,
"config_version": node.config_version,
"created_at": node.created_at,
@@ -75,6 +77,12 @@ class ProxyNodeRegisterRequest(BaseModel):
total_requests: int | None = Field(None, ge=0, description="累计请求数")
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
# TLS
tls_enabled: bool = Field(False, description="是否启用 TLS 加密")
tls_cert_fingerprint: str | None = Field(
None, max_length=128, description="TLS 证书 SHA-256 指纹"
)
@field_validator("ip")
@classmethod
def validate_ip(cls, v: str) -> str:
@@ -275,6 +283,8 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
node.heartbeat_interval = req.heartbeat_interval
node.tls_enabled = req.tls_enabled
node.tls_cert_fingerprint = req.tls_cert_fingerprint
if req.active_connections is not None:
node.active_connections = req.active_connections
if req.total_requests is not None:
@@ -295,6 +305,8 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
active_connections=req.active_connections or 0,
total_requests=req.total_requests or 0,
avg_latency_ms=req.avg_latency_ms,
tls_enabled=req.tls_enabled,
tls_cert_fingerprint=req.tls_cert_fingerprint,
created_at=now,
updated_at=now,
)
@@ -496,7 +508,9 @@ def _build_test_proxy_url(node: ProxyNode) -> str:
# aether-proxy: 使用 HMAC 认证构建代理 URL
from src.clients.http_client import _build_hmac_proxy_url
return _build_hmac_proxy_url(node.ip, node.port, node.id)
return _build_hmac_proxy_url(
node.ip, node.port, node.id, tls_enabled=bool(node.tls_enabled)
)
@dataclass
@@ -634,9 +648,14 @@ class AdminTestProxyNodeAdapter(AdminApiAdapter):
test_url = "https://1.1.1.1/cdn-cgi/trace"
start = _time.monotonic()
# TLS 代理需要 proxy_ssl_context
from src.clients.http_client import _make_proxy_param
proxy_param = _make_proxy_param(proxy_url)
try:
async with httpx.AsyncClient(
proxy=proxy_url,
proxy=proxy_param,
timeout=httpx.Timeout(15.0, connect=10.0),
) as client:
response = await client.get(test_url)

View File

@@ -73,7 +73,12 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"password": node.proxy_password,
}
else:
value = {"ip": node.ip, "port": node.port}
value = {
"ip": node.ip,
"port": node.port,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
}
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return value
@@ -81,12 +86,14 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
db.close()
def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
def _build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
"""
构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http://hmac:{timestamp}.{signature}@{ip}:{port}
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
当 tls_enabled=True 时使用 https:// scheme。
"""
if not config.proxy_hmac_key:
raise ProxyNodeUnavailableError(
@@ -102,7 +109,8 @@ def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
).hexdigest()
host = f"[{ip}]" if ":" in ip else ip
return f"http://hmac:{timestamp}.{signature}@{host}:{int(port)}"
scheme = "https" if tls_enabled else "http"
return f"{scheme}://hmac:{timestamp}.{signature}@{host}:{int(port)}"
# 系统默认代理缓存
@@ -150,9 +158,11 @@ def get_system_proxy_config() -> dict[str, Any] | None:
db.close()
def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
"""
从 ops connector.config 中解析代理 URL(含系统默认回退)
从 ops connector.config 中解析代理参数(含系统默认回退)
优先级:
1. connector_config.proxy_node_id新格式
@@ -163,14 +173,15 @@ def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
connector_config: connector 的 config 字典
Returns:
代理 URL 字符串,或 None
httpx 可接受的代理参数str 或 httpx.Proxy,或 None
"""
if connector_config:
# 新格式proxy_node_id → 通过 build_proxy_url 解析
node_id = connector_config.get("proxy_node_id")
if isinstance(node_id, str) and node_id.strip():
try:
return build_proxy_url({"node_id": node_id.strip(), "enabled": True})
url = build_proxy_url({"node_id": node_id.strip(), "enabled": True})
return _make_proxy_param(url)
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
@@ -184,7 +195,8 @@ def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
system_proxy = get_system_proxy_config()
if system_proxy:
try:
return build_proxy_url(system_proxy)
url = build_proxy_url(system_proxy)
return _make_proxy_param(url)
except Exception as exc:
logger.warning("构建系统默认代理 URL 失败: {}", exc)
return None
@@ -281,7 +293,12 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
return manual_url
# aether-proxy 节点:使用 HMAC 认证
return _build_hmac_proxy_url(node_info["ip"], node_info["port"], node_id)
return _build_hmac_proxy_url(
node_info["ip"],
node_info["port"],
node_id,
tls_enabled=node_info.get("tls_enabled", False),
)
proxy_url: str | None = proxy_config.get("url")
if not proxy_url:
@@ -308,6 +325,26 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
return proxy_url
def _make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
"""
根据代理 URL 返回 httpx 可接受的 proxy 参数。
对于 https:// scheme 的代理 URLTLS aether-proxy 节点),返回 httpx.Proxy
并附带 proxy_ssl_contextCERT_NONE因为使用自签名证书
其他情况返回普通 URL 字符串。
"""
if not proxy_url:
return None
# https:// 代理需要 ssl_context自签名证书场景
if proxy_url.startswith("https://"):
from src.utils.ssl_utils import get_proxy_ssl_context
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
return proxy_url
class HTTPClientPool:
"""
全局HTTP客户端池单例
@@ -521,8 +558,9 @@ class HTTPClientPool:
# 添加代理配置
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
if proxy_url:
client_config["proxy"] = proxy_url
proxy_param = _make_proxy_param(proxy_url)
if proxy_param:
client_config["proxy"] = proxy_param
client = httpx.AsyncClient(**client_config) # type: ignore[arg-type]
cls._proxy_clients[cache_key] = (client, time.time())
@@ -629,10 +667,15 @@ class HTTPClientPool:
pool=config.http_pool_timeout,
)
# 无特定代理时,回退到系统默认代理(与 get_proxy_client 行为一致)
if proxy_config is None:
proxy_config = get_system_proxy_config()
# 添加代理配置
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
if proxy_url:
client_config["proxy"] = proxy_url
proxy_param = _make_proxy_param(proxy_url)
if proxy_param:
client_config["proxy"] = proxy_param
logger.debug(f"创建带代理的HTTP客户端(一次性): {proxy_config.get('url', 'unknown')}")
client_config.update(kwargs)

View File

@@ -842,6 +842,12 @@ class ProxyNode(Base):
total_requests = Column(BigInteger, default=0, nullable=False)
avg_latency_ms = Column(Float, nullable=True)
# TLS 加密
tls_enabled = Column(Boolean, default=False, nullable=False, comment="是否启用 TLS 加密")
tls_cert_fingerprint = Column(
String(128), nullable=True, comment="TLS 证书 SHA-256 指纹hex"
)
# 管理端远程配置(通过心跳下发给 aether-proxy
remote_config = Column(
JSON,

View File

@@ -213,7 +213,7 @@ def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
async def _get_acw_cookie(
base_url: str, timeout: float = 10, proxy: str | None = None
base_url: str, timeout: float = 10, proxy: str | httpx.Proxy | None = None
) -> str | None:
"""
获取 acw_sc__v2 Cookie

View File

@@ -53,7 +53,7 @@ class ProviderConnector(ABC):
# 代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy
self._proxy: str | None = resolve_ops_proxy(self.config)
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config)
# HTTP 客户端配置
self._timeout = self.config.get("timeout", 30)

View File

@@ -5,6 +5,8 @@ SSL 工具函数
import ssl
from loguru import logger
try:
import certifi
@@ -12,6 +14,8 @@ try:
except ImportError:
_SSL_CONTEXT = ssl.create_default_context()
_PROXY_SSL_CONTEXT: ssl.SSLContext | None = None
def get_ssl_context() -> ssl.SSLContext:
"""
@@ -24,3 +28,30 @@ def get_ssl_context() -> ssl.SSLContext:
ssl.SSLContext: SSL 上下文
"""
return _SSL_CONTEXT
def get_proxy_ssl_context(expected_fingerprint: str | None = None) -> ssl.SSLContext:
"""
获取用于代理连接的 SSL 上下文(连接 aether-proxy TLS 端口)
当前使用 CERT_NONE不验证证书因为 aether-proxy 使用自签名证书。
expected_fingerprint 参数预留供未来实现指纹校验。
Args:
expected_fingerprint: 预期的证书 SHA-256 指纹hex预留参数
Returns:
ssl.SSLContext: 代理专用 SSL 上下文
"""
global _PROXY_SSL_CONTEXT
if expected_fingerprint:
logger.warning("TLS 证书指纹校验尚未实现, fingerprint={} 被忽略", expected_fingerprint)
if _PROXY_SSL_CONTEXT is None:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
_PROXY_SSL_CONTEXT = ctx
# TODO: 实现基于 expected_fingerprint 的证书指纹校验
return _PROXY_SSL_CONTEXT