clashctl 0.1.0__py3-none-any.whl
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.
- clashctl/__init__.py +1 -0
- clashctl/__main__.py +4 -0
- clashctl/api/__init__.py +20 -0
- clashctl/api/client.py +164 -0
- clashctl/api/errors.py +24 -0
- clashctl/api/streams.py +45 -0
- clashctl/cli.py +55 -0
- clashctl/config/__init__.py +23 -0
- clashctl/config/__main__.py +29 -0
- clashctl/config/schema.py +135 -0
- clashctl/config/store.py +56 -0
- clashctl/models/__init__.py +28 -0
- clashctl/models/base.py +24 -0
- clashctl/models/config.py +37 -0
- clashctl/models/connection.py +46 -0
- clashctl/models/enums.py +115 -0
- clashctl/models/log.py +20 -0
- clashctl/models/proxy.py +94 -0
- clashctl/models/rule.py +39 -0
- clashctl/models/traffic.py +6 -0
- clashctl/models/version.py +6 -0
- clashctl/state/__init__.py +26 -0
- clashctl/state/app_state.py +148 -0
- clashctl/state/sort.py +280 -0
- clashctl/state/speed.py +69 -0
- clashctl/tui/__init__.py +3 -0
- clashctl/tui/app.py +314 -0
- clashctl/tui/messages.py +101 -0
- clashctl/tui/poller.py +194 -0
- clashctl/tui/screens/__init__.py +4 -0
- clashctl/tui/screens/main.py +185 -0
- clashctl/tui/screens/server_select.py +317 -0
- clashctl/tui/theme.py +69 -0
- clashctl/tui/widgets/__init__.py +15 -0
- clashctl/tui/widgets/connection_list.py +101 -0
- clashctl/tui/widgets/log_view.py +41 -0
- clashctl/tui/widgets/movable_table.py +191 -0
- clashctl/tui/widgets/proxy_tree.py +262 -0
- clashctl/tui/widgets/rule_list.py +74 -0
- clashctl/tui/widgets/status_panel.py +170 -0
- clashctl/utils/__init__.py +0 -0
- clashctl/utils/bytesize.py +47 -0
- clashctl/utils/duration.py +27 -0
- clashctl-0.1.0.dist-info/METADATA +170 -0
- clashctl-0.1.0.dist-info/RECORD +48 -0
- clashctl-0.1.0.dist-info/WHEEL +4 -0
- clashctl-0.1.0.dist-info/entry_points.txt +2 -0
- clashctl-0.1.0.dist-info/licenses/LICENSE +21 -0
clashctl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
clashctl/__main__.py
ADDED
clashctl/api/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from clashctl.api.client import Clash
|
|
2
|
+
from clashctl.api.errors import (
|
|
3
|
+
AuthError,
|
|
4
|
+
ClashError,
|
|
5
|
+
HTTPStatusError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
StreamError,
|
|
8
|
+
)
|
|
9
|
+
from clashctl.api.streams import stream_logs, stream_traffic
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthError",
|
|
13
|
+
"Clash",
|
|
14
|
+
"ClashError",
|
|
15
|
+
"HTTPStatusError",
|
|
16
|
+
"NotFoundError",
|
|
17
|
+
"StreamError",
|
|
18
|
+
"stream_logs",
|
|
19
|
+
"stream_traffic",
|
|
20
|
+
]
|
clashctl/api/client.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from clashctl.api.errors import (
|
|
10
|
+
AuthError,
|
|
11
|
+
ClashError,
|
|
12
|
+
HTTPStatusError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from clashctl.models import (
|
|
16
|
+
ClashConfig,
|
|
17
|
+
Connections,
|
|
18
|
+
Delay,
|
|
19
|
+
Proxies,
|
|
20
|
+
Proxy,
|
|
21
|
+
Rules,
|
|
22
|
+
Version,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Clash:
|
|
27
|
+
"""Async client for the Clash external-controller REST API.
|
|
28
|
+
|
|
29
|
+
Streaming endpoints (`/traffic`, `/logs`) live in `api.streams` so the
|
|
30
|
+
client itself can keep a single bounded HTTP timeout.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
secret: str | None = None,
|
|
37
|
+
timeout_ms: int = 5000,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not base_url.endswith("/"):
|
|
40
|
+
base_url = base_url + "/"
|
|
41
|
+
headers: dict[str, str] = {}
|
|
42
|
+
if secret:
|
|
43
|
+
headers["Authorization"] = f"Bearer {secret}"
|
|
44
|
+
self._base_url = base_url
|
|
45
|
+
self._secret = secret
|
|
46
|
+
self._client = httpx.AsyncClient(
|
|
47
|
+
base_url=base_url,
|
|
48
|
+
headers=headers,
|
|
49
|
+
timeout=timeout_ms / 1000,
|
|
50
|
+
)
|
|
51
|
+
# Separate client without timeout for long-haul streams; lazily created.
|
|
52
|
+
self._stream_client: httpx.AsyncClient | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def base_url(self) -> str:
|
|
56
|
+
return self._base_url
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def secret(self) -> str | None:
|
|
60
|
+
return self._secret
|
|
61
|
+
|
|
62
|
+
def stream_client(self) -> httpx.AsyncClient:
|
|
63
|
+
"""Long-lived client without read timeout, for /traffic and /logs."""
|
|
64
|
+
if self._stream_client is None:
|
|
65
|
+
headers: dict[str, str] = {}
|
|
66
|
+
if self._secret:
|
|
67
|
+
headers["Authorization"] = f"Bearer {self._secret}"
|
|
68
|
+
self._stream_client = httpx.AsyncClient(
|
|
69
|
+
base_url=self._base_url,
|
|
70
|
+
headers=headers,
|
|
71
|
+
timeout=httpx.Timeout(connect=5.0, read=None, write=5.0, pool=5.0),
|
|
72
|
+
)
|
|
73
|
+
return self._stream_client
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> Self:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: type[BaseException] | None,
|
|
81
|
+
exc: BaseException | None,
|
|
82
|
+
tb: TracebackType | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
await self.aclose()
|
|
85
|
+
|
|
86
|
+
async def aclose(self) -> None:
|
|
87
|
+
await self._client.aclose()
|
|
88
|
+
if self._stream_client is not None:
|
|
89
|
+
await self._stream_client.aclose()
|
|
90
|
+
|
|
91
|
+
async def _request(self, method: str, path: str, **kw: Any) -> httpx.Response:
|
|
92
|
+
try:
|
|
93
|
+
resp = await self._client.request(method, path, **kw)
|
|
94
|
+
except httpx.HTTPError as e:
|
|
95
|
+
raise ClashError(f"transport error: {e}") from e
|
|
96
|
+
self._check_status(resp)
|
|
97
|
+
return resp
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _check_status(resp: httpx.Response) -> None:
|
|
101
|
+
sc = resp.status_code
|
|
102
|
+
if sc < 400:
|
|
103
|
+
return
|
|
104
|
+
if sc in (401, 403):
|
|
105
|
+
raise AuthError(f"authentication failed (HTTP {sc})")
|
|
106
|
+
if sc == 404:
|
|
107
|
+
raise NotFoundError(f"not found: {resp.request.url}")
|
|
108
|
+
raise HTTPStatusError(sc, resp.text[:200])
|
|
109
|
+
|
|
110
|
+
# --- one-shot endpoints -------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async def version(self) -> Version:
|
|
113
|
+
r = await self._request("GET", "version")
|
|
114
|
+
return Version.model_validate_json(r.content)
|
|
115
|
+
|
|
116
|
+
async def configs(self) -> ClashConfig:
|
|
117
|
+
r = await self._request("GET", "configs")
|
|
118
|
+
return ClashConfig.model_validate_json(r.content)
|
|
119
|
+
|
|
120
|
+
async def reload_configs(self, path: str, force: bool = False) -> None:
|
|
121
|
+
endpoint = "configs?force" if force else "configs"
|
|
122
|
+
await self._request("PUT", endpoint, json={"path": path})
|
|
123
|
+
|
|
124
|
+
async def proxies(self) -> Proxies:
|
|
125
|
+
r = await self._request("GET", "proxies")
|
|
126
|
+
# Clash returns `{"proxies": {...}}`; unwrap.
|
|
127
|
+
data = r.json()
|
|
128
|
+
inner = data.get("proxies", data)
|
|
129
|
+
return Proxies.model_validate(inner)
|
|
130
|
+
|
|
131
|
+
async def proxy(self, name: str) -> Proxy:
|
|
132
|
+
r = await self._request("GET", f"proxies/{quote(name, safe='')}")
|
|
133
|
+
return Proxy.model_validate_json(r.content)
|
|
134
|
+
|
|
135
|
+
async def rules(self) -> Rules:
|
|
136
|
+
r = await self._request("GET", "rules")
|
|
137
|
+
return Rules.model_validate_json(r.content)
|
|
138
|
+
|
|
139
|
+
async def connections(self) -> Connections:
|
|
140
|
+
r = await self._request("GET", "connections")
|
|
141
|
+
return Connections.model_validate_json(r.content)
|
|
142
|
+
|
|
143
|
+
async def close_connection(self, conn_id: str) -> None:
|
|
144
|
+
await self._request("DELETE", f"connections/{quote(conn_id, safe='')}")
|
|
145
|
+
|
|
146
|
+
async def close_all_connections(self) -> None:
|
|
147
|
+
await self._request("DELETE", "connections")
|
|
148
|
+
|
|
149
|
+
async def select(self, group: str, proxy: str) -> None:
|
|
150
|
+
"""Switch a Selector group's active member."""
|
|
151
|
+
await self._request(
|
|
152
|
+
"PUT",
|
|
153
|
+
f"proxies/{quote(group, safe='')}",
|
|
154
|
+
json={"name": proxy},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def delay(self, proxy: str, test_url: str, timeout_ms: int) -> Delay:
|
|
158
|
+
"""Probe a single proxy's latency. Raises on connection failure."""
|
|
159
|
+
r = await self._request(
|
|
160
|
+
"GET",
|
|
161
|
+
f"proxies/{quote(proxy, safe='')}/delay",
|
|
162
|
+
params={"url": test_url, "timeout": timeout_ms},
|
|
163
|
+
)
|
|
164
|
+
return Delay.model_validate_json(r.content)
|
clashctl/api/errors.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class ClashError(Exception):
|
|
2
|
+
"""Base error for the Clash API client."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthError(ClashError):
|
|
6
|
+
"""401/403 from the Clash controller (bad or missing secret)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NotFoundError(ClashError):
|
|
10
|
+
"""404 from the Clash controller."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HTTPStatusError(ClashError):
|
|
14
|
+
"""Non-2xx response that doesn't fit a more specific subclass."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status_code: int, message: str = "") -> None:
|
|
17
|
+
super().__init__(
|
|
18
|
+
f"HTTP {status_code}: {message}" if message else f"HTTP {status_code}"
|
|
19
|
+
)
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StreamError(ClashError):
|
|
24
|
+
"""Streaming endpoint disconnected or produced unparseable data."""
|
clashctl/api/streams.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from clashctl.api.client import Clash
|
|
9
|
+
from clashctl.api.errors import StreamError
|
|
10
|
+
from clashctl.models import LogLine, Traffic
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def stream_traffic(client: Clash) -> AsyncIterator[Traffic]:
|
|
16
|
+
"""Yield Traffic samples from `/traffic` until the connection drops."""
|
|
17
|
+
async for line in _stream_lines(client, "traffic"):
|
|
18
|
+
try:
|
|
19
|
+
yield Traffic.model_validate_json(line)
|
|
20
|
+
except ValueError as e:
|
|
21
|
+
log.debug("skipping bad /traffic line %r: %s", line[:80], e)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def stream_logs(client: Clash) -> AsyncIterator[LogLine]:
|
|
25
|
+
"""Yield LogLine items from `/logs` until the connection drops."""
|
|
26
|
+
async for line in _stream_lines(client, "logs"):
|
|
27
|
+
try:
|
|
28
|
+
yield LogLine.model_validate_json(line)
|
|
29
|
+
except ValueError as e:
|
|
30
|
+
log.debug("skipping bad /logs line %r: %s", line[:80], e)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _stream_lines(client: Clash, path: str) -> AsyncIterator[str]:
|
|
34
|
+
"""Open a streaming GET and yield each non-empty line."""
|
|
35
|
+
sc = client.stream_client()
|
|
36
|
+
try:
|
|
37
|
+
async with sc.stream("GET", path) as resp:
|
|
38
|
+
if resp.status_code >= 400:
|
|
39
|
+
raise StreamError(f"{path} returned HTTP {resp.status_code}")
|
|
40
|
+
async for raw in resp.aiter_lines():
|
|
41
|
+
line = raw.strip()
|
|
42
|
+
if line:
|
|
43
|
+
yield line
|
|
44
|
+
except httpx.HTTPError as e:
|
|
45
|
+
raise StreamError(f"{path} disconnected: {e}") from e
|
clashctl/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""`clashctl` entrypoint.
|
|
2
|
+
|
|
3
|
+
The full CLI surface is intentionally minimal — clashctl is a TUI app.
|
|
4
|
+
Server management lives inside the TUI (Phase 7).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from clashctl import __version__
|
|
16
|
+
from clashctl.config import default_config_path, load_config
|
|
17
|
+
from clashctl.tui import ClashCtlApp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
p = argparse.ArgumentParser(
|
|
22
|
+
prog="clashctl", description="TUI for the Clash proxy API"
|
|
23
|
+
)
|
|
24
|
+
p.add_argument(
|
|
25
|
+
"-c",
|
|
26
|
+
"--config",
|
|
27
|
+
type=Path,
|
|
28
|
+
default=None,
|
|
29
|
+
help=f"path to config TOML (default: {default_config_path()})",
|
|
30
|
+
)
|
|
31
|
+
p.add_argument("--debug", action="store_true", help="enable verbose logging")
|
|
32
|
+
p.add_argument("--version", action="version", version=f"clashctl {__version__}")
|
|
33
|
+
return p
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
args = _build_parser().parse_args(argv)
|
|
38
|
+
|
|
39
|
+
if args.debug:
|
|
40
|
+
# Textual swallows stdout/stderr in alt-screen; route to a file.
|
|
41
|
+
log_path = os.environ.get("CLASHCTL_DEBUG_LOG", "/tmp/clashctl.log")
|
|
42
|
+
logging.basicConfig(
|
|
43
|
+
filename=log_path,
|
|
44
|
+
level=logging.DEBUG,
|
|
45
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
cfg = load_config(args.config)
|
|
49
|
+
app = ClashCtlApp(cfg)
|
|
50
|
+
app.run()
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
sys.exit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from clashctl.config.schema import (
|
|
2
|
+
AppConfig,
|
|
3
|
+
ConnSortConfig,
|
|
4
|
+
ProxySortConfig,
|
|
5
|
+
RuleSortConfig,
|
|
6
|
+
Server,
|
|
7
|
+
SortsConfig,
|
|
8
|
+
UiConfig,
|
|
9
|
+
)
|
|
10
|
+
from clashctl.config.store import default_config_path, load_config, save_config
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AppConfig",
|
|
14
|
+
"ConnSortConfig",
|
|
15
|
+
"ProxySortConfig",
|
|
16
|
+
"RuleSortConfig",
|
|
17
|
+
"Server",
|
|
18
|
+
"SortsConfig",
|
|
19
|
+
"UiConfig",
|
|
20
|
+
"default_config_path",
|
|
21
|
+
"load_config",
|
|
22
|
+
"save_config",
|
|
23
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""`python -m clashctl.config show` — print the resolved config.
|
|
2
|
+
|
|
3
|
+
Useful for debugging path resolution and TOML parse issues during dev.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from clashctl.config import default_config_path, load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
args = sys.argv[1:] if argv is None else argv
|
|
16
|
+
if not args or args[0] != "show":
|
|
17
|
+
print("usage: python -m clashctl.config show", file=sys.stderr)
|
|
18
|
+
return 2
|
|
19
|
+
|
|
20
|
+
path = default_config_path()
|
|
21
|
+
print(f"# config path: {path}", file=sys.stderr)
|
|
22
|
+
print(f"# exists: {path.exists()}", file=sys.stderr)
|
|
23
|
+
cfg = load_config(path)
|
|
24
|
+
print(json.dumps(cfg.model_dump(mode="json", exclude_none=True), indent=2))
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""TOML-backed user config.
|
|
2
|
+
|
|
3
|
+
Lives at `~/.config/clashctl/config.toml` (resolved via platformdirs).
|
|
4
|
+
This is *clashctl's own* config — distinct from Clash's `/configs` payload
|
|
5
|
+
(`models.config.ClashConfig`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
14
|
+
|
|
15
|
+
from clashctl.state.sort import (
|
|
16
|
+
ConnSort,
|
|
17
|
+
ConnSortBy,
|
|
18
|
+
Order,
|
|
19
|
+
ProxySort,
|
|
20
|
+
ProxySortBy,
|
|
21
|
+
RuleSort,
|
|
22
|
+
RuleSortBy,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _StrictModel(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="ignore", validate_assignment=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Server(_StrictModel):
|
|
31
|
+
"""A Clash controller endpoint."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
url: str
|
|
35
|
+
secret: str | None = None
|
|
36
|
+
|
|
37
|
+
@field_validator("secret", mode="before")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _empty_secret_to_none(cls, v: Any) -> Any:
|
|
40
|
+
if isinstance(v, str) and v == "":
|
|
41
|
+
return None
|
|
42
|
+
return v
|
|
43
|
+
|
|
44
|
+
@field_validator("url")
|
|
45
|
+
@classmethod
|
|
46
|
+
def _validate_url(cls, v: str) -> str:
|
|
47
|
+
parsed = urlparse(v)
|
|
48
|
+
if parsed.scheme not in ("http", "https"):
|
|
49
|
+
raise ValueError(f"url must be http(s): {v!r}")
|
|
50
|
+
if not parsed.netloc:
|
|
51
|
+
raise ValueError(f"url missing host: {v!r}")
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- per-list sort blocks --------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _SortBlock(_StrictModel):
|
|
59
|
+
by: str
|
|
60
|
+
order: Order = Order.Asc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProxySortConfig(_SortBlock):
|
|
64
|
+
by: ProxySortBy = ProxySortBy.Delay # type: ignore[assignment]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RuleSortConfig(_SortBlock):
|
|
68
|
+
by: RuleSortBy = RuleSortBy.Payload # type: ignore[assignment]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConnSortConfig(_SortBlock):
|
|
72
|
+
by: ConnSortBy = ConnSortBy.Time # type: ignore[assignment]
|
|
73
|
+
order: Order = Order.Desc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SortsConfig(_StrictModel):
|
|
77
|
+
proxies: ProxySortConfig = Field(default_factory=ProxySortConfig)
|
|
78
|
+
rules: RuleSortConfig = Field(default_factory=RuleSortConfig)
|
|
79
|
+
connections: ConnSortConfig = Field(default_factory=ConnSortConfig)
|
|
80
|
+
|
|
81
|
+
def to_runtime(self) -> tuple[ProxySort, RuleSort, ConnSort]:
|
|
82
|
+
return (
|
|
83
|
+
ProxySort(by=self.proxies.by, order=self.proxies.order),
|
|
84
|
+
RuleSort(by=self.rules.by, order=self.rules.order),
|
|
85
|
+
ConnSort(by=self.connections.by, order=self.connections.order),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- UI block --------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class UiConfig(_StrictModel):
|
|
93
|
+
test_url: str = "http://www.gstatic.com/generate_204"
|
|
94
|
+
test_timeout_ms: int = 5000
|
|
95
|
+
log_buffer: int = 2000
|
|
96
|
+
traffic_history: int = 500
|
|
97
|
+
refresh_slow_secs: float = 5.0
|
|
98
|
+
refresh_fast_secs: float = 1.0
|
|
99
|
+
sort: SortsConfig = Field(default_factory=SortsConfig)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- top-level ------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AppConfig(_StrictModel):
|
|
106
|
+
"""Persisted user config."""
|
|
107
|
+
|
|
108
|
+
using: str | None = None
|
|
109
|
+
servers: list[Server] = Field(default_factory=list)
|
|
110
|
+
ui: UiConfig = Field(default_factory=UiConfig)
|
|
111
|
+
|
|
112
|
+
def using_server(self) -> Server | None:
|
|
113
|
+
if self.using is None:
|
|
114
|
+
return None
|
|
115
|
+
for s in self.servers:
|
|
116
|
+
if s.url == self.using:
|
|
117
|
+
return s
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def use(self, url: str) -> None:
|
|
121
|
+
if not any(s.url == url for s in self.servers):
|
|
122
|
+
raise ValueError(f"server not found: {url}")
|
|
123
|
+
self.using = url
|
|
124
|
+
|
|
125
|
+
def add_server(self, server: Server) -> None:
|
|
126
|
+
if any(s.url == server.url for s in self.servers):
|
|
127
|
+
raise ValueError(f"server already exists: {server.url}")
|
|
128
|
+
self.servers.append(server)
|
|
129
|
+
if self.using is None:
|
|
130
|
+
self.using = server.url
|
|
131
|
+
|
|
132
|
+
def remove_server(self, url: str) -> None:
|
|
133
|
+
self.servers = [s for s in self.servers if s.url != url]
|
|
134
|
+
if self.using == url:
|
|
135
|
+
self.using = self.servers[0].url if self.servers else None
|
clashctl/config/store.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Loading and saving the TOML config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tomli_w
|
|
11
|
+
from platformdirs import user_config_dir
|
|
12
|
+
|
|
13
|
+
from clashctl.config.schema import AppConfig
|
|
14
|
+
|
|
15
|
+
_APP_NAME = "clashctl"
|
|
16
|
+
_CONFIG_FILENAME = "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_config_path() -> Path:
|
|
20
|
+
"""Return the canonical config path, respecting `CLASHCTL_CONFIG_PATH`.
|
|
21
|
+
|
|
22
|
+
Falls back to `<user_config_dir>/clashctl/config.toml` (XDG on Linux,
|
|
23
|
+
AppData on Windows, ~/Library/Application Support on macOS).
|
|
24
|
+
"""
|
|
25
|
+
override = os.environ.get("CLASHCTL_CONFIG_PATH")
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override).expanduser()
|
|
28
|
+
return Path(user_config_dir(_APP_NAME)) / _CONFIG_FILENAME
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
32
|
+
"""Read and validate the config file. Returns defaults if missing."""
|
|
33
|
+
p = path or default_config_path()
|
|
34
|
+
if not p.exists():
|
|
35
|
+
return AppConfig()
|
|
36
|
+
raw = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
37
|
+
return AppConfig.model_validate(raw)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_config(config: AppConfig, path: Path | None = None) -> Path:
|
|
41
|
+
"""Atomically write `config` to `path`. Creates parent dirs as needed.
|
|
42
|
+
|
|
43
|
+
Returns the path written.
|
|
44
|
+
"""
|
|
45
|
+
p = path or default_config_path()
|
|
46
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
# `model_dump(mode="json")` coerces enums to their string values, which
|
|
49
|
+
# is what tomli_w expects. `exclude_none=True` keeps the file tidy.
|
|
50
|
+
data: dict[str, Any] = config.model_dump(mode="json", exclude_none=True)
|
|
51
|
+
payload = tomli_w.dumps(data)
|
|
52
|
+
|
|
53
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
54
|
+
tmp.write_text(payload, encoding="utf-8")
|
|
55
|
+
os.replace(tmp, p)
|
|
56
|
+
return p
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from clashctl.models.config import ClashConfig
|
|
2
|
+
from clashctl.models.connection import Connection, Connections, Metadata
|
|
3
|
+
from clashctl.models.enums import LogLevel, Mode, ProxyType, RuleType
|
|
4
|
+
from clashctl.models.log import LogLine
|
|
5
|
+
from clashctl.models.proxy import Delay, History, Proxies, Proxy
|
|
6
|
+
from clashctl.models.rule import Rule, Rules
|
|
7
|
+
from clashctl.models.traffic import Traffic
|
|
8
|
+
from clashctl.models.version import Version
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ClashConfig",
|
|
12
|
+
"Connection",
|
|
13
|
+
"Connections",
|
|
14
|
+
"Delay",
|
|
15
|
+
"History",
|
|
16
|
+
"LogLevel",
|
|
17
|
+
"LogLine",
|
|
18
|
+
"Metadata",
|
|
19
|
+
"Mode",
|
|
20
|
+
"Proxies",
|
|
21
|
+
"Proxy",
|
|
22
|
+
"ProxyType",
|
|
23
|
+
"Rule",
|
|
24
|
+
"RuleType",
|
|
25
|
+
"Rules",
|
|
26
|
+
"Traffic",
|
|
27
|
+
"Version",
|
|
28
|
+
]
|
clashctl/models/base.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
from pydantic.alias_generators import to_camel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ClashModel(BaseModel):
|
|
6
|
+
"""Base for Clash API models. Most endpoints use camelCase JSON keys."""
|
|
7
|
+
|
|
8
|
+
model_config = ConfigDict(
|
|
9
|
+
populate_by_name=True,
|
|
10
|
+
alias_generator=to_camel,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _to_kebab(name: str) -> str:
|
|
15
|
+
return name.replace("_", "-")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ClashKebabModel(BaseModel):
|
|
19
|
+
"""Base for endpoints using kebab-case (notably `/configs`)."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(
|
|
22
|
+
populate_by_name=True,
|
|
23
|
+
alias_generator=_to_kebab,
|
|
24
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, field_validator
|
|
4
|
+
|
|
5
|
+
from clashctl.models.base import ClashKebabModel
|
|
6
|
+
from clashctl.models.enums import LogLevel, Mode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClashConfig(ClashKebabModel):
|
|
10
|
+
"""Mirror of Clash's `/configs` payload (kebab-case keys)."""
|
|
11
|
+
|
|
12
|
+
port: int = 0
|
|
13
|
+
socks_port: int = 0
|
|
14
|
+
redir_port: int = 0
|
|
15
|
+
tproxy_port: int = 0
|
|
16
|
+
mixed_port: int = 0
|
|
17
|
+
allow_lan: bool = False
|
|
18
|
+
ipv6: bool = False
|
|
19
|
+
mode: Mode = Mode.Rule
|
|
20
|
+
log_level: LogLevel = LogLevel.Info
|
|
21
|
+
bind_address: str = ""
|
|
22
|
+
authentication: list[str] = Field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@field_validator("mode", mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _coerce_mode(cls, v: Any) -> Any:
|
|
27
|
+
return Mode(v) if isinstance(v, str) else v
|
|
28
|
+
|
|
29
|
+
@field_validator("log_level", mode="before")
|
|
30
|
+
@classmethod
|
|
31
|
+
def _coerce_log_level(cls, v: Any) -> Any:
|
|
32
|
+
return LogLevel(v) if isinstance(v, str) else v
|
|
33
|
+
|
|
34
|
+
@field_validator("authentication", mode="before")
|
|
35
|
+
@classmethod
|
|
36
|
+
def _coerce_authentication(cls, v: Any) -> Any:
|
|
37
|
+
return [] if v is None else v
|