Spaces:
Paused
Paused
File size: 32,412 Bytes
2a7659b ff9409f 1095651 ff9409f 2a7659b 3e9ce57 1095651 ff9409f 2a7659b 1095651 2a7659b 1095651 7a89f6b ff9409f 3e9ce57 1095651 ff9409f 1095651 2a7659b 3e9ce57 2a7659b ac8610d 2a7659b 3e9ce57 ac8610d 3e9ce57 ac8610d 3e9ce57 2a7659b b58324b 2a7659b b58324b 2a7659b b58324b 2a7659b 1095651 b58324b 1095651 2a7659b b58324b 2a7659b b58324b 2a7659b b58324b 2a7659b 3e9ce57 2a7659b 3e9ce57 2a7659b 395f8b0 1095651 395f8b0 1095651 395f8b0 1095651 395f8b0 1095651 395f8b0 1095651 395f8b0 3e9ce57 1095651 3e9ce57 395f8b0 1095651 3e9ce57 395f8b0 1095651 3e9ce57 395f8b0 1095651 3e9ce57 395f8b0 3e9ce57 395f8b0 3e9ce57 395f8b0 3e9ce57 395f8b0 3e9ce57 cd9d8e5 3e9ce57 cd9d8e5 3e9ce57 cd9d8e5 3e9ce57 cd9d8e5 3e9ce57 cd9d8e5 3e9ce57 cd9d8e5 3e9ce57 395f8b0 3e9ce57 1095651 b044a62 1095651 b044a62 1095651 14f8c22 1095651 ff9409f aaf23f9 ff9409f aaf23f9 ff9409f 3e9ce57 395f8b0 2a7659b 1095651 b58324b 1095651 b58324b 1095651 b58324b 3e9ce57 b58324b 2a7659b b58324b 395f8b0 3e9ce57 395f8b0 3e9ce57 395f8b0 cd9d8e5 3e9ce57 1095651 b58324b 395f8b0 1095651 3e9ce57 1095651 3e9ce57 1095651 3e9ce57 395f8b0 1095651 395f8b0 3e9ce57 395f8b0 3e9ce57 395f8b0 3e9ce57 395f8b0 3e9ce57 395f8b0 3e9ce57 438d71d 3e9ce57 395f8b0 f04e29a 1095651 f04e29a 1095651 f04e29a 1095651 f04e29a 1095651 f04e29a 1095651 f04e29a 1095651 f04e29a 395f8b0 b58324b 2a7659b b58324b 1095651 3e9ce57 cd9d8e5 3e9ce57 1095651 395f8b0 f04e29a 1095651 f04e29a 395f8b0 1095651 3e9ce57 1095651 395f8b0 b58324b 3e9ce57 b58324b 2a7659b 3e9ce57 ff9409f |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 |
#!/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
@app.before_request
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 # 标记已初始化
@app.route("/api/nodes", methods=["GET"])
@authenticate
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
@app.route("/api/switch", methods=["PUT"])
@authenticate
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
@app.route("/api/current", methods=["GET"])
@authenticate
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
@app.route("/api/refresh", methods=["POST"])
@authenticate
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
@app.route("/health", methods=["GET"])
def health_check():
"""健康检查接口"""
return jsonify({"status": "ok"})
# --- 代理路由 ---
# 弃用/proxy路由,Clash的代理端口由外部直接访问
# 如果需要在Hugging Face部署,代理通常通过Clash API的节点选择完成
# --- 调试路由 ---
@app.route("/debug/clean", methods=["POST"])
@authenticate
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
@app.route("/debug/config", methods=["GET"])
@authenticate
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 反向代理路由 ---
@app.route('/ui/')
def yacd_index():
"""提供Yacd UI的入口文件"""
# return send_from_directory('static/yacd', 'index.html')
# 重定向到包含 index.html 的目录,确保相对路径正确加载
return redirect('/ui/index.html', code=301)
@app.route('/ui/<path:path>')
def yacd_static(path):
"""提供Yacd UI的静态文件 (CSS, JS, images)"""
return send_from_directory(os.path.join(app.static_folder, 'yacd'), path)
@app.route('/clashapi/<path:subpath>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
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 ---
@app.route("/api/upload_config", methods=["POST"])
@authenticate
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()
@sockets.route('/wsproxy')
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()
# --- 基础路由 ---
@app.route('/', methods=['GET'])
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() |