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()