sing-box-cli 0.0.17__tar.gz → 0.0.20__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.
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/CHANGELOG.md +29 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/PKG-INFO +3 -2
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/pyproject.toml +3 -3
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/src/sing_box_service/__init__.py +1 -1
- sing_box_cli-0.0.20/src/sing_box_service/client.py +245 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/src/sing_box_service/config.py +45 -4
- sing_box_cli-0.0.20/src/sing_box_service/connections.py +449 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/src/sing_box_service/main.py +105 -3
- sing_box_cli-0.0.20/src/sing_box_service/monitor.py +523 -0
- sing_box_cli-0.0.20/src/sing_box_service/policy.py +424 -0
- sing_box_cli-0.0.20/src/sing_box_service/run.py +24 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/src/sing_box_service/service.py +3 -3
- sing_box_cli-0.0.20/src/sing_box_service/widget.py +28 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/uv.lock +163 -194
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/.github/dependabot.yml +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/.github/workflows/main.yml +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/.gitignore +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/.pre-commit-config.yaml +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/LICENSE +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/README.md +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/assets/image.png +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/scripts/bump.sh +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/scripts/format.sh +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/scripts/lint.sh +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/scripts/pre-commit.sh +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/src/sing_box_service/py.typed +0 -0
- {sing_box_cli-0.0.17 → sing_box_cli-0.0.20}/tests/test_config.py +0 -0
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.0.20 - 2025-03-18
|
|
6
|
+
|
|
7
|
+
### 🚀 Features
|
|
8
|
+
|
|
9
|
+
- Add stats command to visualize traffic, memory and connections
|
|
10
|
+
- Add cnns command to manage connections
|
|
11
|
+
- Add proxy command to select policy
|
|
12
|
+
|
|
13
|
+
### 🚜 Refactor
|
|
14
|
+
|
|
15
|
+
- Replace requests with httpx
|
|
16
|
+
- Check url using httpx
|
|
17
|
+
|
|
18
|
+
## 0.0.19 - 2025-03-15
|
|
19
|
+
|
|
20
|
+
### 🚀 Features
|
|
21
|
+
|
|
22
|
+
- Add simple run command
|
|
23
|
+
|
|
24
|
+
### ⚙️ Miscellaneous Tasks
|
|
25
|
+
|
|
26
|
+
- Add type annotationfor __init__
|
|
27
|
+
|
|
28
|
+
## 0.0.18 - 2025-03-15
|
|
29
|
+
|
|
30
|
+
### ⚙️ Miscellaneous Tasks
|
|
31
|
+
|
|
32
|
+
- Bump deps
|
|
33
|
+
|
|
5
34
|
## 0.0.17 - 2025-03-10
|
|
6
35
|
|
|
7
36
|
### 🐛 Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sing-box-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.20
|
|
4
4
|
Summary: cross-platform sing-box-service manager.
|
|
5
5
|
Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
|
|
6
6
|
Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
|
|
@@ -38,8 +38,9 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
38
38
|
Classifier: Programming Language :: Python :: 3.12
|
|
39
39
|
Classifier: Programming Language :: Python :: 3.13
|
|
40
40
|
Requires-Python: >=3.10
|
|
41
|
+
Requires-Dist: httpx>=0.28.1
|
|
42
|
+
Requires-Dist: prompt-toolkit>=3.0.50
|
|
41
43
|
Requires-Dist: psutil>=6.1.1
|
|
42
|
-
Requires-Dist: requests>=2.32.3
|
|
43
44
|
Requires-Dist: typer>=0.15.1
|
|
44
45
|
Description-Content-Type: text/markdown
|
|
45
46
|
|
|
@@ -24,8 +24,9 @@ maintainers = [{ name = "A.J.Zeller", email = "zhouge1831@gmail.com" }]
|
|
|
24
24
|
|
|
25
25
|
dynamic = ["version"] # https://hatch.pypa.io/latest/config/metadata/#version
|
|
26
26
|
dependencies = [
|
|
27
|
+
"httpx>=0.28.1",
|
|
28
|
+
"prompt-toolkit>=3.0.50",
|
|
27
29
|
"psutil>=6.1.1",
|
|
28
|
-
"requests>=2.32.3",
|
|
29
30
|
"typer>=0.15.1",
|
|
30
31
|
]
|
|
31
32
|
|
|
@@ -57,7 +58,6 @@ dev = [
|
|
|
57
58
|
"bump-my-version>=0.28.0",
|
|
58
59
|
"typos>=1.26.8",
|
|
59
60
|
"types-psutil>=6.1.0.20241221",
|
|
60
|
-
"types-requests>=2.32.0.20241016",
|
|
61
61
|
"pytest>=8.3.5",
|
|
62
62
|
]
|
|
63
63
|
|
|
@@ -210,7 +210,7 @@ sort_commits = "oldest"
|
|
|
210
210
|
|
|
211
211
|
|
|
212
212
|
[tool.bumpversion]
|
|
213
|
-
current_version = "0.0.
|
|
213
|
+
current_version = "0.0.20"
|
|
214
214
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
215
215
|
serialize = ["{major}.{minor}.{patch}"]
|
|
216
216
|
search = "{current_version}"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sing-Box API Client module.
|
|
3
|
+
Provides an async client for interacting with Sing-Box HTTP API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SingBoxAPIClient:
|
|
14
|
+
"""Async client for interacting with Sing-Box API.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
base_url: The base URL of the Sing-Box API
|
|
18
|
+
token: The API authentication token
|
|
19
|
+
timeout: Request timeout in seconds
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str = "http://127.0.0.1:9090",
|
|
25
|
+
token: str | None = None,
|
|
26
|
+
timeout: float = 10.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.base_url = base_url
|
|
29
|
+
self.headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
if not self._health_check():
|
|
32
|
+
raise ValueError(f"Invalid to initialize client: {self.base_url}")
|
|
33
|
+
|
|
34
|
+
def _health_check(self) -> bool:
|
|
35
|
+
"""Check if the API is reachable.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if the API is reachable, False otherwise
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
response = httpx.get(
|
|
42
|
+
f"{self.base_url}", timeout=self.timeout, headers=self.headers
|
|
43
|
+
)
|
|
44
|
+
return response.status_code == 200
|
|
45
|
+
except httpx.HTTPError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
async def _make_request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
endpoint: str,
|
|
52
|
+
params: dict[str, Any] | None = None,
|
|
53
|
+
data: dict[str, Any] | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Make a request to the API.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
method: HTTP method (GET, POST, etc.)
|
|
60
|
+
endpoint: API endpoint
|
|
61
|
+
params: URL parameters
|
|
62
|
+
data: Request body data
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
JSON response as dictionary
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
httpx.HTTPError: If the request fails
|
|
69
|
+
"""
|
|
70
|
+
url = f"{self.base_url}{endpoint}"
|
|
71
|
+
async with httpx.AsyncClient() as client:
|
|
72
|
+
response = await client.request(
|
|
73
|
+
method=method,
|
|
74
|
+
url=url,
|
|
75
|
+
params=params,
|
|
76
|
+
json=data,
|
|
77
|
+
headers=self.headers,
|
|
78
|
+
timeout=self.timeout,
|
|
79
|
+
)
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
if response.content:
|
|
82
|
+
return dict(response.json())
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
async def traffic_stream(self) -> AsyncGenerator[dict[str, Any], None]:
|
|
86
|
+
"""
|
|
87
|
+
Get traffic statistics as a stream of updates.
|
|
88
|
+
|
|
89
|
+
Yields:
|
|
90
|
+
Dictionary containing traffic data (up/down in B/s)
|
|
91
|
+
"""
|
|
92
|
+
url = f"{self.base_url}/traffic"
|
|
93
|
+
|
|
94
|
+
async with httpx.AsyncClient() as client:
|
|
95
|
+
async with client.stream(
|
|
96
|
+
"GET", url, headers=self.headers, timeout=None
|
|
97
|
+
) as response:
|
|
98
|
+
response.raise_for_status()
|
|
99
|
+
async for line in response.aiter_lines():
|
|
100
|
+
if line.strip(): # Skip empty lines
|
|
101
|
+
try:
|
|
102
|
+
yield json.loads(line)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
# Skip invalid JSON lines
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
async def memory_stream(self) -> AsyncGenerator[dict[str, Any], None]:
|
|
108
|
+
"""
|
|
109
|
+
Get memory statistics as a stream of updates.
|
|
110
|
+
|
|
111
|
+
Yields:
|
|
112
|
+
Dictionary containing memory data (inuse/total in bytes)
|
|
113
|
+
"""
|
|
114
|
+
url = f"{self.base_url}/memory"
|
|
115
|
+
|
|
116
|
+
async with httpx.AsyncClient() as client:
|
|
117
|
+
async with client.stream(
|
|
118
|
+
"GET", url, headers=self.headers, timeout=None
|
|
119
|
+
) as response:
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
async for line in response.aiter_lines():
|
|
122
|
+
if line.strip(): # Skip empty lines
|
|
123
|
+
try:
|
|
124
|
+
yield json.loads(line)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
# Skip invalid JSON lines
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
async def get_connections(self) -> dict[str, Any]:
|
|
130
|
+
"""
|
|
131
|
+
Get current connections.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Dictionary containing active connections
|
|
135
|
+
"""
|
|
136
|
+
return await self._make_request("GET", "/connections")
|
|
137
|
+
|
|
138
|
+
async def close_connection(self, connection_id: str) -> dict[str, Any]:
|
|
139
|
+
"""
|
|
140
|
+
Close a specific connection.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
connection_id: ID of the connection to close
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Response from the API
|
|
147
|
+
"""
|
|
148
|
+
return await self._make_request("DELETE", f"/connections/{connection_id}")
|
|
149
|
+
|
|
150
|
+
async def close_all_connections(self) -> dict[str, Any]:
|
|
151
|
+
"""
|
|
152
|
+
Close all connections.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Response from the API
|
|
156
|
+
"""
|
|
157
|
+
return await self._make_request("DELETE", "/connections")
|
|
158
|
+
|
|
159
|
+
async def get_groups(self) -> dict[str, Any]:
|
|
160
|
+
"""
|
|
161
|
+
Get all policy groups.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dictionary containing policy groups information
|
|
165
|
+
"""
|
|
166
|
+
return await self._make_request("GET", "/group")
|
|
167
|
+
|
|
168
|
+
async def get_group(self, group_name: str) -> dict[str, Any]:
|
|
169
|
+
"""
|
|
170
|
+
Get information about a specific policy group.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
group_name: Name of the policy group
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dictionary containing the policy group information
|
|
177
|
+
"""
|
|
178
|
+
return await self._make_request("GET", f"/group/{group_name}")
|
|
179
|
+
|
|
180
|
+
async def test_group_delay(
|
|
181
|
+
self,
|
|
182
|
+
group_name: str,
|
|
183
|
+
url: str = "https://cp.cloudflare.com/generate_204",
|
|
184
|
+
timeout: int = 5000,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
"""
|
|
187
|
+
Test delay for all proxies in a policy group.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
group_name: Name of the policy group
|
|
191
|
+
url: URL to test latency against
|
|
192
|
+
timeout: Timeout in milliseconds
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Dictionary containing delay test results
|
|
196
|
+
"""
|
|
197
|
+
params = {"url": url, "timeout": timeout}
|
|
198
|
+
return await self._make_request(
|
|
199
|
+
"GET", f"/group/{group_name}/delay", params=params
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
async def test_proxy_delay(
|
|
203
|
+
self,
|
|
204
|
+
proxy_name: str,
|
|
205
|
+
url: str = "https://cp.cloudflare.com/generate_204",
|
|
206
|
+
timeout: int = 5000,
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
"""
|
|
209
|
+
Test delay for a specific proxy.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
proxy_name: Name of the proxy
|
|
213
|
+
url: URL to test latency against
|
|
214
|
+
timeout: Timeout in milliseconds
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Dictionary containing delay test results
|
|
218
|
+
"""
|
|
219
|
+
params = {"url": url, "timeout": timeout}
|
|
220
|
+
return await self._make_request(
|
|
221
|
+
"GET", f"/proxies/{proxy_name}/delay", params=params
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def select_proxy(self, proxy_name: str, selected: str) -> dict[str, Any]:
|
|
225
|
+
"""
|
|
226
|
+
Select a proxy for a selector proxy group.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
proxy_name: Name of the proxy selector
|
|
230
|
+
selected: Name of the proxy to select
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Response from the API
|
|
234
|
+
"""
|
|
235
|
+
data = {"name": selected}
|
|
236
|
+
return await self._make_request("PUT", f"/proxies/{proxy_name}", data=data)
|
|
237
|
+
|
|
238
|
+
async def get_version(self) -> dict[str, Any]:
|
|
239
|
+
"""
|
|
240
|
+
Get Sing-Box version.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dictionary containing version information
|
|
244
|
+
"""
|
|
245
|
+
return await self._make_request("GET", "/version")
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
import platform
|
|
3
4
|
import shutil
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
5
7
|
|
|
6
|
-
import
|
|
8
|
+
import httpx
|
|
7
9
|
from rich import print
|
|
8
10
|
|
|
9
11
|
|
|
@@ -64,6 +66,28 @@ class Config:
|
|
|
64
66
|
return ""
|
|
65
67
|
return self.subscription_file.read_text().strip()
|
|
66
68
|
|
|
69
|
+
@property
|
|
70
|
+
def api_base_url(self) -> str:
|
|
71
|
+
config = load_json_config(self.config_file)
|
|
72
|
+
url = (
|
|
73
|
+
config.get("experimental", {})
|
|
74
|
+
.get("clash_api", {})
|
|
75
|
+
.get("external_controller", "")
|
|
76
|
+
)
|
|
77
|
+
if isinstance(url, str) and url:
|
|
78
|
+
if not url.startswith("http"):
|
|
79
|
+
url = f"http://{url}"
|
|
80
|
+
return url
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def api_secret(self) -> str:
|
|
85
|
+
config = load_json_config(self.config_file)
|
|
86
|
+
token = config.get("experimental", {}).get("clash_api", {}).get("secret", "")
|
|
87
|
+
if isinstance(token, str) and token:
|
|
88
|
+
return token
|
|
89
|
+
return ""
|
|
90
|
+
|
|
67
91
|
def update_config(self) -> bool:
|
|
68
92
|
"""download configuration from subscription URL and show differences"""
|
|
69
93
|
if not self.sub_url:
|
|
@@ -77,7 +101,7 @@ class Config:
|
|
|
77
101
|
)
|
|
78
102
|
print(f"⌛ Updating configuration from {self.sub_url}")
|
|
79
103
|
try:
|
|
80
|
-
response =
|
|
104
|
+
response = httpx.get(self.sub_url)
|
|
81
105
|
response.raise_for_status()
|
|
82
106
|
new_config = response.text
|
|
83
107
|
self.config_file.write_text(new_config, encoding="utf-8")
|
|
@@ -95,8 +119,7 @@ class Config:
|
|
|
95
119
|
return False
|
|
96
120
|
|
|
97
121
|
def add_subscription(self, url: str) -> bool:
|
|
98
|
-
if not url
|
|
99
|
-
print("❌ Invalid URL format.")
|
|
122
|
+
if not check_url(url):
|
|
100
123
|
return False
|
|
101
124
|
self.subscription_file.write_text(url.strip())
|
|
102
125
|
print("📁 Subscription added successfully.")
|
|
@@ -154,3 +177,21 @@ def show_diff_config(current_config: str, new_config: str) -> None:
|
|
|
154
177
|
print(f"[dim]{line}[/dim]")
|
|
155
178
|
else:
|
|
156
179
|
print(f"[white]{line}[/white]")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_url(url: str) -> bool:
|
|
183
|
+
try:
|
|
184
|
+
response = httpx.head(url)
|
|
185
|
+
response.raise_for_status()
|
|
186
|
+
return True
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f"❌ Invalid URL: {e}")
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def load_json_config(config_file: Path) -> dict[str, Any]:
|
|
193
|
+
try:
|
|
194
|
+
return dict(json.loads(config_file.read_text(encoding="utf-8")))
|
|
195
|
+
except Exception as e:
|
|
196
|
+
print(f"❌ Failed to load configuration: {e}")
|
|
197
|
+
return {}
|