Spaces:
Paused
Paused
Upload 17 files
Browse files- Dockerfile +4 -1
- app/main.py +204 -258
- requirements.txt +1 -4
Dockerfile
CHANGED
|
@@ -91,9 +91,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 91 |
CLASH_API_PORT=9090 \
|
| 92 |
PORT=7860
|
| 93 |
|
| 94 |
-
#
|
| 95 |
COPY app/ ./app/
|
| 96 |
|
|
|
|
|
|
|
|
|
|
| 97 |
# 复制启动脚本并赋予执行权限
|
| 98 |
COPY entrypoint.sh ./
|
| 99 |
RUN chmod +x ./entrypoint.sh
|
|
|
|
| 91 |
CLASH_API_PORT=9090 \
|
| 92 |
PORT=7860
|
| 93 |
|
| 94 |
+
# 复制应用代码和静态文件
|
| 95 |
COPY app/ ./app/
|
| 96 |
|
| 97 |
+
# 复制 Yacd UI 文件
|
| 98 |
+
COPY app/static/yacd /app/app/static/yacd
|
| 99 |
+
|
| 100 |
# 复制启动脚本并赋予执行权限
|
| 101 |
COPY entrypoint.sh ./
|
| 102 |
RUN chmod +x ./entrypoint.sh
|
app/main.py
CHANGED
|
@@ -7,16 +7,12 @@ Simple Clash Relay - Flask 应用入口
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
import logging
|
| 10 |
-
import
|
| 11 |
-
import signal
|
| 12 |
-
import sys
|
| 13 |
-
import json
|
| 14 |
-
import requests
|
| 15 |
-
from flask import Flask, request, jsonify, Response, send_from_directory
|
| 16 |
-
from werkzeug.exceptions import HTTPException
|
| 17 |
from .clash_manager import ClashManager
|
| 18 |
from .sub_manager import SubscriptionManager
|
| 19 |
from .auth import authenticate
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# 配置日志
|
| 22 |
logging.basicConfig(
|
|
@@ -33,52 +29,57 @@ CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
|
|
| 33 |
CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
|
| 34 |
|
| 35 |
# 初始化Flask应用
|
| 36 |
-
app = Flask(__name__)
|
| 37 |
|
| 38 |
# 初始化管理器
|
| 39 |
clash_manager = None
|
| 40 |
sub_manager = None
|
| 41 |
initialization_error = None
|
| 42 |
|
| 43 |
-
@app.
|
| 44 |
-
def
|
| 45 |
"""应用首次请求前的初始化"""
|
| 46 |
-
global clash_manager, sub_manager, initialization_error
|
| 47 |
-
|
| 48 |
-
logger.info("正在初始化应用...")
|
| 49 |
-
|
| 50 |
-
# 初始化订阅管理器
|
| 51 |
-
sub_manager = SubscriptionManager(
|
| 52 |
-
sub_url=SUB_URL,
|
| 53 |
-
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
# 加载订阅并转换为Clash配置
|
| 57 |
-
try:
|
| 58 |
-
sub_manager.load_and_convert_sub()
|
| 59 |
-
logger.info("成功加载并转换订阅")
|
| 60 |
-
except Exception as e:
|
| 61 |
-
err_msg = f"加载订阅失败: {str(e)}"
|
| 62 |
-
logger.error(err_msg)
|
| 63 |
-
initialization_error = err_msg
|
| 64 |
-
return # 继续初始化,但不启动Clash
|
| 65 |
|
| 66 |
-
#
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
@app.route("/api/nodes", methods=["GET"])
|
| 84 |
@authenticate
|
|
@@ -151,7 +152,6 @@ def refresh_subscription():
|
|
| 151 |
try:
|
| 152 |
# 尝试重新加载订阅
|
| 153 |
if sub_manager is None:
|
| 154 |
-
# 如果订阅管理器未初始化,重新创建它
|
| 155 |
sub_manager = SubscriptionManager(
|
| 156 |
sub_url=SUB_URL,
|
| 157 |
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
|
@@ -159,7 +159,6 @@ def refresh_subscription():
|
|
| 159 |
|
| 160 |
sub_manager.load_and_convert_sub()
|
| 161 |
|
| 162 |
-
# 如果Clash未启动,尝试启动它
|
| 163 |
if clash_manager is None:
|
| 164 |
clash_manager = ClashManager(
|
| 165 |
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"),
|
|
@@ -171,7 +170,6 @@ def refresh_subscription():
|
|
| 171 |
initialization_error = None
|
| 172 |
return jsonify({"success": True, "message": "订阅已刷新,Clash已启动"})
|
| 173 |
else:
|
| 174 |
-
# 如果Clash已经启动,重启它
|
| 175 |
clash_manager.restart_clash()
|
| 176 |
initialization_error = None
|
| 177 |
return jsonify({"success": True, "message": "订阅已刷新,Clash已重启"})
|
|
@@ -186,49 +184,12 @@ def health_check():
|
|
| 186 |
"""健康检查接口"""
|
| 187 |
return jsonify({"status": "ok"})
|
| 188 |
|
| 189 |
-
#
|
| 190 |
-
@app.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
|
| 191 |
-
def proxy_request(path):
|
| 192 |
-
"""代理请求转发到Clash Core"""
|
| 193 |
-
global clash_manager, initialization_error
|
| 194 |
-
|
| 195 |
-
if clash_manager is None:
|
| 196 |
-
return jsonify({
|
| 197 |
-
"success": False,
|
| 198 |
-
"error": f"Clash代理未启动: {initialization_error or '未知错误'}"
|
| 199 |
-
}), 503
|
| 200 |
-
|
| 201 |
-
target_url = f"http://127.0.0.1:{CLASH_PROXY_PORT}/{path}"
|
| 202 |
-
logger.debug(f"转发请求到: {target_url}")
|
| 203 |
-
|
| 204 |
-
try:
|
| 205 |
-
# 转发请求
|
| 206 |
-
resp = requests.request(
|
| 207 |
-
method=request.method,
|
| 208 |
-
url=target_url,
|
| 209 |
-
headers={key: value for key, value in request.headers if key != 'Host'},
|
| 210 |
-
data=request.get_data(),
|
| 211 |
-
cookies=request.cookies,
|
| 212 |
-
allow_redirects=False,
|
| 213 |
-
stream=True
|
| 214 |
-
)
|
| 215 |
-
|
| 216 |
-
# 构建响应
|
| 217 |
-
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
|
| 218 |
-
headers = [(name, value) for name, value in resp.raw.headers.items()
|
| 219 |
-
if name.lower() not in excluded_headers]
|
| 220 |
-
|
| 221 |
-
response = Response(resp.content, resp.status_code, headers)
|
| 222 |
-
return response
|
| 223 |
-
except Exception as e:
|
| 224 |
-
logger.error(f"代理请求失败: {str(e)}")
|
| 225 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 226 |
|
| 227 |
-
#
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
return proxy_request("")
|
| 232 |
|
| 233 |
@app.route("/debug/clean", methods=["POST"])
|
| 234 |
@authenticate
|
|
@@ -237,12 +198,10 @@ def debug_clean():
|
|
| 237 |
global clash_manager, sub_manager, initialization_error
|
| 238 |
|
| 239 |
try:
|
| 240 |
-
# 停止Clash(如果正在运行)
|
| 241 |
if clash_manager is not None:
|
| 242 |
clash_manager.stop_clash()
|
| 243 |
clash_manager = None
|
| 244 |
|
| 245 |
-
# 删除配置文件
|
| 246 |
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 247 |
raw_config_path = f"{config_path}.raw"
|
| 248 |
|
|
@@ -253,68 +212,141 @@ def debug_clean():
|
|
| 253 |
if os.path.exists(file_path):
|
| 254 |
try:
|
| 255 |
os.remove(file_path)
|
| 256 |
-
deleted_files.append(file_path)
|
| 257 |
-
except
|
| 258 |
-
logger.
|
| 259 |
|
| 260 |
-
#
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
config_path=config_path
|
| 264 |
-
)
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
# 初始化Clash管理器
|
| 270 |
-
clash_manager = ClashManager(
|
| 271 |
-
config_path=config_path,
|
| 272 |
-
clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"),
|
| 273 |
-
api_port=CLASH_API_PORT,
|
| 274 |
-
proxy_port=CLASH_PROXY_PORT
|
| 275 |
-
)
|
| 276 |
-
|
| 277 |
-
# 启动Clash
|
| 278 |
-
clash_manager.start_clash()
|
| 279 |
-
initialization_error = None
|
| 280 |
-
|
| 281 |
-
return jsonify({
|
| 282 |
-
"success": True,
|
| 283 |
-
"message": f"配置已清理并重新初始化,删除的文件: {', '.join(deleted_files)}"
|
| 284 |
-
})
|
| 285 |
|
| 286 |
except Exception as e:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
initialization_error = error_msg
|
| 290 |
-
return jsonify({"success": False, "error": error_msg}), 500
|
| 291 |
|
| 292 |
@app.route("/debug/config", methods=["GET"])
|
| 293 |
@authenticate
|
| 294 |
def debug_show_config():
|
| 295 |
-
"""
|
| 296 |
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 297 |
|
| 298 |
if not os.path.exists(config_path):
|
| 299 |
return jsonify({"success": False, "error": "配置文件不存在"}), 404
|
| 300 |
-
|
| 301 |
try:
|
| 302 |
with open(config_path, "r", encoding="utf-8") as f:
|
| 303 |
content = f.read()
|
| 304 |
-
|
| 305 |
return jsonify({"success": True, "content": content})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
except Exception as e:
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
@app.route('/', methods=['GET'])
|
| 311 |
def index():
|
| 312 |
-
"""首页 -
|
| 313 |
global initialization_error
|
| 314 |
|
| 315 |
status = "运行中" if initialization_error is None else "初始化失败"
|
| 316 |
error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>"
|
| 317 |
|
|
|
|
|
|
|
|
|
|
| 318 |
return f"""
|
| 319 |
<html>
|
| 320 |
<head>
|
|
@@ -334,67 +366,61 @@ def index():
|
|
| 334 |
text-decoration: none;
|
| 335 |
border-radius: 4px;
|
| 336 |
margin-right: 10px;
|
|
|
|
| 337 |
}}
|
| 338 |
.button.danger {{ background-color: #d9534f; }}
|
|
|
|
| 339 |
.debug-section {{
|
| 340 |
margin-top: 20px;
|
| 341 |
padding: 15px;
|
| 342 |
border: 1px dashed #ccc;
|
| 343 |
background-color: #f9f9f9;
|
| 344 |
}}
|
|
|
|
| 345 |
</style>
|
| 346 |
<script>
|
| 347 |
-
function
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
.
|
| 359 |
-
}}
|
|
|
|
| 360 |
}}
|
| 361 |
|
| 362 |
function viewConfig() {{
|
|
|
|
|
|
|
|
|
|
| 363 |
fetch('/debug/config', {{
|
| 364 |
-
headers: {{ 'X-API-Key':
|
| 365 |
}})
|
| 366 |
.then(response => response.json())
|
| 367 |
.then(data => {{
|
|
|
|
|
|
|
| 368 |
if (data.success) {{
|
| 369 |
const pre = document.createElement('pre');
|
| 370 |
pre.textContent = data.content;
|
| 371 |
-
pre.style.maxHeight = '500px';
|
| 372 |
-
pre.style.overflow = 'auto';
|
| 373 |
-
pre.style.backgroundColor = '#f5f5f5';
|
| 374 |
-
pre.style.padding = '10px';
|
| 375 |
-
pre.style.borderRadius = '4px';
|
| 376 |
-
|
| 377 |
-
const configDiv = document.getElementById('config-content');
|
| 378 |
-
configDiv.innerHTML = '';
|
| 379 |
configDiv.appendChild(pre);
|
| 380 |
}} else {{
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
| 382 |
}}
|
| 383 |
}})
|
| 384 |
-
.catch(error =>
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
fetch('/api/refresh', {{
|
| 389 |
-
method: 'POST',
|
| 390 |
-
headers: {{ 'X-API-Key': prompt('请输入API密钥') }}
|
| 391 |
-
}})
|
| 392 |
-
.then(response => response.json())
|
| 393 |
-
.then(data => {{
|
| 394 |
-
alert(data.success ? data.message : '失败: ' + data.error);
|
| 395 |
-
location.reload();
|
| 396 |
-
}})
|
| 397 |
-
.catch(error => alert('请求失败: ' + error));
|
| 398 |
}}
|
| 399 |
</script>
|
| 400 |
</head>
|
|
@@ -403,112 +429,32 @@ def index():
|
|
| 403 |
<h1>Simple Clash Relay</h1>
|
| 404 |
<p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p>
|
| 405 |
{error_msg}
|
| 406 |
-
|
| 407 |
-
<
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
</
|
| 412 |
-
<h2>操作</h2>
|
| 413 |
-
<button class="button" onclick="refreshSubscription()">刷新订阅</button>
|
| 414 |
|
| 415 |
<div class="debug-section">
|
| 416 |
<h3>调试选项</h3>
|
| 417 |
-
<button class="button" onclick="viewConfig()"
|
| 418 |
-
<button class="button danger" onclick="
|
| 419 |
<div id="config-content" style="margin-top: 15px;"></div>
|
| 420 |
</div>
|
| 421 |
|
| 422 |
<h2>帮助</h2>
|
| 423 |
-
<p
|
|
|
|
|
|
|
| 424 |
</div>
|
| 425 |
</body>
|
| 426 |
</html>
|
| 427 |
"""
|
| 428 |
|
| 429 |
-
# --- Clash API 反向代理 ---
|
| 430 |
-
@app.route('/ui/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'])
|
| 431 |
-
def proxy_clash_api(path):
|
| 432 |
-
"""将 /ui/ 下的请求转发给内部的 Clash API (127.0.0.1:9090)"""
|
| 433 |
-
if not clash_manager:
|
| 434 |
-
return jsonify({"error": "Clash Manager 未初始化"}), 503
|
| 435 |
-
|
| 436 |
-
clash_api_base_url = f"http://127.0.0.1:{clash_manager.api_port}/"
|
| 437 |
-
target_url = clash_api_base_url + path
|
| 438 |
-
|
| 439 |
-
# 准备转发请求头,移除 Host 头,可能需要添加原始 Host 或 Forwarded 头
|
| 440 |
-
headers = {key: value for key, value in request.headers if key.lower() != 'host'}
|
| 441 |
-
# 确保传递原始的 X-Real-IP 或 X-Forwarded-For (如果存在)
|
| 442 |
-
# headers['X-Forwarded-For'] = request.remote_addr
|
| 443 |
-
|
| 444 |
-
# 处理 Websocket 连接 (Clash API 日志/流量推送可能使用)
|
| 445 |
-
if request.headers.get('Upgrade', '').lower() == 'websocket':
|
| 446 |
-
logger.info(f"检测到 WebSocket 请求: {path}")
|
| 447 |
-
# Flask 本身不直接支持代理 WebSocket,需要额外库或特殊处理
|
| 448 |
-
# 这里返回错误,表明此代理不支持 WebSocket
|
| 449 |
-
# 可以考虑使用 flask_sock 或其他库来支持
|
| 450 |
-
logger.warning("当前反向代理不支持 WebSocket 连接")
|
| 451 |
-
return jsonify({"error": "WebSocket proxying not supported"}), 501 # Not Implemented
|
| 452 |
-
|
| 453 |
-
try:
|
| 454 |
-
resp = requests.request(
|
| 455 |
-
method=request.method,
|
| 456 |
-
url=target_url,
|
| 457 |
-
headers=headers,
|
| 458 |
-
data=request.get_data(),
|
| 459 |
-
params=request.args,
|
| 460 |
-
stream=True, # 使用流式传输以处理大数据和流式API (如日志)
|
| 461 |
-
timeout=30 # 设置超时
|
| 462 |
-
)
|
| 463 |
-
|
| 464 |
-
# 准备转发响应头
|
| 465 |
-
response_headers = []
|
| 466 |
-
for name, value in resp.raw.headers.items():
|
| 467 |
-
# 避免转发某些头 (例如 'Transfer-Encoding': 'chunked' 可能导致问题)
|
| 468 |
-
if name.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
|
| 469 |
-
response_headers.append((name, value))
|
| 470 |
-
|
| 471 |
-
# 创建Flask响应对象
|
| 472 |
-
# 使用 resp.content 而不是迭代器,对于非流式响应更简单可靠
|
| 473 |
-
response = Response(resp.content, status=resp.status_code, headers=response_headers)
|
| 474 |
-
return response
|
| 475 |
-
|
| 476 |
-
except requests.exceptions.ConnectionError:
|
| 477 |
-
logger.error(f"无法连接到内部Clash API: {target_url}")
|
| 478 |
-
return jsonify({"error": "无法连接到内部 Clash API"}), 502 # Bad Gateway
|
| 479 |
-
except requests.exceptions.Timeout:
|
| 480 |
-
logger.error(f"连接到内部Clash API超时: {target_url}")
|
| 481 |
-
return jsonify({"error": "内部 Clash API 请求超时"}), 504 # Gateway Timeout
|
| 482 |
-
except Exception as e:
|
| 483 |
-
logger.error(f"代理到 Clash API 时出错: {str(e)}")
|
| 484 |
-
return jsonify({"error": f"代理请求失败: {str(e)}"}), 500
|
| 485 |
-
|
| 486 |
-
# --- 错误处理 ---
|
| 487 |
-
@app.errorhandler(HTTPException)
|
| 488 |
-
def handle_exception(e):
|
| 489 |
-
"""返回JSON格式的HTTP错误"""
|
| 490 |
-
response = e.get_response()
|
| 491 |
-
response.data = json.dumps({
|
| 492 |
-
"code": e.code,
|
| 493 |
-
"name": e.name,
|
| 494 |
-
"description": e.description,
|
| 495 |
-
})
|
| 496 |
-
response.content_type = "application/json"
|
| 497 |
-
return response
|
| 498 |
-
|
| 499 |
-
@app.errorhandler(Exception)
|
| 500 |
-
def handle_generic_exception(e):
|
| 501 |
-
"""处理未捕获的异常"""
|
| 502 |
-
logger.error(f"未捕获的异常: {str(e)}", exc_info=True)
|
| 503 |
-
# 对于非HTTP异常,返回500 Internal Server Error
|
| 504 |
-
return jsonify({
|
| 505 |
-
"code": 500,
|
| 506 |
-
"name": "Internal Server Error",
|
| 507 |
-
"description": f"发生内部错误: {str(e)}"
|
| 508 |
-
}), 500
|
| 509 |
-
|
| 510 |
if __name__ == "__main__":
|
| 511 |
# 如果直接运行此文件,将初始化应用并启动Flask服务器
|
| 512 |
-
|
| 513 |
logger.info(f"启动Flask服务器,监听端口: {FLASK_PORT}")
|
|
|
|
| 514 |
app.run(host="0.0.0.0", port=FLASK_PORT)
|
|
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
import logging
|
| 10 |
+
from flask import Flask, request, jsonify, Response, redirect, send_from_directory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from .clash_manager import ClashManager
|
| 12 |
from .sub_manager import SubscriptionManager
|
| 13 |
from .auth import authenticate
|
| 14 |
+
import requests
|
| 15 |
+
from functools import wraps
|
| 16 |
|
| 17 |
# 配置日志
|
| 18 |
logging.basicConfig(
|
|
|
|
| 29 |
CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
|
| 30 |
|
| 31 |
# 初始化Flask应用
|
| 32 |
+
app = Flask(__name__, static_folder='static')
|
| 33 |
|
| 34 |
# 初始化管理器
|
| 35 |
clash_manager = None
|
| 36 |
sub_manager = None
|
| 37 |
initialization_error = None
|
| 38 |
|
| 39 |
+
@app.before_request
|
| 40 |
+
def initialize_once():
|
| 41 |
"""应用首次请求前的初始化"""
|
| 42 |
+
global clash_manager, sub_manager, initialization_error, _initialized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
# 使用类变量确保只初始化一次
|
| 45 |
+
if not getattr(initialize_once, '_initialized', False):
|
| 46 |
+
logger.info("正在初始化应用 (首次请求)...")
|
| 47 |
+
|
| 48 |
+
# 初始化订阅管理器
|
| 49 |
+
sub_manager = SubscriptionManager(
|
| 50 |
+
sub_url=SUB_URL,
|
| 51 |
+
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# 加载订阅并转换为Clash配置
|
| 55 |
+
try:
|
| 56 |
+
sub_manager.load_and_convert_sub()
|
| 57 |
+
logger.info("成功加载并转换订阅")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
err_msg = f"加载订阅失败: {str(e)}"
|
| 60 |
+
logger.error(err_msg)
|
| 61 |
+
initialization_error = err_msg
|
| 62 |
+
initialize_once._initialized = True # 标记已初始化,即使失败
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
# 初始化Clash管理器
|
| 66 |
+
clash_manager = ClashManager(
|
| 67 |
+
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"),
|
| 68 |
+
clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"),
|
| 69 |
+
api_port=CLASH_API_PORT,
|
| 70 |
+
proxy_port=CLASH_PROXY_PORT
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# 启动Clash Core
|
| 74 |
+
try:
|
| 75 |
+
clash_manager.start_clash()
|
| 76 |
+
logger.info("成功启动Clash Core")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
err_msg = f"启动Clash Core失败: {str(e)}"
|
| 79 |
+
logger.error(err_msg)
|
| 80 |
+
initialization_error = err_msg
|
| 81 |
+
|
| 82 |
+
initialize_once._initialized = True # 标记已初始化
|
| 83 |
|
| 84 |
@app.route("/api/nodes", methods=["GET"])
|
| 85 |
@authenticate
|
|
|
|
| 152 |
try:
|
| 153 |
# 尝试重新加载订阅
|
| 154 |
if sub_manager is None:
|
|
|
|
| 155 |
sub_manager = SubscriptionManager(
|
| 156 |
sub_url=SUB_URL,
|
| 157 |
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
|
|
|
| 159 |
|
| 160 |
sub_manager.load_and_convert_sub()
|
| 161 |
|
|
|
|
| 162 |
if clash_manager is None:
|
| 163 |
clash_manager = ClashManager(
|
| 164 |
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"),
|
|
|
|
| 170 |
initialization_error = None
|
| 171 |
return jsonify({"success": True, "message": "订阅已刷新,Clash已启动"})
|
| 172 |
else:
|
|
|
|
| 173 |
clash_manager.restart_clash()
|
| 174 |
initialization_error = None
|
| 175 |
return jsonify({"success": True, "message": "订阅已刷新,Clash已重启"})
|
|
|
|
| 184 |
"""健康检查接口"""
|
| 185 |
return jsonify({"status": "ok"})
|
| 186 |
|
| 187 |
+
# --- 代理路由 ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
# 弃用/proxy路由,Clash的代理端口由外部直接访问
|
| 190 |
+
# 如果需要在Hugging Face部署,代理通常通过Clash API的节点选择完成
|
| 191 |
+
|
| 192 |
+
# --- 调试路由 ---
|
|
|
|
| 193 |
|
| 194 |
@app.route("/debug/clean", methods=["POST"])
|
| 195 |
@authenticate
|
|
|
|
| 198 |
global clash_manager, sub_manager, initialization_error
|
| 199 |
|
| 200 |
try:
|
|
|
|
| 201 |
if clash_manager is not None:
|
| 202 |
clash_manager.stop_clash()
|
| 203 |
clash_manager = None
|
| 204 |
|
|
|
|
| 205 |
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 206 |
raw_config_path = f"{config_path}.raw"
|
| 207 |
|
|
|
|
| 212 |
if os.path.exists(file_path):
|
| 213 |
try:
|
| 214 |
os.remove(file_path)
|
| 215 |
+
deleted_files.append(os.path.basename(file_path))
|
| 216 |
+
except OSError as e:
|
| 217 |
+
logger.warning(f"删除文件失败: {file_path}, 错误: {e}")
|
| 218 |
|
| 219 |
+
# 重新初始化并启动
|
| 220 |
+
initialize_once._initialized = False # 强制下次请求时重新初始化
|
| 221 |
+
initialize_once()
|
|
|
|
|
|
|
| 222 |
|
| 223 |
+
status_msg = "已清理并重新初始化" if initialization_error is None else f"已清理但初始化失败: {initialization_error}"
|
| 224 |
+
return jsonify({"success": True, "message": status_msg, "deleted_files": deleted_files})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
except Exception as e:
|
| 227 |
+
logger.error(f"清理配置时出错: {str(e)}")
|
| 228 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
|
|
|
|
|
|
| 229 |
|
| 230 |
@app.route("/debug/config", methods=["GET"])
|
| 231 |
@authenticate
|
| 232 |
def debug_show_config():
|
| 233 |
+
"""获取并显示当前Clash配置文件内容"""
|
| 234 |
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 235 |
|
| 236 |
if not os.path.exists(config_path):
|
| 237 |
return jsonify({"success": False, "error": "配置文件不存在"}), 404
|
| 238 |
+
|
| 239 |
try:
|
| 240 |
with open(config_path, "r", encoding="utf-8") as f:
|
| 241 |
content = f.read()
|
|
|
|
| 242 |
return jsonify({"success": True, "content": content})
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger.error(f"读取配置文件时出错: {str(e)}")
|
| 245 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 246 |
+
|
| 247 |
+
# --- Yacd UI & Clash API 反向代理路由 ---
|
| 248 |
+
|
| 249 |
+
@app.route('/ui/')
|
| 250 |
+
def yacd_index():
|
| 251 |
+
"""提供Yacd UI的入口文件"""
|
| 252 |
+
# return send_from_directory('static/yacd', 'index.html')
|
| 253 |
+
# 重定向到包含 index.html 的目录,确保相对路径正确加载
|
| 254 |
+
return redirect('/ui/index.html', code=301)
|
| 255 |
+
|
| 256 |
+
@app.route('/ui/<path:path>')
|
| 257 |
+
def yacd_static(path):
|
| 258 |
+
"""提供Yacd UI的静态文件 (CSS, JS, images)"""
|
| 259 |
+
return send_from_directory(os.path.join(app.static_folder, 'yacd'), path)
|
| 260 |
+
|
| 261 |
+
@app.route('/clashapi/<path:subpath>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
|
| 262 |
+
def clash_api_proxy(subpath):
|
| 263 |
+
"""反向代理请求到内部的Clash API"""
|
| 264 |
+
global clash_manager, initialization_error, API_KEY
|
| 265 |
+
|
| 266 |
+
# 1. 认证检查 (Yacd 使用 Authorization: Bearer <key> 或 ?token=<key>)
|
| 267 |
+
auth_header = request.headers.get('Authorization')
|
| 268 |
+
token = request.args.get('token')
|
| 269 |
+
provided_key = None
|
| 270 |
|
| 271 |
+
if auth_header and auth_header.startswith('Bearer '):
|
| 272 |
+
provided_key = auth_header.split(' ', 1)[1]
|
| 273 |
+
elif token:
|
| 274 |
+
provided_key = token
|
| 275 |
+
|
| 276 |
+
if provided_key != API_KEY:
|
| 277 |
+
logger.warning(f"Clash API代理认证失败,路径: {subpath}")
|
| 278 |
+
return jsonify({"message": "Authentication required"}), 401
|
| 279 |
+
|
| 280 |
+
# 2. 检查Clash是否运行
|
| 281 |
+
if clash_manager is None or clash_manager.clash_process is None or clash_manager.clash_process.poll() is not None:
|
| 282 |
+
logger.error(f"Clash API不可用,无法代理请求: {subpath}")
|
| 283 |
+
return jsonify({
|
| 284 |
+
"success": False,
|
| 285 |
+
"error": f"Clash API未运行: {initialization_error or '内部错误'}"
|
| 286 |
+
}), 503
|
| 287 |
+
|
| 288 |
+
# 3. 构建目标URL
|
| 289 |
+
target_url = f"http://127.0.0.1:{CLASH_API_PORT}/{subpath}"
|
| 290 |
+
if request.query_string:
|
| 291 |
+
target_url += '?' + request.query_string.decode('utf-8')
|
| 292 |
+
|
| 293 |
+
logger.debug(f"代理Clash API请求到: {target_url}")
|
| 294 |
+
|
| 295 |
+
# 4. 转发请求
|
| 296 |
+
try:
|
| 297 |
+
# 准备请求头,移除Host,保留Yacd可能发送的认证头
|
| 298 |
+
req_headers = {key: value for key, value in request.headers if key.lower() != 'host'}
|
| 299 |
+
|
| 300 |
+
# 对于WebSocket连接 (Clash API 日志/流量)
|
| 301 |
+
if request.headers.get('Upgrade', '').lower() == 'websocket':
|
| 302 |
+
# 这里需要一个更复杂的WebSocket代理实现,暂时返回错误
|
| 303 |
+
logger.warning("不支持的WebSocket代理请求")
|
| 304 |
+
return jsonify({"error": "WebSocket proxy not supported"}), 501
|
| 305 |
+
|
| 306 |
+
# 普通HTTP请求
|
| 307 |
+
resp = requests.request(
|
| 308 |
+
method=request.method,
|
| 309 |
+
url=target_url,
|
| 310 |
+
headers=req_headers,
|
| 311 |
+
data=request.get_data(),
|
| 312 |
+
cookies=request.cookies,
|
| 313 |
+
allow_redirects=False,
|
| 314 |
+
stream=True, # 对于大响应或流式响应
|
| 315 |
+
timeout=30 # 添加超时
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
# 5. 构建并返回响应
|
| 319 |
+
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
|
| 320 |
+
resp_headers = [(name, value) for name, value in resp.raw.headers.items()
|
| 321 |
+
if name.lower() not in excluded_headers]
|
| 322 |
+
|
| 323 |
+
# 使用iter_content流式传输响应体,以支持大数据和流
|
| 324 |
+
response = Response(resp.iter_content(chunk_size=8192), resp.status_code, resp_headers)
|
| 325 |
+
return response
|
| 326 |
+
|
| 327 |
+
except requests.exceptions.ConnectionError as e:
|
| 328 |
+
logger.error(f"连接Clash API失败 ({target_url}): {str(e)}")
|
| 329 |
+
return jsonify({"error": f"无法连接到内部Clash API: {str(e)}"}), 502 # Bad Gateway
|
| 330 |
+
except requests.exceptions.Timeout as e:
|
| 331 |
+
logger.error(f"请求Clash API超时 ({target_url}): {str(e)}")
|
| 332 |
+
return jsonify({"error": f"请求内部Clash API超时: {str(e)}"}), 504 # Gateway Timeout
|
| 333 |
except Exception as e:
|
| 334 |
+
logger.error(f"代理Clash API请求时发生意外错误: {str(e)}")
|
| 335 |
+
return jsonify({"error": f"代理请求时发生意外错误: {str(e)}"}), 500
|
| 336 |
+
|
| 337 |
+
# --- 基础路由 ---
|
| 338 |
|
| 339 |
@app.route('/', methods=['GET'])
|
| 340 |
def index():
|
| 341 |
+
"""首页 - 提供简单说明和调试链接"""
|
| 342 |
global initialization_error
|
| 343 |
|
| 344 |
status = "运行中" if initialization_error is None else "初始化失败"
|
| 345 |
error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>"
|
| 346 |
|
| 347 |
+
# 检查Yacd UI是否可用
|
| 348 |
+
yacd_link = "<a href='/ui/' class='button'>访问高级控制面板 (Yacd)</a>"
|
| 349 |
+
|
| 350 |
return f"""
|
| 351 |
<html>
|
| 352 |
<head>
|
|
|
|
| 366 |
text-decoration: none;
|
| 367 |
border-radius: 4px;
|
| 368 |
margin-right: 10px;
|
| 369 |
+
margin-bottom: 10px; /* 添加底部间距 */
|
| 370 |
}}
|
| 371 |
.button.danger {{ background-color: #d9534f; }}
|
| 372 |
+
.button.warning {{ background-color: #f0ad4e; }}
|
| 373 |
.debug-section {{
|
| 374 |
margin-top: 20px;
|
| 375 |
padding: 15px;
|
| 376 |
border: 1px dashed #ccc;
|
| 377 |
background-color: #f9f9f9;
|
| 378 |
}}
|
| 379 |
+
pre {{ max-height: 300px; overflow: auto; background-color: #eee; padding: 10px; border-radius: 4px; }}
|
| 380 |
</style>
|
| 381 |
<script>
|
| 382 |
+
function requestWithApiKey(url, method = 'POST') {{
|
| 383 |
+
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 384 |
+
if (apiKey === null) return; // 用户取消
|
| 385 |
+
|
| 386 |
+
fetch(url, {{
|
| 387 |
+
method: method,
|
| 388 |
+
headers: {{ 'X-API-Key': apiKey }}
|
| 389 |
+
}})
|
| 390 |
+
.then(response => response.json())
|
| 391 |
+
.then(data => {{
|
| 392 |
+
alert(data.success ? data.message : '失败: ' + data.error);
|
| 393 |
+
if(data.success) location.reload();
|
| 394 |
+
}})
|
| 395 |
+
.catch(error => alert('请求失败: ' + error));
|
| 396 |
}}
|
| 397 |
|
| 398 |
function viewConfig() {{
|
| 399 |
+
const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
|
| 400 |
+
if (apiKey === null) return;
|
| 401 |
+
|
| 402 |
fetch('/debug/config', {{
|
| 403 |
+
headers: {{ 'X-API-Key': apiKey }}
|
| 404 |
}})
|
| 405 |
.then(response => response.json())
|
| 406 |
.then(data => {{
|
| 407 |
+
const configDiv = document.getElementById('config-content');
|
| 408 |
+
configDiv.innerHTML = ''; // 清空之前的内容
|
| 409 |
if (data.success) {{
|
| 410 |
const pre = document.createElement('pre');
|
| 411 |
pre.textContent = data.content;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
configDiv.appendChild(pre);
|
| 413 |
}} else {{
|
| 414 |
+
const errorP = document.createElement('p');
|
| 415 |
+
errorP.style.color = 'red';
|
| 416 |
+
errorP.textContent = '获取配置失败: ' + data.error;
|
| 417 |
+
configDiv.appendChild(errorP);
|
| 418 |
}}
|
| 419 |
}})
|
| 420 |
+
.catch(error => {{
|
| 421 |
+
const configDiv = document.getElementById('config-content');
|
| 422 |
+
configDiv.innerHTML = `<p style="color:red">请求失败: ${error}</p>`;
|
| 423 |
+
}});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
}}
|
| 425 |
</script>
|
| 426 |
</head>
|
|
|
|
| 429 |
<h1>Simple Clash Relay</h1>
|
| 430 |
<p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p>
|
| 431 |
{error_msg}
|
| 432 |
+
|
| 433 |
+
<h2>控制面板</h2>
|
| 434 |
+
{yacd_link}
|
| 435 |
+
|
| 436 |
+
<h2>基本操作</h2>
|
| 437 |
+
<button class="button warning" onclick="requestWithApiKey('/api/refresh')">刷新订阅并重启Clash</button>
|
|
|
|
|
|
|
| 438 |
|
| 439 |
<div class="debug-section">
|
| 440 |
<h3>调试选项</h3>
|
| 441 |
+
<button class="button" onclick="viewConfig()">查看当前配置文件</button>
|
| 442 |
+
<button class="button danger" onclick="if(confirm('确定要清理配置并重启服务吗?此操作不可逆!')) requestWithApiKey('/debug/clean')">清理配置并重启</button>
|
| 443 |
<div id="config-content" style="margin-top: 15px;"></div>
|
| 444 |
</div>
|
| 445 |
|
| 446 |
<h2>帮助</h2>
|
| 447 |
+
<p>API密钥可在 .env 文件或环境变量中设置 (API_KEY)。默认为 'changeme'。</p>
|
| 448 |
+
<p>高级控制面板 (Yacd) 需要在设置中配置 API 地址为 /clashapi 并提供密钥。</p>
|
| 449 |
+
<p>更多信息请参考项目文档或README。</p>
|
| 450 |
</div>
|
| 451 |
</body>
|
| 452 |
</html>
|
| 453 |
"""
|
| 454 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
if __name__ == "__main__":
|
| 456 |
# 如果直接运行此文件,将初始化应用并启动Flask服务器
|
| 457 |
+
initialize_once()
|
| 458 |
logger.info(f"启动Flask服务器,监听端口: {FLASK_PORT}")
|
| 459 |
+
app.run(host="0.0.0.0", port=FLASK_PORT)
|
| 460 |
app.run(host="0.0.0.0", port=FLASK_PORT)
|
requirements.txt
CHANGED
|
@@ -4,7 +4,4 @@ requests==2.28.1
|
|
| 4 |
Werkzeug==2.0.1
|
| 5 |
PyYAML==6.0.1
|
| 6 |
# pyyaml==6.0 # 已通过 apk 安装
|
| 7 |
-
# 添加与 Flask==2.0.1 兼容的版本
|
| 8 |
-
|
| 9 |
-
# 确保 requests 库被包含,因为反向代理需要它
|
| 10 |
-
# (requests 已在上面,无需重复添加)
|
|
|
|
| 4 |
Werkzeug==2.0.1
|
| 5 |
PyYAML==6.0.1
|
| 6 |
# pyyaml==6.0 # 已通过 apk 安装
|
| 7 |
+
# 添加与 Flask==2.0.1 兼容的版本
|
|
|
|
|
|
|
|
|