proxyctl 0.5.11__tar.gz → 0.5.13__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.11 → proxyctl-0.5.13}/PKG-INFO +1 -1
  2. {proxyctl-0.5.11 → proxyctl-0.5.13}/pyproject.toml +1 -1
  3. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/check.py +12 -6
  4. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/cli.py +26 -3
  5. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/explain.py +4 -1
  6. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/trace.py +69 -49
  7. {proxyctl-0.5.11 → proxyctl-0.5.13}/.gitignore +0 -0
  8. {proxyctl-0.5.11 → proxyctl-0.5.13}/LICENSE +0 -0
  9. {proxyctl-0.5.11 → proxyctl-0.5.13}/README.md +0 -0
  10. {proxyctl-0.5.11 → proxyctl-0.5.13}/man/proxyctl.1 +0 -0
  11. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/__init__.py +0 -0
  12. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/_io.py +0 -0
  13. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/audit.py +0 -0
  14. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/autostart.py +0 -0
  15. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  16. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  17. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  18. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/completion.py +0 -0
  19. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/connections.py +0 -0
  20. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/connections_filters.py +0 -0
  21. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/connections_human.py +0 -0
  22. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/core/__init__.py +0 -0
  23. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/core/plugin.py +0 -0
  24. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/engine/__init__.py +0 -0
  25. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/engine/base.py +0 -0
  26. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/engine/mihomo.py +0 -0
  27. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/engine/singbox.py +0 -0
  28. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/status.py +0 -0
  29. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/subscription.py +0 -0
  30. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/suggest.py +0 -0
  31. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/suggest_rules.py +0 -0
  32. {proxyctl-0.5.11 → proxyctl-0.5.13}/src/proxyctl/traffic.py +0 -0
  33. {proxyctl-0.5.11 → proxyctl-0.5.13}/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.11
3
+ Version: 0.5.13
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.11"
3
+ version = "0.5.13"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -113,13 +113,19 @@ def _route_from_connections(conns: list[dict], host: str) -> dict:
113
113
  Mihomo reports chains from leaf node to policy group, for example
114
114
  ``["TW-Residential-01", "residential-tw", "claude"]``. The human
115
115
  "line" is the leaf node, not the policy group.
116
+
117
+ /connections 的顺序是任意的,且热 reload 不会掐断旧 keep-alive 连接——
118
+ 规则改过之后旧连接的 chains 还是旧路由。取 start 最新的一条匹配连接
119
+ (check 刚发完探测请求,最新的最接近当前规则的真实路由)。
116
120
  """
117
- for conn in conns:
118
- meta = conn.get("metadata") or {}
119
- conn_host = meta.get("host") or ""
120
- if conn_host != host and not conn_host.endswith("." + host):
121
- continue
122
- chains = [str(c) for c in (conn.get("chains") or []) if c]
121
+ matched = [
122
+ conn for conn in conns
123
+ if (h := (conn.get("metadata") or {}).get("host") or "") == host
124
+ or h.endswith("." + host)
125
+ ]
126
+ if matched:
127
+ newest = max(matched, key=lambda c: str(c.get("start") or ""))
128
+ chains = [str(c) for c in (newest.get("chains") or []) if c]
123
129
  return {
124
130
  "found": True,
125
131
  "line": chains[0] if chains else "?",
@@ -7,6 +7,7 @@
7
7
  """
8
8
 
9
9
  import functools
10
+ import ipaddress
10
11
  import json
11
12
  import os
12
13
  import platform
@@ -1327,6 +1328,30 @@ def cmd_dns_unlock(config: dict):
1327
1328
 
1328
1329
  # ── 命令:env ────────────────────────────────────────────────────────────────
1329
1330
 
1331
+ def _normalize_no_proxy_extra(extra) -> list[str]:
1332
+ """Return NO_PROXY extras compatible with Python/httpx environment parsing."""
1333
+ if not extra:
1334
+ return []
1335
+ if isinstance(extra, str):
1336
+ items = [s.strip() for s in extra.split(",")]
1337
+ else:
1338
+ items = [str(s).strip() for s in extra]
1339
+
1340
+ out: list[str] = []
1341
+ for item in items:
1342
+ if not item:
1343
+ continue
1344
+ try:
1345
+ network = ipaddress.ip_network(item, strict=False)
1346
+ except ValueError:
1347
+ out.append(item)
1348
+ continue
1349
+ if network.version == 6 and "/" in item:
1350
+ continue
1351
+ out.append(item)
1352
+ return out
1353
+
1354
+
1330
1355
  def cmd_env(config: dict, unset: bool = False):
1331
1356
  """输出设置/清除代理环境变量的 shell 语句。
1332
1357
 
@@ -1350,9 +1375,7 @@ def cmd_env(config: dict, unset: bool = False):
1350
1375
  proxy_socks = f"socks5://127.0.0.1:{port}"
1351
1376
  no_proxy = "localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
1352
1377
  # 用户附加的 NO_PROXY 项(个人域名等);接受 list[str] 或逗号分隔 str
1353
- extra = config.get("no_proxy_extra") or []
1354
- if isinstance(extra, str):
1355
- extra = [s.strip() for s in extra.split(",") if s.strip()]
1378
+ extra = _normalize_no_proxy_extra(config.get("no_proxy_extra"))
1356
1379
  if extra:
1357
1380
  no_proxy = no_proxy + "," + ",".join(extra)
1358
1381
 
@@ -217,7 +217,10 @@ def _t_env(backend, config) -> TopicCard:
217
217
  f"`proxyctl env` 输出 export 行,可 eval 进当前 shell(端口 {port})。"
218
218
  ),
219
219
  "file": _io_proxyctl_config_path() + " [no_proxy_extra: 字段]",
220
- "edit": " # no_proxy_extra: 追加内网域名 / Tailscale 段 / 企业 host",
220
+ "edit": (
221
+ " # no_proxy_extra: 追加内网域名 / IPv4 CIDR / 企业 host\n"
222
+ " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY"
223
+ ),
221
224
  "verify": "eval \"$(proxyctl env)\" && env | grep -i proxy",
222
225
  "next_commands": ["env", "env --unset"],
223
226
  }
@@ -506,20 +506,30 @@ def _section_connections(domain: str, resolved_ips: list,
506
506
  """[4/4] 实际连接验证 — 从 Clash API 活跃连接 + 日志历史中取路由信息。"""
507
507
  print(f"\n{BOLD}[4/4] 实际连接{NC}")
508
508
 
509
- domain_conns = []
509
+ host_conns = []
510
+ ip_conns = []
510
511
  for _ in range(3):
511
512
  data = _api_get(api, "/connections", secret)
512
513
  if data and isinstance(data, dict):
513
514
  for c in (data.get("connections") or []):
514
515
  m = c.get("metadata", {})
515
- h = m.get("host", "")
516
516
  di = m.get("destinationIP", "")
517
- if (h == domain or h.endswith("." + domain)
518
- or (di and di in resolved_ips)):
519
- domain_conns.append(c)
520
- if domain_conns:
517
+ # sniffer/TUN 模式下 host 可能是目的 IP 或为空,真实域名落在
518
+ # sniffHost(嗅探到的 SNI / HTTP Host)。两字段独立判断、不互相
519
+ # 遮蔽:任一匹配即纳入;只有两者都缺失(纯 IP)才按目的 IP 回退
520
+ hosts = [x for x in (m.get("host", ""), m.get("sniffHost", "")) if x]
521
+ if any(x == domain or x.endswith("." + domain) for x in hosts):
522
+ host_conns.append(c)
523
+ elif not hosts and di and di in resolved_ips:
524
+ # host/sniffHost 都没有的纯 IP 连接才回退;任一字段非空但
525
+ # 指向别的域名的连接,即使同 IP 也不算进来——否则共享 IP 下
526
+ # 会把同 IP 别站的活跃连接误算成本域名
527
+ ip_conns.append(c)
528
+ if host_conns or ip_conns:
521
529
  break
522
530
  time.sleep(0.3)
531
+ # host 精确匹配优先;只有全是无 host 的连接时才回退到按 IP 匹配的那批
532
+ domain_conns = host_conns if host_conns else ip_conns
523
533
 
524
534
  if not domain_conns:
525
535
  # 没有活跃连接,从引擎日志中捞历史记录
@@ -536,54 +546,64 @@ def _section_connections(domain: str, resolved_ips: list,
536
546
  print(f" {DIM}基于规则预测,该域名应走: {BOLD}{predicted_proxy}{NC}")
537
547
  return
538
548
 
539
- # 去重:按 rule+chains
540
- seen: set = set()
541
- deduped = []
549
+ def fmt_bytes(b: int) -> str:
550
+ if b < 1024: return f"{b}B"
551
+ if b < 1024 * 1024: return f"{b/1024:.1f}K"
552
+ return f"{b/1024/1024:.1f}M"
553
+
554
+ # 按完整链路聚合:相同链路的连接合并计数、流量累加,不再逐条平铺
555
+ groups: dict = {}
542
556
  for c in domain_conns:
543
- rule = c.get("rule", "?")
544
- rp = c.get("rulePayload", "")
545
557
  chains = c.get("chains", [])
546
- key = f"{rule}|{rp}|{','.join(chains)}"
547
- if key not in seen:
548
- seen.add(key)
549
- deduped.append(c)
550
-
551
- for c in deduped:
552
- rule = c.get("rule", "?")
553
- rp = c.get("rulePayload", "")
554
- chains = c.get("chains", [])
555
- chain_str = " → ".join(reversed(chains)) if chains else "?"
556
- rp_str = f"({rp})" if rp else ""
557
- print(f" 规则: {CYAN}{rule}{rp_str}{NC}")
558
- print(f" 链路: {chain_str}")
559
-
560
- # 与预测对比
558
+ chain_str = " → ".join(reversed(chains)) if chains else "?"
559
+ g = groups.setdefault(chain_str, {"chains": chains, "count": 0,
560
+ "up": 0, "down": 0})
561
+ g["count"] += 1
562
+ g["up"] += c.get("upload", 0)
563
+ g["down"] += c.get("download", 0)
564
+
565
+ # 是否符合预测:预测的组名出现在链路任意一跳即算命中。
566
+ # proxy 链 chains=[节点, proxy-tuic, proxy] → 含 'proxy' → 符合
567
+ # DIRECT 链 chains=['DIRECT'] → 不含 → 不符合
568
+ # (不再纠结 chains[0] 是末端节点还是 chains[-1] 是入口组——用集合判定消除歧义)
569
+ def _matches(chains: list):
570
+ if not predicted_proxy:
571
+ return None
572
+ return any(ch.lower() == predicted_proxy.lower() for ch in chains)
573
+
574
+ total = len(domain_conns)
575
+ print(f" {DIM}(mihomo 当前活跃连接 {total} 条){NC}")
561
576
  if predicted_proxy:
562
- actual_chains = domain_conns[0].get("chains", [])
563
- actual_outbound = actual_chains[-1] if actual_chains else "?"
564
- if actual_outbound.lower() != predicted_proxy.lower():
565
- print(f" {YELLOW}⚠ 预测出口 {predicted_proxy},实际出口 {actual_outbound}{NC}")
577
+ print(f" 应走(规则预测): {BOLD}{predicted_proxy}{NC}")
578
+ print(" 实走:")
579
+
580
+ items = [(_matches(g["chains"]), s, g) for s, g in groups.items()]
581
+ items.sort(key=lambda x: (x[0] is not True, -x[2]["count"])) # 符合在前,多的在前
582
+
583
+ match_n = mismatch_n = 0
584
+ for ok, chain_str, g in items:
585
+ if ok is True:
586
+ mark = f"{GREEN}✓{NC}"; match_n += g["count"]
587
+ elif ok is False:
588
+ mark = f"{YELLOW}✗{NC}"; mismatch_n += g["count"]
566
589
  else:
567
- print(f" {GREEN}✓ 与规则预测一致{NC}")
568
-
569
- # 连接详情
570
- print(f" {DIM}---{NC}")
571
- domain_conns.sort(key=lambda x: x.get("start", ""), reverse=True)
572
- for c in domain_conns[:5]:
573
- m = c.get("metadata", {})
574
- host = m.get("host", "?")
575
- dport = m.get("destinationPort", "?")
576
- up = c.get("upload", 0)
577
- down = c.get("download", 0)
578
- chains = c.get("chains", [])
579
- outbound = chains[0] if chains else "?"
580
-
581
- def fmt_bytes(b: int) -> str:
582
- if b < 1024: return f"{b}B"
583
- if b < 1024 * 1024: return f"{b/1024:.1f}K"
584
- return f"{b/1024/1024:.1f}M"
590
+ mark = " "
591
+ flow = f"↑{fmt_bytes(g['up'])} ↓{fmt_bytes(g['down'])}"
592
+ print(f" {mark} {chain_str} {DIM}·{NC} {g['count']} 条 {DIM}{flow}{NC}")
585
593
 
586
- print(f" {host}:{dport} {outbound} ↑{fmt_bytes(up)} ↓{fmt_bytes(down)}")
594
+ if not predicted_proxy:
595
+ return
596
+ if mismatch_n == 0:
597
+ print(f" {GREEN}✓ 结论: 全部 {total} 条都走 {predicted_proxy},与规则一致{NC}")
598
+ elif match_n == 0:
599
+ print(f" {YELLOW}✗ 结论: {total} 条都没走 {predicted_proxy};"
600
+ f"规则可能未生效,或被前置规则截胡{NC}")
601
+ else:
602
+ print(f" {YELLOW}⚠ 结论: {match_n} 条新连接已走 {predicted_proxy},"
603
+ f"另 {mismatch_n} 条仍走旧链路{NC}")
604
+ print(f" {DIM} 旧链路是改规则前建立的连接(建连时已定死,不随规则切换),"
605
+ f"会随超时自然断开;{NC}")
606
+ print(f" {DIM} 刷新页面后即全部走 {predicted_proxy}。{NC}")
587
607
 
588
608
 
589
609
  def cmd_trace(raw_input: str, api: str, secret: str, config: dict = None):
File without changes
File without changes
File without changes
File without changes
File without changes