Files
Aether/_deprecated_py_src/api/admin/proxy_nodes/routes.py
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
2026-04-03 16:26:16 +08:00

629 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理员代理节点ProxyNode管理端点
用于 aether-proxy 在 VPS 上注册、心跳、注销节点,以及管理员查看/删除节点记录。
"""
from __future__ import annotations
import ipaddress
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field, ValidationError, field_validator
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.core.exceptions import InvalidRequestException
from src.database import get_db
from src.services.proxy_node.service import ProxyNodeService, node_to_dict
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
pipeline = get_pipeline()
# ---------------------------------------------------------------------------
# Pydantic 请求模型
# ---------------------------------------------------------------------------
class ProxyNodeRegisterRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="节点名")
ip: str = Field(..., description="公网 IPIPv4/IPv6")
port: int = Field(0, ge=0, le=65535, description="代理端口tunnel 模式下为 0")
region: str | None = Field(None, max_length=100, description="区域标签")
heartbeat_interval: int = Field(30, ge=5, le=600, description="心跳间隔(秒)")
# 指标(可选)
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="平均连接建立延迟(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
def validate_ip(cls, v: str) -> str:
v = v.strip()
try:
ipaddress.ip_address(v)
except ValueError as exc:
raise ValueError("ip 必须是合法的 IPv4/IPv6 地址") from exc
return v
class ProxyNodeHeartbeatRequest(BaseModel):
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
heartbeat_interval: int | None = Field(None, ge=5, le=600, description="心跳间隔(秒)")
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="平均连接建立延迟(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):
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
class ProxyNodeRemoteConfigRequest(BaseModel):
"""管理端远程配置 — 通过心跳下发给 aether-proxy"""
node_name: str | None = Field(None, min_length=1, max_length=100, description="节点名称")
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
def validate_ports(cls, v: list[int] | None) -> list[int] | None:
if v is not None:
for port in v:
if not 1 <= port <= 65535:
raise ValueError(f"端口 {port} 不在有效范围 (1-65535)")
return v
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str | None) -> str | None:
if v is not None and v not in ("trace", "debug", "info", "warn", "error"):
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):
"""手动创建代理节点"""
name: str = Field(..., min_length=1, max_length=100, description="节点名")
proxy_url: str = Field(
..., min_length=1, max_length=500, description="代理 URL (http/https/socks5)"
)
username: str | None = Field(None, max_length=255, description="代理用户名")
password: str | None = Field(None, max_length=500, description="代理密码")
region: str | None = Field(None, max_length=100, description="区域标签")
@field_validator("proxy_url")
@classmethod
def validate_proxy_url(cls, v: str) -> str:
import re
from urllib.parse import urlparse
v = v.strip()
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
parsed = urlparse(v)
if not parsed.hostname:
raise ValueError("代理 URL 必须包含有效的 host")
return v
class ManualProxyNodeUpdateRequest(BaseModel):
"""更新手动代理节点"""
name: str | None = Field(None, min_length=1, max_length=100, description="节点名")
proxy_url: str | None = Field(None, min_length=1, max_length=500, description="代理 URL")
username: str | None = Field(None, max_length=255, description="代理用户名")
password: str | None = Field(None, max_length=500, description="代理密码")
region: str | None = Field(None, max_length=100, description="区域标签")
@field_validator("proxy_url")
@classmethod
def validate_proxy_url(cls, v: str | None) -> str | None:
if v is None:
return None
import re
from urllib.parse import urlparse
v = v.strip()
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
parsed = urlparse(v)
if not parsed.hostname:
raise ValueError("代理 URL 必须包含有效的 host")
return v
# ---------------------------------------------------------------------------
# 路由端点
# ---------------------------------------------------------------------------
@router.post("/register")
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminRegisterProxyNodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/heartbeat")
async def heartbeat_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminHeartbeatProxyNodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/unregister")
async def unregister_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminUnregisterProxyNodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("")
async def list_proxy_nodes(
request: Request,
status: str | None = Query(None, description="按状态筛选online/offline"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminListProxyNodesAdapter(status=status, skip=skip, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/manual")
async def create_manual_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminCreateManualProxyNodeAdapter()
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)
) -> Any:
adapter = AdminUpdateManualProxyNodeAdapter(node_id=node_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{node_id}")
async def delete_proxy_node(node_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminDeleteProxyNodeAdapter(node_id=node_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{node_id}/test")
async def test_proxy_node(node_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminTestProxyNodeAdapter(node_id=node_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/test-url")
async def test_proxy_url(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminTestProxyUrlAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/{node_id}/config")
async def update_proxy_node_config(
node_id: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = AdminUpdateProxyNodeConfigAdapter(node_id=node_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{node_id}/events")
async def list_proxy_node_events(
node_id: str,
request: Request,
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminListProxyNodeEventsAdapter(node_id=node_id, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _format_validation_error(exc: ValidationError) -> str:
parts: list[str] = []
for err in exc.errors():
field = " -> ".join(str(x) for x in err.get("loc", []))
msg = str(err.get("msg", "invalid"))
parts.append(f"{field}: {msg}")
return "; ".join(parts) or "输入验证失败"
# ---------------------------------------------------------------------------
# Adapter 实现
# ---------------------------------------------------------------------------
@dataclass
class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_register_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeRegisterRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = ProxyNodeService.register_node(
context.db,
name=req.name,
ip=req.ip,
port=req.port,
region=req.region,
heartbeat_interval=req.heartbeat_interval,
hardware_info=req.hardware_info,
estimated_max_concurrency=req.estimated_max_concurrency,
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,
)
context.add_audit_metadata(
action="proxy_node_register",
proxy_node_id=node.id,
proxy_node_ip=node.ip,
proxy_node_port=node.port,
)
return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass
class AdminHeartbeatProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_heartbeat_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeHeartbeatRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = ProxyNodeService.heartbeat(
context.db,
node_id=req.node_id,
heartbeat_interval=req.heartbeat_interval,
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(
action="proxy_node_heartbeat",
proxy_node_id=node.id,
)
return {"message": "heartbeat ok", "node": node_to_dict(node)}
@dataclass
class AdminUnregisterProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_unregister_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeUnregisterRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = ProxyNodeService.unregister_node(context.db, node_id=req.node_id)
context.add_audit_metadata(
action="proxy_node_unregister",
proxy_node_id=node.id,
)
return {"message": "unregistered", "node_id": node.id}
@dataclass
class AdminListProxyNodesAdapter(AdminApiAdapter):
name: str = "admin_list_proxy_nodes"
status: str | None = None
skip: int = 0
limit: int = 100
async def handle(self, context: ApiRequestContext) -> Any:
nodes, total = ProxyNodeService.list_nodes(
context.db, status=self.status, skip=self.skip, limit=self.limit
)
return {
"items": [node_to_dict(n) for n in nodes],
"total": total,
"skip": self.skip,
"limit": self.limit,
}
@dataclass
class AdminDeleteProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_delete_proxy_node"
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
result = ProxyNodeService.delete_node(context.db, node_id=self.node_id)
context.add_audit_metadata(
action="proxy_node_delete",
proxy_node_id=self.node_id,
**result.get("node_info", {}),
)
was_system_proxy = result["cleared_system_proxy"]
cleared_providers = result.get("cleared_providers", 0)
cleared_endpoints = result.get("cleared_endpoints", 0)
parts = ["deleted"]
if was_system_proxy:
parts.append("system default proxy cleared")
if cleared_providers or cleared_endpoints:
parts.append(
f"cleared proxy from {cleared_providers} provider(s) "
f"and {cleared_endpoints} endpoint(s)"
)
return {
"message": ", ".join(parts),
"node_id": self.node_id,
"cleared_system_proxy": was_system_proxy,
"cleared_providers": cleared_providers,
"cleared_endpoints": cleared_endpoints,
}
@dataclass
class AdminCreateManualProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_create_manual_proxy_node"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ManualProxyNodeCreateRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = ProxyNodeService.create_manual_node(
context.db,
name=req.name,
proxy_url=req.proxy_url,
username=req.username,
password=req.password,
region=req.region,
registered_by=context.user.id if context.user else None,
)
context.add_audit_metadata(
action="proxy_node_manual_create",
proxy_node_id=node.id,
)
return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass
class AdminUpdateManualProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_update_manual_proxy_node"
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ManualProxyNodeUpdateRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = ProxyNodeService.update_manual_node(
context.db,
node_id=self.node_id,
name=req.name,
proxy_url=req.proxy_url,
username=req.username,
password=req.password,
region=req.region,
)
context.add_audit_metadata(
action="proxy_node_manual_update",
proxy_node_id=node.id,
)
return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass
class AdminTestProxyNodeAdapter(AdminApiAdapter):
"""测试代理节点连通性和延迟"""
name: str = "admin_test_proxy_node"
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
return await ProxyNodeService.test_node(context.db, node_id=self.node_id)
@dataclass
class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
"""更新 aether-proxy 节点的远程配置(通过下次心跳下发)"""
name: str = "admin_update_proxy_node_config"
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = ProxyNodeRemoteConfigRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
# 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:
config_updates["allowed_ports"] = req.allowed_ports
if req.log_level is not None:
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
)
context.add_audit_metadata(
action="proxy_node_config_update",
proxy_node_id=node.id,
config_version=node.config_version,
)
return {
"node_id": node.id,
"config_version": node.config_version,
"remote_config": node.remote_config,
"node": node_to_dict(node),
}
@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)
password: str | None = Field(None, max_length=500)
@dataclass
class AdminTestProxyUrlAdapter(AdminApiAdapter):
"""通过 proxy_url 直接测试代理连通性(无需已注册节点)"""
name: str = "admin_test_proxy_url"
async def handle(self, context: ApiRequestContext) -> Any:
payload = context.ensure_json_body()
try:
req = TestProxyUrlRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
return await ProxyNodeService.test_proxy_url(
proxy_url=req.proxy_url,
username=req.username,
password=req.password,
)
@dataclass
class AdminListProxyNodeEventsAdapter(AdminApiAdapter):
"""查询代理节点连接事件(连接/断开/错误历史)"""
name: str = "admin_list_proxy_node_events"
node_id: str = ""
limit: int = 50
async def handle(self, context: ApiRequestContext) -> Any:
from src.models.database import ProxyNodeEvent
events = (
context.db.query(ProxyNodeEvent)
.filter(ProxyNodeEvent.node_id == self.node_id)
.order_by(ProxyNodeEvent.created_at.desc())
.limit(self.limit)
.all()
)
return {
"items": [
{
"id": e.id,
"event_type": e.event_type,
"detail": e.detail,
"created_at": e.created_at,
}
for e in events
],
}