clash-controller 0.0.1__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.0.1/PKG-INFO +76 -0
- clash_controller-0.0.1/README.md +60 -0
- clash_controller-0.0.1/clash_controller/__init__.py +6 -0
- clash_controller-0.0.1/clash_controller/__main__.py +9 -0
- clash_controller-0.0.1/clash_controller/api.py +197 -0
- clash_controller-0.0.1/clash_controller/cli.py +771 -0
- clash_controller-0.0.1/clash_controller.egg-info/PKG-INFO +76 -0
- clash_controller-0.0.1/clash_controller.egg-info/SOURCES.txt +12 -0
- clash_controller-0.0.1/clash_controller.egg-info/dependency_links.txt +1 -0
- clash_controller-0.0.1/clash_controller.egg-info/entry_points.txt +2 -0
- clash_controller-0.0.1/clash_controller.egg-info/requires.txt +3 -0
- clash_controller-0.0.1/clash_controller.egg-info/top_level.txt +1 -0
- clash_controller-0.0.1/pyproject.toml +32 -0
- clash_controller-0.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clash_controller
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A controller for Clash
|
|
5
|
+
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Moha-Master/Clash-Controller
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Moha-Master/Clash-Controller/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests
|
|
14
|
+
Requires-Dist: requests_unixsocket
|
|
15
|
+
Requires-Dist: InquirerPy
|
|
16
|
+
|
|
17
|
+
# Clash Controller
|
|
18
|
+
|
|
19
|
+
一个使用 `InquirerPy` 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器。它可以让您方便地通过命令行管理和监控一个或多个 `clash` 实例。
|
|
20
|
+
|
|
21
|
+
## 功能特性
|
|
22
|
+
|
|
23
|
+
- **交互式 TUI 界面**: 友好的菜单驱动操作,无需记忆复杂命令。
|
|
24
|
+
- **多端点管理**:
|
|
25
|
+
- 自动保存连接过的 Clash 端点(地址和密钥)。
|
|
26
|
+
- 启动时可从已保存列表中快速选择。
|
|
27
|
+
- 支持添加新的端点。
|
|
28
|
+
- 支持 HTTP 和 Unix Domain Socket 连接。
|
|
29
|
+
- **实时监控面板**:
|
|
30
|
+
- **概览 (Overview)**: 实时显示上/下行流量、内存使用和内核版本。
|
|
31
|
+
- **连接 (Connections)**: 实时展示当前的活动连接列表、总连接数和累计流量。
|
|
32
|
+
- **强大的设置菜单**:
|
|
33
|
+
- **模式切换**: 循环切换 `规则` / `全局` / `直连` 模式,并开关 `TUN` 模式。
|
|
34
|
+
- **重载与重启**: 独立地重载配置文件、GEO 数据库,或重启 Clash 核心。
|
|
35
|
+
- **一键升级**: 在线升级内核、UI 面板和 GEO 数据库。
|
|
36
|
+
- **查看完整配置**: 显示当前 Clash 的全部运行配置。
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install clash-controller
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 使用方法
|
|
45
|
+
|
|
46
|
+
安装后,可以通过以下命令启动程序:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
clashctl
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
程序启动后,会提示您选择一个已保存的 Clash 端点或添加一个新的端点。
|
|
53
|
+
|
|
54
|
+
## 开发者安装
|
|
55
|
+
|
|
56
|
+
如果你想要从源代码运行或者参与开发:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/Moha-Master/clash-controller.git
|
|
60
|
+
cd clash-controller
|
|
61
|
+
|
|
62
|
+
# 创建虚拟环境 (推荐)
|
|
63
|
+
python -m venv venv
|
|
64
|
+
source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
|
|
65
|
+
|
|
66
|
+
# 安装依赖
|
|
67
|
+
pip install -r requirements.txt
|
|
68
|
+
|
|
69
|
+
# 运行程序
|
|
70
|
+
python -m clash_controller
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 要求
|
|
74
|
+
|
|
75
|
+
- Python 3.8+
|
|
76
|
+
- 运行中的 Clash 实例,已开启外部控制 API
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Clash Controller
|
|
2
|
+
|
|
3
|
+
一个使用 `InquirerPy` 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器。它可以让您方便地通过命令行管理和监控一个或多个 `clash` 实例。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- **交互式 TUI 界面**: 友好的菜单驱动操作,无需记忆复杂命令。
|
|
8
|
+
- **多端点管理**:
|
|
9
|
+
- 自动保存连接过的 Clash 端点(地址和密钥)。
|
|
10
|
+
- 启动时可从已保存列表中快速选择。
|
|
11
|
+
- 支持添加新的端点。
|
|
12
|
+
- 支持 HTTP 和 Unix Domain Socket 连接。
|
|
13
|
+
- **实时监控面板**:
|
|
14
|
+
- **概览 (Overview)**: 实时显示上/下行流量、内存使用和内核版本。
|
|
15
|
+
- **连接 (Connections)**: 实时展示当前的活动连接列表、总连接数和累计流量。
|
|
16
|
+
- **强大的设置菜单**:
|
|
17
|
+
- **模式切换**: 循环切换 `规则` / `全局` / `直连` 模式,并开关 `TUN` 模式。
|
|
18
|
+
- **重载与重启**: 独立地重载配置文件、GEO 数据库,或重启 Clash 核心。
|
|
19
|
+
- **一键升级**: 在线升级内核、UI 面板和 GEO 数据库。
|
|
20
|
+
- **查看完整配置**: 显示当前 Clash 的全部运行配置。
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install clash-controller
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 使用方法
|
|
29
|
+
|
|
30
|
+
安装后,可以通过以下命令启动程序:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
clashctl
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
程序启动后,会提示您选择一个已保存的 Clash 端点或添加一个新的端点。
|
|
37
|
+
|
|
38
|
+
## 开发者安装
|
|
39
|
+
|
|
40
|
+
如果你想要从源代码运行或者参与开发:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/Moha-Master/clash-controller.git
|
|
44
|
+
cd clash-controller
|
|
45
|
+
|
|
46
|
+
# 创建虚拟环境 (推荐)
|
|
47
|
+
python -m venv venv
|
|
48
|
+
source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
|
|
49
|
+
|
|
50
|
+
# 安装依赖
|
|
51
|
+
pip install -r requirements.txt
|
|
52
|
+
|
|
53
|
+
# 运行程序
|
|
54
|
+
python -m clash_controller
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 要求
|
|
58
|
+
|
|
59
|
+
- Python 3.8+
|
|
60
|
+
- 运行中的 Clash 实例,已开启外部控制 API
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
|
|
4
|
+
class ClashAPI:
|
|
5
|
+
def __init__(self, base_url, secret=None, timeout=5, working_directory=None):
|
|
6
|
+
"""
|
|
7
|
+
Initializes the Clash API client.
|
|
8
|
+
|
|
9
|
+
:param base_url: The base URL of the Clash controller API.
|
|
10
|
+
(e.g., http://127.0.0.1:9090 or unix:///path/to/socket)
|
|
11
|
+
:param secret: The secret for API authentication.
|
|
12
|
+
:param timeout: Request timeout in seconds.
|
|
13
|
+
:param working_directory: The working directory of the Clash core, used for config paths.
|
|
14
|
+
"""
|
|
15
|
+
self.base_url = base_url
|
|
16
|
+
self.timeout = timeout
|
|
17
|
+
self.headers = {}
|
|
18
|
+
self.working_directory = working_directory
|
|
19
|
+
if secret:
|
|
20
|
+
self.headers['Authorization'] = f'Bearer {secret}'
|
|
21
|
+
|
|
22
|
+
if self.base_url.startswith('unix://'):
|
|
23
|
+
import requests_unixsocket
|
|
24
|
+
self.session = requests_unixsocket.Session()
|
|
25
|
+
encoded_path = quote(self.base_url.lstrip('unix://'), safe='')
|
|
26
|
+
self.base_url = f'http+unix://{encoded_path}'
|
|
27
|
+
else:
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
|
|
30
|
+
def _request(self, method, endpoint, params=None, json_data=None, stream=False):
|
|
31
|
+
"""Helper method to make requests to the API."""
|
|
32
|
+
url = f"{self.base_url}{endpoint}"
|
|
33
|
+
try:
|
|
34
|
+
response = self.session.request(
|
|
35
|
+
method,
|
|
36
|
+
url,
|
|
37
|
+
headers=self.headers,
|
|
38
|
+
params=params,
|
|
39
|
+
json=json_data,
|
|
40
|
+
timeout=self.timeout,
|
|
41
|
+
stream=stream
|
|
42
|
+
)
|
|
43
|
+
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
|
44
|
+
|
|
45
|
+
# For successful responses, return JSON if content exists, otherwise return a success indicator
|
|
46
|
+
if response.status_code == 204 or not response.content:
|
|
47
|
+
return {"status": "success"}, None
|
|
48
|
+
return response.json(), None
|
|
49
|
+
except requests.exceptions.RequestException as e:
|
|
50
|
+
return None, str(e)
|
|
51
|
+
|
|
52
|
+
# === Real-time Data ===
|
|
53
|
+
def get_logs_stream(self):
|
|
54
|
+
"""Get real-time logs. Returns a streaming response object for continuous reading."""
|
|
55
|
+
return self._request('GET', '/logs', stream=True)
|
|
56
|
+
|
|
57
|
+
def get_traffic_stream(self):
|
|
58
|
+
"""Get real-time traffic. Returns a streaming response object for continuous reading."""
|
|
59
|
+
return self._request('GET', '/traffic', stream=True)
|
|
60
|
+
|
|
61
|
+
def get_memory_stream(self):
|
|
62
|
+
"""Get real-time memory usage. Returns a streaming response object for continuous reading."""
|
|
63
|
+
return self._request('GET', '/memory', stream=True)
|
|
64
|
+
|
|
65
|
+
# === General Info & Control ===
|
|
66
|
+
def get_version(self):
|
|
67
|
+
"""Get Clash version."""
|
|
68
|
+
return self._request('GET', '/version')
|
|
69
|
+
|
|
70
|
+
def flush_fake_ip_cache(self):
|
|
71
|
+
"""Flush the fake-ip cache."""
|
|
72
|
+
return self._request('POST', '/cache/fakeip/flush')
|
|
73
|
+
|
|
74
|
+
def restart(self, path="", payload=""):
|
|
75
|
+
"""Restart Clash core."""
|
|
76
|
+
return self._request('POST', '/restart', json_data={"path": path, "payload": payload} or {})
|
|
77
|
+
|
|
78
|
+
# === Configs ===
|
|
79
|
+
def get_configs(self):
|
|
80
|
+
"""Get current configurations."""
|
|
81
|
+
return self._request('GET', '/configs')
|
|
82
|
+
|
|
83
|
+
def update_configs(self, partial_configs: dict):
|
|
84
|
+
"""Update configurations with a partial config."""
|
|
85
|
+
return self._request('PATCH', '/configs', json_data=partial_configs)
|
|
86
|
+
|
|
87
|
+
def reload_configs(self, path="", payload=""):
|
|
88
|
+
"""Reload configuration from path."""
|
|
89
|
+
return self._request('PUT', '/configs', params={'force': 'true'}, json_data={"path": path, "payload": payload} or {})
|
|
90
|
+
|
|
91
|
+
def set_mode(self, mode: str):
|
|
92
|
+
"""Sets the connection mode ('rule', 'global', 'direct')."""
|
|
93
|
+
return self.update_configs({"mode": mode.lower()})
|
|
94
|
+
|
|
95
|
+
def toggle_tun(self, enable: bool):
|
|
96
|
+
"""Enable or disable TUN mode."""
|
|
97
|
+
return self.update_configs({"tun": {"enable": enable}})
|
|
98
|
+
|
|
99
|
+
# === Upgrade ===
|
|
100
|
+
def upgrade_kernel(self):
|
|
101
|
+
"""Request to upgrade the Clash kernel."""
|
|
102
|
+
return self._request('POST', '/upgrade', json_data={})
|
|
103
|
+
|
|
104
|
+
def upgrade_ui(self):
|
|
105
|
+
"""Request to upgrade the external-ui."""
|
|
106
|
+
return self._request('POST', '/upgrade/ui', json_data={})
|
|
107
|
+
|
|
108
|
+
def upgrade_geo_databases(self):
|
|
109
|
+
"""Request to upgrade GEO databases from remote."""
|
|
110
|
+
return self._request('POST', '/upgrade/geo', json_data={})
|
|
111
|
+
|
|
112
|
+
def reload_geo_databases(self):
|
|
113
|
+
"""Request to reload local GEO databases."""
|
|
114
|
+
return self._request('POST', '/configs/geo', json_data={})
|
|
115
|
+
|
|
116
|
+
# === Proxies & Groups ===
|
|
117
|
+
def get_proxies(self):
|
|
118
|
+
"""Get all proxies and groups information."""
|
|
119
|
+
return self._request('GET', '/proxies')
|
|
120
|
+
|
|
121
|
+
def get_proxy(self, name: str):
|
|
122
|
+
"""Get a specific proxy or group's information."""
|
|
123
|
+
return self._request('GET', f'/proxies/{quote(name)}')
|
|
124
|
+
|
|
125
|
+
def select_proxy_in_group(self, group_name: str, proxy_name: str):
|
|
126
|
+
"""Select a proxy for a specific group."""
|
|
127
|
+
return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
|
|
128
|
+
|
|
129
|
+
def test_proxy_delay(self, name: str, url: str, timeout: int):
|
|
130
|
+
"""Test a proxy's delay."""
|
|
131
|
+
params = {'url': url, 'timeout': str(timeout)}
|
|
132
|
+
return self._request('GET', f'/proxies/{quote(name)}/delay', params=params)
|
|
133
|
+
|
|
134
|
+
def get_groups(self):
|
|
135
|
+
"""Get policy groups."""
|
|
136
|
+
return self._request('GET', '/group')
|
|
137
|
+
|
|
138
|
+
def get_group(self, name: str):
|
|
139
|
+
"""Get a specific policy group."""
|
|
140
|
+
return self._request('GET', f'/group/{quote(name)}')
|
|
141
|
+
|
|
142
|
+
def reset_auto_group_selection(self, name: str):
|
|
143
|
+
"""Reset the fixed selection of an auto policy group."""
|
|
144
|
+
return self._request('DELETE', f'/group/{quote(name)}')
|
|
145
|
+
|
|
146
|
+
def test_group_delay(self, name: str, url: str, timeout: int):
|
|
147
|
+
"""Test the delay of all proxies in a group."""
|
|
148
|
+
params = {'url': url, 'timeout': str(timeout)}
|
|
149
|
+
return self._request('GET', f'/group/{quote(name)}/delay', params=params)
|
|
150
|
+
|
|
151
|
+
# === Providers ===
|
|
152
|
+
def get_proxy_providers(self):
|
|
153
|
+
"""Get all proxy providers."""
|
|
154
|
+
return self._request('GET', '/providers/proxies')
|
|
155
|
+
|
|
156
|
+
def get_proxy_provider(self, name: str):
|
|
157
|
+
"""Get a specific proxy provider."""
|
|
158
|
+
return self._request('GET', f'/providers/proxies/{quote(name)}')
|
|
159
|
+
|
|
160
|
+
def update_proxy_provider(self, name: str):
|
|
161
|
+
"""Update a proxy provider."""
|
|
162
|
+
return self._request('PUT', f'/providers/proxies/{quote(name)}')
|
|
163
|
+
|
|
164
|
+
def healthcheck_proxy_provider(self, name: str):
|
|
165
|
+
"""Trigger a health check for a proxy provider."""
|
|
166
|
+
return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
|
|
167
|
+
|
|
168
|
+
def get_rule_providers(self):
|
|
169
|
+
"""Get all rule providers."""
|
|
170
|
+
return self._request('GET', '/providers/rules')
|
|
171
|
+
|
|
172
|
+
def update_rule_provider(self, name: str):
|
|
173
|
+
"""Update a rule provider."""
|
|
174
|
+
return self._request('PUT', f'/providers/rules/{quote(name)}')
|
|
175
|
+
|
|
176
|
+
# === Rules ===
|
|
177
|
+
def get_rules(self):
|
|
178
|
+
"""Get all rules."""
|
|
179
|
+
return self._request('GET', '/rules')
|
|
180
|
+
|
|
181
|
+
# === Connections ===
|
|
182
|
+
def get_connections(self):
|
|
183
|
+
"""Get active connections."""
|
|
184
|
+
return self._request('GET', '/connections')
|
|
185
|
+
|
|
186
|
+
def close_all_connections(self):
|
|
187
|
+
"""Close all active connections."""
|
|
188
|
+
return self._request('DELETE', '/connections')
|
|
189
|
+
|
|
190
|
+
def close_connection(self, conn_id: str):
|
|
191
|
+
"""Close a specific connection by its ID."""
|
|
192
|
+
return self._request('DELETE', f'/connections/{quote(conn_id)}')
|
|
193
|
+
|
|
194
|
+
# === DNS ===
|
|
195
|
+
def query_dns(self, name: str, query_type: str = 'A'):
|
|
196
|
+
"""Query DNS."""
|
|
197
|
+
return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
|
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
import queue
|
|
7
|
+
from InquirerPy import inquirer
|
|
8
|
+
from InquirerPy.validator import EmptyInputValidator
|
|
9
|
+
from InquirerPy.base.control import Choice, Separator
|
|
10
|
+
import requests # Need to import for requests.exceptions.RequestException
|
|
11
|
+
import os.path
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from email.utils import parsedate_to_datetime
|
|
14
|
+
|
|
15
|
+
from .api import ClashAPI
|
|
16
|
+
|
|
17
|
+
# ANSI Color Codes
|
|
18
|
+
COLOR_GREEN = '\033[92m'
|
|
19
|
+
COLOR_YELLOW = '\033[93m'
|
|
20
|
+
COLOR_RED = '\033[91m'
|
|
21
|
+
COLOR_RESET = '\033[0m'
|
|
22
|
+
|
|
23
|
+
# Path for storing connection profiles in the user's home directory
|
|
24
|
+
PROFILE_PATH = os.path.expanduser("~/.config/clash-controller/profiles.json")
|
|
25
|
+
CONFIG_PROVIDERS_PATH = os.path.expanduser("~/.config/clash-controller/config_providers.json")
|
|
26
|
+
TEMP_CONFIG_DIR = os.path.expanduser("~/.config/clash-controller/temp_configs")
|
|
27
|
+
|
|
28
|
+
def get_remote_last_modified(url: str) -> datetime or None:
|
|
29
|
+
"""Fetches the Last-Modified header from a remote URL."""
|
|
30
|
+
try:
|
|
31
|
+
response = requests.head(url, timeout=5) # Use HEAD request to get headers only
|
|
32
|
+
response.raise_for_status()
|
|
33
|
+
last_modified = response.headers.get('Last-Modified')
|
|
34
|
+
if last_modified:
|
|
35
|
+
return parsedate_to_datetime(last_modified)
|
|
36
|
+
except requests.exceptions.RequestException as e:
|
|
37
|
+
add_log(f"Error fetching Last-Modified for {url}: {e}")
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
# Global list to store application logs
|
|
41
|
+
app_logs = []
|
|
42
|
+
|
|
43
|
+
def is_local_api(api: ClashAPI) -> bool:
|
|
44
|
+
"""Checks if the API base URL points to a local address."""
|
|
45
|
+
if not api or not api.base_url:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
# Extract hostname from URL
|
|
49
|
+
try:
|
|
50
|
+
from urllib.parse import urlparse
|
|
51
|
+
parsed_url = urlparse(api.base_url)
|
|
52
|
+
hostname = parsed_url.hostname
|
|
53
|
+
except ImportError:
|
|
54
|
+
# Fallback for older Python versions or if urlparse is not available
|
|
55
|
+
# This is a simplified check and might not cover all edge cases
|
|
56
|
+
if "127.0.0.1" in api.base_url or "localhost" in api.base_url or "::1" in api.base_url:
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
if hostname in ["127.0.0.1", "localhost", "::1"]:
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def add_log(message: str):
|
|
65
|
+
"""Adds a timestamped message to the application log."""
|
|
66
|
+
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
67
|
+
app_logs.append(f"[{timestamp}] {message}")
|
|
68
|
+
|
|
69
|
+
def show_logs_screen():
|
|
70
|
+
"""Displays the accumulated application logs."""
|
|
71
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
72
|
+
print("--- Application Logs ---")
|
|
73
|
+
print("-" * 80)
|
|
74
|
+
if not app_logs:
|
|
75
|
+
print("No logs yet.")
|
|
76
|
+
else:
|
|
77
|
+
for log_entry in app_logs:
|
|
78
|
+
print(log_entry)
|
|
79
|
+
print("-" * 80)
|
|
80
|
+
input("Press Enter to return to the settings menu...")
|
|
81
|
+
|
|
82
|
+
def load_profiles():
|
|
83
|
+
"""Loads connection profiles from the config file."""
|
|
84
|
+
if not os.path.exists(PROFILE_PATH):
|
|
85
|
+
return []
|
|
86
|
+
try:
|
|
87
|
+
with open(PROFILE_PATH, 'r', encoding='utf-8') as f:
|
|
88
|
+
return json.load(f)
|
|
89
|
+
except (json.JSONDecodeError, IOError):
|
|
90
|
+
print(f"Warning: Could not read or parse profiles file at {PROFILE_PATH}")
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
def save_profiles(profiles):
|
|
94
|
+
"""Saves connection profiles to the config file."""
|
|
95
|
+
try:
|
|
96
|
+
os.makedirs(os.path.dirname(PROFILE_PATH), exist_ok=True)
|
|
97
|
+
with open(PROFILE_PATH, 'w', encoding='utf-8') as f:
|
|
98
|
+
json.dump(profiles, f, indent=4, ensure_ascii=False)
|
|
99
|
+
except IOError as e:
|
|
100
|
+
print(f"Error saving profiles to {PROFILE_PATH}: {e}")
|
|
101
|
+
|
|
102
|
+
def load_config_providers():
|
|
103
|
+
"""Loads config provider URLs from the config file."""
|
|
104
|
+
if not os.path.exists(CONFIG_PROVIDERS_PATH):
|
|
105
|
+
return []
|
|
106
|
+
try:
|
|
107
|
+
with open(CONFIG_PROVIDERS_PATH, 'r', encoding='utf-8') as f:
|
|
108
|
+
return json.load(f)
|
|
109
|
+
except (json.JSONDecodeError, IOError):
|
|
110
|
+
print(f"Warning: Could not read or parse config providers file at {CONFIG_PROVIDERS_PATH}")
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
def save_config_providers(providers):
|
|
114
|
+
"""Saves config provider URLs to the config file."""
|
|
115
|
+
try:
|
|
116
|
+
os.makedirs(os.path.dirname(CONFIG_PROVIDERS_PATH), exist_ok=True)
|
|
117
|
+
with open(CONFIG_PROVIDERS_PATH, 'w', encoding='utf-8') as f:
|
|
118
|
+
json.dump(providers, f, indent=4, ensure_ascii=False)
|
|
119
|
+
except IOError as e:
|
|
120
|
+
print(f"Error saving config providers to {CONFIG_PROVIDERS_PATH}: {e}")
|
|
121
|
+
|
|
122
|
+
def _stream_fetcher(api_method, data_queue, stop_event):
|
|
123
|
+
"""
|
|
124
|
+
A worker function to run in a thread.
|
|
125
|
+
It fetches data from a streaming API endpoint and puts it into a queue.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
response, error = api_method() # API now returns (data, error)
|
|
129
|
+
if error:
|
|
130
|
+
add_log(f"Stream fetcher error: {error}")
|
|
131
|
+
data_queue.put(None) # Signal stream end due to error
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if response:
|
|
135
|
+
for line in response.iter_lines():
|
|
136
|
+
if stop_event.is_set():
|
|
137
|
+
break
|
|
138
|
+
if line:
|
|
139
|
+
try:
|
|
140
|
+
json_str = line.decode('utf-8').lstrip('data: ')
|
|
141
|
+
if json_str:
|
|
142
|
+
data_queue.put(json.loads(json_str))
|
|
143
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
144
|
+
add_log(f"Malformed stream line: {line.decode('utf-8', errors='ignore')} - Error: {e}")
|
|
145
|
+
continue # Ignore malformed lines
|
|
146
|
+
except requests.exceptions.RequestException as e:
|
|
147
|
+
add_log(f"Stream connection error: {e}")
|
|
148
|
+
pass
|
|
149
|
+
finally:
|
|
150
|
+
# Signal that this stream has ended, e.g., for error display
|
|
151
|
+
data_queue.put(None)
|
|
152
|
+
|
|
153
|
+
def show_connections_page(api: ClashAPI):
|
|
154
|
+
"""Displays active connections, refreshing periodically."""
|
|
155
|
+
try:
|
|
156
|
+
while True:
|
|
157
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
158
|
+
print("Active Connections (Press Ctrl+C to return)")
|
|
159
|
+
print("-" * 80)
|
|
160
|
+
|
|
161
|
+
connections_data, error = api.get_connections()
|
|
162
|
+
if error:
|
|
163
|
+
print(f"Error retrieving connections: {error}")
|
|
164
|
+
add_log(f"Error retrieving connections: {error}")
|
|
165
|
+
time.sleep(2) # Give user time to read error
|
|
166
|
+
break # Exit connections page on error
|
|
167
|
+
|
|
168
|
+
if connections_data and 'connections' in connections_data:
|
|
169
|
+
connections = connections_data['connections']
|
|
170
|
+
total_dl = connections_data.get('downloadTotal', 0) / (1024*1024)
|
|
171
|
+
total_ul = connections_data.get('uploadTotal', 0) / (1024*1024)
|
|
172
|
+
|
|
173
|
+
print(f"Total Connections: {len(connections)} | Total UL/DL: {total_ul:.2f}MB / {total_dl:.2f}MB")
|
|
174
|
+
print("-" * 80)
|
|
175
|
+
|
|
176
|
+
# Header
|
|
177
|
+
print(f"{'Host':<30} {'Network':<7} {'Type':<10} {'Rule':<12} {'Chains'}")
|
|
178
|
+
print(f"{'-'*30:<30} {'-'*7:<7} {'-'*10:<10} {'-'*12:<12} {'-'*15}")
|
|
179
|
+
|
|
180
|
+
# Display first 20 connections to avoid clutter
|
|
181
|
+
for conn in connections[:20]:
|
|
182
|
+
metadata = conn.get('metadata', {})
|
|
183
|
+
host = metadata.get('host') or metadata.get('destinationIP', 'N/A')
|
|
184
|
+
network = metadata.get('network', 'N/A')
|
|
185
|
+
conn_type = metadata.get('type', 'N/A')
|
|
186
|
+
rule = conn.get('rule', 'N/A')
|
|
187
|
+
chains = " -> ".join(conn.get('chains', []))
|
|
188
|
+
|
|
189
|
+
# Truncate long hostnames
|
|
190
|
+
if len(host) > 28:
|
|
191
|
+
host = host[:25] + "..."
|
|
192
|
+
|
|
193
|
+
print(f"{host:<30} {network:<7} {conn_type:<10} {rule:<12} {chains}")
|
|
194
|
+
|
|
195
|
+
if len(connections) > 20:
|
|
196
|
+
print(f"\n... and {len(connections) - 20} more connections.")
|
|
197
|
+
|
|
198
|
+
else:
|
|
199
|
+
print("Could not retrieve connections or no active connections.")
|
|
200
|
+
|
|
201
|
+
print("-" * 80)
|
|
202
|
+
print(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
203
|
+
|
|
204
|
+
time.sleep(1) # Refresh interval
|
|
205
|
+
|
|
206
|
+
except KeyboardInterrupt:
|
|
207
|
+
print("\nReturning to main menu...")
|
|
208
|
+
time.sleep(0.5)
|
|
209
|
+
|
|
210
|
+
def show_overview_page(api: ClashAPI):
|
|
211
|
+
"""Displays the overview page with real-time stats using streaming."""
|
|
212
|
+
version_info, error = api.get_version()
|
|
213
|
+
version = version_info.get('version', 'N/A') if version_info else 'N/A'
|
|
214
|
+
if error:
|
|
215
|
+
add_log(f"Error fetching version for overview: {error}")
|
|
216
|
+
version = f"N/A (Error: {error})"
|
|
217
|
+
|
|
218
|
+
stop_event = threading.Event()
|
|
219
|
+
traffic_queue = queue.Queue()
|
|
220
|
+
memory_queue = queue.Queue()
|
|
221
|
+
|
|
222
|
+
traffic_thread = threading.Thread(
|
|
223
|
+
target=_stream_fetcher, args=(api.get_traffic_stream, traffic_queue, stop_event), daemon=True
|
|
224
|
+
)
|
|
225
|
+
memory_thread = threading.Thread(
|
|
226
|
+
target=_stream_fetcher, args=(api.get_memory_stream, memory_queue, stop_event), daemon=True
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
traffic_thread.start()
|
|
230
|
+
memory_thread.start()
|
|
231
|
+
|
|
232
|
+
latest_traffic = {"up": 0, "down": 0}
|
|
233
|
+
latest_memory = {"inuse": 0}
|
|
234
|
+
streams_alive = True
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
while streams_alive:
|
|
238
|
+
# Check for new traffic data
|
|
239
|
+
try:
|
|
240
|
+
traffic_data = traffic_queue.get_nowait()
|
|
241
|
+
if traffic_data is None:
|
|
242
|
+
streams_alive = False
|
|
243
|
+
break
|
|
244
|
+
latest_traffic = traffic_data
|
|
245
|
+
except queue.Empty:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# Check for new memory data
|
|
249
|
+
try:
|
|
250
|
+
memory_data = memory_queue.get_nowait()
|
|
251
|
+
if memory_data is None:
|
|
252
|
+
streams_alive = False
|
|
253
|
+
break
|
|
254
|
+
latest_memory = memory_data
|
|
255
|
+
except queue.Empty:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# --- Render UI ---
|
|
259
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
260
|
+
print("Clash Overview (Press Ctrl+C to go back to Main Menu)")
|
|
261
|
+
print("-" * 50)
|
|
262
|
+
print(f" Version: {version}")
|
|
263
|
+
print("-" * 50)
|
|
264
|
+
|
|
265
|
+
# Display Traffic
|
|
266
|
+
up_kbs = latest_traffic.get('up', 0) / 1024
|
|
267
|
+
down_kbs = latest_traffic.get('down', 0) / 1024
|
|
268
|
+
print(" Traffic:")
|
|
269
|
+
print(f" Upload: {up_kbs:.2f} KB/s")
|
|
270
|
+
print(f" Download: {down_kbs:.2f} KB/s")
|
|
271
|
+
|
|
272
|
+
# Display Memory
|
|
273
|
+
mem_mb = latest_memory.get('inuse', 0) / (1024 * 1024)
|
|
274
|
+
print("\n Memory:")
|
|
275
|
+
print(f" In Use: {mem_mb:.2f} MB")
|
|
276
|
+
|
|
277
|
+
print("-" * 50)
|
|
278
|
+
print(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
279
|
+
|
|
280
|
+
time.sleep(0.5) # Refresh rate for the screen
|
|
281
|
+
|
|
282
|
+
if not streams_alive:
|
|
283
|
+
print("\nConnection to a real-time data stream was lost.")
|
|
284
|
+
add_log("Real-time data stream lost.")
|
|
285
|
+
input("Press Enter to return to the main menu...")
|
|
286
|
+
|
|
287
|
+
except KeyboardInterrupt:
|
|
288
|
+
pass # User requested to go back
|
|
289
|
+
finally:
|
|
290
|
+
# --- Cleanup ---
|
|
291
|
+
stop_event.set() # Tell threads to stop
|
|
292
|
+
# The threads are daemons, they will exit anyway, but this is cleaner.
|
|
293
|
+
print("\nReturning to main menu...")
|
|
294
|
+
time.sleep(0.5) # Give a moment for the message to be seen
|
|
295
|
+
|
|
296
|
+
def show_config_menu(api: ClashAPI):
|
|
297
|
+
"""Displays the configuration sub-menu and handles user actions."""
|
|
298
|
+
if not is_local_api(api):
|
|
299
|
+
print(f"{COLOR_RED}\nConfiguration management is only available for local Clash instances (e.g., 127.0.0.1, localhost).{COLOR_RESET}")
|
|
300
|
+
add_log("Attempted to access Configuration menu on a remote Clash instance.")
|
|
301
|
+
input("Press Enter to return to the main menu...")
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
while True:
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
config_providers = load_config_providers()
|
|
308
|
+
|
|
309
|
+
provider_choices = [
|
|
310
|
+
Choice(name=f"{p['name']} ({p['url']})", value=p) for p in config_providers
|
|
311
|
+
]
|
|
312
|
+
provider_choices.extend([
|
|
313
|
+
Separator(),
|
|
314
|
+
Choice(name="Add new config provider", value="new"),
|
|
315
|
+
Choice(name="Reload Config File (Local)", value="reload_local"),
|
|
316
|
+
Separator(),
|
|
317
|
+
Choice(name="Back to Main Menu", value="back"),
|
|
318
|
+
])
|
|
319
|
+
|
|
320
|
+
action = inquirer.select(
|
|
321
|
+
message="Configuration Menu",
|
|
322
|
+
choices=provider_choices,
|
|
323
|
+
default=None,
|
|
324
|
+
).execute()
|
|
325
|
+
|
|
326
|
+
if action == "new":
|
|
327
|
+
try:
|
|
328
|
+
url = inquirer.text(
|
|
329
|
+
message="Enter config file URL (e.g., http://example.com/config.yaml):",
|
|
330
|
+
validate=EmptyInputValidator()
|
|
331
|
+
).execute()
|
|
332
|
+
provider_name = inquirer.text(
|
|
333
|
+
message="Enter a name for this config provider:",
|
|
334
|
+
default=url,
|
|
335
|
+
validate=EmptyInputValidator()
|
|
336
|
+
).execute()
|
|
337
|
+
except KeyboardInterrupt:
|
|
338
|
+
add_log("New config provider creation cancelled by user (KeyboardInterrupt).")
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
new_provider = {"name": provider_name, "url": url}
|
|
342
|
+
config_providers.append(new_provider)
|
|
343
|
+
save_config_providers(config_providers)
|
|
344
|
+
add_log(f"New config provider '{provider_name}' added.")
|
|
345
|
+
print(f"New config provider '{provider_name}' added.")
|
|
346
|
+
|
|
347
|
+
elif action == "reload_local":
|
|
348
|
+
print("\nReloading config file from local storage...")
|
|
349
|
+
_, error = api.reload_configs()
|
|
350
|
+
if not error:
|
|
351
|
+
print("Successfully reloaded config file from local storage.")
|
|
352
|
+
add_log("Successfully reloaded config file from local storage.")
|
|
353
|
+
else:
|
|
354
|
+
print(f"Failed to reload config file from local storage: {error}")
|
|
355
|
+
add_log(f"Failed to reload config file from local storage: {error}")
|
|
356
|
+
|
|
357
|
+
elif action == "back":
|
|
358
|
+
return None
|
|
359
|
+
elif action: # A saved config provider was selected
|
|
360
|
+
provider_url = action['url']
|
|
361
|
+
# Use the working_directory from the active API instance
|
|
362
|
+
download_path = api.working_directory
|
|
363
|
+
if not download_path:
|
|
364
|
+
print("Error: Clash working directory not set for the current profile. Please set it in the main menu.")
|
|
365
|
+
add_log("Error: Clash working directory not set for the current profile.")
|
|
366
|
+
input("Press Enter to continue...")
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
print(f"Fetching config from {provider_url} to {download_path}...")
|
|
370
|
+
add_log(f"Fetching config from {provider_url} to {download_path}...")
|
|
371
|
+
|
|
372
|
+
target_download_path = download_path
|
|
373
|
+
config_file_name = "config.yaml"
|
|
374
|
+
backup_file_name = "config.yaml.bak"
|
|
375
|
+
config_full_path = os.path.join(target_download_path, config_file_name)
|
|
376
|
+
backup_full_path = os.path.join(target_download_path, backup_file_name)
|
|
377
|
+
|
|
378
|
+
# Get remote and local modification times
|
|
379
|
+
remote_mod_time = get_remote_last_modified(provider_url)
|
|
380
|
+
local_mod_time = None
|
|
381
|
+
if os.path.exists(config_full_path):
|
|
382
|
+
local_mod_time = datetime.fromtimestamp(os.path.getmtime(config_full_path), tz=timezone.utc)
|
|
383
|
+
|
|
384
|
+
proceed_download = True
|
|
385
|
+
if remote_mod_time and local_mod_time:
|
|
386
|
+
if remote_mod_time > local_mod_time:
|
|
387
|
+
print(f"{COLOR_GREEN}Remote config is NEWER ({remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}) than local ({local_mod_time.strftime('%Y-%m-%d %H:%M:%S')}){COLOR_RESET}")
|
|
388
|
+
elif remote_mod_time < local_mod_time:
|
|
389
|
+
print(f"{COLOR_RED}Remote config is OLDER ({remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}) than local ({local_mod_time.strftime('%Y-%m-%d %H:%M:%S')}){COLOR_RESET}")
|
|
390
|
+
confirm = inquirer.confirm(
|
|
391
|
+
message="Remote config is older. Do you still want to download and overwrite?",
|
|
392
|
+
default=False
|
|
393
|
+
).execute()
|
|
394
|
+
if not confirm:
|
|
395
|
+
proceed_download = False
|
|
396
|
+
print("Download cancelled by user.")
|
|
397
|
+
else:
|
|
398
|
+
print(f"{COLOR_YELLOW}Remote config is the SAME as local ({remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}){COLOR_RESET}")
|
|
399
|
+
proceed_download = False
|
|
400
|
+
print("Skipping download as remote config is identical.")
|
|
401
|
+
elif remote_mod_time:
|
|
402
|
+
print(f"Remote config last modified: {remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
403
|
+
else:
|
|
404
|
+
print("Could not retrieve remote Last-Modified time. Proceeding with download.")
|
|
405
|
+
|
|
406
|
+
if not proceed_download:
|
|
407
|
+
input("Press Enter to continue...")
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
os.makedirs(target_download_path, exist_ok=True)
|
|
412
|
+
|
|
413
|
+
# Check if the target directory is writable
|
|
414
|
+
if not os.access(target_download_path, os.W_OK):
|
|
415
|
+
print(f"Warning: Directory {target_download_path} is not writable. Attempting to save to a temporary location.")
|
|
416
|
+
add_log(f"Warning: Directory {target_download_path} is not writable. Saving to temporary location.")
|
|
417
|
+
os.makedirs(TEMP_CONFIG_DIR, exist_ok=True)
|
|
418
|
+
target_download_path = TEMP_CONFIG_DIR
|
|
419
|
+
config_full_path = os.path.join(target_download_path, config_file_name)
|
|
420
|
+
backup_full_path = os.path.join(target_download_path, backup_file_name)
|
|
421
|
+
|
|
422
|
+
response = requests.get(provider_url, stream=True)
|
|
423
|
+
response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
|
|
424
|
+
|
|
425
|
+
# Backup existing config.yaml if it exists
|
|
426
|
+
if os.path.exists(config_full_path):
|
|
427
|
+
os.replace(config_full_path, backup_full_path)
|
|
428
|
+
print(f"Backed up existing {config_file_name} to {backup_file_name}")
|
|
429
|
+
add_log(f"Backed up existing {config_file_name} to {backup_file_name}")
|
|
430
|
+
|
|
431
|
+
# Save the new config
|
|
432
|
+
with open(config_full_path, 'wb') as f:
|
|
433
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
434
|
+
f.write(chunk)
|
|
435
|
+
print(f"Successfully downloaded and saved config to {config_full_path}")
|
|
436
|
+
add_log(f"Successfully downloaded and saved config to {config_full_path}")
|
|
437
|
+
|
|
438
|
+
if target_download_path == TEMP_CONFIG_DIR:
|
|
439
|
+
print("\n--- IMPORTANT ---")
|
|
440
|
+
print("The config was saved to a temporary location due to permissions:")
|
|
441
|
+
print(f" {config_full_path}")
|
|
442
|
+
print(f"Please manually move it to your intended Clash working directory: {os.path.join(download_path, config_file_name)}")
|
|
443
|
+
print("You might need to use 'sudo' for this, e.g.:\n")
|
|
444
|
+
print(f"sudo mv {config_full_path} {os.path.join(download_path, config_file_name)}")
|
|
445
|
+
print("After moving, you can reload the config via the 'Reload Config File (Local)' option in this menu.")
|
|
446
|
+
add_log("Config saved to temporary location due to permissions. User instructed to move manually.")
|
|
447
|
+
else:
|
|
448
|
+
# Reload config via API only if saved to the intended location
|
|
449
|
+
print("Reloading config file via Clash API...")
|
|
450
|
+
_, error = api.reload_configs(path=config_full_path)
|
|
451
|
+
if not error:
|
|
452
|
+
print("Successfully reloaded config file via Clash API.")
|
|
453
|
+
add_log("Successfully reloaded config file via Clash API.")
|
|
454
|
+
else:
|
|
455
|
+
print(f"Failed to reload config file via Clash API: {error}")
|
|
456
|
+
add_log(f"Failed to reload config file via Clash API: {error}")
|
|
457
|
+
|
|
458
|
+
except requests.exceptions.RequestException as e:
|
|
459
|
+
print(f"Error fetching config: {e}")
|
|
460
|
+
add_log(f"Error fetching config from {provider_url}: {e}")
|
|
461
|
+
except IOError as e:
|
|
462
|
+
print(f"Error saving config file: {e}")
|
|
463
|
+
add_log(f"Error saving config file: {e}")
|
|
464
|
+
except Exception as e:
|
|
465
|
+
print(f"An unexpected error occurred during config download/save: {e}")
|
|
466
|
+
add_log(f"Unexpected error during config download/save: {e}")
|
|
467
|
+
input("Press Enter to continue...")
|
|
468
|
+
|
|
469
|
+
except KeyboardInterrupt:
|
|
470
|
+
add_log("Configuration menu exited by user (KeyboardInterrupt).")
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
def show_settings_menu(api: ClashAPI):
|
|
474
|
+
|
|
475
|
+
"""Displays the settings sub-menu and handles user actions."""
|
|
476
|
+
while True:
|
|
477
|
+
try:
|
|
478
|
+
current_configs, error = api.get_configs()
|
|
479
|
+
if error:
|
|
480
|
+
print(f"Error: Could not fetch settings: {error}. Going back to main menu.")
|
|
481
|
+
add_log(f"Error: Could not fetch settings: {error}.")
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
tun_enabled = current_configs.get('tun', {}).get('enable', False)
|
|
485
|
+
tun_status_str = "ON" if tun_enabled else "OFF"
|
|
486
|
+
current_mode = current_configs.get('mode', 'N/A').capitalize()
|
|
487
|
+
|
|
488
|
+
action = inquirer.select(
|
|
489
|
+
message="Settings",
|
|
490
|
+
choices=[
|
|
491
|
+
Choice(name=f"Toggle TUN Mode (Current: {tun_status_str})", value="toggle_tun"),
|
|
492
|
+
Choice(name=f"Switch Mode (Current: {current_mode})", value="switch_mode"),
|
|
493
|
+
Separator(),
|
|
494
|
+
# Section 2: Reload & Restart
|
|
495
|
+
Choice(name="Reload GEO Databases", value="reload_geo"),
|
|
496
|
+
Choice(name="Restart Clash Core", value="restart"),
|
|
497
|
+
Separator(),
|
|
498
|
+
# Section 3: Upgrade
|
|
499
|
+
Choice(name="Upgrade Kernel", value="upgrade_kernel"),
|
|
500
|
+
Choice(name="Upgrade UI", value="upgrade_ui"),
|
|
501
|
+
Choice(name="Upgrade GEO Databases", value="upgrade_geo"),
|
|
502
|
+
Separator(),
|
|
503
|
+
# Section 4: Endpoint Management
|
|
504
|
+
Choice(name="Switch Endpoint", value="switch_endpoint"),
|
|
505
|
+
Choice(name="View Logs", value="view_logs"), # New option
|
|
506
|
+
Separator(),
|
|
507
|
+
Choice(name="Back to Main Menu", value="back"),
|
|
508
|
+
],
|
|
509
|
+
).execute()
|
|
510
|
+
|
|
511
|
+
if action == "toggle_tun":
|
|
512
|
+
new_state = not tun_enabled
|
|
513
|
+
_, error = api.toggle_tun(new_state)
|
|
514
|
+
if not error:
|
|
515
|
+
print(f"Successfully {'enabled' if new_state else 'disabled'} TUN mode.")
|
|
516
|
+
add_log(f"Successfully {'enabled' if new_state else 'disabled'} TUN mode.")
|
|
517
|
+
else:
|
|
518
|
+
print(f"Failed to toggle TUN mode: {error}")
|
|
519
|
+
add_log(f"Failed to toggle TUN mode: {error}")
|
|
520
|
+
elif action == "switch_mode":
|
|
521
|
+
modes = ['rule', 'global', 'direct']
|
|
522
|
+
current_mode_lower = current_configs.get('mode', 'rule')
|
|
523
|
+
try:
|
|
524
|
+
current_index = modes.index(current_mode_lower)
|
|
525
|
+
next_index = (current_index + 1) % len(modes)
|
|
526
|
+
next_mode = modes[next_index]
|
|
527
|
+
except ValueError:
|
|
528
|
+
next_mode = 'rule' # Default if current mode is not in list
|
|
529
|
+
_, error = api.set_mode(next_mode)
|
|
530
|
+
if not error:
|
|
531
|
+
print(f"Successfully switched mode to {next_mode.capitalize()}.")
|
|
532
|
+
add_log(f"Successfully switched mode to {next_mode.capitalize()}.")
|
|
533
|
+
else:
|
|
534
|
+
print(f"Failed to switch mode to {next_mode.capitalize()}: {error}")
|
|
535
|
+
add_log(f"Failed to switch mode to {next_mode.capitalize()}: {error}")
|
|
536
|
+
elif action == "reload_geo":
|
|
537
|
+
print("\nRequesting GEO databases reload...")
|
|
538
|
+
_, error = api.reload_geo_databases()
|
|
539
|
+
if not error:
|
|
540
|
+
print("Successfully requested GEO databases reload.")
|
|
541
|
+
add_log("Successfully requested GEO databases reload.")
|
|
542
|
+
else:
|
|
543
|
+
print(f"Failed to request GEO databases reload: {error}")
|
|
544
|
+
add_log(f"Failed to request GEO databases reload: {error}")
|
|
545
|
+
elif action == "restart":
|
|
546
|
+
print("\nRestarting Clash Core...")
|
|
547
|
+
_, error = api.restart()
|
|
548
|
+
if not error:
|
|
549
|
+
print("Successfully restarted Clash Core.")
|
|
550
|
+
add_log("Successfully restarted Clash Core.")
|
|
551
|
+
else:
|
|
552
|
+
print(f"Failed to restart Clash Core: {error}")
|
|
553
|
+
add_log(f"Failed to restart Clash Core: {error}")
|
|
554
|
+
elif action == "upgrade_kernel":
|
|
555
|
+
print("\nRequesting Kernel upgrade...")
|
|
556
|
+
_, error = api.upgrade_kernel()
|
|
557
|
+
if not error:
|
|
558
|
+
print("Successfully requested Kernel upgrade. Check logs for details.")
|
|
559
|
+
add_log("Successfully requested Kernel upgrade.")
|
|
560
|
+
else:
|
|
561
|
+
print(f"Failed to request Kernel upgrade: {error}")
|
|
562
|
+
add_log(f"Failed to request Kernel upgrade: {error}")
|
|
563
|
+
elif action == "upgrade_ui":
|
|
564
|
+
print("\nRequesting UI upgrade...")
|
|
565
|
+
_, error = api.upgrade_ui()
|
|
566
|
+
if not error:
|
|
567
|
+
print("Successfully requested UI upgrade. Check logs for details.")
|
|
568
|
+
add_log("Successfully requested UI upgrade.")
|
|
569
|
+
else:
|
|
570
|
+
print(f"Failed to request UI upgrade: {error}")
|
|
571
|
+
add_log(f"Failed to request UI upgrade: {error}")
|
|
572
|
+
elif action == "upgrade_geo":
|
|
573
|
+
print("\nRequesting GEO databases upgrade...")
|
|
574
|
+
_, error = api.upgrade_geo_databases()
|
|
575
|
+
if not error:
|
|
576
|
+
print("Successfully requested GEO databases upgrade. Check logs for details.")
|
|
577
|
+
add_log("Successfully requested GEO databases upgrade.")
|
|
578
|
+
else:
|
|
579
|
+
print(f"Failed to request GEO databases upgrade: {error}")
|
|
580
|
+
add_log(f"Failed to request GEO databases upgrade: {error}")
|
|
581
|
+
elif action == "switch_endpoint":
|
|
582
|
+
return "switch_endpoint"
|
|
583
|
+
elif action == "view_logs": # Handle new logs option
|
|
584
|
+
show_logs_screen()
|
|
585
|
+
elif action == "back":
|
|
586
|
+
return None
|
|
587
|
+
except KeyboardInterrupt:
|
|
588
|
+
add_log("Settings menu exited by user (KeyboardInterrupt).")
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
def show_main_menu(api: ClashAPI):
|
|
592
|
+
"""Displays the main menu and handles user actions."""
|
|
593
|
+
version_info, error = api.get_version()
|
|
594
|
+
version = version_info.get('version', 'unknown') if version_info else 'unknown'
|
|
595
|
+
if error:
|
|
596
|
+
add_log(f"Error fetching version for main menu: {error}")
|
|
597
|
+
version = f"N/A (Error: {error})"
|
|
598
|
+
|
|
599
|
+
print(f"\nSuccessfully connected to Clash (version: {version})!")
|
|
600
|
+
add_log(f"Successfully connected to Clash (version: {version}).")
|
|
601
|
+
|
|
602
|
+
while True:
|
|
603
|
+
try:
|
|
604
|
+
choices_list = [
|
|
605
|
+
Choice(name="Overview", value="overview"),
|
|
606
|
+
Choice(name="Connections", value="connections"),
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
if is_local_api(api):
|
|
610
|
+
choices_list.append(Choice(name="Configuration", value="configuration"))
|
|
611
|
+
else:
|
|
612
|
+
choices_list.append(Choice(name="Configuration (Local Only)", value="configuration_disabled", enabled=False))
|
|
613
|
+
|
|
614
|
+
choices_list.extend([
|
|
615
|
+
Choice(name="Settings", value="settings"),
|
|
616
|
+
Choice(name="Exit", value="exit")
|
|
617
|
+
])
|
|
618
|
+
|
|
619
|
+
action = inquirer.select(
|
|
620
|
+
message="Main Menu",
|
|
621
|
+
choices=choices_list,
|
|
622
|
+
default=None,
|
|
623
|
+
).execute()
|
|
624
|
+
|
|
625
|
+
if action == "overview":
|
|
626
|
+
show_overview_page(api)
|
|
627
|
+
elif action == "connections":
|
|
628
|
+
show_connections_page(api)
|
|
629
|
+
elif action == "configuration":
|
|
630
|
+
show_config_menu(api)
|
|
631
|
+
elif action == "settings":
|
|
632
|
+
result = show_settings_menu(api)
|
|
633
|
+
if result == "switch_endpoint":
|
|
634
|
+
return "switch_endpoint"
|
|
635
|
+
elif action == "exit":
|
|
636
|
+
print("Exiting...")
|
|
637
|
+
add_log("Application exited by user.")
|
|
638
|
+
return "exit"
|
|
639
|
+
except KeyboardInterrupt:
|
|
640
|
+
print("\nExiting...")
|
|
641
|
+
add_log("Main menu exited by user (KeyboardInterrupt).")
|
|
642
|
+
return "exit"
|
|
643
|
+
|
|
644
|
+
def main():
|
|
645
|
+
"""Main function to run the TUI application."""
|
|
646
|
+
add_log("Application started.")
|
|
647
|
+
while True:
|
|
648
|
+
profiles = load_profiles()
|
|
649
|
+
|
|
650
|
+
profile_choices = [
|
|
651
|
+
Choice(name=f"{p['name']} ({p['url']})", value=p) for p in profiles
|
|
652
|
+
]
|
|
653
|
+
profile_choices.extend([
|
|
654
|
+
Separator(),
|
|
655
|
+
Choice(name="Add a new connection", value="new"),
|
|
656
|
+
Choice(name="Exit", value="exit")
|
|
657
|
+
])
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
selected_profile = inquirer.select(
|
|
661
|
+
message="Select a Clash connection profile:",
|
|
662
|
+
choices=profile_choices,
|
|
663
|
+
default=None,
|
|
664
|
+
).execute()
|
|
665
|
+
except KeyboardInterrupt:
|
|
666
|
+
print("\nOperation cancelled by user. Exiting.")
|
|
667
|
+
add_log("Profile selection cancelled by user (KeyboardInterrupt).")
|
|
668
|
+
break
|
|
669
|
+
|
|
670
|
+
if selected_profile == "exit" or selected_profile is None:
|
|
671
|
+
add_log("Profile selection exited by user.")
|
|
672
|
+
break
|
|
673
|
+
|
|
674
|
+
api = None
|
|
675
|
+
if selected_profile == "new":
|
|
676
|
+
try:
|
|
677
|
+
url = inquirer.text(
|
|
678
|
+
message="Enter Clash controller URL (e.g., http://127.0.0.1:9090):",
|
|
679
|
+
validate=EmptyInputValidator()
|
|
680
|
+
).execute()
|
|
681
|
+
secret = inquirer.text(message="Enter API secret (optional):").execute()
|
|
682
|
+
profile_name = inquirer.text(
|
|
683
|
+
message="Enter a name for this profile:",
|
|
684
|
+
default=url,
|
|
685
|
+
validate=EmptyInputValidator()
|
|
686
|
+
).execute()
|
|
687
|
+
working_directory = inquirer.text(
|
|
688
|
+
message="Enter Clash working directory (e.g., ~/.config/clash):",
|
|
689
|
+
default=os.path.expanduser("~/.config/clash"),
|
|
690
|
+
validate=EmptyInputValidator()
|
|
691
|
+
).execute()
|
|
692
|
+
except KeyboardInterrupt:
|
|
693
|
+
print("\nOperation cancelled by user. Exiting.")
|
|
694
|
+
add_log("New profile creation cancelled by user (KeyboardInterrupt).")
|
|
695
|
+
break
|
|
696
|
+
|
|
697
|
+
new_profile = {"name": profile_name, "url": url, "secret": secret, "working_directory": working_directory}
|
|
698
|
+
profiles.append(new_profile)
|
|
699
|
+
save_profiles(profiles)
|
|
700
|
+
add_log(f"New profile '{profile_name}' added.")
|
|
701
|
+
|
|
702
|
+
api = ClashAPI(base_url=url, secret=secret, working_directory=working_directory)
|
|
703
|
+
elif selected_profile:
|
|
704
|
+
# Find the actual profile object in the profiles list
|
|
705
|
+
current_profile_obj = None
|
|
706
|
+
for p in profiles:
|
|
707
|
+
if p['name'] == selected_profile['name'] and p['url'] == selected_profile['url']:
|
|
708
|
+
current_profile_obj = p
|
|
709
|
+
break
|
|
710
|
+
|
|
711
|
+
if current_profile_obj:
|
|
712
|
+
# Check for and prompt for working_directory if missing (for backward compatibility)
|
|
713
|
+
if 'working_directory' not in current_profile_obj or not current_profile_obj['working_directory']:
|
|
714
|
+
print(f"\nProfile '{current_profile_obj['name']}' is missing a working directory.")
|
|
715
|
+
try:
|
|
716
|
+
working_directory = inquirer.text(
|
|
717
|
+
message="Enter Clash working directory for this profile (e.g., ~/.config/clash):",
|
|
718
|
+
default=os.path.expanduser("~/.config/clash"),
|
|
719
|
+
validate=EmptyInputValidator()
|
|
720
|
+
).execute()
|
|
721
|
+
current_profile_obj['working_directory'] = working_directory
|
|
722
|
+
save_profiles(profiles) # Now this should save the updated list
|
|
723
|
+
add_log(f"Updated profile '{current_profile_obj['name']}' with working directory '{working_directory}'.")
|
|
724
|
+
except KeyboardInterrupt:
|
|
725
|
+
print("\nOperation cancelled by user. Returning to profile selection.")
|
|
726
|
+
add_log("Working directory prompt cancelled by user (KeyboardInterrupt).")
|
|
727
|
+
continue # Return to profile selection instead of breaking
|
|
728
|
+
|
|
729
|
+
api = ClashAPI(base_url=current_profile_obj['url'], secret=current_profile_obj.get('secret'), working_directory=current_profile_obj['working_directory'])
|
|
730
|
+
add_log(f"Selected profile '{current_profile_obj['name']}'.")
|
|
731
|
+
else:
|
|
732
|
+
# This case should ideally not happen if selected_profile is always from profiles
|
|
733
|
+
print("Error: Selected profile not found in the loaded profiles list.")
|
|
734
|
+
add_log("Error: Selected profile not found in the loaded profiles list.")
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
if api:
|
|
738
|
+
print("Connecting...")
|
|
739
|
+
add_log(f"Attempting to connect to Clash at {api.base_url}...")
|
|
740
|
+
version_info, error = api.get_version()
|
|
741
|
+
if version_info:
|
|
742
|
+
add_log(f"Successfully connected to Clash (version: {version_info.get('version', 'unknown')}).")
|
|
743
|
+
result = show_main_menu(api)
|
|
744
|
+
if result == "switch_endpoint":
|
|
745
|
+
print("\nReturning to endpoint selection...")
|
|
746
|
+
add_log("Returning to endpoint selection.")
|
|
747
|
+
continue
|
|
748
|
+
else:
|
|
749
|
+
break
|
|
750
|
+
else:
|
|
751
|
+
print(f"\nConnection failed. Please check your URL, secret, and make sure Clash is running. Error: {error}")
|
|
752
|
+
add_log(f"Connection failed to {api.base_url}. Error: {error}")
|
|
753
|
+
try:
|
|
754
|
+
go_back = inquirer.confirm(message="Go back to endpoint selection?", default=True).execute()
|
|
755
|
+
if go_back:
|
|
756
|
+
add_log("User chose to go back to endpoint selection.")
|
|
757
|
+
continue
|
|
758
|
+
else:
|
|
759
|
+
add_log("User chose to exit after connection failure.")
|
|
760
|
+
break
|
|
761
|
+
except KeyboardInterrupt:
|
|
762
|
+
print("\nExiting.")
|
|
763
|
+
add_log("User exited during connection failure prompt (KeyboardInterrupt).")
|
|
764
|
+
break
|
|
765
|
+
|
|
766
|
+
if __name__ == '__main__':
|
|
767
|
+
try:
|
|
768
|
+
main()
|
|
769
|
+
except Exception as e:
|
|
770
|
+
print(f"\nAn unexpected error occurred: {e}", file=sys.stderr)
|
|
771
|
+
add_log(f"An unexpected error occurred: {e}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clash_controller
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A controller for Clash
|
|
5
|
+
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Moha-Master/Clash-Controller
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Moha-Master/Clash-Controller/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests
|
|
14
|
+
Requires-Dist: requests_unixsocket
|
|
15
|
+
Requires-Dist: InquirerPy
|
|
16
|
+
|
|
17
|
+
# Clash Controller
|
|
18
|
+
|
|
19
|
+
一个使用 `InquirerPy` 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器。它可以让您方便地通过命令行管理和监控一个或多个 `clash` 实例。
|
|
20
|
+
|
|
21
|
+
## 功能特性
|
|
22
|
+
|
|
23
|
+
- **交互式 TUI 界面**: 友好的菜单驱动操作,无需记忆复杂命令。
|
|
24
|
+
- **多端点管理**:
|
|
25
|
+
- 自动保存连接过的 Clash 端点(地址和密钥)。
|
|
26
|
+
- 启动时可从已保存列表中快速选择。
|
|
27
|
+
- 支持添加新的端点。
|
|
28
|
+
- 支持 HTTP 和 Unix Domain Socket 连接。
|
|
29
|
+
- **实时监控面板**:
|
|
30
|
+
- **概览 (Overview)**: 实时显示上/下行流量、内存使用和内核版本。
|
|
31
|
+
- **连接 (Connections)**: 实时展示当前的活动连接列表、总连接数和累计流量。
|
|
32
|
+
- **强大的设置菜单**:
|
|
33
|
+
- **模式切换**: 循环切换 `规则` / `全局` / `直连` 模式,并开关 `TUN` 模式。
|
|
34
|
+
- **重载与重启**: 独立地重载配置文件、GEO 数据库,或重启 Clash 核心。
|
|
35
|
+
- **一键升级**: 在线升级内核、UI 面板和 GEO 数据库。
|
|
36
|
+
- **查看完整配置**: 显示当前 Clash 的全部运行配置。
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install clash-controller
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 使用方法
|
|
45
|
+
|
|
46
|
+
安装后,可以通过以下命令启动程序:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
clashctl
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
程序启动后,会提示您选择一个已保存的 Clash 端点或添加一个新的端点。
|
|
53
|
+
|
|
54
|
+
## 开发者安装
|
|
55
|
+
|
|
56
|
+
如果你想要从源代码运行或者参与开发:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/Moha-Master/clash-controller.git
|
|
60
|
+
cd clash-controller
|
|
61
|
+
|
|
62
|
+
# 创建虚拟环境 (推荐)
|
|
63
|
+
python -m venv venv
|
|
64
|
+
source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
|
|
65
|
+
|
|
66
|
+
# 安装依赖
|
|
67
|
+
pip install -r requirements.txt
|
|
68
|
+
|
|
69
|
+
# 运行程序
|
|
70
|
+
python -m clash_controller
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 要求
|
|
74
|
+
|
|
75
|
+
- Python 3.8+
|
|
76
|
+
- 运行中的 Clash 实例,已开启外部控制 API
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
clash_controller/__init__.py
|
|
4
|
+
clash_controller/__main__.py
|
|
5
|
+
clash_controller/api.py
|
|
6
|
+
clash_controller/cli.py
|
|
7
|
+
clash_controller.egg-info/PKG-INFO
|
|
8
|
+
clash_controller.egg-info/SOURCES.txt
|
|
9
|
+
clash_controller.egg-info/dependency_links.txt
|
|
10
|
+
clash_controller.egg-info/entry_points.txt
|
|
11
|
+
clash_controller.egg-info/requires.txt
|
|
12
|
+
clash_controller.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clash_controller
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clash_controller"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "A controller for Clash"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Moha-Master", email = "hongkongreporter@outlook.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"requests",
|
|
21
|
+
"requests_unixsocket",
|
|
22
|
+
"InquirerPy"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# 命令行入口
|
|
26
|
+
[project.scripts]
|
|
27
|
+
clashctl = "clash_controller.cli:main"
|
|
28
|
+
|
|
29
|
+
# (可选) 项目链接
|
|
30
|
+
[project.urls]
|
|
31
|
+
"Homepage" = "https://github.com/Moha-Master/Clash-Controller"
|
|
32
|
+
"Bug Tracker" = "https://github.com/Moha-Master/Clash-Controller/issues"
|