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