clash-controller 0.2.6__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.6 → clash_controller-0.2.7}/PKG-INFO +9 -1
- {clash_controller-0.2.6 → clash_controller-0.2.7}/README.md +8 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/api.py +38 -13
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/config.py +8 -4
- {clash_controller-0.2.6 → 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.6 → 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.6 → clash_controller-0.2.7}/clash_controller/screens/config.py +39 -4
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/overview.py +1 -1
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/profiles.py +15 -4
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/ssh.py +75 -29
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/PKG-INFO +9 -1
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/SOURCES.txt +4 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/pyproject.toml +1 -1
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/app.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/app.tcss +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/proxies.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/home.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/nodes.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/settings.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/ui.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/widgets.py +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.6 → 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
|
|
@@ -96,6 +96,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
|
|
|
96
96
|
clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
|
|
97
97
|
```
|
|
98
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
|
+
|
|
99
107
|
## 开发者安装
|
|
100
108
|
|
|
101
109
|
```bash
|
|
@@ -69,6 +69,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
|
|
|
69
69
|
clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
|
|
70
70
|
```
|
|
71
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
|
+
|
|
72
80
|
## 开发者安装
|
|
73
81
|
|
|
74
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
|
|
@@ -105,13 +132,10 @@ class ClashAPI:
|
|
|
105
132
|
return {"status": "success", "status_code": response.status_code}, None
|
|
106
133
|
|
|
107
134
|
# Try JSON first, fall back to text
|
|
108
|
-
|
|
109
|
-
parsed = response.json()
|
|
110
|
-
except Exception:
|
|
111
|
-
parsed = response.text
|
|
135
|
+
parsed = _parse_body(response)
|
|
112
136
|
|
|
113
137
|
if self.debug:
|
|
114
|
-
raw = (response
|
|
138
|
+
raw = _decode_body(response)[:4096]
|
|
115
139
|
self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
116
140
|
|
|
117
141
|
# If the parsed result is a dict, inject status_code for caller convenience
|
|
@@ -147,9 +171,13 @@ class ClashAPI:
|
|
|
147
171
|
return {"status": "success"}, None
|
|
148
172
|
|
|
149
173
|
if self.debug:
|
|
150
|
-
raw = response
|
|
174
|
+
raw = _decode_body(response)[:4096]
|
|
151
175
|
self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
152
|
-
|
|
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
|
|
153
181
|
|
|
154
182
|
except requests.exceptions.RequestException as exc:
|
|
155
183
|
if self.debug:
|
|
@@ -182,16 +210,13 @@ class ClashAPI:
|
|
|
182
210
|
resp = getattr(e, 'response', None)
|
|
183
211
|
if resp is not None:
|
|
184
212
|
try:
|
|
185
|
-
body = resp
|
|
213
|
+
body = _parse_body(resp)
|
|
186
214
|
except Exception:
|
|
187
|
-
|
|
188
|
-
body = resp.text
|
|
189
|
-
except Exception:
|
|
190
|
-
body = ''
|
|
215
|
+
body = ''
|
|
191
216
|
|
|
192
217
|
if self.debug:
|
|
193
218
|
try:
|
|
194
|
-
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]}")
|
|
195
220
|
except Exception:
|
|
196
221
|
self._log(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
|
|
197
222
|
|
|
@@ -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
|