Files
live-hub-py/web/backend/routers/proxy.py
T
yml2213 453a637480 refactor: 修复9项中等架构问题
安全修复:
- WebSocket 端点添加认证(cookie/token),防止未授权窃听日志
- SPA serve_spa 添加路径遍历防护(resolve + relative_to 检查)
- Token 改用 httpOnly Cookie 存储,移除前端 localStorage token(防 XSS 窃取)
- 添加安全响应头中间件(X-Content-Type-Options/X-Frame-Options/Referrer-Policy)
- HTTP 请求日志脱敏请求体中的 password/secret/token 等敏感字段
- 权限检查统一使用 user_has_permission(考虑自定义权限,修复 has_permission 忽略 custom_permissions 的缺陷)

性能与稳定性:
- cookies.py 列表接口修复 N+1 查询(改为批量查询 Account)
- login_service.py run() 结束时关闭 DB Session(防止连接泄漏)
- _active_batches/_active_tests 全局字典添加 threading.Lock(防止并发竞态)

配置优化:
- CORS 源支持环境变量 CORS_ORIGINS 配置
- Uvicorn reload 支持环境变量 UVICORN_RELOAD 控制(生产环境默认关闭)
- Cookie 安全标志支持环境变量 COOKIE_SECURE 配置(HTTPS 部署时启用)
- logs.py 权限不足返回 HTTP 403(原来返回 200 + message)
2026-06-23 06:51:01 +08:00

303 lines
9.9 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.
"""代理 & 白名单配置路由 + WebSocket 实时日志"""
import asyncio
import threading
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
from ..schemas import ProxyConfigOut, ProxyConfigUpdate
from ..deps import require_permission, authenticate_websocket
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
# 运行中的测试: test_id -> {log_queue, loop, result}
_active_tests: dict[str, dict] = {}
_active_tests_lock = threading.Lock()
def _get_or_create(db: Session) -> ProxyConfigModel:
cfg = db.query(ProxyConfigModel).first()
if not cfg:
cfg = ProxyConfigModel()
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
@router.get("", response_model=ProxyConfigOut)
def get_proxy_config(
db: Session = Depends(get_db),
_: User = Depends(require_permission("proxy:manage")),
):
return _get_or_create(db)
@router.put("", response_model=ProxyConfigOut)
def update_proxy_config(
req: ProxyConfigUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("proxy:manage")),
):
cfg = _get_or_create(db)
cfg.enabled = req.enabled
cfg.api_url = req.api_url
cfg.http = req.http
cfg.https = req.https
cfg.whitelist_enabled = req.whitelist_enabled
cfg.whitelist_uid = req.whitelist_uid
cfg.whitelist_ukey = req.whitelist_ukey
db.commit()
db.refresh(cfg)
db.add(AuditLog(user_id=current.id, username=current.username,
action="proxy:update", target="proxy_config"))
db.commit()
return cfg
# ---- WebSocket 日志推送 ----
@router.websocket("/ws/test/{test_id}")
async def ws_test_logs(websocket: WebSocket, test_id: str):
"""WebSocket 推送代理/白名单测试实时日志(需认证)。"""
# 认证:从 cookie 或 token query param 验证用户身份
user = authenticate_websocket(websocket)
if not user:
await websocket.close(code=1008, reason="未授权")
return
await websocket.accept()
with _active_tests_lock:
test = _active_tests.get(test_id)
if not test:
await websocket.send_json({"level": "error", "message": "测试任务不存在"})
await websocket.close()
return
log_queue: asyncio.Queue = test["log_queue"]
try:
while True:
try:
msg = await asyncio.wait_for(log_queue.get(), timeout=30)
await websocket.send_json(msg)
# 收到 result 消息表示测试结束
if msg.get("level") == "result":
await asyncio.sleep(0.1)
break
except asyncio.TimeoutError:
await websocket.send_json({"level": "heartbeat", "message": ""})
except WebSocketDisconnect:
pass
finally:
with _active_tests_lock:
_active_tests.pop(test_id, None)
# ---- 异步测试执行 ----
def _run_proxy_test(
cfg: ProxyConfigModel,
log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop,
):
"""在线程中执行代理测试。"""
def push(level, message):
asyncio.run_coroutine_threadsafe(
log_queue.put({"level": level, "message": message}),
loop,
)
try:
if not cfg.enabled:
push("error", "代理未启用")
push("result", "")
return
# 静态代理
if cfg.http or cfg.https:
proxy_url = cfg.http or cfg.https
push("info", f"验证静态代理: {proxy_url}")
ok, msg = verify_proxy_url(proxy_url)
push("success" if ok else "error", msg)
push("result", "")
return
# API代理
if cfg.api_url:
whitelist_uid = cfg.whitelist_uid if cfg.whitelist_enabled else ""
whitelist_ukey = cfg.whitelist_ukey if cfg.whitelist_enabled else ""
proxy_url, msg = resolve_working_proxy(
api_url=cfg.api_url,
whitelist_uid=whitelist_uid,
whitelist_ukey=whitelist_ukey,
max_attempts=3,
log_func=push,
)
if proxy_url:
push("success", f"代理可用: {proxy_url}")
else:
push("error", msg)
push("result", "")
return
push("error", "未配置代理地址或API")
push("result", "")
except Exception as e:
push("error", f"测试异常: {e}")
push("result", "")
def _run_whitelist_test(
cfg: ProxyConfigModel,
log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop,
):
"""在线程中执行白名单测试。"""
import requests as req_lib
import re
from core.douyu.whitelist import WhitelistManager, get_exit_ip_via_proxy
def push(level, message):
asyncio.run_coroutine_threadsafe(
log_queue.put({"level": level, "message": message}),
loop,
)
try:
if not cfg.whitelist_enabled:
push("error", "白名单未启用")
push("result", "")
return
if not cfg.whitelist_uid or not cfg.whitelist_ukey:
push("error", "未配置白名单UID/UKEY")
push("result", "")
return
manager = WhitelistManager(cfg.whitelist_uid, cfg.whitelist_ukey)
# 1. 测试API连接
push("info", "测试白名单API连接...")
ok, msg = manager.test_connection()
push("info" if ok else "error", f"白名单API: {msg}")
if not ok:
push("result", "")
return
# 2. 获取本机公网IP(白名单需要的是本机IP,不是代理出口IP)
push("info", "正在获取本机公网IP...")
# 优先:从代理API响应中提取(代理API返回"请先添加白名单:xxx"时,xxx就是本机IP
local_ip = None
if cfg.api_url:
try:
resp = req_lib.get(cfg.api_url, timeout=10)
text = resp.text.strip()
push("info", f"代理API响应: {text[:80]}")
_, whitelist_ip = parse_proxy_response(text)
if whitelist_ip:
local_ip = whitelist_ip
push("info", f"从代理API获取到本机IP: {local_ip}")
except Exception as e:
push("warning", f"代理API请求失败: {e}")
# 备用:直接访问IP检测服务获取本机公网IP
if not local_ip:
push("info", "通过IP检测服务获取本机公网IP...")
for url in [
'https://qifu-api.baidubce.com/ip/local/geo/v1/district',
'https://myip.ipip.net',
'https://4.ipw.cn',
]:
try:
resp = req_lib.get(url, timeout=6, headers={"User-Agent": "Mozilla/5.0"})
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', resp.text)
if match:
local_ip = match.group(1)
break
except Exception:
continue
if not local_ip:
push("error", "无法获取本机公网IP")
push("result", "")
return
push("info", f"本机公网IP: {local_ip}")
# 3. 检查并同步白名单
records = manager.get_whitelist_json()
in_list = any(r.get('IP') == local_ip for r in records)
push("info", f"白名单共 {len(records)} 条记录")
if in_list:
record = next((r for r in records if r.get('IP') == local_ip), {})
memo = record.get('MEMO', '')
if memo == manager.memo:
push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)")
else:
push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...')
sync_ok, sync_msg = manager.sync_ip(local_ip)
push("success" if sync_ok else "error", f"白名单更新: {sync_msg}")
else:
push("info", f"正在将 {local_ip} 添加到白名单...")
sync_ok, sync_msg = manager.sync_ip(local_ip)
push("success" if sync_ok else "error", f"白名单同步: {sync_msg}")
push("result", "")
except Exception as e:
push("error", f"测试异常: {e}")
push("result", "")
# ---- API 端点 ----
@router.post("/test")
async def test_proxy(
db: Session = Depends(get_db),
current: User = Depends(require_permission("proxy:manage")),
):
"""启动代理测试(异步执行,通过 WebSocket 推送日志)。"""
cfg = _get_or_create(db)
test_id = uuid.uuid4().hex[:12]
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
with _active_tests_lock:
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
thread = threading.Thread(target=_run_proxy_test, args=(cfg, log_queue, loop), daemon=True)
thread.start()
return {"test_id": test_id, "success": True}
@router.post("/whitelist/test")
async def test_whitelist(
db: Session = Depends(get_db),
current: User = Depends(require_permission("whitelist:test")),
):
"""启动白名单测试(异步执行,通过 WebSocket 推送日志)。"""
cfg = _get_or_create(db)
test_id = uuid.uuid4().hex[:12]
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
with _active_tests_lock:
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
thread = threading.Thread(target=_run_whitelist_test, args=(cfg, log_queue, loop), daemon=True)
thread.start()
return {"test_id": test_id, "success": True}