From db6f72c6b6db8c05c207891c78df7ae910cf3ab2 Mon Sep 17 00:00:00 2001 From: Alnnt <1138745158@qq.com> Date: Wed, 31 Dec 2025 23:28:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0WebUI=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E4=BB=A4=E7=89=8C=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81IP=E9=94=81=E5=AE=9A=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- src/config/config.py | 2 + src/config/official_configs.py | 17 ++- src/webui/app.py | 27 +++- src/webui/routes.py | 132 +++++++++++++++++++- src/webui/static.py | 222 +++++++++++++++++++++++++++++++-- template/template_config.toml | 6 + 7 files changed, 387 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index d50a5f0..34cd83a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,6 @@ WORKDIR /adapters COPY . . -EXPOSE 8095 +EXPOSE 8095 8096 ENTRYPOINT ["python", "main.py"] \ No newline at end of file diff --git a/src/config/config.py b/src/config/config.py index f3b90bb..bc8119e 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -18,6 +18,7 @@ from src.config.official_configs import ( NapcatServerConfig, NicknameConfig, VoiceConfig, + WebUIConfig, ) install(extra_lines=3) @@ -118,6 +119,7 @@ class Config(ConfigBase): chat: ChatConfig voice: VoiceConfig debug: DebugConfig + web_ui: WebUIConfig = None # 可选配置,向后兼容 def load_config(config_path: str) -> Config: diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 98b4552..a9fab43 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -59,7 +59,7 @@ class ChatConfig(ConfigBase): """私聊列表类型 白名单/黑名单""" private_list: list[int] = field(default_factory=[]) - """私聊列表""" + """e""" ban_user_id: list[int] = field(default_factory=[]) """被封禁的用户ID列表,封禁后将无法与其进行交互""" @@ -81,3 +81,18 @@ class VoiceConfig(ConfigBase): class DebugConfig(ConfigBase): level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" """日志级别,默认为INFO""" + + +@dataclass +class WebUIConfig(ConfigBase): + enable: bool = True + """是否启用 WebUI""" + + token: str = "" + """WebUI访问令牌,若为空则无需验证""" + + host: str = "0.0.0.0" + """WebUI监听地址""" + + port: int = 8096 + """WebUI端口""" diff --git a/src/webui/app.py b/src/webui/app.py index 16c3880..167a1ac 100644 --- a/src/webui/app.py +++ b/src/webui/app.py @@ -12,24 +12,41 @@ from .routes import setup_routes _runner: Optional[web.AppRunner] = None _site: Optional[web.TCPSite] = None -# WebUI 配置 -WEBUI_HOST = "0.0.0.0" -WEBUI_PORT = 8096 + +def get_webui_config(): + """获取 WebUI 配置""" + if global_config.web_ui: + return global_config.web_ui.host, global_config.web_ui.port + return "0.0.0.0", 8096 # 默认值 + + +def is_webui_enabled() -> bool: + """检查 WebUI 是否启用""" + if global_config.web_ui: + return global_config.web_ui.enable + return True # 默认启用 async def start_webui(): """启动 WebUI 服务""" global _runner, _site + # 检查是否启用 WebUI + if not is_webui_enabled(): + logger.info("WebUI 已禁用,跳过启动") + return + + host, port = get_webui_config() + app = web.Application() setup_routes(app) _runner = web.AppRunner(app) await _runner.setup() - _site = web.TCPSite(_runner, WEBUI_HOST, WEBUI_PORT) + _site = web.TCPSite(_runner, host, port) await _site.start() - logger.info(f"WebUI 已启动,访问地址: http://{WEBUI_HOST}:{WEBUI_PORT}") + logger.info(f"WebUI 已启动,访问地址: http://{host}:{port}") async def stop_webui(): diff --git a/src/webui/routes.py b/src/webui/routes.py index 791593f..ed99244 100644 --- a/src/webui/routes.py +++ b/src/webui/routes.py @@ -3,6 +3,9 @@ WebUI 路由定义 """ import json +import hmac +import time +from collections import defaultdict from aiohttp import web from src.config import global_config from src.logger import logger @@ -16,20 +19,146 @@ from .config_manager import ( ) from .static import get_index_html +# 登录失败记录:IP -> (失败次数, 最后失败时间) +_login_failures: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0)) +# 最大允许失败次数 +MAX_LOGIN_FAILURES = 5 +# 锁定时间(秒) +LOCKOUT_TIME = 300 # 5分钟 + + +def get_client_ip(request: web.Request) -> str: + """获取客户端 IP 地址""" + # 优先从 X-Forwarded-For 获取(如果在反向代理后面) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + # 否则从直接连接获取 + peername = request.transport.get_extra_info("peername") + if peername: + return peername[0] + return "unknown" + + +def is_ip_locked(ip: str) -> bool: + """检查 IP 是否被锁定""" + failures, last_failure_time = _login_failures[ip] + if failures >= MAX_LOGIN_FAILURES: + # 检查是否还在锁定期内 + if time.time() - last_failure_time < LOCKOUT_TIME: + return True + else: + # 锁定期已过,重置计数 + _login_failures[ip] = (0, 0.0) + return False + + +def record_login_failure(ip: str): + """记录登录失败""" + failures, _ = _login_failures[ip] + _login_failures[ip] = (failures + 1, time.time()) + + +def reset_login_failures(ip: str): + """重置登录失败计数""" + _login_failures[ip] = (0, 0.0) + + +def get_configured_token() -> str: + """获取配置的 token""" + if global_config.web_ui: + return global_config.web_ui.token or "" + return "" + + +def secure_compare(a: str, b: str) -> bool: + """安全的字符串比较,防止时序攻击""" + return hmac.compare_digest(a.encode('utf-8'), b.encode('utf-8')) + + +def verify_token(request: web.Request) -> bool: + """验证请求中的 token""" + configured_token = get_configured_token() + if not configured_token: + return True # 未配置 token,无需验证 + + # 从 Authorization header 获取 token + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + request_token = auth_header[7:] + return secure_compare(request_token, configured_token) + return False + async def index_handler(request: web.Request) -> web.Response: """返回主页面""" return web.Response(text=get_index_html(), content_type="text/html") +async def verify_token_handler(request: web.Request) -> web.Response: + """验证 token 接口""" + configured_token = get_configured_token() + client_ip = get_client_ip(request) + + # 如果未配置 token,直接返回成功 + if not configured_token: + return web.json_response({"success": True, "message": "无需验证", "required": False}) + + # 检查 IP 是否被锁定 + if is_ip_locked(client_ip): + remaining_time = int(LOCKOUT_TIME - (time.time() - _login_failures[client_ip][1])) + logger.warning(f"[WebUI] IP {client_ip} 登录尝试被拒绝(已锁定,剩余 {remaining_time} 秒)") + return web.json_response({ + "success": False, + "message": f"登录尝试次数过多,请在 {remaining_time} 秒后重试", + "required": True + }, status=429) + + try: + data = await request.json() + input_token = data.get("token", "") + + if secure_compare(input_token, configured_token): + reset_login_failures(client_ip) + logger.info(f"[WebUI] Token 验证成功 (IP: {client_ip})") + return web.json_response({"success": True, "message": "验证成功", "required": True}) + else: + record_login_failure(client_ip) + failures, _ = _login_failures[client_ip] + remaining_attempts = MAX_LOGIN_FAILURES - failures + logger.warning(f"[WebUI] Token 验证失败 (IP: {client_ip}, 剩余尝试次数: {remaining_attempts})") + return web.json_response({"success": False, "message": "Token 错误", "required": True}, status=401) + except json.JSONDecodeError: + return web.json_response({"success": False, "message": "无效的请求数据"}, status=400) + + +async def check_auth_handler(request: web.Request) -> web.Response: + """检查是否需要验证以及当前 token 是否有效""" + configured_token = get_configured_token() + + if not configured_token: + return web.json_response({"required": False, "valid": True}) + + if verify_token(request): + return web.json_response({"required": True, "valid": True}) + else: + return web.json_response({"required": True, "valid": False}) + + async def get_config_handler(request: web.Request) -> web.Response: """获取当前配置""" + if not verify_token(request): + return web.json_response({"success": False, "error": "未授权"}, status=401) + config = get_chat_config() return web.json_response(config) async def update_config_handler(request: web.Request) -> web.Response: """更新配置""" + if not verify_token(request): + return web.json_response({"success": False, "error": "未授权"}, status=401) + try: data = await request.json() @@ -97,6 +226,7 @@ async def update_config_handler(request: web.Request) -> web.Response: def setup_routes(app: web.Application): """设置路由""" app.router.add_get("/", index_handler) + app.router.add_get("/api/auth/check", check_auth_handler) + app.router.add_post("/api/auth/verify", verify_token_handler) app.router.add_get("/api/config", get_config_handler) app.router.add_post("/api/config", update_config_handler) - diff --git a/src/webui/static.py b/src/webui/static.py index 5deb59d..bacddc3 100644 --- a/src/webui/static.py +++ b/src/webui/static.py @@ -77,6 +77,22 @@ def get_index_html() -> str: border-color: #667eea; } + .form-group input[type="password"], + .form-group input[type="text"] { + width: 100%; + padding: 12px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 16px; + transition: border-color 0.3s; + } + + .form-group input[type="password"]:focus, + .form-group input[type="text"]:focus { + outline: none; + border-color: #667eea; + } + .list-container { background: #f8f9fa; border-radius: 8px; @@ -154,6 +170,28 @@ def get_index_html() -> str: background: #5a6fd6; } + .login-btn { + width: 100%; + padding: 14px 20px; + background: #667eea; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 16px; + transition: background 0.3s; + } + + .login-btn:hover { + background: #5a6fd6; + } + + .login-btn:disabled { + background: #ccc; + cursor: not-allowed; + } + .status { position: fixed; top: 20px; @@ -192,10 +230,46 @@ def get_index_html() -> str: font-style: italic; padding: 10px 0; } + + .hidden { + display: none !important; + } + + .login-container { + max-width: 400px; + margin: 100px auto; + } + + .login-container .card { + text-align: center; + } + + .login-container h2 { + border-bottom: none !important; + } + + .login-icon { + font-size: 48px; + margin-bottom: 10px; + } -
+ + + + +