sing-box-cli 0.0.25__tar.gz → 0.0.27__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.
Files changed (35) hide show
  1. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/CHANGELOG.md +25 -0
  2. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/PKG-INFO +2 -1
  3. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/pyproject.toml +2 -1
  4. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/src/sing_box_service/__init__.py +1 -1
  5. sing_box_cli-0.0.27/src/sing_box_service/api/__init__.py +99 -0
  6. sing_box_cli-0.0.27/src/sing_box_service/api/client.py +367 -0
  7. {sing_box_cli-0.0.25/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/connections.py +31 -32
  8. sing_box_cli-0.0.27/src/sing_box_service/api/logs.py +93 -0
  9. {sing_box_cli-0.0.25/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/monitor.py +23 -26
  10. {sing_box_cli-0.0.25/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/policy.py +39 -82
  11. sing_box_cli-0.0.27/src/sing_box_service/common.py +26 -0
  12. sing_box_cli-0.0.27/src/sing_box_service/config/__init__.py +46 -0
  13. {sing_box_cli-0.0.25/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/config}/config.py +24 -57
  14. sing_box_cli-0.0.27/src/sing_box_service/config/utils.py +51 -0
  15. sing_box_cli-0.0.27/src/sing_box_service/main.py +74 -0
  16. sing_box_cli-0.0.27/src/sing_box_service/service/__init__.py +71 -0
  17. sing_box_cli-0.0.25/src/sing_box_service/service.py → sing_box_cli-0.0.27/src/sing_box_service/service/manager.py +21 -29
  18. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/tests/test_config.py +1 -1
  19. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/uv.lock +2 -0
  20. sing_box_cli-0.0.25/src/sing_box_service/client.py +0 -245
  21. sing_box_cli-0.0.25/src/sing_box_service/main.py +0 -262
  22. sing_box_cli-0.0.25/src/sing_box_service/run.py +0 -24
  23. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/.github/dependabot.yml +0 -0
  24. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/.github/workflows/main.yml +0 -0
  25. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/.gitignore +0 -0
  26. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/.pre-commit-config.yaml +0 -0
  27. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/LICENSE +0 -0
  28. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/README.md +0 -0
  29. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/assets/image.png +0 -0
  30. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/scripts/bump.sh +0 -0
  31. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/scripts/format.sh +0 -0
  32. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/scripts/lint.sh +0 -0
  33. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/scripts/pre-commit.sh +0 -0
  34. {sing_box_cli-0.0.25/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/widget.py +0 -0
  35. {sing_box_cli-0.0.25 → sing_box_cli-0.0.27}/src/sing_box_service/py.typed +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.0.27 - 2025-03-21
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - Catch `asyncio.exceptions.CancelledError` while canceling stats display
10
+ - Logic of load groups, when to update delay and load delay history
11
+
12
+ ### 🚜 Refactor
13
+
14
+ - Replace SingBoxCli class with typer context and modularize commands files
15
+ - Make_stream_request
16
+ - Display logs from api_client
17
+ - Simplify config_dir
18
+
19
+ ### ⚙️ Miscellaneous Tasks
20
+
21
+ - Remove EmptyResponse
22
+ - Simplify api commands in top level
23
+
24
+ ## 0.0.26 - 2025-03-20
25
+
26
+ ### 🚜 Refactor
27
+
28
+ - Validate responses using pydantic
29
+
5
30
  ## 0.0.25 - 2025-03-20
6
31
 
7
32
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sing-box-cli
3
- Version: 0.0.25
3
+ Version: 0.0.27
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
@@ -42,6 +42,7 @@ Requires-Dist: httpx>=0.28.1
42
42
  Requires-Dist: plotext>=5.3.2
43
43
  Requires-Dist: prompt-toolkit>=3.0.50
44
44
  Requires-Dist: psutil>=6.1.1
45
+ Requires-Dist: pydantic>=2.10.6
45
46
  Requires-Dist: typer>=0.15.1
46
47
  Description-Content-Type: text/markdown
47
48
 
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "plotext>=5.3.2",
29
29
  "prompt-toolkit>=3.0.50",
30
30
  "psutil>=6.1.1",
31
+ "pydantic>=2.10.6",
31
32
  "typer>=0.15.1",
32
33
  ]
33
34
 
@@ -212,7 +213,7 @@ sort_commits = "oldest"
212
213
 
213
214
 
214
215
  [tool.bumpversion]
215
- current_version = "0.0.25"
216
+ current_version = "0.0.27"
216
217
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
217
218
  serialize = ["{major}.{minor}.{patch}"]
218
219
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.0.25"
5
+ __version__ = "0.0.27"
@@ -0,0 +1,99 @@
1
+ import asyncio
2
+ from functools import lru_cache
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from ..common import LogLevel, StrOrNone
8
+ from ..config import SingBoxConfig
9
+ from .client import SingBoxAPIClient
10
+ from .connections import ConnectionsManager
11
+ from .logs import get_logs
12
+ from .monitor import ResourceMonitor, ResourceVisualizer
13
+ from .policy import PolicyGroupManager
14
+
15
+ ApiUrlOption = Annotated[
16
+ StrOrNone,
17
+ typer.Option(
18
+ "--base-url",
19
+ "-u",
20
+ help="Base URL of the sing-box API, read from configuration file if not provided",
21
+ ),
22
+ ]
23
+ ApiTokenOption = Annotated[
24
+ StrOrNone,
25
+ typer.Option(
26
+ "--token",
27
+ "-t",
28
+ help="Authentication token for the sing-box API, read from configuration file if not provided",
29
+ ),
30
+ ]
31
+
32
+ LogLevelOption = Annotated[
33
+ LogLevel,
34
+ typer.Option(
35
+ "--log-level",
36
+ "-l",
37
+ help="Log level of trace, debug, info, warning, error, fatal, panic",
38
+ case_sensitive=False,
39
+ ),
40
+ ]
41
+
42
+ api = typer.Typer(help="sing-box manager.")
43
+
44
+
45
+ @lru_cache
46
+ def create_client(
47
+ config: SingBoxConfig, base_url: StrOrNone = None, token: StrOrNone = None
48
+ ) -> SingBoxAPIClient:
49
+ # read from config if not provided
50
+ if base_url is None:
51
+ base_url = config.api_base_url
52
+ if token is None:
53
+ token = config.api_secret
54
+ return SingBoxAPIClient(base_url, token)
55
+
56
+
57
+ @api.command()
58
+ def stats(
59
+ ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
60
+ ) -> None:
61
+ """Show sing-box traffic, memory statistics and connections, requires API token(Optional)"""
62
+
63
+ api_client = create_client(ctx.obj.config, base_url, token)
64
+ visualizer = ResourceVisualizer()
65
+ monitor = ResourceMonitor(api_client, visualizer)
66
+ asyncio.run(monitor.start())
67
+
68
+
69
+ @api.command()
70
+ def conns(
71
+ ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
72
+ ) -> None:
73
+ """Manage sing-box connections, requires API token(Optional)"""
74
+ api_client = create_client(ctx.obj.config, base_url, token)
75
+ manager = ConnectionsManager(api_client)
76
+ asyncio.run(manager.run())
77
+
78
+
79
+ @api.command()
80
+ def proxy(
81
+ ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
82
+ ) -> None:
83
+ """Manage sing-box policy groups, requires API token(Optional)"""
84
+ api_client = create_client(ctx.obj.config, base_url, token)
85
+ manager = PolicyGroupManager(api_client)
86
+ asyncio.run(manager.run())
87
+
88
+
89
+ @api.command()
90
+ def logs(
91
+ ctx: typer.Context,
92
+ log_level: LogLevelOption = LogLevel.info,
93
+ base_url: ApiUrlOption = None,
94
+ token: ApiTokenOption = None,
95
+ ) -> None:
96
+ """Show sing-box logs, requires API token(Optional)"""
97
+ api_client = create_client(ctx.obj.config, base_url, token)
98
+ print("⌛ Showing real-time logs (Press Ctrl+C to exit)")
99
+ asyncio.run(get_logs(api_client, log_level))
@@ -0,0 +1,367 @@
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, TypeVar
9
+
10
+ import httpx
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+
14
+ class FrozenBaseModel(BaseModel):
15
+ """Base model with frozen configuration."""
16
+
17
+ model_config = ConfigDict(frozen=True)
18
+
19
+
20
+ # Define Pydantic models for API responses
21
+ class TrafficData(FrozenBaseModel):
22
+ """http://base_url/traffic/"""
23
+
24
+ up: int = Field(default=0, description="Upload speed in B/s")
25
+ down: int = Field(default=0, description="Download speed in B/s")
26
+
27
+
28
+ class MemoryData(FrozenBaseModel):
29
+ """http://base_url/memory/"""
30
+
31
+ inuse: int = Field(default=0, description="Memory in use in bytes")
32
+ total: int = Field(default=0, description="Total memory in bytes")
33
+
34
+
35
+ class ConnectionMetadata(FrozenBaseModel):
36
+ destinationIP: str = ""
37
+ destinationPort: str
38
+ dnsMode: str
39
+ host: str = ""
40
+ network: str = Field(description="Network type")
41
+ processPath: str = ""
42
+ sourceIP: str
43
+ sourcePort: str
44
+ type: str = Field(description="Inbound type")
45
+
46
+
47
+ class ConnectionInfo(FrozenBaseModel):
48
+ """https://base_url/connections/"""
49
+
50
+ id: str
51
+ download: int = Field(default=0, description="Download in bytes")
52
+ upload: int = Field(default=0, description="Upload in bytes")
53
+ rule: str = Field(description="Rule name => outbound")
54
+ start: str = Field(description="Start time")
55
+ chains: list[str] = Field(description="Proxy chains list")
56
+ metadata: ConnectionMetadata
57
+
58
+
59
+ class ConnectionData(FrozenBaseModel):
60
+ uploadTotal: int = Field(default=0, description="Total upload in bytes")
61
+ downloadTotal: int = Field(default=0, description="Total download in bytes")
62
+ memory: int = Field(default=0, description="Memory usage in bytes")
63
+ connections: list[ConnectionInfo] = Field(description="List of active connections")
64
+
65
+
66
+ class DelayHistory(FrozenBaseModel):
67
+ time: str = ""
68
+ delay: int = Field(default=0, description="Outbound delay in milliseconds")
69
+
70
+
71
+ class GroupInfo(FrozenBaseModel):
72
+ """http://base_url/group/{group_name}/"""
73
+
74
+ type: str = Field(description="Proxy Group type")
75
+ name: str
76
+ udp: bool = Field(description="UDP support")
77
+ history: list[DelayHistory] = Field(
78
+ description="History delay of selected proxies", default=[]
79
+ )
80
+ now: str = Field(description="Currently selected proxy")
81
+ all: list[str] = Field(description="List of all outbound proxies")
82
+
83
+
84
+ class GroupsData(FrozenBaseModel):
85
+ """http://base_url/group/"""
86
+
87
+ proxies: list[GroupInfo] = Field(description="List of proxies in the group")
88
+
89
+
90
+ class DelayTestResult(FrozenBaseModel):
91
+ """Delay test result model."""
92
+
93
+ outbound: str = Field(default="", description="Outbound proxy name")
94
+ delay: int = Field(default=0, description="Outbound delay in milliseconds")
95
+
96
+
97
+ class LogEntry(FrozenBaseModel):
98
+ type: str = Field(description="Logs level")
99
+ payload: str = Field(description="Log message")
100
+
101
+
102
+ # Generic response type for API methods
103
+ T = TypeVar("T", bound=FrozenBaseModel)
104
+
105
+
106
+ class SingBoxAPIClient:
107
+ """Async client for interacting with Sing-Box API.
108
+
109
+ Args:
110
+ base_url: The base URL of the Sing-Box API
111
+ token: The API authentication token
112
+ timeout: Request timeout in seconds
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ base_url: str = "http://127.0.0.1:9090",
118
+ token: str | None = None,
119
+ timeout: float = 10.0,
120
+ ) -> None:
121
+ self.base_url = base_url
122
+ self.headers = {"Authorization": f"Bearer {token}"} if token else {}
123
+ self.timeout = timeout
124
+ if not self._health_check():
125
+ raise ValueError(f"Invalid to initialize client: {self.base_url}")
126
+
127
+ def _health_check(self) -> bool:
128
+ """Check if the API is reachable.
129
+
130
+ Returns:
131
+ True if the API is reachable, False otherwise
132
+ """
133
+ try:
134
+ response = httpx.get(
135
+ f"{self.base_url}", timeout=self.timeout, headers=self.headers
136
+ )
137
+ return response.status_code == 200
138
+ except httpx.HTTPError:
139
+ return False
140
+
141
+ async def _make_request_raw(
142
+ self,
143
+ method: str,
144
+ endpoint: str,
145
+ params: dict[str, Any] | None = None,
146
+ data: dict[str, Any] | None = None,
147
+ ) -> dict[str, Any]:
148
+ """
149
+ Make a request to the API.
150
+
151
+ Args:
152
+ method: HTTP method (GET, POST, etc.)
153
+ endpoint: API endpoint
154
+ params: URL parameters
155
+ data: Request body data
156
+
157
+ Returns:
158
+ JSON response as dictionary
159
+
160
+ Raises:
161
+ httpx.HTTPError: If the request fails
162
+ """
163
+ url = f"{self.base_url}{endpoint}"
164
+ async with httpx.AsyncClient() as client:
165
+ response = await client.request(
166
+ method=method,
167
+ url=url,
168
+ params=params,
169
+ json=data,
170
+ headers=self.headers,
171
+ timeout=self.timeout,
172
+ )
173
+ response.raise_for_status()
174
+ if response.content:
175
+ return dict(response.json())
176
+ return {}
177
+
178
+ async def _make_request(
179
+ self,
180
+ method: str,
181
+ endpoint: str,
182
+ model_class: type[T],
183
+ params: dict[str, Any] | None = None,
184
+ data: dict[str, Any] | None = None,
185
+ ) -> T:
186
+ """Make a request and return data as a specific model."""
187
+ raw = await self._make_request_raw(method, endpoint, params, data)
188
+ return model_class.model_validate(raw)
189
+
190
+ async def _make_stream_request(
191
+ self, endpoint: str, model_class: type[T], params: dict[str, str] | None = None
192
+ ) -> AsyncGenerator[T, None]:
193
+ """
194
+ Make a streaming request to the API.
195
+
196
+ Args:
197
+ endpoint: API endpoint
198
+ model_class: Pydantic model class to validate response data
199
+ params: Optional query parameters for the request
200
+
201
+ Yields:
202
+ Validated model instances from the stream
203
+ """
204
+ url = f"{self.base_url}{endpoint}"
205
+
206
+ async with httpx.AsyncClient() as client:
207
+ async with client.stream(
208
+ "GET", url, headers=self.headers, params=params, timeout=None
209
+ ) as response:
210
+ response.raise_for_status()
211
+ async for line in response.aiter_lines():
212
+ if line.strip(): # Skip empty lines
213
+ try:
214
+ data = json.loads(line)
215
+ yield model_class.model_validate(data)
216
+ except json.JSONDecodeError:
217
+ # Skip invalid JSON lines
218
+ continue
219
+
220
+ async def traffic_stream(self) -> AsyncGenerator[TrafficData, None]:
221
+ """
222
+ Get traffic statistics as a stream of updates.
223
+
224
+ Yields:
225
+ TrafficData object containing traffic data (up/down in B/s)
226
+ """
227
+ async for data in self._make_stream_request("/traffic", TrafficData):
228
+ yield data
229
+
230
+ async def memory_stream(self) -> AsyncGenerator[MemoryData, None]:
231
+ """
232
+ Get memory statistics as a stream of updates.
233
+
234
+ Yields:
235
+ MemoryData object containing memory data (inuse/total in bytes)
236
+ """
237
+ async for data in self._make_stream_request("/memory", MemoryData):
238
+ yield data
239
+
240
+ async def log_stream(
241
+ self, level: str | None = None
242
+ ) -> AsyncGenerator[LogEntry, None]:
243
+ """
244
+ Get log entries as a stream of updates.
245
+
246
+ Yields:
247
+ LogEntry object containing log data (type/payload)
248
+ """
249
+ params = {"level": level} if level else None
250
+ async for data in self._make_stream_request("/logs", LogEntry, params=params):
251
+ yield data
252
+
253
+ async def get_connections(self) -> ConnectionData:
254
+ """
255
+ Get current connections.
256
+
257
+ Returns:
258
+ ConnectionData object containing active connections
259
+ """
260
+ return await self._make_request(
261
+ "GET", "/connections", model_class=ConnectionData
262
+ )
263
+
264
+ async def close_connection(self, connection_id: str) -> dict[str, Any]:
265
+ """
266
+ Close a specific connection.
267
+
268
+ Args:
269
+ connection_id: ID of the connection to close
270
+
271
+ Returns:
272
+ Response from the API
273
+ """
274
+ return await self._make_request_raw("DELETE", f"/connections/{connection_id}")
275
+
276
+ async def close_all_connections(self) -> dict[str, Any]:
277
+ """
278
+ Close all connections.
279
+
280
+ Returns:
281
+ Response from the API
282
+ """
283
+ return await self._make_request_raw("DELETE", "/connections")
284
+
285
+ async def get_groups(self) -> GroupsData:
286
+ """
287
+ Get all policy groups.
288
+
289
+ Returns:
290
+ GroupData object containing policy groups information
291
+ """
292
+ return await self._make_request("GET", "/group", model_class=GroupsData)
293
+
294
+ async def get_group(self, group_name: str) -> GroupInfo:
295
+ """
296
+ Get information about a specific policy group.
297
+
298
+ Args:
299
+ group_name: Name of the policy group
300
+
301
+ Returns:
302
+ GroupData object containing the policy group information
303
+ """
304
+ return await self._make_request(
305
+ "GET", f"/group/{group_name}", model_class=GroupInfo
306
+ )
307
+
308
+ async def test_group_delay(
309
+ self,
310
+ group_name: str,
311
+ url: str = "https://cp.cloudflare.com/generate_204",
312
+ timeout: int = 5000,
313
+ ) -> list[DelayTestResult]:
314
+ """
315
+ Test delay for all proxies in a policy group.
316
+
317
+ Args:
318
+ group_name: Name of the policy group
319
+ timeout: Timeout in milliseconds
320
+ """
321
+ params = {"url": url, "timeout": timeout}
322
+ reps = await self._make_request_raw(
323
+ "GET", f"/group/{group_name}/delay", params=params
324
+ )
325
+ return [
326
+ DelayTestResult(outbound=key, delay=value) for key, value in reps.items()
327
+ ]
328
+
329
+ async def test_proxy_delay(
330
+ self,
331
+ proxy_name: str,
332
+ url: str = "https://cp.cloudflare.com/generate_204",
333
+ timeout: int = 5000,
334
+ ) -> DelayTestResult:
335
+ """
336
+ Test delay for a specific proxy.
337
+
338
+ Args:
339
+ timeout: Timeout in milliseconds
340
+ """
341
+ params = {"url": url, "timeout": timeout}
342
+ return await self._make_request(
343
+ "GET",
344
+ f"/proxies/{proxy_name}/delay",
345
+ params=params,
346
+ model_class=DelayTestResult,
347
+ )
348
+
349
+ async def select_proxy(self, proxy_name: str, selected: str) -> dict[str, Any]:
350
+ """
351
+ Select a proxy for a selector proxy group.
352
+
353
+ Args:
354
+ proxy_name: Name of the proxy selector
355
+ selected: Name of the proxy to select
356
+ """
357
+ data = {"name": selected}
358
+ return await self._make_request_raw("PUT", f"/proxies/{proxy_name}", data=data)
359
+
360
+ async def get_version(self) -> dict[str, Any]:
361
+ """
362
+ Get Sing-Box version.
363
+
364
+ Returns:
365
+ Dictionary containing version information
366
+ """
367
+ return await self._make_request_raw("GET", "/version")