sing-box-cli 0.1.6__tar.gz → 0.1.8__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.1.6 → sing_box_cli-0.1.8}/PKG-INFO +1 -1
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/docs/config-serve.md +3 -3
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/pyproject.toml +1 -1
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/__init__.py +1 -1
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/__init__.py +8 -1
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/client.py +11 -2
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/connections.py +9 -1
- sing_box_cli-0.1.8/src/sing_box_cli/api/logs.py +89 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/monitor.py +56 -22
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/serve.py +3 -41
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/main.py +2 -2
- sing_box_cli-0.1.6/src/sing_box_cli/api/logs.py +0 -93
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/.gitignore +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/LICENSE +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/README.md +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/docs/traefik-config-serve.yml +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/hatch_build.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/policy.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/widget.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/common.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/__init__.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/_serve_worker.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/config.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/serve_backend.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/serve_tokens.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/utils.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/installation.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/py.typed +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/service/__init__.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/service/manager.py +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/common.go +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/common_test.go +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/go.mod +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/go.sum +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/integration_windows_test.go +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/main_other.go +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/main_windows.go +0 -0
- {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/testdata/core.go +0 -0
|
@@ -33,9 +33,9 @@ every token for that file without affecting other files. Multiple tokens may
|
|
|
33
33
|
authorize the same file, so rotation can create a replacement before revoking the
|
|
34
34
|
old token. Revocation never prints the secret.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
`config serve token create/list/revoke` subcommands
|
|
38
|
-
|
|
36
|
+
Token management uses only the top-level `sbc token` options. The previous
|
|
37
|
+
`config serve token` entry point and `token create/list/revoke` subcommands have
|
|
38
|
+
been removed.
|
|
39
39
|
|
|
40
40
|
Clients can send the token as a Bearer header using `-t` / `--token`, or use the
|
|
41
41
|
printed URL with `?token=...` in applications that cannot set headers:
|
|
@@ -215,7 +215,7 @@ topo_order = false
|
|
|
215
215
|
sort_commits = "oldest"
|
|
216
216
|
|
|
217
217
|
[tool.bumpversion]
|
|
218
|
-
current_version = "0.1.
|
|
218
|
+
current_version = "0.1.8"
|
|
219
219
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
220
220
|
serialize = ["{major}.{minor}.{patch}"]
|
|
221
221
|
search = "{current_version}"
|
|
@@ -2,12 +2,13 @@ import asyncio
|
|
|
2
2
|
from collections.abc import Callable, Coroutine
|
|
3
3
|
from typing import Annotated, Any
|
|
4
4
|
|
|
5
|
+
import httpx
|
|
5
6
|
import typer
|
|
6
7
|
|
|
7
8
|
from ..common import LogLevel, StrOrNone
|
|
8
9
|
from ..config.config import ConfigHandler
|
|
9
10
|
from ..service import get_context_obj
|
|
10
|
-
from .client import SingBoxAPIClient
|
|
11
|
+
from .client import SingBoxAPIClient, SingBoxAPIError
|
|
11
12
|
from .connections import ConnectionsManager
|
|
12
13
|
from .logs import get_logs
|
|
13
14
|
from .monitor import ResourceMonitor, ResourceVisualizer
|
|
@@ -67,6 +68,12 @@ def _run_tui(
|
|
|
67
68
|
asyncio.run(command(api_client))
|
|
68
69
|
except (KeyboardInterrupt, EOFError):
|
|
69
70
|
pass
|
|
71
|
+
except SingBoxAPIError as e:
|
|
72
|
+
typer.echo(f"Error: {e}", err=True)
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
except httpx.HTTPError as e:
|
|
75
|
+
typer.echo(f"Error: sing-box API request failed: {e}", err=True)
|
|
76
|
+
raise typer.Exit(1)
|
|
70
77
|
print("Exited.")
|
|
71
78
|
|
|
72
79
|
|
|
@@ -104,6 +104,10 @@ class LogEntry(FrozenBaseModel):
|
|
|
104
104
|
T = TypeVar("T", bound=FrozenBaseModel)
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
class SingBoxAPIError(Exception):
|
|
108
|
+
"""The sing-box API is unreachable or rejected the client."""
|
|
109
|
+
|
|
110
|
+
|
|
107
111
|
class SingBoxAPIClient:
|
|
108
112
|
"""Async client for interacting with Sing-Box API.
|
|
109
113
|
|
|
@@ -123,7 +127,10 @@ class SingBoxAPIClient:
|
|
|
123
127
|
self.headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
124
128
|
self.timeout = timeout
|
|
125
129
|
if not self._health_check():
|
|
126
|
-
raise
|
|
130
|
+
raise SingBoxAPIError(
|
|
131
|
+
f"sing-box API is not reachable at {self.base_url}, "
|
|
132
|
+
"check that sing-box is running and the API URL and token are correct"
|
|
133
|
+
)
|
|
127
134
|
|
|
128
135
|
def _health_check(self) -> bool:
|
|
129
136
|
"""Check if the API is reachable.
|
|
@@ -204,9 +211,11 @@ class SingBoxAPIClient:
|
|
|
204
211
|
"""
|
|
205
212
|
url = f"{self.base_url}{endpoint}"
|
|
206
213
|
|
|
214
|
+
# streams may stay idle for long, so only reads are unbounded
|
|
215
|
+
timeout = httpx.Timeout(self.timeout, read=None)
|
|
207
216
|
async with httpx.AsyncClient() as client:
|
|
208
217
|
async with client.stream(
|
|
209
|
-
"GET", url, headers=self.headers, params=params, timeout=
|
|
218
|
+
"GET", url, headers=self.headers, params=params, timeout=timeout
|
|
210
219
|
) as response:
|
|
211
220
|
response.raise_for_status()
|
|
212
221
|
async for line in response.aiter_lines():
|
|
@@ -440,10 +440,18 @@ class ConnectionsManager:
|
|
|
440
440
|
pass
|
|
441
441
|
|
|
442
442
|
async def periodic_refresh(self, interval: float = 0.5) -> None:
|
|
443
|
-
"""Periodically refresh connections."""
|
|
443
|
+
"""Periodically refresh connections, retrying while the API is unreachable."""
|
|
444
|
+
failed = False
|
|
444
445
|
while self.running:
|
|
445
446
|
try:
|
|
446
447
|
await asyncio.sleep(interval)
|
|
447
448
|
await self.refresh_connections()
|
|
448
449
|
except asyncio.CancelledError:
|
|
449
450
|
break
|
|
451
|
+
except Exception:
|
|
452
|
+
# refresh_connections already shows the error in the status bar
|
|
453
|
+
failed = True
|
|
454
|
+
continue
|
|
455
|
+
if failed:
|
|
456
|
+
failed = False
|
|
457
|
+
self.status_message = self.get_filter_state()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from contextlib import aclosing
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from rich import print
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from ..common import LogLevel
|
|
9
|
+
from .client import SingBoxAPIClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_logs(client: SingBoxAPIClient, log_level: LogLevel) -> None:
|
|
13
|
+
# Define color mapping for log levels with brighter colors
|
|
14
|
+
# Ref: https://rich.readthedocs.io/en/stable/appendix/colors.html
|
|
15
|
+
log_level_colors = {
|
|
16
|
+
"trace": "bright_black",
|
|
17
|
+
"debug": "grey66",
|
|
18
|
+
"info": "bright_cyan",
|
|
19
|
+
"warning": "bright_yellow",
|
|
20
|
+
"error": "bright_red",
|
|
21
|
+
"fatal": "bright_magenta",
|
|
22
|
+
"panic": "bright_magenta",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Bright colors for IDs
|
|
26
|
+
id_colors = [
|
|
27
|
+
"bright_red",
|
|
28
|
+
"bright_green",
|
|
29
|
+
"bright_yellow",
|
|
30
|
+
"bright_blue",
|
|
31
|
+
"bright_magenta",
|
|
32
|
+
"bright_cyan",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Dictionary to store ID to color mapping
|
|
36
|
+
id_color_map = {}
|
|
37
|
+
|
|
38
|
+
async with aclosing(client.log_stream(log_level.value.lower())) as stream:
|
|
39
|
+
async for entry in stream:
|
|
40
|
+
# Get current timestamp with automatic timezone offset
|
|
41
|
+
now = datetime.now().astimezone()
|
|
42
|
+
timestamp = now.strftime("%z %Y-%m-%d %H:%M:%S")
|
|
43
|
+
|
|
44
|
+
# Get level color
|
|
45
|
+
# Start building the output message
|
|
46
|
+
message = Text()
|
|
47
|
+
message.append(timestamp, style="bright_black")
|
|
48
|
+
message.append(" ")
|
|
49
|
+
message.append(
|
|
50
|
+
entry.type.upper(),
|
|
51
|
+
style=log_level_colors.get(entry.type.lower(), "bright_white"),
|
|
52
|
+
)
|
|
53
|
+
message.append(" ")
|
|
54
|
+
|
|
55
|
+
# Process the payload
|
|
56
|
+
payload = entry.payload
|
|
57
|
+
|
|
58
|
+
# Find all ID patterns in the payload
|
|
59
|
+
pattern = r"\[(\d+) (\d+ms)\]"
|
|
60
|
+
last_end = 0
|
|
61
|
+
|
|
62
|
+
for match in re.finditer(pattern, payload):
|
|
63
|
+
# Add text before the match
|
|
64
|
+
message.append(payload[last_end : match.start()])
|
|
65
|
+
|
|
66
|
+
# Extract ID and time part
|
|
67
|
+
log_id = match.group(1)
|
|
68
|
+
time_part = match.group(2)
|
|
69
|
+
|
|
70
|
+
# Assign a consistent color to this ID
|
|
71
|
+
if log_id not in id_color_map:
|
|
72
|
+
color_index = hash(log_id) % len(id_colors)
|
|
73
|
+
id_color_map[log_id] = id_colors[color_index]
|
|
74
|
+
|
|
75
|
+
id_color = id_color_map[log_id]
|
|
76
|
+
|
|
77
|
+
# Add the ID with coloring
|
|
78
|
+
message.append("[", style="default")
|
|
79
|
+
message.append(log_id, style=id_color)
|
|
80
|
+
message.append(f" {time_part}]", style="default")
|
|
81
|
+
|
|
82
|
+
last_end = match.end()
|
|
83
|
+
|
|
84
|
+
# Add any remaining text
|
|
85
|
+
if last_end < len(payload):
|
|
86
|
+
message.append(payload[last_end:])
|
|
87
|
+
|
|
88
|
+
# Print the complete message
|
|
89
|
+
print(message)
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import math
|
|
3
3
|
import statistics
|
|
4
4
|
from collections import deque
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable
|
|
5
6
|
from contextlib import aclosing
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from enum import Enum
|
|
@@ -14,7 +15,13 @@ from rich.panel import Panel
|
|
|
14
15
|
from rich.table import Table
|
|
15
16
|
from rich.text import Text
|
|
16
17
|
|
|
17
|
-
from .client import
|
|
18
|
+
from .client import (
|
|
19
|
+
ConnectionInfo,
|
|
20
|
+
FrozenBaseModel,
|
|
21
|
+
MemoryData,
|
|
22
|
+
SingBoxAPIClient,
|
|
23
|
+
TrafficData,
|
|
24
|
+
)
|
|
18
25
|
from .widget import RichPlotMixin, create_header_panel
|
|
19
26
|
|
|
20
27
|
|
|
@@ -575,7 +582,10 @@ class ResourceVisualizer:
|
|
|
575
582
|
return table
|
|
576
583
|
|
|
577
584
|
def create_resources_layout(
|
|
578
|
-
self,
|
|
585
|
+
self,
|
|
586
|
+
traffic_data: TrafficData,
|
|
587
|
+
memory_data: MemoryData,
|
|
588
|
+
api_error: str | None = None,
|
|
579
589
|
) -> Layout:
|
|
580
590
|
"""Create the main layout with all resource components."""
|
|
581
591
|
layout = Layout()
|
|
@@ -617,7 +627,17 @@ class ResourceVisualizer:
|
|
|
617
627
|
)
|
|
618
628
|
|
|
619
629
|
# Add footer
|
|
620
|
-
|
|
630
|
+
if api_error:
|
|
631
|
+
footer = Text(
|
|
632
|
+
f"{api_error} | Press Ctrl+C to exit",
|
|
633
|
+
style="bold red",
|
|
634
|
+
justify="center",
|
|
635
|
+
no_wrap=True,
|
|
636
|
+
overflow="ellipsis",
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
footer = Text("Press Ctrl+C to exit", justify="center")
|
|
640
|
+
layout["footer"].update(footer)
|
|
621
641
|
|
|
622
642
|
return layout
|
|
623
643
|
|
|
@@ -642,30 +662,44 @@ class ResourceMonitor:
|
|
|
642
662
|
self.current_memory = MemoryData(inuse=0, total=0) # in bytes
|
|
643
663
|
# 1.0 fix interval for avoiding repeated data
|
|
644
664
|
self.task_interval = RefreshRate.SLOW.value
|
|
665
|
+
self.reconnect_interval = RefreshRate.SLOW.value
|
|
666
|
+
self.api_error: str | None = None
|
|
645
667
|
|
|
646
668
|
async def monitor_traffic(self) -> None:
|
|
647
669
|
"""Monitor traffic stream from the API."""
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
self.current_traffic = traffic_data
|
|
652
|
-
if not self.running:
|
|
653
|
-
break
|
|
654
|
-
await asyncio.sleep(self.task_interval)
|
|
655
|
-
except Exception as e:
|
|
656
|
-
self.visualizer.console.print(f"[red]Error monitoring traffic: {str(e)}")
|
|
670
|
+
await self._follow_stream(
|
|
671
|
+
self.api_client.traffic_stream, "current_traffic", TrafficData()
|
|
672
|
+
)
|
|
657
673
|
|
|
658
674
|
async def monitor_memory(self) -> None:
|
|
659
675
|
"""Monitor memory stream from the API."""
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
676
|
+
await self._follow_stream(
|
|
677
|
+
self.api_client.memory_stream, "current_memory", MemoryData()
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
async def _follow_stream(
|
|
681
|
+
self,
|
|
682
|
+
open_stream: Callable[[], AsyncGenerator[FrozenBaseModel, None]],
|
|
683
|
+
attr: str,
|
|
684
|
+
empty: FrozenBaseModel,
|
|
685
|
+
) -> None:
|
|
686
|
+
"""Keep ``attr`` updated from a stream, reopening it whenever it ends."""
|
|
687
|
+
while self.running:
|
|
688
|
+
try:
|
|
689
|
+
async with aclosing(open_stream()) as stream:
|
|
690
|
+
async for data in stream:
|
|
691
|
+
setattr(self, attr, data)
|
|
692
|
+
self.api_error = None
|
|
693
|
+
if not self.running:
|
|
694
|
+
return
|
|
695
|
+
await asyncio.sleep(self.task_interval)
|
|
696
|
+
reason = "closed by sing-box"
|
|
697
|
+
except Exception as e:
|
|
698
|
+
reason = str(e) or type(e).__name__
|
|
699
|
+
# don't keep showing stale numbers while sing-box is down
|
|
700
|
+
setattr(self, attr, empty)
|
|
701
|
+
self.api_error = f"sing-box API stream lost ({reason}), reconnecting..."
|
|
702
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
669
703
|
|
|
670
704
|
async def refresh_display(self) -> None:
|
|
671
705
|
"""Main display refresh loop."""
|
|
@@ -685,7 +719,7 @@ class ResourceMonitor:
|
|
|
685
719
|
|
|
686
720
|
# Update display
|
|
687
721
|
layout = self.visualizer.create_resources_layout(
|
|
688
|
-
traffic_data, memory_data
|
|
722
|
+
traffic_data, memory_data, self.api_error
|
|
689
723
|
)
|
|
690
724
|
live.update(layout)
|
|
691
725
|
|
|
@@ -25,17 +25,10 @@ from .serve_backend import (
|
|
|
25
25
|
state_directory,
|
|
26
26
|
validate_config,
|
|
27
27
|
)
|
|
28
|
-
from .serve_tokens import
|
|
29
|
-
create_token,
|
|
30
|
-
load_tokens,
|
|
31
|
-
revoke_subscription_tokens,
|
|
32
|
-
revoke_token,
|
|
33
|
-
)
|
|
28
|
+
from .serve_tokens import create_token, load_tokens, revoke_subscription_tokens
|
|
34
29
|
|
|
35
30
|
WORKER_MODULE = "sing_box_cli.config._serve_worker"
|
|
36
31
|
serve = typer.Typer(help="Serve private GitHub configurations through your own domain.")
|
|
37
|
-
tokens = typer.Typer(help="Create, list and revoke independent subscription tokens.")
|
|
38
|
-
serve.add_typer(tokens, name="token")
|
|
39
32
|
|
|
40
33
|
|
|
41
34
|
class ProcessState(BaseModel):
|
|
@@ -278,7 +271,6 @@ def start(
|
|
|
278
271
|
print(f"Log: {directory / 'serve.log'}")
|
|
279
272
|
|
|
280
273
|
|
|
281
|
-
@tokens.callback(invoke_without_command=True)
|
|
282
274
|
def token_options(
|
|
283
275
|
ctx: typer.Context,
|
|
284
276
|
list_: Annotated[
|
|
@@ -310,12 +302,8 @@ def token_options(
|
|
|
310
302
|
str | None, typer.Option("--name", "-n", help="Optional name for a new token.")
|
|
311
303
|
] = None,
|
|
312
304
|
) -> None:
|
|
313
|
-
"""
|
|
305
|
+
"""Create, list and revoke independent subscription tokens."""
|
|
314
306
|
actions = int(list_) + int(create is not None) + int(revoke is not None)
|
|
315
|
-
if ctx.invoked_subcommand is not None:
|
|
316
|
-
if actions or domain is not None or name is not None:
|
|
317
|
-
raise typer.BadParameter("Use options or a legacy subcommand, not both.")
|
|
318
|
-
return
|
|
319
307
|
if actions == 0 and domain is None and name is None:
|
|
320
308
|
print(ctx.get_help())
|
|
321
309
|
return
|
|
@@ -348,18 +336,7 @@ def token_options(
|
|
|
348
336
|
print(f"Revoked {count} subscription token(s).")
|
|
349
337
|
|
|
350
338
|
|
|
351
|
-
|
|
352
|
-
def token_create(
|
|
353
|
-
name: Annotated[
|
|
354
|
-
str, typer.Argument(help="Unique name for this subscription token.")
|
|
355
|
-
],
|
|
356
|
-
domain: Annotated[
|
|
357
|
-
str, typer.Option("--domain", help="Registered subscription domain.")
|
|
358
|
-
],
|
|
359
|
-
filename: Annotated[
|
|
360
|
-
str, typer.Option("--file", help="Root configuration filename.")
|
|
361
|
-
],
|
|
362
|
-
) -> None:
|
|
339
|
+
def token_create(name: str, domain: str, filename: str) -> None:
|
|
363
340
|
"""Generate a file-scoped token and print its secret and URL once."""
|
|
364
341
|
with command_errors():
|
|
365
342
|
domain = normalize_domain(domain)
|
|
@@ -379,7 +356,6 @@ def token_create(
|
|
|
379
356
|
print("Save the token now; only its hash is stored on the server.")
|
|
380
357
|
|
|
381
358
|
|
|
382
|
-
@tokens.command("list", hidden=True)
|
|
383
359
|
def token_list() -> None:
|
|
384
360
|
"""List token names and scopes without displaying their secrets."""
|
|
385
361
|
with command_errors():
|
|
@@ -390,20 +366,6 @@ def token_list() -> None:
|
|
|
390
366
|
print(f"{item.name}\t{item.domain}\t{item.filename}\t{item.created_at}")
|
|
391
367
|
|
|
392
368
|
|
|
393
|
-
@tokens.command("revoke", hidden=True)
|
|
394
|
-
def token_revoke(
|
|
395
|
-
name: Annotated[
|
|
396
|
-
str, typer.Argument(help="Name of the subscription token to revoke.")
|
|
397
|
-
],
|
|
398
|
-
) -> None:
|
|
399
|
-
"""Revoke a token; the next request is denied without a service restart."""
|
|
400
|
-
with command_errors():
|
|
401
|
-
directory = state_directory()
|
|
402
|
-
with control_lock(directory):
|
|
403
|
-
revoke_token(directory, name)
|
|
404
|
-
print(f"Revoked subscription token: {name}")
|
|
405
|
-
|
|
406
|
-
|
|
407
369
|
@serve.command("stop")
|
|
408
370
|
def stop() -> None:
|
|
409
371
|
"""Stop this user's background configuration service."""
|
|
@@ -9,7 +9,7 @@ from sing_box_cli.api import api as api_app
|
|
|
9
9
|
from sing_box_cli.common import ClearCacheOption, UpdateConfigOption, ensure_root
|
|
10
10
|
from sing_box_cli.config import config as config_app
|
|
11
11
|
from sing_box_cli.config.config import get_config, run_args
|
|
12
|
-
from sing_box_cli.config.serve import
|
|
12
|
+
from sing_box_cli.config.serve import token_options
|
|
13
13
|
from sing_box_cli.installation import install_launchers
|
|
14
14
|
from sing_box_cli.service import SharedContext, get_context_obj, service as service_app
|
|
15
15
|
from sing_box_cli.service.manager import create_service
|
|
@@ -18,7 +18,7 @@ app = typer.Typer(help="sing-box manager.")
|
|
|
18
18
|
app.add_typer(api_app)
|
|
19
19
|
app.add_typer(service_app, name="service")
|
|
20
20
|
app.add_typer(config_app, name="config")
|
|
21
|
-
app.
|
|
21
|
+
app.command("token")(token_options)
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@app.callback(invoke_without_command=False)
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
from contextlib import aclosing
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
|
|
5
|
-
from rich import print
|
|
6
|
-
from rich.text import Text
|
|
7
|
-
|
|
8
|
-
from ..common import LogLevel
|
|
9
|
-
from .client import SingBoxAPIClient
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
async def get_logs(client: SingBoxAPIClient, log_level: LogLevel) -> None:
|
|
13
|
-
# Define color mapping for log levels with brighter colors
|
|
14
|
-
# Ref: https://rich.readthedocs.io/en/stable/appendix/colors.html
|
|
15
|
-
log_level_colors = {
|
|
16
|
-
"trace": "bright_black",
|
|
17
|
-
"debug": "grey66",
|
|
18
|
-
"info": "bright_cyan",
|
|
19
|
-
"warning": "bright_yellow",
|
|
20
|
-
"error": "bright_red",
|
|
21
|
-
"fatal": "bright_magenta",
|
|
22
|
-
"panic": "bright_magenta",
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
# Bright colors for IDs
|
|
26
|
-
id_colors = [
|
|
27
|
-
"bright_red",
|
|
28
|
-
"bright_green",
|
|
29
|
-
"bright_yellow",
|
|
30
|
-
"bright_blue",
|
|
31
|
-
"bright_magenta",
|
|
32
|
-
"bright_cyan",
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
# Dictionary to store ID to color mapping
|
|
36
|
-
id_color_map = {}
|
|
37
|
-
|
|
38
|
-
try:
|
|
39
|
-
async with aclosing(client.log_stream(log_level.value.lower())) as stream:
|
|
40
|
-
async for entry in stream:
|
|
41
|
-
# Get current timestamp with automatic timezone offset
|
|
42
|
-
now = datetime.now().astimezone()
|
|
43
|
-
timestamp = now.strftime("%z %Y-%m-%d %H:%M:%S")
|
|
44
|
-
|
|
45
|
-
# Get level color
|
|
46
|
-
# Start building the output message
|
|
47
|
-
message = Text()
|
|
48
|
-
message.append(timestamp, style="bright_black")
|
|
49
|
-
message.append(" ")
|
|
50
|
-
message.append(
|
|
51
|
-
entry.type.upper(),
|
|
52
|
-
style=log_level_colors.get(entry.type.lower(), "bright_white"),
|
|
53
|
-
)
|
|
54
|
-
message.append(" ")
|
|
55
|
-
|
|
56
|
-
# Process the payload
|
|
57
|
-
payload = entry.payload
|
|
58
|
-
|
|
59
|
-
# Find all ID patterns in the payload
|
|
60
|
-
pattern = r"\[(\d+) (\d+ms)\]"
|
|
61
|
-
last_end = 0
|
|
62
|
-
|
|
63
|
-
for match in re.finditer(pattern, payload):
|
|
64
|
-
# Add text before the match
|
|
65
|
-
message.append(payload[last_end : match.start()])
|
|
66
|
-
|
|
67
|
-
# Extract ID and time part
|
|
68
|
-
log_id = match.group(1)
|
|
69
|
-
time_part = match.group(2)
|
|
70
|
-
|
|
71
|
-
# Assign a consistent color to this ID
|
|
72
|
-
if log_id not in id_color_map:
|
|
73
|
-
color_index = hash(log_id) % len(id_colors)
|
|
74
|
-
id_color_map[log_id] = id_colors[color_index]
|
|
75
|
-
|
|
76
|
-
id_color = id_color_map[log_id]
|
|
77
|
-
|
|
78
|
-
# Add the ID with coloring
|
|
79
|
-
message.append("[", style="default")
|
|
80
|
-
message.append(log_id, style=id_color)
|
|
81
|
-
message.append(f" {time_part}]", style="default")
|
|
82
|
-
|
|
83
|
-
last_end = match.end()
|
|
84
|
-
|
|
85
|
-
# Add any remaining text
|
|
86
|
-
if last_end < len(payload):
|
|
87
|
-
message.append(payload[last_end:])
|
|
88
|
-
|
|
89
|
-
# Print the complete message
|
|
90
|
-
print(message)
|
|
91
|
-
except Exception as e:
|
|
92
|
-
print(f"[red]Error:[/red] {e}")
|
|
93
|
-
raise
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|