Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Simple Clash Relay - Flask 应用入口 | |
| """ | |
| import os | |
| import logging | |
| import socket | |
| import threading | |
| from flask import Flask, request, jsonify, Response, redirect, send_from_directory, flash | |
| from flask_sockets import Sockets # 导入 Sockets | |
| from .clash_manager import ClashManager | |
| from .sub_manager import SubscriptionManager | |
| from .auth import authenticate | |
| import requests | |
| from functools import wraps | |
| from werkzeug.utils import secure_filename | |
| import time | |
| import gevent # 导入 gevent | |
| from gevent import pywsgi | |
| from geventwebsocket.handler import WebSocketHandler # 导入 WebSocketHandler | |
| # 配置日志 | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # 从环境变量加载配置 | |
| SUB_URL = os.environ.get("SUB_URL") | |
| API_KEY = os.environ.get("API_KEY", "changeme") | |
| FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860 | |
| CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890)) | |
| CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090)) | |
| # 添加标记文件路径 | |
| MANUAL_CONFIG_MARKER = os.path.join(os.path.dirname(__file__), "data", ".use_manual_config") | |
| # 初始化Flask应用 和 Sockets | |
| app = Flask(__name__, static_folder='static') | |
| app.secret_key = os.environ.get("FLASK_SECRET_KEY", "supersecretkey") # 用于flash消息 | |
| sockets = Sockets(app) # 初始化 Sockets | |
| # 初始化管理器 | |
| clash_manager = None | |
| sub_manager = None | |
| initialization_error = None | |
| def initialize_once(): | |
| """应用首次请求前的初始化""" | |
| global clash_manager, sub_manager, initialization_error, _initialized | |
| # 使用类变量确保只初始化一次 | |
| if not getattr(initialize_once, '_initialized', False): | |
| logger.info("正在初始化应用 (首次请求)...") | |
| # 初始化订阅管理器 | |
| sub_manager = SubscriptionManager( | |
| sub_url=SUB_URL, | |
| config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml") | |
| ) | |
| # 加载订阅并转换为Clash配置 | |
| try: | |
| sub_manager.load_and_convert_sub() | |
| logger.info("成功加载并转换订阅") | |
| except Exception as e: | |
| err_msg = f"加载订阅失败: {str(e)}" | |
| logger.error(err_msg) | |
| initialization_error = err_msg | |
| initialize_once._initialized = True # 标记已初始化,即使失败 | |
| return | |
| # 初始化Clash管理器 | |
| clash_manager = ClashManager( | |
| config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"), | |
| clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"), | |
| api_port=CLASH_API_PORT, | |
| proxy_port=CLASH_PROXY_PORT | |
| ) | |
| # 启动Clash Core | |
| try: | |
| clash_manager.start_clash() | |
| logger.info("成功启动Clash Core") | |
| except Exception as e: | |
| err_msg = f"启动Clash Core失败: {str(e)}" | |
| logger.error(err_msg) | |
| initialization_error = err_msg | |
| initialize_once._initialized = True # 标记已初始化 | |
| def get_nodes(): | |
| """获取可用节点列表""" | |
| global clash_manager, initialization_error | |
| if clash_manager is None: | |
| return jsonify({ | |
| "success": False, | |
| "error": f"Clash未启动: {initialization_error or '未知错误'}" | |
| }), 503 | |
| try: | |
| nodes = clash_manager.get_nodes() | |
| return jsonify({"success": True, "nodes": nodes}) | |
| except Exception as e: | |
| logger.error(f"获取节点列表失败: {str(e)}") | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def switch_node(): | |
| """切换到指定节点""" | |
| global clash_manager, initialization_error | |
| if clash_manager is None: | |
| return jsonify({ | |
| "success": False, | |
| "error": f"Clash未启动: {initialization_error or '未知错误'}" | |
| }), 503 | |
| data = request.get_json() | |
| if not data or "node" not in data: | |
| return jsonify({"success": False, "error": "缺少'node'参数"}), 400 | |
| node_name = data["node"] | |
| try: | |
| clash_manager.switch_node(node_name) | |
| return jsonify({"success": True, "message": f"已切换到节点: {node_name}"}) | |
| except Exception as e: | |
| logger.error(f"切换到节点 {node_name} 失败: {str(e)}") | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def get_current_node(): | |
| """获取当前使用的节点""" | |
| global clash_manager, initialization_error | |
| if clash_manager is None: | |
| return jsonify({ | |
| "success": False, | |
| "error": f"Clash未启动: {initialization_error or '未知错误'}" | |
| }), 503 | |
| try: | |
| current_node = clash_manager.get_current_node() | |
| return jsonify({"success": True, "current_node": current_node}) | |
| except Exception as e: | |
| logger.error(f"获取当前节点失败: {str(e)}") | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def refresh_subscription(): | |
| """刷新订阅并重新加载Clash配置(如果未使用手动配置)""" | |
| global clash_manager, sub_manager, initialization_error | |
| # 检查是否正在使用手动配置 | |
| if os.path.exists(MANUAL_CONFIG_MARKER): | |
| logger.info("正在使用手动配置文件,跳过订阅刷新。") | |
| return jsonify({"success": True, "message": "当前使用手动配置文件,无需刷新订阅。"}) | |
| try: | |
| # 尝试重新加载订阅 | |
| if sub_manager is None: | |
| sub_manager = SubscriptionManager( | |
| sub_url=SUB_URL, | |
| config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml") | |
| ) | |
| sub_manager.load_and_convert_sub() | |
| if clash_manager is None: | |
| clash_manager = ClashManager( | |
| config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"), | |
| clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"), | |
| api_port=CLASH_API_PORT, | |
| proxy_port=CLASH_PROXY_PORT | |
| ) | |
| clash_manager.start_clash() | |
| initialization_error = None | |
| return jsonify({"success": True, "message": "订阅已刷新,Clash已启动"}) | |
| else: | |
| clash_manager.restart_clash() | |
| initialization_error = None | |
| return jsonify({"success": True, "message": "订阅已刷新,Clash已重启"}) | |
| except Exception as e: | |
| error_msg = f"刷新订阅失败: {str(e)}" | |
| logger.error(error_msg) | |
| initialization_error = error_msg | |
| return jsonify({"success": False, "error": error_msg}), 500 | |
| def health_check(): | |
| """健康检查接口""" | |
| return jsonify({"status": "ok"}) | |
| # --- 代理路由 --- | |
| # 弃用/proxy路由,Clash的代理端口由外部直接访问 | |
| # 如果需要在Hugging Face部署,代理通常通过Clash API的节点选择完成 | |
| # --- 调试路由 --- | |
| def debug_clean(): | |
| """清理配置、标记文件并重新初始化(恢复使用订阅)""" | |
| global clash_manager, sub_manager, initialization_error | |
| try: | |
| # 停止Clash | |
| if clash_manager is not None: | |
| clash_manager.stop_clash() | |
| clash_manager = None | |
| # 删除配置文件和标记文件 | |
| config_dir = os.path.join(os.path.dirname(__file__), "data") | |
| config_path = os.path.join(config_dir, "config.yaml") | |
| raw_config_path = f"{config_path}.raw" | |
| marker_path = MANUAL_CONFIG_MARKER | |
| files_to_delete = [config_path, raw_config_path, marker_path] | |
| deleted_files = [] | |
| for file_path in files_to_delete: | |
| if os.path.exists(file_path): | |
| try: | |
| os.remove(file_path) | |
| deleted_files.append(os.path.basename(file_path)) | |
| logger.info(f"已删除文件: {file_path}") | |
| except OSError as e: | |
| logger.warning(f"删除文件失败: {file_path}, 错误: {e}") | |
| # 强制重新初始化 (将恢复使用订阅,因为标记文件已删除) | |
| initialize_once._initialized = False | |
| initialize_once() | |
| status_msg = "配置已清理,恢复使用订阅链接。" if initialization_error is None else f"配置已清理,但重新初始化失败: {initialization_error}" | |
| return jsonify({"success": True, "message": status_msg, "deleted_files": deleted_files}) | |
| except Exception as e: | |
| logger.exception(f"清理配置时出错: {str(e)}") | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| def debug_show_config(): | |
| """获取并显示当前Clash配置文件内容""" | |
| config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml") | |
| if not os.path.exists(config_path): | |
| return jsonify({"success": False, "error": "配置文件不存在"}), 404 | |
| try: | |
| with open(config_path, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| return jsonify({"success": True, "content": content}) | |
| except Exception as e: | |
| logger.error(f"读取配置文件时出错: {str(e)}") | |
| return jsonify({"success": False, "error": str(e)}), 500 | |
| # --- Yacd UI & Clash API 反向代理路由 --- | |
| def yacd_index(): | |
| """提供Yacd UI的入口文件""" | |
| # return send_from_directory('static/yacd', 'index.html') | |
| # 重定向到包含 index.html 的目录,确保相对路径正确加载 | |
| return redirect('/ui/index.html', code=301) | |
| def yacd_static(path): | |
| """提供Yacd UI的静态文件 (CSS, JS, images)""" | |
| return send_from_directory(os.path.join(app.static_folder, 'yacd'), path) | |
| def clash_api_proxy(subpath): | |
| """反向代理请求到内部的Clash API""" | |
| global clash_manager, initialization_error, API_KEY | |
| # 1. 认证检查 (Yacd 使用 Authorization: Bearer <key> 或 ?token=<key>) | |
| auth_header = request.headers.get('Authorization') | |
| token = request.args.get('token') | |
| provided_key = None | |
| if auth_header and auth_header.startswith('Bearer '): | |
| provided_key = auth_header.split(' ', 1)[1] | |
| elif token: | |
| provided_key = token | |
| if provided_key != API_KEY: | |
| logger.warning(f"Clash API代理认证失败,路径: {subpath}") | |
| return jsonify({"message": "Authentication required"}), 401 | |
| # 2. 检查Clash是否运行 | |
| if clash_manager is None: | |
| logger.error(f"Clash API不可用,无法代理请求: {subpath}") | |
| return jsonify({ | |
| "success": False, | |
| "error": f"Clash API未运行: {initialization_error or '内部错误'}" | |
| }), 503 | |
| # 特殊处理 /logs 路径 (这是WebSocket接口,我们不支持) | |
| if subpath == 'logs': | |
| logger.info("请求了日志WebSocket接口,但当前版本不支持WebSocket代理") | |
| return jsonify({ | |
| "success": False, | |
| "error": "不支持WebSocket日志接口。请使用标准HTTP API。" | |
| }), 400 | |
| # 3. 构建目标URL | |
| target_url = f"http://127.0.0.1:{CLASH_API_PORT}/{subpath}" | |
| if request.query_string: | |
| target_url += '?' + request.query_string.decode('utf-8') | |
| logger.debug(f"代理Clash API请求到: {target_url}") | |
| # 4. 转发请求 | |
| try: | |
| # 准备请求头,移除Host | |
| req_headers = { | |
| k: v for k, v in request.headers.items() | |
| if k.lower() not in ['host', 'content-length'] | |
| } | |
| # 确保Content-Type正确传递,尤其对PUT请求 | |
| if request.method in ['POST', 'PUT', 'PATCH'] and 'Content-Type' not in req_headers: | |
| req_headers['Content-Type'] = 'application/json' | |
| # 对于PUT请求,确保正确获取请求数据 | |
| if request.method == 'PUT': | |
| logger.debug(f"处理PUT请求到 {target_url}, 数据: {request.data}") | |
| req_data = request.get_data() | |
| else: | |
| req_data = request.get_data() | |
| # 普通HTTP请求 | |
| resp = requests.request( | |
| method=request.method, | |
| url=target_url, | |
| headers=req_headers, | |
| data=req_data, | |
| cookies=request.cookies, | |
| allow_redirects=False, | |
| stream=True, # 对于大响应或流式响应 | |
| timeout=10 # 缩短超时,防止工作进程卡住 | |
| ) | |
| # 5. 构建并返回响应 | |
| excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'] | |
| resp_headers = [(name, value) for name, value in resp.raw.headers.items() | |
| if name.lower() not in excluded_headers] | |
| # 使用iter_content流式传输响应体,以支持大数据和流 | |
| response = Response(resp.iter_content(chunk_size=8192), resp.status_code, resp_headers) | |
| return response | |
| except requests.exceptions.ConnectionError as e: | |
| logger.error(f"连接Clash API失败 ({target_url}): {str(e)}") | |
| return jsonify({"error": f"无法连接到内部Clash API: {str(e)}"}), 502 # Bad Gateway | |
| except requests.exceptions.Timeout as e: | |
| logger.error(f"请求Clash API超时 ({target_url}): {str(e)}") | |
| return jsonify({"error": f"请求内部Clash API超时: {str(e)}"}), 504 # Gateway Timeout | |
| except Exception as e: | |
| logger.error(f"代理Clash API请求时发生意外错误: {str(e)}") | |
| return jsonify({"error": f"代理请求时发生意外错误: {str(e)}"}), 500 | |
| # --- 新增:上传配置 API --- | |
| def upload_config(): | |
| """上传并使用自定义配置文件""" | |
| global clash_manager, initialization_error | |
| if 'config_file' not in request.files: | |
| logger.error("上传配置请求中未找到名为 'config_file' 的文件") | |
| return jsonify({"success": False, "error": "未找到配置文件部分"}), 400 | |
| file = request.files['config_file'] | |
| if file.filename == '' or not file: | |
| logger.error("上传的文件无效或未选择") | |
| return jsonify({"success": False, "error": "未选择文件或文件无效"}), 400 | |
| # 限制文件名,防止路径遍历 | |
| filename = secure_filename(file.filename) | |
| if not filename.lower().endswith(('.yaml', '.yml')): | |
| logger.error(f"上传的文件类型不支持: {filename}") | |
| return jsonify({"success": False, "error": "只允许上传 .yaml 或 .yml 文件"}), 400 | |
| # 修正路径,确保指向 /app/data/ | |
| app_root = os.path.dirname(os.path.dirname(__file__)) | |
| config_dir = os.path.join(app_root, "data") | |
| config_path = os.path.join(config_dir, "config.yaml") | |
| marker_path = os.path.join(config_dir, ".use_manual_config") # 保持 marker 在 data 目录 | |
| try: | |
| # 确保数据目录存在 | |
| os.makedirs(config_dir, exist_ok=True) | |
| # 保存上传的文件,覆盖旧的 config.yaml | |
| file.save(config_path) | |
| logger.info(f"成功保存上传的配置文件到: {config_path}") | |
| # 创建标记文件,表示正在使用手动配置 | |
| with open(marker_path, 'w') as f: | |
| f.write(f"Uploaded at {time.time()}") | |
| logger.info(f"已创建手动配置标记文件: {marker_path}") | |
| # 重启Clash以加载新配置 | |
| if clash_manager is not None: | |
| logger.info("正在重启Clash以加载手动配置...") | |
| clash_manager.restart_clash() | |
| initialization_error = None # 清除之前的初始化错误 | |
| logger.info("Clash已使用手动配置重启") | |
| else: | |
| # 如果Clash未运行,尝试初始化并启动 | |
| logger.info("Clash未运行,尝试使用手动配置进行初始化和启动...") | |
| initialize_once._initialized = False | |
| initialize_once() | |
| if initialization_error: | |
| logger.error(f"使用手动配置启动Clash失败: {initialization_error}") | |
| # 即使启动失败,也保留手动配置和标记 | |
| else: | |
| logger.info("Clash已使用手动配置成功启动") | |
| return jsonify({"success": True, "message": "配置文件已上传并应用,Clash已重启。"}) | |
| except Exception as e: | |
| logger.exception(f"处理上传的配置文件时出错: {str(e)}") | |
| # 清理可能不完整的状态 | |
| if os.path.exists(marker_path): | |
| os.remove(marker_path) | |
| return jsonify({"success": False, "error": f"处理上传文件时出错: {str(e)}"}), 500 | |
| # --- 新增:WebSocket代理隧道 --- | |
| def forward_websocket_to_tcp(ws, tcp_socket): | |
| """从WebSocket读取数据并写入TCP Socket""" | |
| try: | |
| while not ws.closed: | |
| message = ws.receive() | |
| if message: | |
| # logger.debug(f"WS -> TCP: 转发 {len(message)} 字节") | |
| tcp_socket.sendall(message) | |
| else: | |
| # WebSocket连接关闭或收到空消息 | |
| break | |
| except Exception as e: | |
| logger.warning(f"WS -> TCP 转发错误: {e}") | |
| finally: | |
| logger.info("WS -> TCP 转发协程结束") | |
| if not tcp_socket._closed: | |
| tcp_socket.close() | |
| if not ws.closed: | |
| ws.close() | |
| def forward_tcp_to_websocket(tcp_socket, ws): | |
| """从TCP Socket读取数据并写入WebSocket""" | |
| try: | |
| while not ws.closed: | |
| data = tcp_socket.recv(4096) # 每次最多读取4KB | |
| if data: | |
| # logger.debug(f"TCP -> WS: 转发 {len(data)} 字节") | |
| ws.send(data) | |
| else: | |
| # TCP连接关闭 | |
| break | |
| except Exception as e: | |
| # 忽略WebSocket可能已经关闭的错误 | |
| if "closed" not in str(e).lower(): | |
| logger.warning(f"TCP -> WS 转发错误: {e}") | |
| finally: | |
| logger.info("TCP -> WS 转发协程结束") | |
| if not tcp_socket._closed: | |
| tcp_socket.close() | |
| if not ws.closed: | |
| ws.close() | |
| def websocket_proxy_tunnel(ws): | |
| """处理WebSocket连接,建立到Clash代理端口的TCP隧道""" | |
| global clash_manager, initialization_error | |
| # +++ 添加日志记录 +++ | |
| logger.info(f"收到 /wsproxy 请求,来自: {request.remote_addr}") | |
| logger.info("请求头:") | |
| for header, value in request.headers.items(): | |
| logger.info(f" {header}: {value}") | |
| # --- 日志记录结束 --- | |
| logger.info(f"开始处理 WebSocket 连接...") # 修改日志信息 | |
| # 1. 检查Clash是否运行 | |
| if clash_manager is None: | |
| logger.error(f"Clash服务未运行,无法建立WebSocket隧道") | |
| # 对于WebSocket错误,最好是直接关闭而不是返回HTTP错误 | |
| # ws.close(reason="Clash service is not running") | |
| # 但这里因为还没进入ws上下文,可能需要不同的处理 | |
| # 尝试返回一个错误,虽然客户端可能不期望 | |
| return "Clash service not running", 503 | |
| # 2. 建立到内部Clash代理端口的TCP连接 | |
| target_host = "127.0.0.1" | |
| target_port = CLASH_PROXY_PORT # 7890 | |
| tcp_socket = None | |
| try: | |
| tcp_socket = socket.create_connection((target_host, target_port), timeout=5) | |
| logger.info(f"成功连接到内部Clash代理端口: {target_host}:{target_port}") | |
| except Exception as e: | |
| logger.error(f"连接到内部Clash代理端口失败: {e}") | |
| ws.close(reason=f"Failed to connect to internal proxy: {e}") | |
| return | |
| # 3. 创建两个协程进行双向数据转发 | |
| logger.info("启动WebSocket和TCP之间的双向转发协程...") | |
| ws_to_tcp_greenlet = gevent.spawn(forward_websocket_to_tcp, ws, tcp_socket) | |
| tcp_to_ws_greenlet = gevent.spawn(forward_tcp_to_websocket, tcp_socket, ws) | |
| # 4. 等待任一协程结束 (表示连接中断) | |
| try: | |
| gevent.joinall([ws_to_tcp_greenlet, tcp_to_ws_greenlet], raise_error=True) | |
| except Exception as e: | |
| logger.warning(f"转发协程出现错误: {e}") | |
| finally: | |
| logger.info("WebSocket隧道连接已关闭") | |
| if tcp_socket and not tcp_socket._closed: | |
| tcp_socket.close() | |
| if ws and not ws.closed: | |
| ws.close() | |
| # --- 基础路由 --- | |
| def index(): | |
| """首页 - 提供说明、状态和操作""" | |
| global initialization_error | |
| using_manual_config = os.path.exists(MANUAL_CONFIG_MARKER) | |
| status = "运行中" if initialization_error is None else "初始化失败" | |
| error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>" | |
| config_mode_msg = "<p class='note'>当前模式:<b>手动配置文件</b></p>" if using_manual_config else "<p class='note'>当前模式:<b>订阅链接</b></p>" | |
| # 刷新按钮状态 | |
| refresh_button_disabled = "disabled" if using_manual_config else "" | |
| refresh_button_title = "title='当前使用手动配置,无需刷新订阅'" if using_manual_config else "" | |
| yacd_link = "<a href='/ui/' class='button'>访问高级控制面板 (Yacd)</a>" | |
| return f""" | |
| <html> | |
| <head> | |
| <title>Simple Clash Relay</title> | |
| <style> | |
| body {{ font-family: Arial, sans-serif; padding: 20px; }} | |
| h1 {{ color: #333; }} | |
| .status {{ padding: 10px; border-radius: 5px; display: inline-block; }} | |
| .running {{ background-color: #dff0d8; color: #3c763d; }} | |
| .error {{ background-color: #f2dede; color: #a94442; }} | |
| .container {{ max-width: 800px; margin: 0 auto; }} | |
| .button {{ | |
| display: inline-block; | |
| padding: 8px 16px; | |
| background-color: #337ab7; | |
| color: white; | |
| text-decoration: none; | |
| border-radius: 4px; | |
| margin-right: 10px; | |
| margin-bottom: 10px; /* 添加底部间距 */ | |
| }} | |
| .button.danger {{ background-color: #d9534f; }} | |
| .button.warning {{ background-color: #f0ad4e; }} | |
| .debug-section {{ | |
| margin-top: 20px; | |
| padding: 15px; | |
| border: 1px dashed #ccc; | |
| background-color: #f9f9f9; | |
| }} | |
| .note {{ | |
| background-color: #fcf8e3; | |
| border-left: 4px solid #f0ad4e; | |
| padding: 10px 15px; | |
| margin: 10px 0; | |
| color: #8a6d3b; | |
| font-size: 14px; | |
| }} | |
| pre {{ max-height: 300px; overflow: auto; background-color: #eee; padding: 10px; border-radius: 4px; }} | |
| .note b {{ font-weight: bold; color: #555; }} | |
| .upload-section {{ margin-top: 20px; padding: 15px; border: 1px dashed #46b8da; background-color: #d9edf7; }} | |
| .upload-section label {{ display: block; margin-bottom: 5px; font-weight: bold; }} | |
| .upload-section input[type='file'] {{ margin-bottom: 10px; }} | |
| </style> | |
| <script> | |
| // 请求API (带密钥) | |
| function requestWithApiKey(url, method = 'POST') {{ | |
| const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme'); | |
| if (apiKey === null) return; | |
| fetch(url, {{ | |
| method: method, | |
| headers: {{ 'X-API-Key': apiKey }} | |
| }}) | |
| .then(response => response.json()) | |
| .then(data => {{ | |
| alert(data.success ? data.message : ('失败: ' + (data.error || '未知错误'))); | |
| if(data.success) location.reload(); | |
| }}) | |
| .catch(error => alert('请求失败: ' + error)); | |
| }} | |
| // 查看配置 | |
| function viewConfig() {{ | |
| const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme'); | |
| if (apiKey === null) return; | |
| fetch('/debug/config', {{ | |
| headers: {{ 'X-API-Key': apiKey }} | |
| }}) | |
| .then(response => response.json()) | |
| .then(data => {{ | |
| const configDiv = document.getElementById('config-content'); | |
| configDiv.innerHTML = ''; // 清空之前的内容 | |
| if (data.success) {{ | |
| const pre = document.createElement('pre'); | |
| pre.textContent = data.content; | |
| configDiv.appendChild(pre); | |
| }} else {{ | |
| const errorP = document.createElement('p'); | |
| errorP.style.color = 'red'; | |
| errorP.textContent = '获取配置失败: ' + data.error; | |
| configDiv.appendChild(errorP); | |
| }} | |
| }}) | |
| .catch(error => {{ | |
| const configDiv = document.getElementById('config-content'); | |
| configDiv.innerHTML = '<p style="color:red">请求失败: ' + error + '</p>'; | |
| }}); | |
| }} | |
| // 上传配置 | |
| function uploadConfig() {{ | |
| const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme'); | |
| if (apiKey === null) return; | |
| const fileInput = document.getElementById('configFileInput'); | |
| const file = fileInput.files[0]; | |
| if (!file) {{ | |
| alert('请先选择一个 .yaml 或 .yml 文件。'); | |
| return; | |
| }} | |
| if (!file.name.toLowerCase().endsWith('.yaml') && !file.name.toLowerCase().endsWith('.yml')) {{ | |
| alert('只允许上传 .yaml 或 .yml 文件。'); | |
| return; | |
| }} | |
| const formData = new FormData(); | |
| formData.append('config_file', file); | |
| const uploadButton = document.getElementById('uploadButton'); | |
| uploadButton.disabled = true; | |
| uploadButton.textContent = '上传中...'; | |
| fetch('/api/upload_config', {{ | |
| method: 'POST', | |
| headers: {{ 'X-API-Key': apiKey }}, | |
| body: formData | |
| }}) | |
| .then(response => response.json()) | |
| .then(data => {{ | |
| alert(data.success ? data.message : ('上传失败: ' + (data.error || '未知错误'))); | |
| if(data.success) location.reload(); | |
| }}) | |
| .catch(error => {{ | |
| alert('上传请求失败: ' + error); | |
| }}) | |
| .finally(() => {{ | |
| uploadButton.disabled = false; | |
| uploadButton.textContent = '上传并应用配置'; | |
| }}); | |
| }} | |
| </script> | |
| </head> | |
| <body> | |
| <div class='container'> | |
| <h1>Simple Clash Relay</h1> | |
| <p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p> | |
| {error_msg} | |
| {config_mode_msg} | |
| <h2>控制面板</h2> | |
| {yacd_link} | |
| <div class="note"> | |
| <strong>⚠️ 重要提示:</strong> | |
| <ul> | |
| <li>首次使用高级控制面板时,您需要在设置中填入: | |
| <ul> | |
| <li>外部控制器地址:<code>/clashapi</code></li> | |
| <li>密钥:<code>changeme</code> (除非您自定义了API_KEY)</li> | |
| </ul> | |
| </li> | |
| <li>当前版本对控制面板有以下限制: | |
| <ul> | |
| <li>不支持WebSocket接口,因此日志查看功能不可用</li> | |
| <li>某些PUT操作可能不稳定,如果遇到问题请刷新页面</li> | |
| </ul> | |
| </li> | |
| </ul> | |
| </div> | |
| <h2>基本操作</h2> | |
| <button class="button warning" onclick="requestWithApiKey('/api/refresh')" {refresh_button_disabled} {refresh_button_title}>刷新订阅并重启Clash</button> | |
| <div class="upload-section"> | |
| <h3>上传手动配置</h3> | |
| <p>您可以上传自己的 <code>config.yaml</code> 文件来覆盖订阅链接。上传后,系统将只使用此文件,不再进行订阅更新。</p> | |
| <label for="configFileInput">选择配置文件 (.yaml 或 .yml):</label> | |
| <input type="file" id="configFileInput" name="config_file" accept=".yaml,.yml"> | |
| <button class="button" id="uploadButton" onclick="uploadConfig()">上传并应用配置</button> | |
| <p><small>要恢复使用订阅链接,请使用下面的"清理配置并重启"按钮。</small></p> | |
| </div> | |
| <div class="debug-section"> | |
| <h3>调试与恢复</h3> | |
| <button class="button" onclick="viewConfig()">查看当前配置文件</button> | |
| <button class="button danger" onclick="if(confirm('确定要清理配置并重启服务吗?此操作将删除手动配置(如果存在)并恢复使用订阅链接!')) requestWithApiKey('/debug/clean')">清理配置并重启 (恢复订阅)</button> | |
| <div id="config-content" style="margin-top: 15px;"></div> | |
| </div> | |
| <h2>帮助</h2> | |
| <p>API密钥可在 .env 文件或环境变量中设置 (API_KEY)。默认为 'changeme'。</p> | |
| <p>高级控制面板 (Yacd) 需要在设置中配置 API 地址为 /clashapi 并提供密钥。</p> | |
| <p>更多信息请参考项目文档或README。</p> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == "__main__": | |
| # 如果直接运行此文件,将初始化应用并启动Flask服务器 | |
| initialize_once() | |
| logger.info(f"启动 Flask/Gevent WebSocket 服务器,监听端口: {FLASK_PORT}") | |
| # 使用 gevent 的 WSGI 服务器来支持 WebSocket | |
| server = pywsgi.WSGIServer(('0.0.0.0', FLASK_PORT), app, handler_class=WebSocketHandler) | |
| server.serve_forever() |