proxyctl 0.5.6__tar.gz → 0.5.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.
- {proxyctl-0.5.6 → proxyctl-0.5.8}/PKG-INFO +1 -1
- {proxyctl-0.5.6 → proxyctl-0.5.8}/pyproject.toml +1 -1
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/builtin_plugins/connectivity_basic.py +11 -4
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/check.py +142 -7
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/core/plugin.py +1 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/.gitignore +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/LICENSE +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/README.md +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/man/proxyctl.1 +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/autostart.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/cli.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/connections.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/connections_filters.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/connections_human.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/explain.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/suggest_rules.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/trace.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/traffic.py +0 -0
- {proxyctl-0.5.6 → proxyctl-0.5.8}/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:
|
|
@@ -354,10 +458,13 @@ def _ipgeo(ip: str, cache_file: str, api_secret: str,
|
|
|
354
458
|
env = {k: v for k, v in os.environ.items()
|
|
355
459
|
if k not in ("http_proxy", "https_proxy", "all_proxy",
|
|
356
460
|
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
|
|
461
|
+
# Geo lookup is metadata lookup for an already-known IP; it should not be
|
|
462
|
+
# routed through the proxy under test. In particular, users may route
|
|
463
|
+
# ipinfo.io itself through the claude group, so a claude outage must not
|
|
464
|
+
# hide the direct/proxy geo labels.
|
|
357
465
|
r = subprocess.run(
|
|
358
|
-
["curl", "-s", "--max-time", "6",
|
|
359
|
-
|
|
360
|
-
f"https://ipinfo.io/{ip}/json"],
|
|
466
|
+
["curl", "-s", "--max-time", "6", "--noproxy", "*",
|
|
467
|
+
f"http://ip-api.com/json/{ip}?fields=status,countryCode,city,isp,org,query"],
|
|
361
468
|
capture_output=True, text=True, env=env, timeout=10
|
|
362
469
|
)
|
|
363
470
|
if not r.stdout:
|
|
@@ -365,9 +472,9 @@ def _ipgeo(ip: str, cache_file: str, api_secret: str,
|
|
|
365
472
|
try:
|
|
366
473
|
d = json.loads(r.stdout)
|
|
367
474
|
city = d.get("city", "")
|
|
368
|
-
country = d.get("country", "")
|
|
369
|
-
org = d.get("org", "")
|
|
370
|
-
if org:
|
|
475
|
+
country = d.get("countryCode") or d.get("country", "")
|
|
476
|
+
org = d.get("isp") or d.get("org", "")
|
|
477
|
+
if org and _re_mod.match(r"^AS\d+\s+", org):
|
|
371
478
|
parts = org.split(" ", 1)
|
|
372
479
|
org = parts[1] if len(parts) > 1 else org
|
|
373
480
|
loc = ",".join(filter(None, [city, country]))
|
|
@@ -909,6 +1016,10 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
909
1016
|
groups = []
|
|
910
1017
|
if registry is not None:
|
|
911
1018
|
groups = registry.collect("check_groups")
|
|
1019
|
+
groups = _merge_check_groups(
|
|
1020
|
+
groups,
|
|
1021
|
+
_rule_target_groups_from_config(getattr(engine, "config_file", "")),
|
|
1022
|
+
)
|
|
912
1023
|
groups_data: list = []
|
|
913
1024
|
_proxy_groups_section(api, api_secret, groups=groups,
|
|
914
1025
|
collect_into=groups_data if as_json else None)
|
|
@@ -975,6 +1086,15 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
975
1086
|
ok, line = _test_url(target.url, target.name,
|
|
976
1087
|
target.mode, target.timeout,
|
|
977
1088
|
proxy_port=proxy_port)
|
|
1089
|
+
expected = getattr(target, "expected_proxy", "")
|
|
1090
|
+
if ok and expected:
|
|
1091
|
+
route_ok, route_msg = _target_uses_expected_proxy(
|
|
1092
|
+
api, api_secret, target.url, expected)
|
|
1093
|
+
ok = route_ok
|
|
1094
|
+
if route_ok:
|
|
1095
|
+
line += f" {GREEN}{route_msg}{NC}"
|
|
1096
|
+
else:
|
|
1097
|
+
line += f" {RED}{route_msg}{NC}"
|
|
978
1098
|
except Exception as e:
|
|
979
1099
|
ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
|
|
980
1100
|
results[idx] = (line, ok)
|
|
@@ -1006,9 +1126,24 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1006
1126
|
cache_file = os.path.join(sb_dir, ".ipgeo-cache")
|
|
1007
1127
|
if not probes:
|
|
1008
1128
|
print(f" {YELLOW}—{NC} 无出口探测项")
|
|
1129
|
+
geo_results: dict[str, str] = {p.name: "" for p in probes}
|
|
1130
|
+
geo_threads = [
|
|
1131
|
+
threading.Thread(
|
|
1132
|
+
target=lambda p=probe: geo_results.__setitem__(
|
|
1133
|
+
p.name,
|
|
1134
|
+
_ipgeo(probe_ips.get(p.name, ""), cache_file, api_secret,
|
|
1135
|
+
proxy_port=proxy_port),
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
for probe in probes
|
|
1139
|
+
]
|
|
1140
|
+
for t in geo_threads:
|
|
1141
|
+
t.start()
|
|
1142
|
+
for t in geo_threads:
|
|
1143
|
+
t.join()
|
|
1009
1144
|
for probe in probes:
|
|
1010
1145
|
ip = probe_ips.get(probe.name, "")
|
|
1011
|
-
geo =
|
|
1146
|
+
geo = geo_results.get(probe.name, "")
|
|
1012
1147
|
print(f" {probe.name:<7s}{_fmt_ip(ip, geo)}")
|
|
1013
1148
|
collector["stages"]["outbound_ip"][probe.name] = {
|
|
1014
1149
|
"ip": ip, "geo": geo, "mode": getattr(probe, "mode", ""),
|
|
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
|