File size: 14,888 Bytes
6f17509
 
 
 
 
 
 
 
 
 
 
 
 
 
03d4200
6f17509
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b58324b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f17509
03d4200
 
 
 
 
 
 
 
 
 
 
 
 
 
6f17509
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03d4200
 
 
6f17509
03d4200
6f17509
03d4200
 
 
6f17509
03d4200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f17509
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b58324b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Clash管理器 - 负责Clash Core进程的启动、停止和API调用
"""

import os
import time
import signal
import logging
import subprocess
import requests
import json
from .debug_tools import run_debug_diagnostics

logger = logging.getLogger(__name__)

class ClashManager:
    """管理Clash Core进程和与其API的交互"""
    
    def __init__(self, config_path, clash_path, api_port=9090, proxy_port=7890):
        """
        初始化Clash管理器
        
        Args:
            config_path: Clash配置文件路径
            clash_path: Clash可执行文件路径
            api_port: Clash API监听端口
            proxy_port: Clash代理监听端口
        """
        self.config_path = os.path.abspath(config_path)
        self.clash_path = os.path.abspath(clash_path)
        self.api_port = api_port
        self.proxy_port = proxy_port
        self.api_base_url = f"http://127.0.0.1:{api_port}"
        self.clash_process = None
        
        # 确保Clash可执行文件存在
        if not os.path.exists(clash_path):
            raise FileNotFoundError(f"Clash可执行文件未找到: {clash_path}")
    
    def start_clash(self):
        """启动Clash Core进程"""
        if self.clash_process and self.clash_process.poll() is None:
            logger.info("Clash Core已经在运行中")
            return
        
        # 确保配置文件存在
        if not os.path.exists(self.config_path):
            # 检查目录是否存在
            config_dir = os.path.dirname(self.config_path)
            if not os.path.exists(config_dir):
                try:
                    os.makedirs(config_dir, exist_ok=True)
                    logger.info(f"创建了配置目录: {config_dir}")
                except Exception as e:
                    logger.error(f"无法创建配置目录: {str(e)}")
            
            # 如果目录存在但文件不存在,尝试创建一个基础配置
            try:
                with open(self.config_path, "w", encoding="utf-8") as f:
                    f.write(self._get_base_config())
                logger.info(f"创建了基础配置文件: {self.config_path}")
            except Exception as e:
                logger.error(f"无法创建配置文件: {str(e)}")
                raise FileNotFoundError(f"Clash配置文件未找到且无法创建: {self.config_path}")
        
        # 验证配置文件内容
        self._validate_config_file()
        
        # 运行调试诊断
        logger.info("运行环境诊断...")
        try:
            diagnostics = run_debug_diagnostics(self.clash_path, self.config_path)
            if not diagnostics.get("clash_binary", {}).get("is_executable", False):
                logger.warning("Clash Core不是可执行文件,尝试设置执行权限")
                try:
                    os.chmod(self.clash_path, 0o755)
                    logger.info("已设置执行权限")
                except Exception as e:
                    logger.error(f"设置执行权限失败: {str(e)}")
        except Exception as e:
            logger.error(f"运行诊断时出错: {str(e)}")
        
        # 设置Clash命令行参数 (兼容Clash Meta)
        cmd = [
            self.clash_path,
            "-f", self.config_path,
            "-d", os.path.dirname(self.config_path)
        ]
        
        # 为Clash Meta添加额外参数
        if "meta" in self.clash_path.lower():
            # Clash Meta特有参数
            cmd.extend([
                "-ext-ctl", f"127.0.0.1:{self.api_port}",
                # 如果需要可以添加更多Clash Meta特有参数
            ])
        else:
            # 原始Clash参数
            cmd.extend([
                "-ext-ctl", f"127.0.0.1:{self.api_port}",
                "-ext-ui", "" # 禁用外部UI
            ])
        
        # 启动Clash进程
        logger.info(f"正在启动Clash Core: {' '.join(cmd)}")
        
        # 首先尝试检查Clash可执行文件是否存在并且可执行
        if not os.path.exists(self.clash_path):
            raise FileNotFoundError(f"Clash可执行文件未找到: {self.clash_path}")
        
        # 检查Clash文件是否有执行权限
        try:
            # 尝试设置执行权限
            os.chmod(self.clash_path, 0o755)
            logger.info(f"已设置Clash Core的执行权限")
        except Exception as e:
            logger.warning(f"无法设置执行权限: {str(e)}, 尝试继续执行...")
        
        # 启动进程并捕获输出
        try:
            self.clash_process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                universal_newlines=True
            )
            
            # 等待Clash启动
            logger.info("等待Clash Core启动...")
            time.sleep(2)
            
            # 检查进程是否成功启动
            if self.clash_process.poll() is not None:
                # 如果进程已经退出,获取错误输出
                stdout, stderr = self.clash_process.communicate()
                logger.error(f"Clash启动失败,退出代码: {self.clash_process.returncode}")
                logger.error(f"标准输出: {stdout}")
                logger.error(f"错误输出: {stderr}")
                
                # 如果没有错误输出,尝试读取配置文件周围的日志
                if not stderr.strip():
                    try:
                        # 查看配置文件内容
                        with open(self.config_path, 'r', encoding='utf-8') as f:
                            config_content = f.read()
                        logger.error(f"配置文件内容预览 (前100字符): {config_content[:100]}")
                    except Exception as file_err:
                        logger.error(f"读取配置文件失败: {str(file_err)}")
                
                # 创建更有意义的错误消息
                error_message = stderr or stdout or "无错误信息,可能是程序无法执行或立即崩溃"
                raise RuntimeError(f"Clash启动失败: {error_message}")
            
            # 进程仍在运行,验证API是否可访问
            logger.info("进程仍在运行,验证API...")
            
            try:
                # 尝试多次连接API,有时需要一些时间
                max_retries = 3
                for i in range(max_retries):
                    try:
                        self._call_api("GET", "/version")
                        logger.info("Clash API已就绪")
                        return  # 成功,退出函数
                    except Exception as api_err:
                        if i < max_retries - 1:
                            logger.warning(f"API连接尝试 {i+1}/{max_retries} 失败: {str(api_err)},重试...")
                            time.sleep(1)
                        else:
                            # 最后一次尝试失败,捕获并记录错误
                            stdout, stderr = self.clash_process.communicate(timeout=1)
                            logger.error(f"API连接失败,标准输出: {stdout}")
                            logger.error(f"API连接失败,错误输出: {stderr}")
                            # 杀掉进程并抛出异常
                            self.stop_clash()
                            raise RuntimeError(f"无法连接到Clash API: {str(api_err)},进程输出: {stderr or stdout}")
            except Exception as e:
                # 捕获其他异常
                self.stop_clash()
                raise RuntimeError(f"验证API时出错: {str(e)}")
                
        except (subprocess.SubprocessError, OSError) as e:
            # 进程启动失败
            logger.error(f"启动Clash进程时出错: {str(e)}")
            raise RuntimeError(f"无法启动Clash进程: {str(e)}")
    
    def stop_clash(self):
        """停止Clash Core进程"""
        if self.clash_process and self.clash_process.poll() is None:
            logger.info("正在停止Clash Core...")
            
            # 尝试优雅地终止进程
            self.clash_process.terminate()
            
            # 等待进程终止
            try:
                self.clash_process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                # 如果进程没有及时终止,强制结束
                logger.warning("Clash进程未响应终止信号,强制结束...")
                self.clash_process.kill()
            
            self.clash_process = None
            logger.info("Clash Core已停止")
    
    def restart_clash(self):
        """重启Clash Core进程"""
        logger.info("正在重启Clash Core...")
        self.stop_clash()
        time.sleep(1)  # 给进程一些时间完全终止
        self.start_clash()
        logger.info("Clash Core已重启")
    
    def get_nodes(self):
        """
        获取所有可用的代理节点名称列表
        
        Returns:
            list: 节点名称列表
        """
        response = self._call_api("GET", "/proxies")
        proxies = response.get("proxies", {})
        
        # 过滤出实际的代理节点(排除DIRECT, REJECT等内置代理和策略组)
        node_names = []
        for name, proxy in proxies.items():
            if proxy.get("type") not in ["Direct", "Reject", "Selector", "URLTest", "Fallback", "LoadBalance"]:
                node_names.append(name)
        
        return node_names
    
    def switch_node(self, node_name):
        """
        切换到指定的代理节点
        
        Args:
            node_name: 节点名称
            
        Raises:
            ValueError: 如果节点名称无效
        """
        # 获取所有节点以验证目标节点存在
        all_nodes = self.get_nodes()
        if node_name not in all_nodes:
            raise ValueError(f"无效的节点名称: {node_name}")
        
        # 切换GLOBAL策略组到指定节点
        # 注意:这里假设使用GLOBAL作为顶级策略组,你可能需要根据实际配置调整
        try:
            self._call_api("PUT", "/proxies/GLOBAL", json={"name": node_name})
            logger.info(f"已切换到节点: {node_name}")
        except Exception as e:
            raise RuntimeError(f"切换节点失败: {str(e)}")
    
    def get_current_node(self):
        """
        获取当前使用的节点名称
        
        Returns:
            str: 当前节点名称
        """
        # 获取GLOBAL策略组的当前选择
        # 注意:这里假设使用GLOBAL作为顶级策略组,你可能需要根据实际配置调整
        response = self._call_api("GET", "/proxies/GLOBAL")
        return response.get("now", "unknown")
    
    def _call_api(self, method, endpoint, **kwargs):
        """
        调用Clash的API
        
        Args:
            method: HTTP方法 (GET, POST, PUT等)
            endpoint: API端点路径
            **kwargs: 传递给requests的其他参数
            
        Returns:
            dict: API响应的JSON数据
            
        Raises:
            RuntimeError: 如果API调用失败
        """
        url = f"{self.api_base_url}{endpoint}"
        logger.debug(f"调用Clash API: {method} {url}")
        
        try:
            response = requests.request(method, url, timeout=10, **kwargs)
            response.raise_for_status()
            return response.json()
        except requests.RequestException as e:
            logger.error(f"Clash API调用失败: {str(e)}")
            raise RuntimeError(f"Clash API调用失败: {str(e)}")
    
    def __del__(self):
        """析构函数,确保进程在对象销毁时被终止"""
        self.stop_clash()
    
    def _validate_config_file(self):
        """验证配置文件内容,确保基本配置存在"""
        try:
            with open(self.config_path, "r", encoding="utf-8") as f:
                content = f.read()
            
            # 检查文件大小
            if len(content) < 10:
                logger.warning(f"配置文件内容过短: {len(content)} 字节")
                # 尝试使用基础配置替换
                with open(self.config_path, "w", encoding="utf-8") as f:
                    f.write(self._get_base_config())
                logger.info("已使用基础配置替换")
                return
            
            # 检查基本配置是否存在 (非严格YAML解析,简单文本检查)
            missing_configs = []
            if "mixed-port:" not in content and "port:" not in content:
                missing_configs.append("端口配置")
            
            if "proxies:" not in content:
                missing_configs.append("代理配置")
            
            if missing_configs:
                logger.warning(f"配置文件缺少: {', '.join(missing_configs)}")
                # 如果缺少关键配置,尝试修复
                has_patch = False
                
                if "mixed-port:" not in content and "port:" not in content:
                    content = f"mixed-port: {self.proxy_port}\n" + content
                    has_patch = True
                
                if "external-controller:" not in content:
                    content = f"external-controller: 127.0.0.1:{self.api_port}\n" + content
                    has_patch = True
                
                if "proxies:" not in content:
                    content += "\nproxies:\n  - name: DIRECT\n    type: Direct\n"
                    has_patch = True
                
                if has_patch:
                    with open(self.config_path, "w", encoding="utf-8") as f:
                        f.write(content)
                    logger.info("已修补配置文件")
        
        except Exception as e:
            logger.error(f"验证配置文件时出错: {str(e)}")
            # 如果验证失败,尝试使用基础配置
            try:
                with open(self.config_path, "w", encoding="utf-8") as f:
                    f.write(self._get_base_config())
                logger.info("已使用基础配置替换")
            except Exception as write_err:
                logger.error(f"无法写入基础配置: {str(write_err)}")
    
    def _get_base_config(self):
        """返回基础Clash配置"""
        return f"""# 基础Clash配置
mixed-port: {self.proxy_port}
allow-lan: true
mode: Rule
log-level: info
external-controller: 127.0.0.1:{self.api_port}
secret: ""

proxies:
  - name: DIRECT
    type: Direct

proxy-groups:
  - name: GLOBAL
    type: select
    proxies:
      - DIRECT

rules:
  - MATCH,DIRECT
"""