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:
@@ -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)
|
||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import signal
|
import signal
|
||||||
|
from services.proxy_service import ProxyService
|
||||||
from services.project_engine import ProjectEngine
|
from services.project_engine import ProjectEngine
|
||||||
from services.pyenv_manager import PyEnvManager
|
from services.pyenv_manager import PyEnvManager
|
||||||
from services.web_panel.routes import projects
|
from services.web_panel.routes import projects
|
||||||
@@ -181,20 +182,23 @@ class CatFramework:
|
|||||||
|
|
||||||
# 注册框架关闭处理器
|
# 注册框架关闭处理器
|
||||||
shutdown_service.register_shutdown_handler(self._framework_shutdown_handler)
|
shutdown_service.register_shutdown_handler(self._framework_shutdown_handler)
|
||||||
# 11.7 项目引擎
|
# 11.7 项目引擎 + 反向代理
|
||||||
try:
|
try:
|
||||||
logger.info("> 初始化 项目引擎 中...")
|
|
||||||
project_engine = ProjectEngine(self.service_manager)
|
project_engine = ProjectEngine(self.service_manager)
|
||||||
await project_engine.start()
|
await project_engine.start()
|
||||||
self.service_manager.register_service("project_engine", project_engine)
|
self.service_manager.register_service("project_engine", project_engine)
|
||||||
pe_mgr = PyEnvManager("data/projects")
|
self.service_manager.register_service("pyenv", PyEnvManager("data/projects"))
|
||||||
self.service_manager.register_service("pyenv", pe_mgr)
|
logger.info("> 初始化 反向代理 中...")
|
||||||
# project routes registered by web_panel manager (before router freeze)
|
proxy_service = ProxyService(self.service_manager)
|
||||||
logger.info("✅ 项目引擎就绪")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"项目引擎初始化跳过: {e}")
|
logger.warning(f"引擎/代理初始化跳过: {e}")
|
||||||
|
|
||||||
logger.info("🎉 SenSu 初始化完成!")
|
logger.info("🎉 DreamSu 初始化完成!")
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
# 显示欢迎日志
|
# 显示欢迎日志
|
||||||
@@ -414,6 +418,10 @@ if __name__ == "__main__":
|
|||||||
try:
|
try:
|
||||||
# 设置更详细的异常处理
|
# 设置更详细的异常处理
|
||||||
import signal
|
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):
|
def signal_handler(signum, frame):
|
||||||
"""信号处理"""
|
"""信号处理"""
|
||||||
|
|||||||
@@ -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 已关闭")
|
||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from aiohttp import web
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ class WebPanelManager:
|
|||||||
commands.setup_routes(app, self.base_path)
|
commands.setup_routes(app, self.base_path)
|
||||||
logs.setup_routes(app, self.base_path)
|
logs.setup_routes(app, self.base_path)
|
||||||
projects.setup_project_routes(app, self.sm)
|
projects.setup_project_routes(app, self.sm)
|
||||||
|
proxy.setup_proxy_routes(app, self.sm)
|
||||||
|
|
||||||
# 注册日志广播
|
# 注册日志广播
|
||||||
ls = self.sm.get_service("log")
|
ls = self.sm.get_service("log")
|
||||||
|
|||||||
@@ -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("🔀 代理路由已注册")
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user