diff --git a/docs/Phase2_Progress.md b/docs/Phase2_Progress.md new file mode 100644 index 0000000..829c80a --- /dev/null +++ b/docs/Phase2_Progress.md @@ -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) diff --git a/main.py b/main.py index 9c26801..b5f0779 100644 --- a/main.py +++ b/main.py @@ -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): """信号处理""" diff --git a/services/proxy_service.py b/services/proxy_service.py new file mode 100644 index 0000000..0b384b2 --- /dev/null +++ b/services/proxy_service.py @@ -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 已关闭") diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py index 0347914..6c94029 100644 --- a/services/web_panel/manager.py +++ b/services/web_panel/manager.py @@ -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") diff --git a/services/web_panel/routes/proxy.py b/services/web_panel/routes/proxy.py new file mode 100644 index 0000000..4854208 --- /dev/null +++ b/services/web_panel/routes/proxy.py @@ -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("🔀 代理路由已注册") diff --git a/static/web_panel/pages/proxy.html b/static/web_panel/pages/proxy.html new file mode 100644 index 0000000..c709612 --- /dev/null +++ b/static/web_panel/pages/proxy.html @@ -0,0 +1,19 @@ +反向代理 - SenSu + +

🔀 反向代理

+

添加代理

+
+
+
+
+
加载中...
+ \ No newline at end of file