Phase 2: ProxyService reverse proxy (HTTP+WebSocket forwarding)

- New: services/proxy_service.py (path->URL mapping)
- New: services/web_panel/routes/proxy.py (3 REST endpoints)
- New: static/web_panel/pages/proxy.html (WebUI)
- Tests: 28/28 passing
This commit is contained in:
2026-06-10 20:47:17 +08:00
parent 8dbf628dd6
commit c680fea8e0
6 changed files with 238 additions and 9 deletions
+13
View File
@@ -0,0 +1,13 @@
# Phase 2 开发进度总结
> 完成: 2026-06-10
> 测试: 28/28 通过
## 新增
- `services/proxy_service.py` (160行) — 反向代理 (HTTP+WS转发)
- `services/web_panel/routes/proxy.py` — 代理管理 API
- `static/web_panel/pages/proxy.html` — 代理管理 WebUI
## 集成
- Web panel manager 自动加载 proxy 路由
- main.py 初始化 ProxyService (step 11.8)
+16 -8
View File
@@ -5,6 +5,7 @@ import logging
import asyncio
import sys
import signal
from services.proxy_service import ProxyService
from services.project_engine import ProjectEngine
from services.pyenv_manager import PyEnvManager
from services.web_panel.routes import projects
@@ -181,20 +182,23 @@ class CatFramework:
# 注册框架关闭处理器
shutdown_service.register_shutdown_handler(self._framework_shutdown_handler)
# 11.7 项目引擎
# 11.7 项目引擎 + 反向代理
try:
logger.info("> 初始化 项目引擎 中...")
project_engine = ProjectEngine(self.service_manager)
await project_engine.start()
self.service_manager.register_service("project_engine", project_engine)
pe_mgr = PyEnvManager("data/projects")
self.service_manager.register_service("pyenv", pe_mgr)
# project routes registered by web_panel manager (before router freeze)
logger.info("✅ 项目引擎就绪")
self.service_manager.register_service("pyenv", PyEnvManager("data/projects"))
logger.info("> 初始化 反向代理 中...")
proxy_service = ProxyService(self.service_manager)
await proxy_service.start()
if internet_service and internet_service.http_app:
proxy_service.setup_routes(internet_service.http_app)
self.service_manager.register_service("proxy", proxy_service)
logger.info("✅ 项目引擎+反向代理就绪")
except Exception as e:
logger.warning(f"项目引擎初始化跳过: {e}")
logger.warning(f"引擎/代理初始化跳过: {e}")
logger.info("🎉 SenSu 初始化完成!")
logger.info("🎉 DreamSu 初始化完成!")
self.is_running = True
# 显示欢迎日志
@@ -414,6 +418,10 @@ if __name__ == "__main__":
try:
# 设置更详细的异常处理
import signal
from services.proxy_service import ProxyService
from services.project_engine import ProjectEngine
from services.pyenv_manager import PyEnvManager
from services.web_panel.routes import projects
def signal_handler(signum, frame):
"""信号处理"""
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""SenSu ProxyService — 反向代理,将任意 URL 映射到框架路径"""
import asyncio, logging, aiohttp
from aiohttp import web, ClientSession, WSMsgType
from typing import Dict, Optional
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class ProxyTarget:
path: str
target_url: str
description: str = ""
is_external: bool = False
status: str = "active"
strip_prefix: bool = True
class ProxyService:
def __init__(self, service_manager=None):
self.sm = service_manager
self.proxies: Dict[str, ProxyTarget] = {}
self._session: Optional[ClientSession] = None
async def start(self):
self._session = ClientSession()
logger.info("ProxyService 已就绪")
async def register_proxy(self, path: str, target_url: str,
description: str = "", is_external: bool = False,
strip_prefix: bool = True) -> bool:
path = "/" + path.strip("/")
if path in self.proxies:
logger.warning(f"代理路径已存在: {path}")
return False
if not target_url.endswith("/"):
target_url += "/"
self.proxies[path] = ProxyTarget(path=path, target_url=target_url,
description=description, is_external=is_external,
strip_prefix=strip_prefix)
logger.info(f"代理已注册: {path}{target_url}")
return True
def unregister_proxy(self, path: str):
path = "/" + path.strip("/")
if path in self.proxies:
del self.proxies[path]
logger.info(f"代理已注销: {path}")
def list_proxies(self):
return [{"path": p.path, "target": p.target_url, "status": p.status,
"description": p.description, "external": p.is_external}
for p in self.proxies.values()]
def setup_routes(self, app: web.Application):
"""注册代理路由到 aiohttp app"""
async def proxy_handler(request):
path = request.path
# Find matching proxy (longest prefix match)
proxy = None
for p in sorted(self.proxies.keys(), key=len, reverse=True):
if path.startswith(p) or path == p:
proxy = self.proxies[p]
break
if not proxy:
# Check bare path
lookup = "/" + path.strip("/")
proxy = self.proxies.get(lookup)
if not proxy:
return web.json_response({"error": "no proxy for path"}, status=404)
# Build target URL
remaining = path[len(proxy.path):] if proxy.strip_prefix else path
target = proxy.target_url.rstrip("/") + "/" + remaining.lstrip("/")
try:
# Forward request
headers = {k: v for k, v in request.headers.items()
if k.lower() not in ("host", "content-length")}
headers["X-Forwarded-For"] = request.remote
headers["X-Proxy-By"] = "SenSu"
async with self._session.request(
request.method, target, headers=headers,
data=await request.read(), timeout=30
) as resp:
body = await resp.read()
proxy_resp = web.Response(body=body, status=resp.status)
for k, v in resp.headers.items():
if k.lower() not in ("transfer-encoding", "content-encoding"):
proxy_resp.headers[k] = v
return proxy_resp
except asyncio.TimeoutError:
return web.json_response({"error": "proxy timeout"}, status=504)
except Exception as e:
logger.error(f"代理错误 {path}: {e}")
return web.json_response({"error": str(e)}, status=502)
# WebSocket proxy
async def ws_proxy_handler(request):
path = request.path
proxy = None
for p in sorted(self.proxies.keys(), key=len, reverse=True):
if path.startswith(p):
proxy = self.proxies[p]
break
if not proxy:
return web.json_response({"error": "no ws proxy"}, status=404)
target = proxy.target_url.rstrip("/") + "/" + path[len(proxy.path):].lstrip("/")
if target.startswith("http"):
target = target.replace("http://", "ws://").replace("https://", "wss://")
ws_client = web.WebSocketResponse()
await ws_client.prepare(request)
try:
async with self._session.ws_connect(target) as ws_target:
async def forward(src, dst):
async for msg in src:
if msg.type == WSMsgType.TEXT:
await dst.send_str(msg.data)
elif msg.type == WSMsgType.BINARY:
await dst.send_bytes(msg.data)
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
break
await asyncio.gather(
forward(ws_client, ws_target),
forward(ws_target, ws_client),
)
except Exception as e:
logger.debug(f"WS proxy error: {e}")
return ws_client
app.router.add_route("*", "/proxy/{tail:.*}", proxy_handler)
# Register individual proxy routes
for path in self.proxies:
app.router.add_route("*", f"{path}/{{tail:.*}}", proxy_handler)
# WebSocket proxy
app.router.add_route("GET", "/wsproxy/{tail:.*}", ws_proxy_handler)
logger.info(f"代理路由已注册 ({len(self.proxies)} targets)")
async def check_health(self, path: str) -> dict:
proxy = self.proxies.get("/" + path.strip("/"))
if not proxy:
return {"ok": False, "error": "not found"}
try:
async with self._session.get(proxy.target_url, timeout=5) as resp:
return {"ok": True, "status": resp.status, "target": proxy.target_url}
except Exception as e:
return {"ok": False, "error": str(e)}
async def shutdown(self):
if self._session:
await self._session.close()
logger.info("ProxyService 已关闭")
+2 -1
View File
@@ -5,7 +5,7 @@ import os
import logging
from pathlib import Path
from aiohttp import web
from .routes import auth, status, plugins, commands, logs, projects
from .routes import auth, status, plugins, commands, logs, projects, proxy
logger = logging.getLogger(__name__)
@@ -63,6 +63,7 @@ class WebPanelManager:
commands.setup_routes(app, self.base_path)
logs.setup_routes(app, self.base_path)
projects.setup_project_routes(app, self.sm)
proxy.setup_proxy_routes(app, self.sm)
# 注册日志广播
ls = self.sm.get_service("log")
+30
View File
@@ -0,0 +1,30 @@
from aiohttp import web, ClientSession
import json, logging, asyncio
logger = logging.getLogger(__name__)
def setup_proxy_routes(app, service_manager):
async def list_proxies(request):
ps = service_manager.get_service("proxy")
return web.json_response({"proxies": ps.list_proxies() if ps else []})
async def add_proxy(request):
try:
data = await request.json()
ps = service_manager.get_service("proxy")
ok = await ps.register_proxy(
path=data.get("path",""), target_url=data.get("target",""),
description=data.get("description",""), is_external=data.get("external",False))
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
async def remove_proxy(request):
path = request.match_info.get("path","")
ps = service_manager.get_service("proxy")
ps.unregister_proxy(path)
return web.json_response({"ok": True})
app.router.add_get("/api/proxy", list_proxies)
app.router.add_post("/api/proxy", add_proxy)
app.router.add_delete("/api/proxy/{path}", remove_proxy)
logger.info("🔀 代理路由已注册")
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>反向代理 - SenSu</title>
<style>body{font-family:monospace;background:#0e1416;color:#e0e3e4;margin:0;padding:16px}
h2{color:#00bcd4;border-bottom:1px solid #333;padding-bottom:8px}
.card{background:#1a1f21;border-radius:8px;padding:12px;margin:8px 0}
input,button{padding:6px 10px;margin:4px;border-radius:4px;border:1px solid #444;background:#222;color:#e0e3e4}
button{background:#00bcd4;color:#000;cursor:pointer;font-weight:bold}
button.danger{background:#ff5252}.status-active{color:#4caf50}
</style></head><body>
<h2>🔀 反向代理</h2>
<div class="card"><h3>添加代理</h3>
<input id="px-path" placeholder="路径 (如: /app)" style="width:200px"><br>
<input id="px-target" placeholder="目标 (如: http://192.168.1.5:8080)" style="width:400px"><br>
<input id="px-desc" placeholder="描述" style="width:300px"><br>
<button onclick="add()">添加</button></div>
<div id="list">加载中...</div>
<script>
async function r(){const d=await(await fetch("/SenSu/api/proxy")).json();let h="";for(const p of d.proxies||[]){h+=`<div class="card"><b>${p.path}</b> <span class="status-active">● ${p.status}</span><br>→ ${p.target} ${p.description||""}<button class="danger" onclick="del('${p.path}')">删除</button></div>`}document.getElementById("list").innerHTML=h||"暂无代理"}
async function add(){await fetch("/SenSu/api/proxy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:document.getElementById("px-path").value,target:document.getElementById("px-target").value,description:document.getElementById("px-desc").value})});r()}
async function del(p){await fetch(`/SenSu/api/proxy/${encodeURIComponent(p)}`,{method:"DELETE"});r()}r()</script></body></html>