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.
Files changed (33) hide show
  1. {proxyctl-0.5.6 → proxyctl-0.5.8}/PKG-INFO +1 -1
  2. {proxyctl-0.5.6 → proxyctl-0.5.8}/pyproject.toml +1 -1
  3. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/builtin_plugins/connectivity_basic.py +11 -4
  4. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/check.py +142 -7
  5. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/core/plugin.py +1 -0
  6. {proxyctl-0.5.6 → proxyctl-0.5.8}/.gitignore +0 -0
  7. {proxyctl-0.5.6 → proxyctl-0.5.8}/LICENSE +0 -0
  8. {proxyctl-0.5.6 → proxyctl-0.5.8}/README.md +0 -0
  9. {proxyctl-0.5.6 → proxyctl-0.5.8}/man/proxyctl.1 +0 -0
  10. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/__init__.py +0 -0
  11. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/_io.py +0 -0
  12. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/audit.py +0 -0
  13. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/autostart.py +0 -0
  14. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  15. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  16. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/cli.py +0 -0
  17. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/completion.py +0 -0
  18. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/connections.py +0 -0
  19. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/connections_filters.py +0 -0
  20. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/connections_human.py +0 -0
  21. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/core/__init__.py +0 -0
  22. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/__init__.py +0 -0
  23. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/base.py +0 -0
  24. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/mihomo.py +0 -0
  25. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/engine/singbox.py +0 -0
  26. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/explain.py +0 -0
  27. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/status.py +0 -0
  28. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/subscription.py +0 -0
  29. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/suggest.py +0 -0
  30. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/suggest_rules.py +0 -0
  31. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/trace.py +0 -0
  32. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/traffic.py +0 -0
  33. {proxyctl-0.5.6 → proxyctl-0.5.8}/src/proxyctl/traffic_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.5.6
3
+ Version: 0.5.8
4
4
  Summary: Proxy configuration lifecycle management for macOS and Linux
5
5
  Project-URL: Homepage, https://github.com/crhan/proxyctl
6
6
  Project-URL: Issues, https://github.com/crhan/proxyctl/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.5.6"
3
+ version = "0.5.8"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,8 +3,9 @@
3
3
  提供一组跨所有用户都通用的连通性测试点:
4
4
  - 海外(走代理):google / github
5
5
  - 国内(直连):baidu
6
+ - AI 线路:anthropic(走 claude 规则组)
6
7
 
7
- 本机特例(discord/anthropic/telegram、企业内网等)请走用户插件。
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
- "--proxy", f"socks5h://127.0.0.1:{proxy_port}",
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 = _ipgeo(ip, cache_file, api_secret, proxy_port=proxy_port)
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", ""),
@@ -40,6 +40,7 @@ class CheckTarget:
40
40
  url: str
41
41
  mode: str = "proxy"
42
42
  timeout: int = 8
43
+ expected_proxy: str = ""
43
44
  only_when: Optional[Callable[[dict], bool]] = None # ctx -> bool,控制是否启用
44
45
 
45
46
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes