feat(proxy): 支持远程推送升级与proxy元数据上报

- aether-proxy 注册和心跳时上报 proxy_metadata(含版本号)
- 心跳 ACK 支持 upgrade_to 字段,proxy 收到后自动执行升级
- 重构 upgrade 逻辑,新增 perform_upgrade 用于远程触发的自动升级
- stream_handler 延迟统计改为仅记录连接建立延迟(DNS+TCP/TLS+TTFB)
- 后端新增 proxy_metadata 数据库字段和批量升级 API
- 远程配置支持下发 upgrade_to 版本指令
- 前端展示节点版本号,支持单节点和批量升级操作
This commit is contained in:
fawney19
2026-03-02 12:58:41 +08:00
parent f978888759
commit 68bae686da
14 changed files with 538 additions and 68 deletions

View File

@@ -39,11 +39,15 @@ class ProxyNodeRegisterRequest(BaseModel):
# 指标(可选)
active_connections: int | None = Field(None, ge=0, description="当前活跃连接数")
total_requests: int | None = Field(None, ge=0, description="累计请求数")
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
avg_latency_ms: float | None = Field(None, ge=0, description="平均连接建立延迟(ms)")
# 硬件信息
hardware_info: dict | None = Field(None, description="硬件信息 JSON")
estimated_max_concurrency: int | None = Field(None, ge=0, description="估算最大并发连接数")
proxy_metadata: dict[str, Any] | None = Field(None, description="aether-proxy 元数据(版本等)")
proxy_version: str | None = Field(
None, max_length=20, description="兼容字段aether-proxy 软件版本"
)
@field_validator("ip")
@classmethod
@@ -62,7 +66,11 @@ class ProxyNodeHeartbeatRequest(BaseModel):
active_connections: int | None = Field(None, ge=0, description="当前活跃连接数")
total_requests: int | None = Field(None, ge=0, description="累计请求数")
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
avg_latency_ms: float | None = Field(None, ge=0, description="平均连接建立延迟(ms)")
proxy_metadata: dict[str, Any] | None = Field(None, description="aether-proxy 元数据(版本等)")
proxy_version: str | None = Field(
None, max_length=20, description="兼容字段aether-proxy 软件版本"
)
class ProxyNodeUnregisterRequest(BaseModel):
@@ -76,6 +84,7 @@ class ProxyNodeRemoteConfigRequest(BaseModel):
allowed_ports: list[int] | None = Field(None, description="允许代理的目标端口")
log_level: str | None = Field(None, description="日志级别 (trace/debug/info/warn/error)")
heartbeat_interval: int | None = Field(None, ge=5, le=600, description="心跳间隔(秒)")
upgrade_to: str | None = Field(None, max_length=50, description="下发升级目标版本")
@field_validator("allowed_ports")
@classmethod
@@ -93,6 +102,28 @@ class ProxyNodeRemoteConfigRequest(BaseModel):
raise ValueError("log_level 必须是 trace/debug/info/warn/error 之一")
return v
@field_validator("upgrade_to")
@classmethod
def validate_upgrade_to(cls, v: str | None) -> str | None:
if v is None:
return None
vv = v.strip()
if not vv:
return None
return vv
class ProxyNodeBatchUpgradeRequest(BaseModel):
version: str = Field(..., min_length=1, max_length=50, description="目标版本号")
@field_validator("version")
@classmethod
def validate_version(cls, v: str) -> str:
vv = v.strip()
if not vv:
raise ValueError("version 不能为空")
return vv
class ManualProxyNodeCreateRequest(BaseModel):
"""手动创建代理节点"""
@@ -187,6 +218,12 @@ async def create_manual_proxy_node(request: Request, db: Session = Depends(get_d
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/upgrade")
async def batch_upgrade_proxy_nodes(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminBatchUpgradeProxyNodesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{node_id}")
async def update_manual_proxy_node(
node_id: str, request: Request, db: Session = Depends(get_db)
@@ -274,6 +311,8 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
active_connections=req.active_connections,
total_requests=req.total_requests,
avg_latency_ms=req.avg_latency_ms,
proxy_metadata=req.proxy_metadata,
proxy_version=req.proxy_version,
registered_by=context.user.id if context.user else None,
)
@@ -305,6 +344,8 @@ class AdminHeartbeatProxyNodeAdapter(AdminApiAdapter):
active_connections=req.active_connections,
total_requests=req.total_requests,
avg_latency_ms=req.avg_latency_ms,
proxy_metadata=req.proxy_metadata,
proxy_version=req.proxy_version,
)
context.add_audit_metadata(
@@ -477,6 +518,7 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
# Build config dict with only the supplied fields
config_updates: dict[str, Any] = {}
fields_set = req.model_fields_set
if req.node_name is not None:
config_updates["node_name"] = req.node_name
if req.allowed_ports is not None:
@@ -485,6 +527,8 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
config_updates["log_level"] = req.log_level
if req.heartbeat_interval is not None:
config_updates["heartbeat_interval"] = req.heartbeat_interval
if "upgrade_to" in fields_set:
config_updates["upgrade_to"] = req.upgrade_to
node = ProxyNodeService.update_node_config(
context.db, node_id=self.node_id, config_updates=config_updates
@@ -504,6 +548,29 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
}
@dataclass
class AdminBatchUpgradeProxyNodesAdapter(AdminApiAdapter):
"""批量向在线 tunnel 节点下发升级指令。"""
name: str = "admin_batch_upgrade_proxy_nodes"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeBatchUpgradeRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
result = ProxyNodeService.batch_upgrade_online_nodes(context.db, version=req.version)
context.add_audit_metadata(
action="proxy_node_batch_upgrade",
version=result["version"],
updated=result["updated"],
skipped=result["skipped"],
)
return result
class TestProxyUrlRequest(BaseModel):
proxy_url: str = Field(..., min_length=1, max_length=500)
username: str | None = Field(None, max_length=255)

View File

@@ -919,10 +919,11 @@ class ProxyNode(Base):
# 性能指标(心跳上报)
active_connections = Column(Integer, default=0, nullable=False)
total_requests = Column(BigInteger, default=0, nullable=False)
avg_latency_ms = Column(Float, nullable=True)
avg_latency_ms = Column(Float, nullable=True, comment="平均连接建立延迟(ms), DNS+TCP/TLS+TTFB")
failed_requests = Column(BigInteger, default=0, nullable=False, comment="累计失败请求数")
dns_failures = Column(BigInteger, default=0, nullable=False, comment="累计 DNS 失败数")
stream_errors = Column(BigInteger, default=0, nullable=False, comment="累计流错误数")
proxy_metadata = Column(JSON, nullable=True, comment="aether-proxy 上报元数据(版本等)")
# 硬件信息注册时上报JSON 可扩展)
hardware_info = Column(

View File

@@ -351,7 +351,7 @@ class HubConnectionManager:
def _sync_heartbeat() -> dict[str, object]:
from src.database import create_session
from src.services.proxy_node.service import ProxyNodeService
from src.services.proxy_node.service import ProxyNodeService, build_heartbeat_ack
if not node_id:
return {}
@@ -367,12 +367,10 @@ class HubConnectionManager:
failed_requests=data.get("failed_requests"),
dns_failures=data.get("dns_failures"),
stream_errors=data.get("stream_errors"),
proxy_metadata=data.get("proxy_metadata"),
proxy_version=data.get("proxy_version"),
)
result: dict[str, object] = {}
if node.remote_config:
result["remote_config"] = node.remote_config
result["config_version"] = node.config_version or 0
return result
return build_heartbeat_ack(node)
finally:
db.close()

View File

@@ -62,6 +62,7 @@ def node_to_dict(node: ProxyNode) -> dict[str, Any]:
"failed_requests": node.failed_requests,
"dns_failures": node.dns_failures,
"stream_errors": node.stream_errors,
"proxy_metadata": node.proxy_metadata,
"hardware_info": node.hardware_info,
"estimated_max_concurrency": node.estimated_max_concurrency,
"remote_config": node.remote_config,
@@ -95,6 +96,40 @@ def _sanitize_proxy_error(err: Exception) -> str:
return re.sub(r"://[^@/]+@", "://***@", str(err))
def _normalize_proxy_metadata(
proxy_metadata: Any | None, proxy_version: str | None = None
) -> dict[str, Any] | None:
"""规范化 proxy 元数据,兼容旧版单独上报 proxy_version。"""
normalized: dict[str, Any] = {}
if isinstance(proxy_metadata, dict):
normalized = {str(k): v for k, v in proxy_metadata.items() if k is not None}
version: str | None = None
raw_version = normalized.pop("version", None)
if isinstance(raw_version, str) and raw_version.strip():
version = raw_version.strip()[:20]
if proxy_version is not None and proxy_version.strip():
version = proxy_version.strip()[:20]
if version is not None:
normalized["version"] = version
return normalized or None
def build_heartbeat_ack(node: ProxyNode) -> dict[str, Any]:
"""从心跳后的节点构建 ACK 响应 payload供 hub_transport / tunnel_manager 使用)。"""
result: dict[str, Any] = {}
if not node.remote_config:
return result
result["remote_config"] = node.remote_config
result["config_version"] = node.config_version or 0
if isinstance(node.remote_config, dict):
raw_upgrade = node.remote_config.get("upgrade_to")
if isinstance(raw_upgrade, str) and raw_upgrade.strip():
result["upgrade_to"] = raw_upgrade.strip()
return result
async def _test_proxy_connectivity(proxy_url: str) -> dict[str, Any]:
"""通过代理 URL 测试连通性,返回标准化结果 dict"""
import time as _time
@@ -250,11 +285,14 @@ class ProxyNodeService:
active_connections: int | None = None,
total_requests: int | None = None,
avg_latency_ms: float | None = None,
proxy_metadata: dict[str, Any] | None = None,
proxy_version: str | None = None,
registered_by: str | None = None,
) -> ProxyNode:
"""注册或更新 aether-proxy 节点tunnel 模式)"""
now = datetime.now(timezone.utc)
normalized_proxy_metadata = _normalize_proxy_metadata(proxy_metadata, proxy_version)
node = (
db.query(ProxyNode)
@@ -283,6 +321,8 @@ class ProxyNodeService:
node.total_requests = total_requests
if avg_latency_ms is not None:
node.avg_latency_ms = avg_latency_ms
if normalized_proxy_metadata is not None:
node.proxy_metadata = normalized_proxy_metadata
else:
node = ProxyNode(
id=str(uuid.uuid4()),
@@ -298,6 +338,7 @@ class ProxyNodeService:
active_connections=active_connections or 0,
total_requests=total_requests or 0,
avg_latency_ms=avg_latency_ms,
proxy_metadata=normalized_proxy_metadata,
hardware_info=hardware_info,
estimated_max_concurrency=estimated_max_concurrency,
tunnel_mode=True,
@@ -322,6 +363,8 @@ class ProxyNodeService:
failed_requests: int | None = None,
dns_failures: int | None = None,
stream_errors: int | None = None,
proxy_metadata: dict[str, Any] | None = None,
proxy_version: str | None = None,
) -> ProxyNode:
"""处理节点心跳(仅 tunnel 模式节点,更新指标并修正状态不一致)
@@ -357,6 +400,9 @@ class ProxyNodeService:
values["active_connections"] = active_connections
if avg_latency_ms is not None:
values["avg_latency_ms"] = avg_latency_ms
normalized_proxy_metadata = _normalize_proxy_metadata(proxy_metadata, proxy_version)
if normalized_proxy_metadata is not None:
values["proxy_metadata"] = normalized_proxy_metadata
# 区间增量指标 -- 使用数据库原子自增,避免并发心跳读改写丢增量
if total_requests is not None and total_requests > 0:
@@ -626,7 +672,11 @@ class ProxyNodeService:
# Merge with existing config (so partial updates are preserved)
# Copy to a new dict so SQLAlchemy detects the change on the JSON column
existing = dict(node.remote_config) if node.remote_config else {}
existing.update(config_updates)
for key, value in config_updates.items():
if key == "upgrade_to" and value is None:
existing.pop("upgrade_to", None)
continue
existing[key] = value
node.remote_config = existing
node.config_version = (node.config_version or 0) + 1
@@ -635,3 +685,44 @@ class ProxyNodeService:
db.commit()
db.refresh(node)
return node
@staticmethod
def batch_upgrade_online_nodes(db: Session, *, version: str) -> dict[str, Any]:
"""批量向在线 tunnel 节点下发 upgrade_to。"""
normalized = version.strip()
if not normalized:
raise InvalidRequestException("version 不能为空")
nodes = (
db.query(ProxyNode)
.filter(
ProxyNode.is_manual == False, # noqa: E712
ProxyNode.tunnel_mode == True, # noqa: E712
ProxyNode.status == ProxyNodeStatus.ONLINE,
)
.all()
)
updated_node_ids: list[str] = []
skipped = 0
now = datetime.now(timezone.utc)
for node in nodes:
existing = dict(node.remote_config) if node.remote_config else {}
if existing.get("upgrade_to") == normalized:
skipped += 1
continue
existing["upgrade_to"] = normalized
node.remote_config = existing
node.config_version = (node.config_version or 0) + 1
node.updated_at = now
updated_node_ids.append(node.id)
if updated_node_ids:
db.commit()
return {
"version": normalized,
"updated": len(updated_node_ids),
"skipped": skipped,
"node_ids": updated_node_ids,
}

View File

@@ -470,7 +470,7 @@ class TunnelManager:
def _sync_heartbeat() -> dict[str, Any]:
from src.database import create_session
from src.services.proxy_node.service import ProxyNodeService
from src.services.proxy_node.service import ProxyNodeService, build_heartbeat_ack
db = create_session()
try:
@@ -483,12 +483,10 @@ class TunnelManager:
failed_requests=data.get("failed_requests"),
dns_failures=data.get("dns_failures"),
stream_errors=data.get("stream_errors"),
proxy_metadata=data.get("proxy_metadata"),
proxy_version=data.get("proxy_version"),
)
result: dict[str, Any] = {}
if node.remote_config:
result["remote_config"] = node.remote_config
result["config_version"] = node.config_version or 0
return result
return build_heartbeat_ack(node)
finally:
db.close()