proxyctl 0.5.6__tar.gz → 0.5.7__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.
- {proxyctl-0.5.6 → proxyctl-0.5.7}/PKG-INFO +1 -1
- {proxyctl-0.5.6 → proxyctl-0.5.7}/pyproject.toml +1 -1
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/builtin_plugins/connectivity_basic.py +11 -4
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/check.py +117 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/core/plugin.py +1 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/.gitignore +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/LICENSE +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/README.md +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/man/proxyctl.1 +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/autostart.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/cli.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/connections.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/connections_filters.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/connections_human.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/explain.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/suggest_rules.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/trace.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/traffic.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.7}/src/proxyctl/traffic_store.py +0 -0
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
提供一组跨所有用户都通用的连通性测试点:
|
|
4
4
|
- 海外(走代理):google / github
|
|
5
5
|
- 国内(直连):baidu
|
|
6
|
+
- AI 线路:anthropic(走 claude 规则组)
|
|
6
7
|
|
|
7
|
-
本机特例(discord/
|
|
8
|
+
本机特例(discord/telegram、企业内网等)请走用户插件。
|
|
8
9
|
|
|
9
10
|
代理组:默认提供 "proxy"(绝大多数 mihomo/sing-box 配置都有这个组)。
|
|
10
11
|
其他组(claude / residential-* 等)由用户插件提供。
|
|
@@ -24,12 +25,18 @@ class ConnectivityBasic(Plugin):
|
|
|
24
25
|
CheckTarget(name="google", url="https://www.google.com", mode="proxy"),
|
|
25
26
|
CheckTarget(name="github", url="https://github.com", mode="proxy"),
|
|
26
27
|
CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
|
|
28
|
+
CheckTarget(name="anthropic",
|
|
29
|
+
url="https://api.anthropic.com",
|
|
30
|
+
mode="proxy",
|
|
31
|
+
expected_proxy="claude"),
|
|
27
32
|
]
|
|
28
33
|
|
|
29
34
|
def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
|
|
30
|
-
# 只提供 proxy 出口探测——direct 探测严重依赖本地网络环境
|
|
31
|
-
# (国内用户用 myip.ipip.net 这种国内服务才稳,海外用户用 ipify 更好),
|
|
32
|
-
# 因此 direct 探测由用户插件提供。
|
|
33
35
|
return [
|
|
34
36
|
OutboundProbe(name="proxy", mode="proxy", url="https://api.ipify.org"),
|
|
37
|
+
OutboundProbe(name="claude", mode="proxy", url="https://ipinfo.io/ip"),
|
|
38
|
+
OutboundProbe(name="direct",
|
|
39
|
+
mode="direct",
|
|
40
|
+
url="https://myip.ipip.net",
|
|
41
|
+
extract_re=r"\d+\.\d+\.\d+\.\d+"),
|
|
35
42
|
]
|
|
@@ -76,6 +76,43 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
|
|
|
76
76
|
return False, f" {YELLOW}?{NC} {desc:<18s} {url:<44s} {YELLOW}{code}{NC}"
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def _api_connections(api_base: str, api_secret: str) -> list[dict]:
|
|
80
|
+
r = subprocess.run(
|
|
81
|
+
["curl", "-s", "--noproxy", "*",
|
|
82
|
+
"-H", f"Authorization: Bearer {api_secret}",
|
|
83
|
+
f"{api_base.rstrip('/')}/connections"],
|
|
84
|
+
capture_output=True, text=True, timeout=2
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(r.stdout or "{}")
|
|
88
|
+
except Exception:
|
|
89
|
+
return []
|
|
90
|
+
conns = data.get("connections")
|
|
91
|
+
return conns if isinstance(conns, list) else []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
|
|
95
|
+
expected_proxy: str) -> tuple[bool, str]:
|
|
96
|
+
host = urllib.parse.urlparse(url).hostname or ""
|
|
97
|
+
if not host or not expected_proxy:
|
|
98
|
+
return True, ""
|
|
99
|
+
|
|
100
|
+
for _ in range(3):
|
|
101
|
+
for conn in _api_connections(api_base, api_secret):
|
|
102
|
+
meta = conn.get("metadata") or {}
|
|
103
|
+
conn_host = meta.get("host") or ""
|
|
104
|
+
if conn_host != host and not conn_host.endswith("." + host):
|
|
105
|
+
continue
|
|
106
|
+
chains = conn.get("chains") or []
|
|
107
|
+
actual = chains[-1] if chains else ""
|
|
108
|
+
chain_str = " → ".join(reversed(chains)) if chains else "?"
|
|
109
|
+
if actual.lower() == expected_proxy.lower():
|
|
110
|
+
return True, f" via {chain_str}"
|
|
111
|
+
return False, f" expected {expected_proxy}, got {actual or '?'}"
|
|
112
|
+
time.sleep(0.2)
|
|
113
|
+
return False, f" expected {expected_proxy}, no active connection found"
|
|
114
|
+
|
|
115
|
+
|
|
79
116
|
def _test_dns(desc: str, server: str, domain: str) -> tuple:
|
|
80
117
|
"""通用 DNS 可达性测试:dig @server domain(用于 CheckTarget mode='dns')。
|
|
81
118
|
|
|
@@ -102,6 +139,73 @@ def _test_tcp(host: str, port: int, desc: str) -> tuple:
|
|
|
102
139
|
return False, f" {RED}✗{NC} {desc:<18s} {addr:<44s} {RED}unreachable{NC}"
|
|
103
140
|
|
|
104
141
|
|
|
142
|
+
def _rule_target_groups_from_config(config_path: str) -> list[str]:
|
|
143
|
+
"""Return proxy groups that are used as mihomo rule targets.
|
|
144
|
+
|
|
145
|
+
`check` is a maintenance view: if a top-level rule can route traffic into a
|
|
146
|
+
group, that group should be visible even when it is not a child of `proxy`.
|
|
147
|
+
"""
|
|
148
|
+
if not config_path or not os.path.isfile(config_path):
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
import yaml
|
|
153
|
+
with open(config_path, encoding="utf-8") as f:
|
|
154
|
+
data = yaml.safe_load(f) or {}
|
|
155
|
+
except Exception:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
group_names: set[str] = set()
|
|
162
|
+
for group in data.get("proxy-groups") or []:
|
|
163
|
+
if isinstance(group, dict) and isinstance(group.get("name"), str):
|
|
164
|
+
group_names.add(group["name"])
|
|
165
|
+
|
|
166
|
+
if not group_names:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
# Mihomo rule strings use comma-separated fields; the policy is normally
|
|
170
|
+
# the last field, except trailing options such as `no-resolve`.
|
|
171
|
+
option_tokens = {"no-resolve", "src", "dst", "in", "out"}
|
|
172
|
+
targets: list[str] = []
|
|
173
|
+
seen: set[str] = set()
|
|
174
|
+
|
|
175
|
+
def add_target(name: str) -> None:
|
|
176
|
+
if name in group_names and name not in seen:
|
|
177
|
+
seen.add(name)
|
|
178
|
+
targets.append(name)
|
|
179
|
+
|
|
180
|
+
for rule in data.get("rules") or []:
|
|
181
|
+
if isinstance(rule, str):
|
|
182
|
+
parts = [p.strip() for p in rule.split(",") if p.strip()]
|
|
183
|
+
for token in reversed(parts[1:]):
|
|
184
|
+
if token.lower() in option_tokens:
|
|
185
|
+
continue
|
|
186
|
+
add_target(token)
|
|
187
|
+
break
|
|
188
|
+
elif isinstance(rule, dict):
|
|
189
|
+
for key in ("policy", "target", "proxy", "outbound"):
|
|
190
|
+
value = rule.get(key)
|
|
191
|
+
if isinstance(value, str):
|
|
192
|
+
add_target(value)
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
return targets
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _merge_check_groups(base: list[str] | None, rule_targets: list[str]) -> list[str]:
|
|
199
|
+
"""Merge plugin groups and config-derived rule target groups preserving order."""
|
|
200
|
+
merged: list[str] = []
|
|
201
|
+
seen: set[str] = set()
|
|
202
|
+
for name in list(base or []) + list(rule_targets or []):
|
|
203
|
+
if name and name not in seen:
|
|
204
|
+
seen.add(name)
|
|
205
|
+
merged.append(name)
|
|
206
|
+
return merged
|
|
207
|
+
|
|
208
|
+
|
|
105
209
|
def _proxy_groups_section(api_base: str, api_secret: str,
|
|
106
210
|
groups: list[str] | None = None,
|
|
107
211
|
collect_into: list | None = None) -> bool:
|
|
@@ -909,6 +1013,10 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
909
1013
|
groups = []
|
|
910
1014
|
if registry is not None:
|
|
911
1015
|
groups = registry.collect("check_groups")
|
|
1016
|
+
groups = _merge_check_groups(
|
|
1017
|
+
groups,
|
|
1018
|
+
_rule_target_groups_from_config(getattr(engine, "config_file", "")),
|
|
1019
|
+
)
|
|
912
1020
|
groups_data: list = []
|
|
913
1021
|
_proxy_groups_section(api, api_secret, groups=groups,
|
|
914
1022
|
collect_into=groups_data if as_json else None)
|
|
@@ -975,6 +1083,15 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
975
1083
|
ok, line = _test_url(target.url, target.name,
|
|
976
1084
|
target.mode, target.timeout,
|
|
977
1085
|
proxy_port=proxy_port)
|
|
1086
|
+
expected = getattr(target, "expected_proxy", "")
|
|
1087
|
+
if ok and expected:
|
|
1088
|
+
route_ok, route_msg = _target_uses_expected_proxy(
|
|
1089
|
+
api, api_secret, target.url, expected)
|
|
1090
|
+
ok = route_ok
|
|
1091
|
+
if route_ok:
|
|
1092
|
+
line += f" {GREEN}{route_msg}{NC}"
|
|
1093
|
+
else:
|
|
1094
|
+
line += f" {RED}{route_msg}{NC}"
|
|
978
1095
|
except Exception as e:
|
|
979
1096
|
ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
|
|
980
1097
|
results[idx] = (line, ok)
|
|
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
|
|
File without changes
|
|
File without changes
|