clash-linux commited on
Commit
3e9ce57
·
verified ·
1 Parent(s): 7bba2af

Upload 17 files

Browse files
Files changed (3) hide show
  1. Dockerfile +4 -1
  2. app/main.py +204 -258
  3. 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 time
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.before_first_request
44
- def initialize():
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
- # 初始化Clash管理器
67
- clash_manager = ClashManager(
68
- config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"),
69
- clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"),
70
- api_port=CLASH_API_PORT,
71
- proxy_port=CLASH_PROXY_PORT
72
- )
73
-
74
- # 启动Clash Core
75
- try:
76
- clash_manager.start_clash()
77
- logger.info("成功启动Clash Core")
78
- except Exception as e:
79
- err_msg = f"启动Clash Core失败: {str(e)}"
80
- logger.error(err_msg)
81
- initialization_error = err_msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 新增:处理没有path的根代理请求
228
- @app.route('/proxy', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
229
- def proxy_root():
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 Exception as e:
258
- logger.error(f"删除文件 {file_path} 失败: {str(e)}")
259
 
260
- # 重新初始化
261
- sub_manager = SubscriptionManager(
262
- sub_url=SUB_URL,
263
- config_path=config_path
264
- )
265
 
266
- # 加载订阅并转换为Clash配置
267
- sub_manager.load_and_convert_sub()
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
- error_msg = f"清理配置失败: {str(e)}"
288
- logger.error(error_msg)
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
- return jsonify({"success": False, "error": f"读取配置文件失败: {str(e)}"}), 500
 
 
 
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 cleanConfig() {{
348
- if (confirm('确定要清理配置并重启服务吗?')) {{
349
- fetch('/debug/clean', {{
350
- method: 'POST',
351
- headers: {{ 'X-API-Key': prompt('请输入API密钥') }}
352
- }})
353
- .then(response => response.json())
354
- .then(data => {{
355
- alert(data.success ? data.message : '失败: ' + data.error);
356
- location.reload();
357
- }})
358
- .catch(error => alert('请求失败: ' + error));
359
- }}
 
360
  }}
361
 
362
  function viewConfig() {{
 
 
 
363
  fetch('/debug/config', {{
364
- headers: {{ 'X-API-Key': prompt('请输入API密钥') }}
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
- alert('获取配置失败: ' + data.error);
 
 
 
382
  }}
383
  }})
384
- .catch(error => alert('请求失败: ' + error));
385
- }}
386
-
387
- function refreshSubscription() {{
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
- <h2>功能</h2>
407
- <ul>
408
- <li>API端点: /api/* (需要认证)</li>
409
- <li>代理端点: /proxy</li>
410
- <li>健康检查: /health</li>
411
- </ul>
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()">查看配置文件</button>
418
- <button class="button danger" onclick="cleanConfig()">清理配置并重启</button>
419
  <div id="config-content" style="margin-top: 15px;"></div>
420
  </div>
421
 
422
  <h2>帮助</h2>
423
- <p>更多信息请查看 <a href='https://github.com/yourusername/simple-clash-relay'>文档</a>。</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
- initialize()
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 兼容的版本