proxyctl 0.5.9__tar.gz → 0.5.11__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.9 → proxyctl-0.5.11}/PKG-INFO +1 -1
  2. {proxyctl-0.5.9 → proxyctl-0.5.11}/pyproject.toml +1 -1
  3. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/audit.py +28 -11
  4. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/builtin_plugins/connectivity_basic.py +8 -5
  5. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/check.py +66 -15
  6. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/trace.py +83 -2
  7. {proxyctl-0.5.9 → proxyctl-0.5.11}/.gitignore +0 -0
  8. {proxyctl-0.5.9 → proxyctl-0.5.11}/LICENSE +0 -0
  9. {proxyctl-0.5.9 → proxyctl-0.5.11}/README.md +0 -0
  10. {proxyctl-0.5.9 → proxyctl-0.5.11}/man/proxyctl.1 +0 -0
  11. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/__init__.py +0 -0
  12. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/_io.py +0 -0
  13. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/autostart.py +0 -0
  14. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  15. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  16. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/cli.py +0 -0
  17. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/completion.py +0 -0
  18. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/connections.py +0 -0
  19. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/connections_filters.py +0 -0
  20. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/connections_human.py +0 -0
  21. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/core/__init__.py +0 -0
  22. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/core/plugin.py +0 -0
  23. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/engine/__init__.py +0 -0
  24. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/engine/base.py +0 -0
  25. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/engine/mihomo.py +0 -0
  26. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/engine/singbox.py +0 -0
  27. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/explain.py +0 -0
  28. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/status.py +0 -0
  29. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/subscription.py +0 -0
  30. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/suggest.py +0 -0
  31. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/suggest_rules.py +0 -0
  32. {proxyctl-0.5.9 → proxyctl-0.5.11}/src/proxyctl/traffic.py +0 -0
  33. {proxyctl-0.5.9 → proxyctl-0.5.11}/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.9
3
+ Version: 0.5.11
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.9"
3
+ version = "0.5.11"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -180,8 +180,18 @@ def _is_covered(host: str, suffix_set: set) -> bool:
180
180
  return any(".".join(parts[i:]) in suffix_set for i in range(len(parts)))
181
181
 
182
182
 
183
+ _AUDIT_DENY_OUTBOUNDS = {"block", "reject"}
184
+
185
+
183
186
  def _load_rules() -> tuple:
184
- """从双 config 读取 direct/proxy 域名后缀规则集。"""
187
+ """从双 config 读取 direct/proxy 域名后缀规则集。
188
+
189
+ 分类策略:``direct`` outbound 归 direct;``block`` / ``reject`` 忽略;
190
+ 其它任意 outbound 名(用户的命名组如 ``proxy`` / ``residential-tw`` /
191
+ 个人命名组等)一律视为"代理类",归入 ``proxy_suffixes``。
192
+ 这里**不 hardcode 任何具体的代理组名**——通用基线插件和 audit 都不
193
+ 应假设用户配置里有某个特定名字的组。
194
+ """
185
195
  direct_suffixes: set = set()
186
196
  proxy_suffixes: set = set()
187
197
 
@@ -189,12 +199,15 @@ def _load_rules() -> tuple:
189
199
  try:
190
200
  cfg = json.load(open(SB_CONFIG))
191
201
  for rule in cfg.get("route", {}).get("rules", []):
192
- ob = rule.get("outbound", "")
202
+ ob = (rule.get("outbound") or "").strip()
203
+ if not ob:
204
+ continue
205
+ ob_lc = ob.lower()
193
206
  for s in rule.get("domain_suffix", []):
194
207
  s = s.lstrip(".")
195
- if ob == "direct":
208
+ if ob_lc == "direct":
196
209
  direct_suffixes.add(s)
197
- elif ob in ("proxy", "claude"):
210
+ elif ob_lc not in _AUDIT_DENY_OUTBOUNDS:
198
211
  proxy_suffixes.add(s)
199
212
  except Exception:
200
213
  pass
@@ -202,13 +215,17 @@ def _load_rules() -> tuple:
202
215
  # mihomo YAML (文本扫描,无需解析完整 YAML)
203
216
  try:
204
217
  for line in open(MH_CONFIG):
205
- m = re.match(r'\s*-\s+DOMAIN-SUFFIX,([^,]+),(DIRECT|proxy|claude)', line, re.I)
206
- if m:
207
- dom, target = m.group(1), m.group(2)
208
- if target.upper() == "DIRECT":
209
- direct_suffixes.add(dom)
210
- else:
211
- proxy_suffixes.add(dom)
218
+ # group(2) 取第一个 outbound 段(到下一个逗号或空白),
219
+ # 避免把 "proxy,no-resolve" 整体当作 outbound 名。
220
+ m = re.match(r'\s*-\s+DOMAIN-SUFFIX,([^,]+),([^,\s]+)', line, re.I)
221
+ if not m:
222
+ continue
223
+ dom, target = m.group(1), m.group(2)
224
+ target_lc = target.lower()
225
+ if target_lc == "direct":
226
+ direct_suffixes.add(dom)
227
+ elif target_lc not in _AUDIT_DENY_OUTBOUNDS:
228
+ proxy_suffixes.add(dom)
212
229
  except Exception:
213
230
  pass
214
231
 
@@ -3,12 +3,17 @@
3
3
  提供一组跨所有用户都通用的连通性测试点:
4
4
  - 海外(走代理):google / github
5
5
  - 国内(直连):baidu
6
- - AI 线路:openai / anthropic(anthropic 走 claude 规则组)
6
+ - AI 线路:openai / anthropic
7
7
 
8
8
  本机特例(discord/telegram、企业内网等)请走用户插件。
9
9
 
10
10
  代理组:默认提供 "proxy"(绝大多数 mihomo/sing-box 配置都有这个组)。
11
- 其他组(claude / residential-* 等)由用户插件提供。
11
+ 命名组(如 claude / residential-* 等)和针对它们的 expected_proxy 校验、
12
+ 出口探测都属本机特例,请放到 ~/.config/proxyctl/plugins/ 的用户插件里,
13
+ 不要 hardcode 进这个通用基线插件。
14
+
15
+ 出口探测:默认只提供 proxy(走主代理)/ direct(绕代理)两路对照,
16
+ 足够做分流校验;其它命名出口由用户插件按 name 追加或覆盖。
12
17
  """
13
18
 
14
19
  from proxyctl.core.plugin import CheckTarget, OutboundProbe, Plugin
@@ -30,14 +35,12 @@ class ConnectivityBasic(Plugin):
30
35
  CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
31
36
  CheckTarget(name="anthropic",
32
37
  url="https://api.anthropic.com/v1/models",
33
- mode="proxy",
34
- expected_proxy="claude"),
38
+ mode="proxy"),
35
39
  ]
36
40
 
37
41
  def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
38
42
  return [
39
43
  OutboundProbe(name="proxy", mode="proxy", url="https://api.ipify.org"),
40
- OutboundProbe(name="claude", mode="proxy", url="https://ipinfo.io/ip"),
41
44
  OutboundProbe(name="direct",
42
45
  mode="direct",
43
46
  url="https://myip.ipip.net",
@@ -35,6 +35,11 @@ def _strip_ansi(s: str) -> str:
35
35
  return _ANSI_RE.sub("", s).strip()
36
36
 
37
37
 
38
+ def _visible_len(s: str) -> int:
39
+ """Return printable width for simple check output alignment."""
40
+ return len(_ANSI_RE.sub("", s))
41
+
42
+
38
43
  def _port_listening(port: int) -> bool:
39
44
  try:
40
45
  with socket.create_connection(("127.0.0.1", port), timeout=0.5):
@@ -162,11 +167,15 @@ def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
162
167
 
163
168
 
164
169
  def _connectivity_line_value(mode: str) -> str:
165
- """Return the default route-line label for one check target mode."""
170
+ """Return the default route-line label for one check target mode.
171
+
172
+ proxy 模式默认值是 em dash ("—") 而非问号——含义是"反查不到活动连接",
173
+ 比裸 "?" 信息密度更高、视觉更安静。真实反查命中时会被实际链路覆盖。
174
+ """
166
175
  if mode == "direct":
167
176
  return "direct"
168
177
  if mode == "proxy":
169
- return "?"
178
+ return "—"
170
179
  return "-"
171
180
 
172
181
 
@@ -174,7 +183,8 @@ def _append_route_column(line: str, route_line: str,
174
183
  route_chain: str = "") -> str:
175
184
  """Append the observed route in the same style as Mihomo chains."""
176
185
  value = route_chain or route_line or "-"
177
- return f"{line} {DIM}via{NC} {value:<32s}"
186
+ pad = max(2, 74 - _visible_len(line))
187
+ return f"{line}{' ' * pad}{DIM}via{NC} {value:<32s}"
178
188
 
179
189
 
180
190
  def _dedupe_check_targets(targets: list) -> list:
@@ -202,17 +212,23 @@ def _dedupe_check_targets(targets: list) -> list:
202
212
 
203
213
 
204
214
  def _dedupe_outbound_probes(probes: list) -> list:
205
- """Drop exact duplicate outbound probes from multiple plugins."""
206
- out = []
207
- seen = set()
215
+ """Collapse outbound probes that share a (name, mode) identity.
216
+
217
+ A probe answers one question — "what IP does this egress show?" — so two
218
+ probes with the same ``name`` and ``mode`` are the same egress row even if
219
+ they hit different URLs (e.g. a builtin ``https://myip.ipip.net`` vs a user
220
+ plugin's ``http://`` variant). User plugins load after builtins, so the
221
+ later probe wins on conflict (本机特例覆盖通用基线), while the original
222
+ display position is preserved so row order stays stable across runs.
223
+ """
224
+ out: list = []
225
+ index: dict = {} # (name, mode) -> position in out
208
226
  for probe in probes:
209
- key = (getattr(probe, "name", ""),
210
- getattr(probe, "mode", ""),
211
- getattr(probe, "url", ""),
212
- getattr(probe, "extract_re", ""))
213
- if key in seen:
227
+ key = (getattr(probe, "name", ""), getattr(probe, "mode", ""))
228
+ if key in index:
229
+ out[index[key]] = probe # later (user) probe overrides earlier
214
230
  continue
215
- seen.add(key)
231
+ index[key] = len(out)
216
232
  out.append(probe)
217
233
  return out
218
234
 
@@ -1192,14 +1208,49 @@ def cmd_check(engine, api: str, api_secret: str,
1192
1208
  parts = target.url.removeprefix("tcp:").rsplit(":", 1)
1193
1209
  ok, line = _test_tcp(parts[0], int(parts[1]), target.name)
1194
1210
  else:
1211
+ # proxy 模式:在 curl 测试**进行中**并发轮询 mihomo 的
1212
+ # /connections,趁连接还活着抓到自己请求走的链路。
1213
+ # curl 短连接测完即从 mihomo 连接表移除,如果等 curl 结束
1214
+ # 后再查(旧实现),几乎抓不到自己刚发的请求 —— 这是
1215
+ # google/github/openai 的 via 列长期显示 "?" 的根因。
1216
+ host = urllib.parse.urlparse(target.url).hostname or ""
1217
+ captured: dict = {}
1218
+ stop_evt = threading.Event()
1219
+ poller = None
1220
+ if target.mode == "proxy" and host:
1221
+ def _poll():
1222
+ # Event.wait(timeout) 在 set 时立即返回 True,
1223
+ # 不会被 sleep 拖住停止响应。
1224
+ while not stop_evt.is_set():
1225
+ r = _route_from_connections(
1226
+ _api_connections(api, api_secret), host)
1227
+ if r.get("found"):
1228
+ captured.update(r)
1229
+ return
1230
+ stop_evt.wait(0.08)
1231
+ poller = threading.Thread(target=_poll, daemon=True)
1232
+ poller.start()
1233
+
1195
1234
  ok, line = _test_url(target.url, target.name,
1196
1235
  target.mode, target.timeout,
1197
1236
  proxy_port=proxy_port)
1237
+
1238
+ if poller is not None:
1239
+ stop_evt.set()
1240
+ poller.join(timeout=0.5)
1241
+
1198
1242
  expected = getattr(target, "expected_proxy", "")
1199
1243
  if target.mode == "proxy" and ok:
1200
- route = _target_route(api, api_secret, target.url)
1201
- route_line = str(route.get("line") or "?")
1202
- route_chain = str(route.get("chain") or "")
1244
+ # 优先取 curl 期间并发抓到的;抓不到时回退 _target_route,
1245
+ # 它可能从外部进程的活动长连接里命中(如用户的浏览器/
1246
+ # Claude Code 正在访问同一 host)。两者都没找到时
1247
+ # route_line 保留默认的 "—" 占位(_connectivity_line_value
1248
+ # 提供的友好兜底),而不是裸 "?"。
1249
+ route = (captured if captured.get("found")
1250
+ else _target_route(api, api_secret, target.url))
1251
+ if route.get("found"):
1252
+ route_line = str(route.get("line") or route_line)
1253
+ route_chain = str(route.get("chain") or "")
1203
1254
  if expected:
1204
1255
  route_ok, route_msg = _route_matches_expected_proxy(
1205
1256
  route, expected)
@@ -93,6 +93,52 @@ def _is_ip(s: str) -> bool:
93
93
  return False
94
94
 
95
95
 
96
+ def _cidr_contains_any(cidr: str, ips: list) -> bool:
97
+ """resolved_ips 里是否有任一 IP 落在 cidr 网段内。"""
98
+ try:
99
+ net = ipaddress.ip_network(cidr, strict=False)
100
+ except ValueError:
101
+ return False
102
+ for ip in ips:
103
+ try:
104
+ if ipaddress.ip_address(ip) in net:
105
+ return True
106
+ except ValueError:
107
+ pass
108
+ return False
109
+
110
+
111
+ def _load_noresolve_payloads(config_path: str = None) -> set:
112
+ """读取 mihomo 配置 rules 段,返回带 no-resolve 的 IP 规则 payload 集合。
113
+
114
+ Clash /rules API 不暴露 no-resolve 标志,预测器只能从配置文件解析。
115
+ 这些规则在域名连接(HTTP 代理 / fake-ip)下引擎不会解析域名,故不命中——
116
+ 预测器必须据此跳过,否则会误报命中(典型:100.64.0.0/10,DIRECT,no-resolve)。
117
+ """
118
+ import os
119
+ if config_path is None:
120
+ config_path = os.path.expanduser("~/.config/mihomo/config.yaml")
121
+ payloads: set = set()
122
+ try:
123
+ text = open(config_path).read()
124
+ except OSError:
125
+ return payloads
126
+ in_rules = False
127
+ for line in text.splitlines():
128
+ if re.match(r'^rules:\s*$', line):
129
+ in_rules = True
130
+ continue
131
+ if not in_rules:
132
+ continue
133
+ # rules 段结束:遇到顶格的新顶层键(非缩进、非列表项)
134
+ if line and not line[0].isspace() and not line.lstrip().startswith('-'):
135
+ break
136
+ m = re.match(r'\s*-\s*(?:IP-CIDR6?|IP-SUFFIX),([^,]+),[^,]+,\s*no-resolve\b', line)
137
+ if m:
138
+ payloads.add(m.group(1).strip())
139
+ return payloads
140
+
141
+
96
142
  def _section_dns(domain: str, api: str, secret: str,
97
143
  fakeip_active: bool = False, corp_dns: dict = None) -> list:
98
144
  """
@@ -208,9 +254,16 @@ def _section_dns(domain: str, api: str, secret: str,
208
254
  return resolved_ips
209
255
 
210
256
 
211
- def _section_rules(domain: str, resolved_ips: list, api: str, secret: str) -> tuple:
257
+ def _section_rules(domain: str, resolved_ips: list, api: str, secret: str,
258
+ noresolve_can_match: bool = True,
259
+ noresolve_payloads: set = None) -> tuple:
212
260
  """
213
261
  [2/4] 规则匹配 — 按引擎规则顺序逐条预测。
262
+
263
+ Args:
264
+ noresolve_can_match: 当前连接是否带真实目的 IP。域名经 HTTP 代理 / fake-ip
265
+ 进来时为 False——此时引擎走到 no-resolve 的 IP 规则尚未解析,会跳过它们。
266
+ noresolve_payloads: 配置里带 no-resolve 的 IP 规则 payload 集合(_load_noresolve_payloads)。
214
267
  返回 (predicted_rule, predicted_proxy)。
215
268
  """
216
269
  print(f"\n{BOLD}[2/4] 规则匹配{NC}")
@@ -219,6 +272,9 @@ def _section_rules(domain: str, resolved_ips: list, api: str, secret: str) -> tu
219
272
  print(f" {RED}无法获取规则列表{NC}")
220
273
  return None, None
221
274
 
275
+ noresolve_payloads = noresolve_payloads or set()
276
+ skipped_noresolve: list = [] # 因 no-resolve 被跳过、但本可命中的规则(用于提示)
277
+
222
278
  predicted_rule = predicted_proxy = None
223
279
  for rule in rules_data.get("rules", []):
224
280
  rtype = rule.get("type", "")
@@ -241,6 +297,14 @@ def _section_rules(domain: str, resolved_ips: list, api: str, secret: str) -> tu
241
297
  matched = True
242
298
  detail = f"精确域名 {payload}"
243
299
  elif rtype == "IPCIDR" and resolved_ips:
300
+ # no-resolve 的 IP 规则只在连接本身带真实目的 IP 时才会被引擎匹配。
301
+ # 域名经 HTTP 代理 / fake-ip 进来时,引擎走到这条还没解析 → 跳过,
302
+ # 否则误报命中(典型坑:100.64.0.0/10,DIRECT,no-resolve 被预测成 DIRECT,
303
+ # 实际却落兜底走 proxy)。
304
+ if (not noresolve_can_match) and payload in noresolve_payloads:
305
+ if _cidr_contains_any(payload, resolved_ips):
306
+ skipped_noresolve.append((rtype, payload, proxy))
307
+ continue
244
308
  try:
245
309
  net = ipaddress.ip_network(payload, strict=False)
246
310
  for ip in resolved_ips:
@@ -276,6 +340,13 @@ def _section_rules(domain: str, resolved_ips: list, api: str, secret: str) -> tu
276
340
  )
277
341
  print(f" {DIM}注: 跳过了 {len(uncertain)} 条 GeoSite/GeoIP 规则 "
278
342
  f"(无法客户端匹配): {types_str}{NC}")
343
+
344
+ if skipped_noresolve:
345
+ nr_str = ", ".join(
346
+ f"{t}({p})→{pr}" for t, p, pr in skipped_noresolve[-3:]
347
+ )
348
+ print(f" {YELLOW}注: 跳过了 {len(skipped_noresolve)} 条 no-resolve IP 规则 "
349
+ f"(域名连接下引擎不解析,本应命中却不命中): {nr_str}{NC}")
279
350
  break
280
351
 
281
352
  if not predicted_rule:
@@ -568,7 +639,17 @@ def cmd_trace(raw_input: str, api: str, secret: str, config: dict = None):
568
639
  resolved_ips = _section_dns(domain, api, secret, fakeip_active, corp_dns)
569
640
  collector["stages"]["dns"] = {"resolved": list(resolved_ips), "skipped": False}
570
641
 
571
- predicted_rule, predicted_proxy = _section_rules(domain, resolved_ips, api, secret)
642
+ # no-resolve 的 IP 规则只在连接带真实目的 IP 时才会被引擎匹配:
643
+ # - 目标输入就是 IP
644
+ # - redir-host + TUN on(内核按真实 IP 路由)
645
+ # 域名经 HTTP 代理 / fake-ip 进来时尚未解析 → 这些规则被引擎跳过
646
+ noresolve_can_match = _is_ip(domain) or (
647
+ mode["enhanced_mode"] != "fake-ip" and mode["tun_enabled"]
648
+ )
649
+ predicted_rule, predicted_proxy = _section_rules(
650
+ domain, resolved_ips, api, secret,
651
+ noresolve_can_match, _load_noresolve_payloads(),
652
+ )
572
653
  collector["stages"]["rules"] = {
573
654
  "predicted_rule": predicted_rule,
574
655
  "predicted_proxy": predicted_proxy,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes