Spaces:
Paused
Paused
| #!/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 | |
| """ |