clash-controller 0.2.5__tar.gz → 0.2.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clash_controller-0.2.5 → clash_controller-0.2.7}/PKG-INFO +23 -4
- {clash_controller-0.2.5 → clash_controller-0.2.7}/README.md +22 -3
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/api.py +170 -31
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/app.tcss +29 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/config.py +8 -4
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/deploy.py +50 -13
- clash_controller-0.2.7/clash_controller/elevated_helper.py +36 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/main.py +9 -0
- clash_controller-0.2.7/clash_controller/platform_utils.py +631 -0
- clash_controller-0.2.7/clash_controller/proxies.py +238 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/config.py +39 -4
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/home.py +10 -5
- clash_controller-0.2.7/clash_controller/screens/nodes.py +617 -0
- clash_controller-0.2.7/clash_controller/screens/overview.py +565 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/profiles.py +15 -4
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/ssh.py +75 -29
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/widgets.py +9 -2
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/PKG-INFO +23 -4
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/SOURCES.txt +8 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/pyproject.toml +1 -1
- clash_controller-0.2.5/clash_controller/screens/overview.py +0 -234
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/app.py +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/settings.py +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/ui.py +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.5 → clash_controller-0.2.7}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clash_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: A Textual TUI for managing Clash proxy instances
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -38,7 +38,8 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
38
38
|
- 支持 HTTP 与 Unix Domain Socket 连接。
|
|
39
39
|
- 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
|
|
40
40
|
- **实时监控面板**:
|
|
41
|
-
-
|
|
41
|
+
- **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量;连接表支持关键字(地址 / 规则 / 节点 / 进程)、节点、隐藏 DIRECT 筛选,以及按默认(API 原始顺序)、上/下载速度、流量、时长、地址、规则、节点排序(可切换升/降序)。
|
|
42
|
+
- **节点管理**:按策略组浏览组内成员(节点 / 嵌套组 / DIRECT / REJECT),显示服务端记录的最新延迟、失败与未测状态;支持单节点测速、整组测速、在组内手动指定当前节点,以及对 URLTest / 故障转移组恢复自动(清除固定选择)。provider 节点测速自动走 provider 作用域接口。
|
|
42
43
|
- **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
|
|
43
44
|
- **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
|
|
44
45
|
|
|
@@ -51,9 +52,19 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
51
52
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
|
|
52
53
|
| | `Ctrl+R` | 刷新列表 |
|
|
53
54
|
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
54
|
-
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
55
|
+
| **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达概览 / 节点 / 配置管理 / 设置 / 切换端点 |
|
|
55
56
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
56
|
-
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `
|
|
57
|
+
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `5`) |
|
|
58
|
+
| **概览** | `↑` / `↓` / `PgUp` / `PgDn` | 滚动连接表 |
|
|
59
|
+
| | `/` | 聚焦筛选输入框(地址 / 规则 / 节点 / 进程) |
|
|
60
|
+
| | `Tab` / `Shift+Tab` | 在筛选框、节点 / 排序下拉、隐藏 DIRECT 开关间轮切 |
|
|
61
|
+
| | `Ctrl+R` | 刷新(焦点在输入框时让位给文本编辑) |
|
|
62
|
+
| **节点** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 在组内指定所选节点 |
|
|
63
|
+
| | `Ctrl+T` | 测所选节点延迟 |
|
|
64
|
+
| | `Ctrl+G` | 测整组延迟(URLTest / 故障转移组会清除固定选择,先确认) |
|
|
65
|
+
| | `Ctrl+U` | 恢复自动(清除 URLTest / 故障转移组的固定选择) |
|
|
66
|
+
| | `/` | 聚焦节点名称筛选框 |
|
|
67
|
+
| | `Ctrl+R` | 刷新策略组与延迟 |
|
|
57
68
|
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
58
69
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
59
70
|
| | `Ctrl+R` | 刷新 |
|
|
@@ -85,6 +96,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
|
|
|
85
96
|
clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
|
|
86
97
|
```
|
|
87
98
|
|
|
99
|
+
## Windows 说明
|
|
100
|
+
|
|
101
|
+
- Clash 配置目录默认从 `%USERPROFILE%\.config\clash` / `%USERPROFILE%\.config\mihomo` 探测,也可在端点里手动填写(支持 `~`、`%VAR%`)。
|
|
102
|
+
- 若配置目录位于 `Program Files`、`ProgramData` 等受保护位置,部署时会弹出 UAC 请求管理员写入;请在端点设置中保持“管理员写入 (UAC)”开启,并在弹出的对话框中点击“是”。提权后由程序自身的管理员进程完成写入(不借助 PowerShell,避免被安全软件当成攻击行为掐掉)。部署界面会显示当前阶段(请求授权 / 已授权正在写入),详情同时写入“应用日志”页。
|
|
103
|
+
- 写入是否成功以 `config.yaml` 的实际内容为准:只要目标文件已替换为拉取到的配置就判定成功,不会因提权进程未回写确认文件而误报“等待 UAC 超时”。替换走系统 `ReplaceFileW`,会保留 `config.yaml` 原有的访问权限(ACL),原配置备份为同目录的 `config.yaml.bak`。
|
|
104
|
+
- 如果当前进程已经是管理员权限、或账户不属于管理员组,程序不会白弹 UAC,而是直接说明真正的原因(只读属性 / ACL 拒绝 / 文件被占用,或建议把配置目录放到用户目录)。
|
|
105
|
+
- 订阅与本地配置会按 UTF-8 / GB18030 / 带 BOM 的 UTF-16 自动识别,并统一以 UTF-8(无 BOM、LF 换行)写入,不会再把中文变成乱码;控制 API 响应体同样按字节解码,中文节点名不受 `Content-Type` 缺 charset 影响。
|
|
106
|
+
|
|
88
107
|
## 开发者安装
|
|
89
108
|
|
|
90
109
|
```bash
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
- 支持 HTTP 与 Unix Domain Socket 连接。
|
|
12
12
|
- 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
|
|
13
13
|
- **实时监控面板**:
|
|
14
|
-
-
|
|
14
|
+
- **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量;连接表支持关键字(地址 / 规则 / 节点 / 进程)、节点、隐藏 DIRECT 筛选,以及按默认(API 原始顺序)、上/下载速度、流量、时长、地址、规则、节点排序(可切换升/降序)。
|
|
15
|
+
- **节点管理**:按策略组浏览组内成员(节点 / 嵌套组 / DIRECT / REJECT),显示服务端记录的最新延迟、失败与未测状态;支持单节点测速、整组测速、在组内手动指定当前节点,以及对 URLTest / 故障转移组恢复自动(清除固定选择)。provider 节点测速自动走 provider 作用域接口。
|
|
15
16
|
- **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
|
|
16
17
|
- **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
|
|
17
18
|
|
|
@@ -24,9 +25,19 @@
|
|
|
24
25
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
|
|
25
26
|
| | `Ctrl+R` | 刷新列表 |
|
|
26
27
|
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
27
|
-
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
28
|
+
| **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达概览 / 节点 / 配置管理 / 设置 / 切换端点 |
|
|
28
29
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
29
|
-
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `
|
|
30
|
+
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `5`) |
|
|
31
|
+
| **概览** | `↑` / `↓` / `PgUp` / `PgDn` | 滚动连接表 |
|
|
32
|
+
| | `/` | 聚焦筛选输入框(地址 / 规则 / 节点 / 进程) |
|
|
33
|
+
| | `Tab` / `Shift+Tab` | 在筛选框、节点 / 排序下拉、隐藏 DIRECT 开关间轮切 |
|
|
34
|
+
| | `Ctrl+R` | 刷新(焦点在输入框时让位给文本编辑) |
|
|
35
|
+
| **节点** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 在组内指定所选节点 |
|
|
36
|
+
| | `Ctrl+T` | 测所选节点延迟 |
|
|
37
|
+
| | `Ctrl+G` | 测整组延迟(URLTest / 故障转移组会清除固定选择,先确认) |
|
|
38
|
+
| | `Ctrl+U` | 恢复自动(清除 URLTest / 故障转移组的固定选择) |
|
|
39
|
+
| | `/` | 聚焦节点名称筛选框 |
|
|
40
|
+
| | `Ctrl+R` | 刷新策略组与延迟 |
|
|
30
41
|
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
31
42
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
32
43
|
| | `Ctrl+R` | 刷新 |
|
|
@@ -58,6 +69,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
|
|
|
58
69
|
clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
|
|
59
70
|
```
|
|
60
71
|
|
|
72
|
+
## Windows 说明
|
|
73
|
+
|
|
74
|
+
- Clash 配置目录默认从 `%USERPROFILE%\.config\clash` / `%USERPROFILE%\.config\mihomo` 探测,也可在端点里手动填写(支持 `~`、`%VAR%`)。
|
|
75
|
+
- 若配置目录位于 `Program Files`、`ProgramData` 等受保护位置,部署时会弹出 UAC 请求管理员写入;请在端点设置中保持“管理员写入 (UAC)”开启,并在弹出的对话框中点击“是”。提权后由程序自身的管理员进程完成写入(不借助 PowerShell,避免被安全软件当成攻击行为掐掉)。部署界面会显示当前阶段(请求授权 / 已授权正在写入),详情同时写入“应用日志”页。
|
|
76
|
+
- 写入是否成功以 `config.yaml` 的实际内容为准:只要目标文件已替换为拉取到的配置就判定成功,不会因提权进程未回写确认文件而误报“等待 UAC 超时”。替换走系统 `ReplaceFileW`,会保留 `config.yaml` 原有的访问权限(ACL),原配置备份为同目录的 `config.yaml.bak`。
|
|
77
|
+
- 如果当前进程已经是管理员权限、或账户不属于管理员组,程序不会白弹 UAC,而是直接说明真正的原因(只读属性 / ACL 拒绝 / 文件被占用,或建议把配置目录放到用户目录)。
|
|
78
|
+
- 订阅与本地配置会按 UTF-8 / GB18030 / 带 BOM 的 UTF-16 自动识别,并统一以 UTF-8(无 BOM、LF 换行)写入,不会再把中文变成乱码;控制 API 响应体同样按字节解码,中文节点名不受 `Content-Type` 缺 charset 影响。
|
|
79
|
+
|
|
61
80
|
## 开发者安装
|
|
62
81
|
|
|
63
82
|
```bash
|
|
@@ -1,12 +1,39 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from urllib.parse import quote
|
|
2
3
|
|
|
3
4
|
import requests
|
|
4
5
|
|
|
6
|
+
BODY_ENCODINGS = ("utf-8-sig", "gb18030")
|
|
7
|
+
|
|
5
8
|
|
|
6
9
|
def _noop(_message: str) -> None:
|
|
7
10
|
pass
|
|
8
11
|
|
|
9
12
|
|
|
13
|
+
def _decode_body(response) -> str:
|
|
14
|
+
"""按字节显式解码响应体。
|
|
15
|
+
|
|
16
|
+
不要直接用 ``response.text``:中间层(反向代理 / CDN)可能把 Content-Type 改成
|
|
17
|
+
没有 charset 的 ``text/*``,requests 会强制按 ISO-8859-1 解码,中文节点名直接乱码。
|
|
18
|
+
"""
|
|
19
|
+
data = response.content or b""
|
|
20
|
+
for encoding in BODY_ENCODINGS:
|
|
21
|
+
try:
|
|
22
|
+
return data.decode(encoding)
|
|
23
|
+
except UnicodeDecodeError:
|
|
24
|
+
continue
|
|
25
|
+
return data.decode("utf-8", errors="replace")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_body(response):
|
|
29
|
+
"""解析响应体:优先按 JSON,失败则退回解码后的文本。"""
|
|
30
|
+
text = _decode_body(response)
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(text)
|
|
33
|
+
except ValueError:
|
|
34
|
+
return text
|
|
35
|
+
|
|
36
|
+
|
|
10
37
|
class ClashAPI:
|
|
11
38
|
# Special sentinel returned when the request was sent but the connection
|
|
12
39
|
# was interrupted (server closed connection / connection reset) and the
|
|
@@ -54,7 +81,10 @@ class ClashAPI:
|
|
|
54
81
|
except Exception:
|
|
55
82
|
return False
|
|
56
83
|
|
|
57
|
-
def _request(
|
|
84
|
+
def _request(
|
|
85
|
+
self, method, endpoint, params=None, json_data=None, stream=False,
|
|
86
|
+
timeout: float | None = None, retry: bool = True,
|
|
87
|
+
):
|
|
58
88
|
"""Helper method to make requests to the API.
|
|
59
89
|
|
|
60
90
|
Behavior changes:
|
|
@@ -62,8 +92,10 @@ class ClashAPI:
|
|
|
62
92
|
- Treats PUT /configs ReadTimeout conservatively as success (server-side reload may have completed).
|
|
63
93
|
- For streaming requests (stream=True) returns the raw response object so callers can iterate.
|
|
64
94
|
- When debug=True, emits basic request/response information via the log callback.
|
|
95
|
+
- ``timeout``(秒)覆盖本次请求超时;``retry=False`` 时 ReadTimeout 不重试(测速不能重复打内核)。
|
|
65
96
|
"""
|
|
66
97
|
url = f"{self.base_url}{endpoint}"
|
|
98
|
+
effective_timeout = self.timeout if timeout is None else timeout
|
|
67
99
|
|
|
68
100
|
def do_request(timeout_val):
|
|
69
101
|
return self.session.request(
|
|
@@ -79,11 +111,11 @@ class ClashAPI:
|
|
|
79
111
|
try:
|
|
80
112
|
if self.debug:
|
|
81
113
|
try:
|
|
82
|
-
self._log(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={
|
|
114
|
+
self._log(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={effective_timeout}")
|
|
83
115
|
except Exception:
|
|
84
116
|
pass
|
|
85
117
|
|
|
86
|
-
response = do_request(
|
|
118
|
+
response = do_request(effective_timeout)
|
|
87
119
|
|
|
88
120
|
# If caller requested streaming, return the raw response so the caller can iterate
|
|
89
121
|
if stream:
|
|
@@ -100,13 +132,10 @@ class ClashAPI:
|
|
|
100
132
|
return {"status": "success", "status_code": response.status_code}, None
|
|
101
133
|
|
|
102
134
|
# Try JSON first, fall back to text
|
|
103
|
-
|
|
104
|
-
parsed = response.json()
|
|
105
|
-
except Exception:
|
|
106
|
-
parsed = response.text
|
|
135
|
+
parsed = _parse_body(response)
|
|
107
136
|
|
|
108
137
|
if self.debug:
|
|
109
|
-
raw = (response
|
|
138
|
+
raw = _decode_body(response)[:4096]
|
|
110
139
|
self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
111
140
|
|
|
112
141
|
# If the parsed result is a dict, inject status_code for caller convenience
|
|
@@ -116,6 +145,11 @@ class ClashAPI:
|
|
|
116
145
|
return {"body": parsed, "status_code": response.status_code}, None
|
|
117
146
|
|
|
118
147
|
except requests.exceptions.ReadTimeout as e:
|
|
148
|
+
# 测速等场景不能重试(会重复打内核),由调用方显式关闭。
|
|
149
|
+
if not retry:
|
|
150
|
+
if self.debug:
|
|
151
|
+
self._log("[DEBUG] ReadTimeout occurred, retry disabled")
|
|
152
|
+
return None, "请求超时"
|
|
119
153
|
# Retry once with an increased timeout. If retry fails due to
|
|
120
154
|
# connection problems and the API is local, return the sentinel so
|
|
121
155
|
# caller can ask user to verify the outcome manually.
|
|
@@ -137,9 +171,13 @@ class ClashAPI:
|
|
|
137
171
|
return {"status": "success"}, None
|
|
138
172
|
|
|
139
173
|
if self.debug:
|
|
140
|
-
raw = response
|
|
174
|
+
raw = _decode_body(response)[:4096]
|
|
141
175
|
self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
142
|
-
|
|
176
|
+
parsed = _parse_body(response)
|
|
177
|
+
if isinstance(parsed, dict):
|
|
178
|
+
parsed.setdefault('status_code', response.status_code)
|
|
179
|
+
return parsed, None
|
|
180
|
+
return {"body": parsed, "status_code": response.status_code}, None
|
|
143
181
|
|
|
144
182
|
except requests.exceptions.RequestException as exc:
|
|
145
183
|
if self.debug:
|
|
@@ -172,16 +210,13 @@ class ClashAPI:
|
|
|
172
210
|
resp = getattr(e, 'response', None)
|
|
173
211
|
if resp is not None:
|
|
174
212
|
try:
|
|
175
|
-
body = resp
|
|
213
|
+
body = _parse_body(resp)
|
|
176
214
|
except Exception:
|
|
177
|
-
|
|
178
|
-
body = resp.text
|
|
179
|
-
except Exception:
|
|
180
|
-
body = ''
|
|
215
|
+
body = ''
|
|
181
216
|
|
|
182
217
|
if self.debug:
|
|
183
218
|
try:
|
|
184
|
-
self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp
|
|
219
|
+
self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={_decode_body(resp)[:4096]}")
|
|
185
220
|
except Exception:
|
|
186
221
|
self._log(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
|
|
187
222
|
|
|
@@ -267,16 +302,24 @@ class ClashAPI:
|
|
|
267
302
|
|
|
268
303
|
def get_proxy(self, name: str):
|
|
269
304
|
"""Get a specific proxy or group's information."""
|
|
270
|
-
return self._request('GET', f'/proxies/{quote(name)}')
|
|
305
|
+
return self._request('GET', f'/proxies/{quote(name, safe="")}')
|
|
271
306
|
|
|
272
307
|
def select_proxy_in_group(self, group_name: str, proxy_name: str):
|
|
273
308
|
"""Select a proxy for a specific group."""
|
|
274
|
-
return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
|
|
309
|
+
return self._request('PUT', f'/proxies/{quote(group_name, safe="")}', json_data={"name": proxy_name})
|
|
275
310
|
|
|
276
|
-
def test_proxy_delay(self, name: str, url: str, timeout: int):
|
|
277
|
-
"""Test a proxy's delay
|
|
311
|
+
def test_proxy_delay(self, name: str, url: str, timeout: int = 5000, expected=None):
|
|
312
|
+
"""Test a proxy's delay (timeout 单位为毫秒,内核限制 <= 32767)。"""
|
|
278
313
|
params = {'url': url, 'timeout': str(timeout)}
|
|
279
|
-
|
|
314
|
+
if expected:
|
|
315
|
+
params['expected'] = str(expected)
|
|
316
|
+
return self._request(
|
|
317
|
+
'GET',
|
|
318
|
+
f'/proxies/{quote(name, safe="")}/delay',
|
|
319
|
+
params=params,
|
|
320
|
+
timeout=timeout / 1000 + 10,
|
|
321
|
+
retry=False,
|
|
322
|
+
)
|
|
280
323
|
|
|
281
324
|
def get_groups(self):
|
|
282
325
|
"""Get policy groups."""
|
|
@@ -284,16 +327,31 @@ class ClashAPI:
|
|
|
284
327
|
|
|
285
328
|
def get_group(self, name: str):
|
|
286
329
|
"""Get a specific policy group."""
|
|
287
|
-
return self._request('GET', f'/group/{quote(name)}')
|
|
330
|
+
return self._request('GET', f'/group/{quote(name, safe="")}')
|
|
288
331
|
|
|
289
332
|
def reset_auto_group_selection(self, name: str):
|
|
290
|
-
"""Reset the fixed selection of an auto policy group.
|
|
291
|
-
return self._request('DELETE', f'/group/{quote(name)}')
|
|
333
|
+
"""Reset the fixed selection of an auto policy group.
|
|
292
334
|
|
|
293
|
-
|
|
294
|
-
|
|
335
|
+
内核没有 ``DELETE /group/{name}``;清除 URLTest / Fallback 的 fixed 走
|
|
336
|
+
``DELETE /proxies/{name}``(仅 SelectAble 且非 Selector 的组返回 204)。
|
|
337
|
+
"""
|
|
338
|
+
return self._request('DELETE', f'/proxies/{quote(name, safe="")}')
|
|
339
|
+
|
|
340
|
+
def test_group_delay(self, name: str, url: str, timeout: int = 5000, expected=None):
|
|
341
|
+
"""Test the delay of all proxies in a group.
|
|
342
|
+
|
|
343
|
+
注意:该端点会先清除 URLTest / Fallback 的 fixed,再并发测试成员。
|
|
344
|
+
"""
|
|
295
345
|
params = {'url': url, 'timeout': str(timeout)}
|
|
296
|
-
|
|
346
|
+
if expected:
|
|
347
|
+
params['expected'] = str(expected)
|
|
348
|
+
return self._request(
|
|
349
|
+
'GET',
|
|
350
|
+
f'/group/{quote(name, safe="")}/delay',
|
|
351
|
+
params=params,
|
|
352
|
+
timeout=timeout / 1000 + 10,
|
|
353
|
+
retry=False,
|
|
354
|
+
)
|
|
297
355
|
|
|
298
356
|
# === Providers ===
|
|
299
357
|
def get_proxy_providers(self):
|
|
@@ -302,15 +360,32 @@ class ClashAPI:
|
|
|
302
360
|
|
|
303
361
|
def get_proxy_provider(self, name: str):
|
|
304
362
|
"""Get a specific proxy provider."""
|
|
305
|
-
return self._request('GET', f'/providers/proxies/{quote(name)}')
|
|
363
|
+
return self._request('GET', f'/providers/proxies/{quote(name, safe="")}')
|
|
306
364
|
|
|
307
365
|
def update_proxy_provider(self, name: str):
|
|
308
366
|
"""Update a proxy provider."""
|
|
309
|
-
return self._request('PUT', f'/providers/proxies/{quote(name)}')
|
|
367
|
+
return self._request('PUT', f'/providers/proxies/{quote(name, safe="")}')
|
|
310
368
|
|
|
311
369
|
def healthcheck_proxy_provider(self, name: str):
|
|
312
370
|
"""Trigger a health check for a proxy provider."""
|
|
313
|
-
return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
|
|
371
|
+
return self._request('GET', f'/providers/proxies/{quote(name, safe="")}/healthcheck')
|
|
372
|
+
|
|
373
|
+
def test_provider_proxy_delay(self, provider: str, node: str, url: str, timeout: int = 5000, expected=None):
|
|
374
|
+
"""Test a single provider node's delay.
|
|
375
|
+
|
|
376
|
+
provider 节点不在 ``/proxies`` 顶层字典里,单测必须走 provider 作用域
|
|
377
|
+
(``/providers/proxies/{provider}/{node}/healthcheck``),否则 404。
|
|
378
|
+
"""
|
|
379
|
+
params = {'url': url, 'timeout': str(timeout)}
|
|
380
|
+
if expected:
|
|
381
|
+
params['expected'] = str(expected)
|
|
382
|
+
return self._request(
|
|
383
|
+
'GET',
|
|
384
|
+
f'/providers/proxies/{quote(provider, safe="")}/{quote(node, safe="")}/healthcheck',
|
|
385
|
+
params=params,
|
|
386
|
+
timeout=timeout / 1000 + 10,
|
|
387
|
+
retry=False,
|
|
388
|
+
)
|
|
314
389
|
|
|
315
390
|
def get_rule_providers(self):
|
|
316
391
|
"""Get all rule providers."""
|
|
@@ -318,7 +393,7 @@ class ClashAPI:
|
|
|
318
393
|
|
|
319
394
|
def update_rule_provider(self, name: str):
|
|
320
395
|
"""Update a rule provider."""
|
|
321
|
-
return self._request('PUT', f'/providers/rules/{quote(name)}')
|
|
396
|
+
return self._request('PUT', f'/providers/rules/{quote(name, safe="")}')
|
|
322
397
|
|
|
323
398
|
def update_all_providers(self):
|
|
324
399
|
"""更新所有代理合集 (proxy-providers) 与规则合集 (rule-providers)。
|
|
@@ -476,6 +551,70 @@ def interpret_api_result(action_desc: str, result, error) -> tuple[str, str]:
|
|
|
476
551
|
return (f"{action_desc}失败:{error}", "err")
|
|
477
552
|
|
|
478
553
|
|
|
554
|
+
DELAY_OK = "ok"
|
|
555
|
+
DELAY_TIMEOUT = "timeout"
|
|
556
|
+
DELAY_ERROR = "error"
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _delay_error_kind(error) -> str | None:
|
|
560
|
+
"""把传输层错误归一化为 timeout / error;无错误返回 None。"""
|
|
561
|
+
if error == ClashAPI.SENT_BUT_DISCONNECTED:
|
|
562
|
+
return DELAY_TIMEOUT
|
|
563
|
+
if not error:
|
|
564
|
+
return None
|
|
565
|
+
low = str(error).lower()
|
|
566
|
+
if "timeout" in low or "timed out" in low or "超时" in low:
|
|
567
|
+
return DELAY_TIMEOUT
|
|
568
|
+
return DELAY_ERROR
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def interpret_delay_result(result, error) -> tuple[str, int | None]:
|
|
572
|
+
"""解释单节点测速结果:``(DELAY_OK|DELAY_TIMEOUT|DELAY_ERROR, 毫秒或 None)``。
|
|
573
|
+
|
|
574
|
+
内核把 504 当正常响应体返回(ClashAPI 不抛异常),因此必须读 ``status_code``。
|
|
575
|
+
"""
|
|
576
|
+
kind = _delay_error_kind(error)
|
|
577
|
+
if kind is not None:
|
|
578
|
+
return kind, None
|
|
579
|
+
if not isinstance(result, dict):
|
|
580
|
+
return DELAY_ERROR, None
|
|
581
|
+
status_code = result.get("status_code")
|
|
582
|
+
if status_code == 504:
|
|
583
|
+
return DELAY_TIMEOUT, None
|
|
584
|
+
if status_code in (400, 404, 503):
|
|
585
|
+
return DELAY_ERROR, None
|
|
586
|
+
if status_code != 200:
|
|
587
|
+
return DELAY_ERROR, None
|
|
588
|
+
delay = result.get("delay")
|
|
589
|
+
if isinstance(delay, bool) or not isinstance(delay, int) or delay <= 0:
|
|
590
|
+
return DELAY_ERROR, None
|
|
591
|
+
return DELAY_OK, delay
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def interpret_group_delay_result(result, error) -> tuple[str, dict[str, int]]:
|
|
595
|
+
"""解释整组测速结果:``(状态, {节点名: 毫秒})``;只有整数项被保留。"""
|
|
596
|
+
kind = _delay_error_kind(error)
|
|
597
|
+
if kind is not None:
|
|
598
|
+
return kind, {}
|
|
599
|
+
if not isinstance(result, dict):
|
|
600
|
+
return DELAY_ERROR, {}
|
|
601
|
+
status_code = result.get("status_code")
|
|
602
|
+
if status_code == 504:
|
|
603
|
+
return DELAY_TIMEOUT, {}
|
|
604
|
+
if status_code in (400, 404, 503):
|
|
605
|
+
return DELAY_ERROR, {}
|
|
606
|
+
if status_code != 200:
|
|
607
|
+
return DELAY_ERROR, {}
|
|
608
|
+
mapping: dict[str, int] = {}
|
|
609
|
+
for key, value in result.items():
|
|
610
|
+
if key == "status_code":
|
|
611
|
+
continue
|
|
612
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
613
|
+
continue
|
|
614
|
+
mapping[str(key)] = value
|
|
615
|
+
return DELAY_OK, mapping
|
|
616
|
+
|
|
617
|
+
|
|
479
618
|
def _brief(raw, limit: int = 120) -> str:
|
|
480
619
|
"""把任意响应体压成一行短文本,供提示栏/通知展示。"""
|
|
481
620
|
if raw is None:
|
|
@@ -112,6 +112,35 @@ Button:disabled {
|
|
|
112
112
|
margin-left: 2;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/* 概览页连接筛选:Select 显式定宽(否则内部 1fr 标签会吃掉整行),Switch 压成单行 */
|
|
116
|
+
#cn-node {
|
|
117
|
+
width: 20;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#cn-sort {
|
|
121
|
+
width: 14;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* 节点页:策略组 / 排序下拉显式定宽(Select 内部 1fr 标签会吃掉整行) */
|
|
125
|
+
#nd-group {
|
|
126
|
+
width: 24;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#nd-sort {
|
|
130
|
+
width: 12;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.filter-row Switch {
|
|
134
|
+
height: 1;
|
|
135
|
+
border: none;
|
|
136
|
+
padding: 0 1;
|
|
137
|
+
margin-left: 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.filter-row Switch:focus {
|
|
141
|
+
background-tint: $foreground 8%;
|
|
142
|
+
}
|
|
143
|
+
|
|
115
144
|
/* ---------- 表格 ---------- */
|
|
116
145
|
.tbl {
|
|
117
146
|
height: 1fr;
|
|
@@ -31,9 +31,13 @@ def _load_json(path: Path, log: Callable[[str], None]) -> list:
|
|
|
31
31
|
if not path.exists():
|
|
32
32
|
return []
|
|
33
33
|
try:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
raw = path.read_bytes()
|
|
35
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
36
|
+
text = raw.decode("utf-16")
|
|
37
|
+
else:
|
|
38
|
+
text = raw.decode("utf-8-sig")
|
|
39
|
+
data = json.loads(text)
|
|
40
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e:
|
|
37
41
|
log(f"无法读取或解析配置文件 {path}: {e}")
|
|
38
42
|
return []
|
|
39
43
|
return data if isinstance(data, list) else []
|
|
@@ -41,7 +45,7 @@ def _load_json(path: Path, log: Callable[[str], None]) -> list:
|
|
|
41
45
|
|
|
42
46
|
def _save_json(path: Path, data: list) -> Path:
|
|
43
47
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
-
with open(path, "w", encoding="utf-8") as f:
|
|
48
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
45
49
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
46
50
|
return path
|
|
47
51
|
|
|
@@ -8,8 +8,11 @@ from urllib.parse import urlparse
|
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
|
+
from .platform_utils import default_local_config_dir, expand_local_path
|
|
11
12
|
from .ssh import fetch_remote_file_meta_and_content
|
|
12
13
|
|
|
14
|
+
CONFIG_ENCODINGS = ("utf-8-sig", "gb18030")
|
|
15
|
+
|
|
13
16
|
|
|
14
17
|
def _noop(_message: str) -> None:
|
|
15
18
|
pass
|
|
@@ -40,25 +43,59 @@ def default_ssh_host(url: str) -> str:
|
|
|
40
43
|
return '127.0.0.1'
|
|
41
44
|
|
|
42
45
|
|
|
43
|
-
def
|
|
44
|
-
"""
|
|
46
|
+
def decode_config_bytes(data: bytes) -> str:
|
|
47
|
+
"""把订阅 / 本地配置字节解码为文本。
|
|
48
|
+
|
|
49
|
+
不能用 ``response.text``:requests 对没有 charset 的 ``text/*`` 会强制按
|
|
50
|
+
ISO-8859-1 解码,中文会变成乱码。这里显式按 UTF-8(去 BOM)→ GB18030
|
|
51
|
+
的顺序尝试,覆盖国内机场常见的两种编码;带 BOM 的 UTF-16(Windows 记事本
|
|
52
|
+
“Unicode”格式)也能识别。
|
|
53
|
+
"""
|
|
54
|
+
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
55
|
+
# Windows 记事本“Unicode”保存的是带 BOM 的 UTF-16。
|
|
56
|
+
try:
|
|
57
|
+
return data.decode("utf-16")
|
|
58
|
+
except UnicodeDecodeError:
|
|
59
|
+
pass
|
|
60
|
+
for encoding in CONFIG_ENCODINGS:
|
|
61
|
+
try:
|
|
62
|
+
return data.decode(encoding)
|
|
63
|
+
except UnicodeDecodeError:
|
|
64
|
+
continue
|
|
65
|
+
raise RuntimeError("配置内容不是有效的 UTF-8 / GB18030 文本,无法部署")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_config_text(text: str) -> str:
|
|
69
|
+
"""统一为 LF 换行,避免 Windows 文本模式写出的 CRLF 造成哈希反复不一致。"""
|
|
70
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def canonical_config_bytes(data: bytes) -> bytes:
|
|
74
|
+
"""把配置规范化为 UTF-8(无 BOM、LF 换行)字节,用于落盘与哈希比对。"""
|
|
75
|
+
return normalize_config_text(decode_config_bytes(data)).encode("utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_last_modified(value: str | None) -> datetime | None:
|
|
79
|
+
if not value:
|
|
80
|
+
return None
|
|
45
81
|
try:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
last_modified = response.headers.get('Last-Modified')
|
|
49
|
-
if last_modified:
|
|
50
|
-
return parsedate_to_datetime(last_modified)
|
|
51
|
-
except requests.exceptions.RequestException:
|
|
82
|
+
return parsedate_to_datetime(value)
|
|
83
|
+
except (TypeError, ValueError, OverflowError):
|
|
52
84
|
return None
|
|
53
|
-
return None
|
|
54
85
|
|
|
55
86
|
|
|
56
87
|
def fetch_remote_config(url: str) -> tuple[str, str, datetime | None]:
|
|
57
|
-
"""拉取远端配置,返回 (
|
|
88
|
+
"""拉取远端配置,返回 (规范化文本, sha256, last_modified)。失败抛异常。
|
|
89
|
+
|
|
90
|
+
直接读取 ``response.content`` 并显式解码,最后统一换行;哈希基于规范化后的
|
|
91
|
+
UTF-8 字节,保证与本地落盘内容可比。Last-Modified 取自本次 GET 响应,避免
|
|
92
|
+
额外 HEAD 请求(部分订阅服务器不支持 HEAD)。
|
|
93
|
+
"""
|
|
58
94
|
response = requests.get(url, timeout=30)
|
|
59
95
|
response.raise_for_status()
|
|
60
|
-
|
|
61
|
-
|
|
96
|
+
payload = canonical_config_bytes(response.content)
|
|
97
|
+
text = payload.decode('utf-8')
|
|
98
|
+
return text, compute_sha256(payload), parse_last_modified(response.headers.get('Last-Modified'))
|
|
62
99
|
|
|
63
100
|
|
|
64
101
|
def read_current_config(
|
|
@@ -70,7 +107,7 @@ def read_current_config(
|
|
|
70
107
|
if profile.get('endpoint_type', 'local') == 'remote':
|
|
71
108
|
return fetch_remote_file_meta_and_content(profile, log=log, debug=debug)
|
|
72
109
|
|
|
73
|
-
config_dir = profile.get('config_directory'
|
|
110
|
+
config_dir = expand_local_path(profile.get('config_directory') or default_local_config_dir())
|
|
74
111
|
config_path = os.path.join(config_dir, 'config.yaml')
|
|
75
112
|
if not os.path.exists(config_path):
|
|
76
113
|
raise RuntimeError(f"local config file not found: {config_path}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Windows 提权写入 helper:由 ``platform_utils`` 通过 UAC 以管理员令牌再启动本包执行。
|
|
2
|
+
|
|
3
|
+
职责刻意收窄成「读 payload → 原子写目标 → 回写状态」三件事:不读应用配置、不联网、不碰 UI,
|
|
4
|
+
所以被提权的进程能做的事非常有限,出错也只可能来自这几个路径。
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .platform_utils import HELPER_FLAG, elevated_apply_payload, write_status
|
|
10
|
+
|
|
11
|
+
EXIT_OK = 0
|
|
12
|
+
EXIT_FAILED = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog=f"clashctl {HELPER_FLAG}",
|
|
18
|
+
description="以管理员权限写入 Clash 配置文件(由 clashctl 内部调用,无需手动执行)",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("payload", help="待写入内容所在的临时文件")
|
|
21
|
+
parser.add_argument("target", help="目标 config.yaml 的绝对路径")
|
|
22
|
+
parser.add_argument("backup", help="备份文件路径")
|
|
23
|
+
parser.add_argument("status", help="回写结果的状态文件路径")
|
|
24
|
+
args = parser.parse_args(argv)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
elevated_apply_payload(args.payload, args.target, args.backup)
|
|
28
|
+
except BaseException as exc: # noqa: BLE001 # 提权进程必须把任何失败原因带回主进程
|
|
29
|
+
write_status(args.status, f"ERR {type(exc).__name__}: {exc}")
|
|
30
|
+
return EXIT_FAILED
|
|
31
|
+
write_status(args.status, "OK")
|
|
32
|
+
return EXIT_OK
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
sys.exit(main())
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from . import __version__
|
|
9
9
|
from .config import CONFIG_DIR_ENV, DEFAULT_CONFIG_DIR
|
|
10
|
+
from .platform_utils import HELPER_FLAG
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _setup_logging() -> None:
|
|
@@ -37,6 +38,14 @@ def parse_args():
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def main() -> int:
|
|
41
|
+
# 提权 helper:打包成 exe 后 sys.executable 就是本程序,UAC 提权重启时由这里分派;
|
|
42
|
+
# pip 安装则直接走 `python -m clash_controller.elevated_helper`,不经过本函数。
|
|
43
|
+
argv = sys.argv[1:]
|
|
44
|
+
if argv[:1] == [HELPER_FLAG]:
|
|
45
|
+
from .elevated_helper import main as elevated_main
|
|
46
|
+
|
|
47
|
+
return elevated_main(argv[1:])
|
|
48
|
+
|
|
40
49
|
_setup_logging()
|
|
41
50
|
args = parse_args()
|
|
42
51
|
os.environ[CONFIG_DIR_ENV] = args.dir
|