proxyctl 0.5.7__tar.gz → 0.5.9__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 (34) hide show
  1. {proxyctl-0.5.7 → proxyctl-0.5.9}/PKG-INFO +1 -1
  2. {proxyctl-0.5.7 → proxyctl-0.5.9}/pyproject.toml +1 -1
  3. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/builtin_plugins/connectivity_basic.py +5 -2
  4. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/check.py +174 -38
  5. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/connections.py +159 -14
  6. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/connections_filters.py +86 -13
  7. proxyctl-0.5.9/src/proxyctl/connections_human.py +526 -0
  8. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/explain.py +7 -1
  9. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/traffic.py +12 -1
  10. proxyctl-0.5.7/src/proxyctl/connections_human.py +0 -239
  11. {proxyctl-0.5.7 → proxyctl-0.5.9}/.gitignore +0 -0
  12. {proxyctl-0.5.7 → proxyctl-0.5.9}/LICENSE +0 -0
  13. {proxyctl-0.5.7 → proxyctl-0.5.9}/README.md +0 -0
  14. {proxyctl-0.5.7 → proxyctl-0.5.9}/man/proxyctl.1 +0 -0
  15. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/__init__.py +0 -0
  16. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/_io.py +0 -0
  17. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/audit.py +0 -0
  18. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/autostart.py +0 -0
  19. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  20. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  21. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/cli.py +0 -0
  22. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/completion.py +0 -0
  23. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/core/__init__.py +0 -0
  24. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/core/plugin.py +0 -0
  25. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/engine/__init__.py +0 -0
  26. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/engine/base.py +0 -0
  27. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/engine/mihomo.py +0 -0
  28. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/engine/singbox.py +0 -0
  29. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/status.py +0 -0
  30. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/subscription.py +0 -0
  31. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/suggest.py +0 -0
  32. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/suggest_rules.py +0 -0
  33. {proxyctl-0.5.7 → proxyctl-0.5.9}/src/proxyctl/trace.py +0 -0
  34. {proxyctl-0.5.7 → proxyctl-0.5.9}/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.7
3
+ Version: 0.5.9
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.7"
3
+ version = "0.5.9"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  提供一组跨所有用户都通用的连通性测试点:
4
4
  - 海外(走代理):google / github
5
5
  - 国内(直连):baidu
6
- - AI 线路:anthropic(走 claude 规则组)
6
+ - AI 线路:openai / anthropic(anthropic claude 规则组)
7
7
 
8
8
  本机特例(discord/telegram、企业内网等)请走用户插件。
9
9
 
@@ -24,9 +24,12 @@ class ConnectivityBasic(Plugin):
24
24
  return [
25
25
  CheckTarget(name="google", url="https://www.google.com", mode="proxy"),
26
26
  CheckTarget(name="github", url="https://github.com", mode="proxy"),
27
+ CheckTarget(name="openai",
28
+ url="https://api.openai.com/v1/models",
29
+ mode="proxy"),
27
30
  CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
28
31
  CheckTarget(name="anthropic",
29
- url="https://api.anthropic.com",
32
+ url="https://api.anthropic.com/v1/models",
30
33
  mode="proxy",
31
34
  expected_proxy="claude"),
32
35
  ]
@@ -54,7 +54,7 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
54
54
  env = {k: v for k, v in os.environ.items()
55
55
  if k not in ("http_proxy", "https_proxy", "all_proxy",
56
56
  "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
57
- cmd = ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
57
+ cmd = ["curl", "--http1.1", "-s", "-o", "/dev/null", "-w", "%{http_code}",
58
58
  "--max-time", str(timeout)]
59
59
  if mode == "proxy":
60
60
  cmd += ["--proxy", f"socks5h://127.0.0.1:{proxy_port}"]
@@ -66,7 +66,8 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
66
66
  code = r.stdout.strip()
67
67
 
68
68
  if code == "000" or not code:
69
- return False, f" {RED}✗{NC} {desc:<18s} {url:<44s} {RED}timeout{NC}"
69
+ reason = _curl_failure_reason(r.stderr)
70
+ return False, f" {RED}✗{NC} {desc:<18s} {url:<44s} {RED}{reason}{NC}"
70
71
  elif code.startswith(("2", "3", "4")):
71
72
  return True, f" {GREEN}✓{NC} {desc:<18s} {url:<44s} {GREEN}{code}{NC}"
72
73
  elif code.startswith("5"):
@@ -76,6 +77,16 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
76
77
  return False, f" {YELLOW}?{NC} {desc:<18s} {url:<44s} {YELLOW}{code}{NC}"
77
78
 
78
79
 
80
+ def _curl_failure_reason(stderr: str) -> str:
81
+ """Return a concise curl failure reason for HTTP code 000."""
82
+ text = (stderr or "").strip()
83
+ if not text:
84
+ return "timeout"
85
+ first = text.splitlines()[0].strip()
86
+ first = first.removeprefix("curl: ").strip()
87
+ return first[:120] if first else "timeout"
88
+
89
+
79
90
  def _api_connections(api_base: str, api_secret: str) -> list[dict]:
80
91
  r = subprocess.run(
81
92
  ["curl", "-s", "--noproxy", "*",
@@ -91,26 +102,119 @@ def _api_connections(api_base: str, api_secret: str) -> list[dict]:
91
102
  return conns if isinstance(conns, list) else []
92
103
 
93
104
 
94
- def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
95
- expected_proxy: str) -> tuple[bool, str]:
105
+ def _route_from_connections(conns: list[dict], host: str) -> dict:
106
+ """Find one Mihomo route for host and expose leaf line + policy group.
107
+
108
+ Mihomo reports chains from leaf node to policy group, for example
109
+ ``["TW-Residential-01", "residential-tw", "claude"]``. The human
110
+ "line" is the leaf node, not the policy group.
111
+ """
112
+ for conn in conns:
113
+ meta = conn.get("metadata") or {}
114
+ conn_host = meta.get("host") or ""
115
+ if conn_host != host and not conn_host.endswith("." + host):
116
+ continue
117
+ chains = [str(c) for c in (conn.get("chains") or []) if c]
118
+ return {
119
+ "found": True,
120
+ "line": chains[0] if chains else "?",
121
+ "group": chains[-1] if chains else "",
122
+ "chain": " → ".join(reversed(chains)) if chains else "?",
123
+ }
124
+ return {"found": False, "line": "?", "group": "", "chain": ""}
125
+
126
+
127
+ def _target_route(api_base: str, api_secret: str, url: str) -> dict:
128
+ """Return the observed route for a URL from local Mihomo connections."""
96
129
  host = urllib.parse.urlparse(url).hostname or ""
97
- if not host or not expected_proxy:
98
- return True, ""
130
+ if not host:
131
+ return {"found": False, "line": "?", "group": "", "chain": ""}
99
132
 
100
133
  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 '?'}"
134
+ route = _route_from_connections(_api_connections(api_base, api_secret),
135
+ host)
136
+ if route.get("found"):
137
+ return route
112
138
  time.sleep(0.2)
113
- return False, f" expected {expected_proxy}, no active connection found"
139
+ return {"found": False, "line": "?", "group": "", "chain": ""}
140
+
141
+
142
+ def _route_matches_expected_proxy(route: dict,
143
+ expected_proxy: str) -> tuple[bool, str]:
144
+ """Validate an observed route's terminal policy group."""
145
+ if not expected_proxy:
146
+ return True, ""
147
+ if not route.get("found"):
148
+ return False, f" expected {expected_proxy}, no active connection found"
149
+
150
+ actual = str(route.get("group") or "")
151
+ if actual.lower() == expected_proxy.lower():
152
+ return True, ""
153
+ return False, f" expected {expected_proxy}, got {actual or '?'}"
154
+
155
+
156
+ def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
157
+ expected_proxy: str) -> tuple[bool, str]:
158
+ if not urllib.parse.urlparse(url).hostname or not expected_proxy:
159
+ return True, ""
160
+ return _route_matches_expected_proxy(
161
+ _target_route(api_base, api_secret, url), expected_proxy)
162
+
163
+
164
+ def _connectivity_line_value(mode: str) -> str:
165
+ """Return the default route-line label for one check target mode."""
166
+ if mode == "direct":
167
+ return "direct"
168
+ if mode == "proxy":
169
+ return "?"
170
+ return "-"
171
+
172
+
173
+ def _append_route_column(line: str, route_line: str,
174
+ route_chain: str = "") -> str:
175
+ """Append the observed route in the same style as Mihomo chains."""
176
+ value = route_chain or route_line or "-"
177
+ return f"{line} {DIM}via{NC} {value:<32s}"
178
+
179
+
180
+ def _dedupe_check_targets(targets: list) -> list:
181
+ """Drop exact duplicate connectivity targets from multiple plugins.
182
+
183
+ Builtin plugins load before user plugins. Keep the first target's display
184
+ order, but merge an expected_proxy from a duplicate if the first one lacks
185
+ it, so a stale user plugin cannot remove route validation by accident.
186
+ """
187
+ out = []
188
+ seen = {}
189
+ for target in targets:
190
+ key = (getattr(target, "name", ""),
191
+ getattr(target, "url", ""),
192
+ getattr(target, "mode", ""))
193
+ if key in seen:
194
+ kept = seen[key]
195
+ expected = getattr(target, "expected_proxy", "")
196
+ if expected and not getattr(kept, "expected_proxy", ""):
197
+ kept.expected_proxy = expected
198
+ continue
199
+ seen[key] = target
200
+ out.append(target)
201
+ return out
202
+
203
+
204
+ def _dedupe_outbound_probes(probes: list) -> list:
205
+ """Drop exact duplicate outbound probes from multiple plugins."""
206
+ out = []
207
+ seen = set()
208
+ 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:
214
+ continue
215
+ seen.add(key)
216
+ out.append(probe)
217
+ return out
114
218
 
115
219
 
116
220
  def _test_dns(desc: str, server: str, domain: str) -> tuple:
@@ -458,10 +562,13 @@ def _ipgeo(ip: str, cache_file: str, api_secret: str,
458
562
  env = {k: v for k, v in os.environ.items()
459
563
  if k not in ("http_proxy", "https_proxy", "all_proxy",
460
564
  "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
565
+ # Geo lookup is metadata lookup for an already-known IP; it should not be
566
+ # routed through the proxy under test. In particular, users may route
567
+ # ipinfo.io itself through the claude group, so a claude outage must not
568
+ # hide the direct/proxy geo labels.
461
569
  r = subprocess.run(
462
- ["curl", "-s", "--max-time", "6",
463
- "--proxy", f"socks5h://127.0.0.1:{proxy_port}",
464
- f"https://ipinfo.io/{ip}/json"],
570
+ ["curl", "-s", "--max-time", "6", "--noproxy", "*",
571
+ f"http://ip-api.com/json/{ip}?fields=status,countryCode,city,isp,org,query"],
465
572
  capture_output=True, text=True, env=env, timeout=10
466
573
  )
467
574
  if not r.stdout:
@@ -469,9 +576,9 @@ def _ipgeo(ip: str, cache_file: str, api_secret: str,
469
576
  try:
470
577
  d = json.loads(r.stdout)
471
578
  city = d.get("city", "")
472
- country = d.get("country", "")
473
- org = d.get("org", "")
474
- if org:
579
+ country = d.get("countryCode") or d.get("country", "")
580
+ org = d.get("isp") or d.get("org", "")
581
+ if org and _re_mod.match(r"^AS\d+\s+", org):
475
582
  parts = org.split(" ", 1)
476
583
  org = parts[1] if len(parts) > 1 else org
477
584
  loc = ",".join(filter(None, [city, country]))
@@ -713,7 +820,7 @@ def _collect_fail_hints(collector: dict, *, dns_bad: bool, failed: bool) -> list
713
820
  if basic.get("daemon_up") is False:
714
821
  hints.append("engine not running")
715
822
 
716
- # connectivity: 列表形式,每项 {name, url, ok, message}
823
+ # connectivity: 列表形式,每项 {name, url, mode, line, route_chain, ok, message}
717
824
  conn = stages.get("connectivity") or []
718
825
  failed_conns = [c.get("name", "?") for c in conn
719
826
  if isinstance(c, dict) and not c.get("ok")]
@@ -791,7 +898,8 @@ def cmd_check(engine, api: str, api_secret: str,
791
898
  # [4/4] 出口 IP 探测:从插件收集到的 probes 决定有哪些出口
792
899
  probes = []
793
900
  if registry is not None:
794
- probes = registry.collect("check_outbound_probes", ctx={})
901
+ probes = _dedupe_outbound_probes(
902
+ registry.collect("check_outbound_probes", ctx={}))
795
903
  probe_ips: dict[str, str] = {p.name: "" for p in probes}
796
904
 
797
905
  # 临时常量:probes 在端口解析之前启动,所以这里用 config 直接拿
@@ -1048,7 +1156,8 @@ def cmd_check(engine, api: str, api_secret: str,
1048
1156
  ctx = {"corp_net": corp_net, "mode": mode, "engine": engine.name}
1049
1157
  targets = []
1050
1158
  if registry is not None:
1051
- targets = registry.collect("check_targets", ctx=ctx)
1159
+ targets = _dedupe_check_targets(
1160
+ registry.collect("check_targets", ctx=ctx))
1052
1161
 
1053
1162
  # 应用 only_when 过滤
1054
1163
  def _target_enabled(t):
@@ -1071,6 +1180,9 @@ def cmd_check(engine, api: str, api_secret: str,
1071
1180
  ready = [threading.Event() for _ in targets]
1072
1181
 
1073
1182
  def _run_test(idx, target):
1183
+ route_line = _connectivity_line_value(target.mode)
1184
+ route_chain = ""
1185
+ route_note = ""
1074
1186
  try:
1075
1187
  if target.mode == "dns":
1076
1188
  # url 格式: dns:<server>:<domain>
@@ -1084,17 +1196,24 @@ def cmd_check(engine, api: str, api_secret: str,
1084
1196
  target.mode, target.timeout,
1085
1197
  proxy_port=proxy_port)
1086
1198
  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}"
1199
+ 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 "")
1203
+ if expected:
1204
+ route_ok, route_msg = _route_matches_expected_proxy(
1205
+ route, expected)
1206
+ ok = route_ok
1207
+ if route_ok:
1208
+ route_note = f" {GREEN}{route_msg}{NC}"
1209
+ else:
1210
+ route_note = f" {RED}{route_msg}{NC}"
1211
+ line = _append_route_column(line, route_line, route_chain)
1212
+ line += route_note
1095
1213
  except Exception as e:
1096
1214
  ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
1097
- results[idx] = (line, ok)
1215
+ line = _append_route_column(line, route_line, route_chain)
1216
+ results[idx] = (line, ok, route_line, route_chain)
1098
1217
  ready[idx].set()
1099
1218
 
1100
1219
  if targets:
@@ -1103,7 +1222,7 @@ def cmd_check(engine, api: str, api_secret: str,
1103
1222
  pool.submit(_run_test, idx, target)
1104
1223
  for idx in range(len(targets)):
1105
1224
  ready[idx].wait()
1106
- line, ok = results[idx]
1225
+ line, ok, route_line, route_chain = results[idx]
1107
1226
  print(line)
1108
1227
  if not ok:
1109
1228
  fail = True
@@ -1111,6 +1230,8 @@ def cmd_check(engine, api: str, api_secret: str,
1111
1230
  "name": targets[idx].name,
1112
1231
  "url": targets[idx].url,
1113
1232
  "mode": targets[idx].mode,
1233
+ "line": route_line,
1234
+ "route_chain": route_chain,
1114
1235
  "ok": bool(ok),
1115
1236
  "message": _strip_ansi(line),
1116
1237
  })
@@ -1123,9 +1244,24 @@ def cmd_check(engine, api: str, api_secret: str,
1123
1244
  cache_file = os.path.join(sb_dir, ".ipgeo-cache")
1124
1245
  if not probes:
1125
1246
  print(f" {YELLOW}—{NC} 无出口探测项")
1247
+ geo_results: dict[str, str] = {p.name: "" for p in probes}
1248
+ geo_threads = [
1249
+ threading.Thread(
1250
+ target=lambda p=probe: geo_results.__setitem__(
1251
+ p.name,
1252
+ _ipgeo(probe_ips.get(p.name, ""), cache_file, api_secret,
1253
+ proxy_port=proxy_port),
1254
+ )
1255
+ )
1256
+ for probe in probes
1257
+ ]
1258
+ for t in geo_threads:
1259
+ t.start()
1260
+ for t in geo_threads:
1261
+ t.join()
1126
1262
  for probe in probes:
1127
1263
  ip = probe_ips.get(probe.name, "")
1128
- geo = _ipgeo(ip, cache_file, api_secret, proxy_port=proxy_port)
1264
+ geo = geo_results.get(probe.name, "")
1129
1265
  print(f" {probe.name:<7s}{_fmt_ip(ip, geo)}")
1130
1266
  collector["stages"]["outbound_ip"][probe.name] = {
1131
1267
  "ip": ip, "geo": geo, "mode": getattr(probe, "mode", ""),
@@ -87,6 +87,7 @@ class LocalConnection:
87
87
  "pid": self.pid,
88
88
  "fd": self.fd,
89
89
  "process": self.process,
90
+ "command": self.command,
90
91
  "app_contexts": _detect_app_contexts(self._match_text()),
91
92
  "local_source_port": self.source_port,
92
93
  "target_host": self.target_host,
@@ -163,6 +164,9 @@ class ConnectionArgs:
163
164
  agent_filters: Agent/tool filters from ``--agent``.
164
165
  query_filters: Free-text filters from ``--query`` / ``--filter``.
165
166
  all_apps: Whether ``--all`` was explicitly requested.
167
+ verbose: Whether ``--verbose`` was set — human view should expand
168
+ per-socket detail (rule / chains / upload / process / command /
169
+ match_reasons) instead of only the destination-group summary.
166
170
  """
167
171
 
168
172
  app_filters: list[str]
@@ -173,6 +177,7 @@ class ConnectionArgs:
173
177
  agent_filters: list[str] = field(default_factory=list)
174
178
  query_filters: list[str] = field(default_factory=list)
175
179
  all_apps: bool = False
180
+ verbose: bool = False
176
181
 
177
182
  def has_filters(self) -> bool:
178
183
  """Return whether any narrowing filter is active."""
@@ -194,6 +199,12 @@ def parse_args(args: list[str]) -> ConnectionArgs:
194
199
  ``--host`` / ``--chain`` / ``--route`` / ``--preset`` / ``--agent`` /
195
200
  ``--query`` are repeatable. ``--line`` aliases ``--chain``.
196
201
  ``--app`` is kept as a legacy alias for process/app filtering.
202
+
203
+ Positional keywords:
204
+ Any non-flag token (not starting with ``--``) is appended to
205
+ ``query_filters`` and behaves identically to ``--query <kw>``.
206
+ Multiple positional/--query keywords are combined with AND across
207
+ cross-field smart matching dimensions (IP/host, port, PID, process).
197
208
  """
198
209
  parsed = ConnectionArgs(app_filters=[])
199
210
  idx = 0
@@ -203,20 +214,27 @@ def parse_args(args: list[str]) -> ConnectionArgs:
203
214
  parsed.all_apps = True
204
215
  idx += 1
205
216
  continue
217
+ if arg == "--verbose":
218
+ parsed.verbose = True
219
+ idx += 1
220
+ continue
206
221
  if arg in ("--app", "--host", "--chain", "--line", "--route",
207
222
  "--preset", "--agent",
208
223
  "--query", "--filter"):
209
224
  _append_filter_arg(parsed, arg, args, idx)
210
225
  idx += 2
211
226
  continue
212
- _io.fail(f"未识别 connections 参数:{arg}",
213
- hints=["proxyctl connections --host anthropic.com",
214
- "proxyctl connections --chain SG-Residential-01",
215
- "proxyctl connections --route proxy",
216
- "proxyctl connections --preset ai",
217
- "proxyctl connections --agent codex --json",
218
- "proxyctl connections --json"],
219
- doc="agent-protocol", code=_io.USAGE, cmd="connections")
227
+ if arg.startswith("--"):
228
+ _io.fail(f"未识别 connections 参数:{arg}",
229
+ hints=["proxyctl connections codex",
230
+ "proxyctl connections 443 anthropic",
231
+ "proxyctl connections claude --verbose",
232
+ "proxyctl connections --host anthropic.com",
233
+ "proxyctl connections --preset ai",
234
+ "proxyctl connections --json"],
235
+ doc="agent-protocol", code=_io.USAGE, cmd="connections")
236
+ parsed.query_filters.append(arg)
237
+ idx += 1
220
238
  return parsed
221
239
 
222
240
 
@@ -665,7 +683,12 @@ def _proxy_owner_selection_reason(conn: dict[str, Any], owner: ProxyOwner,
665
683
 
666
684
  def _proxy_owner_item_matches_filters(item: dict[str, Any],
667
685
  args: ConnectionArgs) -> bool:
668
- """Return whether a proxy-owner item satisfies active filters."""
686
+ """Return whether a proxy-owner item satisfies active filters.
687
+
688
+ Side effect: when keyword/--query filters match, the per-keyword
689
+ dimension hits are written into ``item["match_reasons"]`` so downstream
690
+ rendering (JSON, human highlight) can explain why the row was kept.
691
+ """
669
692
  owner = item.get("owner") or {}
670
693
  process_text = " ".join([
671
694
  str(owner.get("app") or ""),
@@ -675,32 +698,70 @@ def _proxy_owner_item_matches_filters(item: dict[str, Any],
675
698
  ])
676
699
  contexts = list(item.get("candidate_contexts") or [])
677
700
  contexts.extend(owner.get("app_contexts") or [])
678
- detail = item.get("mihomo")
679
- return _matches_filter_dimensions(
701
+ detail = item.get("mihomo") or {}
702
+ row_fields = {
703
+ "target_host": detail.get("host") or "",
704
+ "destination_ip": detail.get("destination_ip") or "",
705
+ "app": owner.get("app") or "",
706
+ "process": owner.get("process") or "",
707
+ "command": owner.get("command") or "",
708
+ "target_port": detail.get("destination_port") or item.get("target_port"),
709
+ "source_port": detail.get("source_port") or item.get("local_source_port"),
710
+ "pid": owner.get("pid"),
711
+ }
712
+ reasons = _matches_filter_dimensions(
680
713
  process_text, _detail_text(detail), contexts, args,
681
714
  chain_text=_chain_text(detail),
682
715
  route_kind=_route_kind_text(detail),
716
+ row_fields=row_fields,
683
717
  )
718
+ if reasons is None:
719
+ return False
720
+ if reasons:
721
+ item["match_reasons"] = reasons
722
+ return True
684
723
 
685
724
 
686
725
  def _local_item_matches_filters(item: dict[str, Any],
687
726
  args: ConnectionArgs) -> bool:
688
- """Return whether a local lsof item satisfies active filters."""
727
+ """Return whether a local lsof item satisfies active filters.
728
+
729
+ Side effect: see ``_proxy_owner_item_matches_filters``.
730
+ """
689
731
  process_text = " ".join([
690
732
  str(item.get("app") or ""),
691
733
  os.path.basename(str(item.get("process") or "")),
692
734
  str(item.get("process") or ""),
735
+ str(item.get("command") or ""),
693
736
  ])
694
737
  target_text = " ".join([
695
738
  str(item.get("target_host") or ""),
696
739
  _detail_text(item.get("mihomo")),
697
740
  ])
698
- detail = item.get("mihomo")
699
- return _matches_filter_dimensions(
741
+ detail = item.get("mihomo") or {}
742
+ # When mihomo joined this socket, prefer its host/destination_ip over the
743
+ # lsof-side target (which for proxy traffic is just 127.0.0.1:7890).
744
+ row_fields = {
745
+ "target_host": detail.get("host") or item.get("target_host") or "",
746
+ "destination_ip": detail.get("destination_ip") or "",
747
+ "app": item.get("app") or "",
748
+ "process": item.get("process") or "",
749
+ "command": item.get("command") or "",
750
+ "target_port": detail.get("destination_port") or item.get("target_port"),
751
+ "source_port": item.get("local_source_port"),
752
+ "pid": item.get("pid"),
753
+ }
754
+ reasons = _matches_filter_dimensions(
700
755
  process_text, target_text, item.get("app_contexts") or [], args,
701
756
  chain_text=_chain_text(detail),
702
757
  route_kind=_route_kind_text(detail),
758
+ row_fields=row_fields,
703
759
  )
760
+ if reasons is None:
761
+ return False
762
+ if reasons:
763
+ item["match_reasons"] = reasons
764
+ return True
704
765
 
705
766
 
706
767
  def _proxy_group_key(item: dict[str, Any]) -> tuple[str, str]:
@@ -851,6 +912,10 @@ def build_report(backend_name: str, config: dict[str, Any],
851
912
  proxy_owner_rows = _proxy_owner_connections(
852
913
  remote_rows, proxy_port, parsed_args)
853
914
  proxy_owner_group_rows = _proxy_owner_groups(proxy_owner_rows)
915
+ history_status: dict[str, Any] = {"loaded": False}
916
+ if parsed_args.verbose:
917
+ history_status = _attach_history_to_rows(
918
+ config, joined, proxy_owner_rows)
854
919
  return {
855
920
  "proxy_port": proxy_port,
856
921
  "backend": backend_name,
@@ -865,6 +930,8 @@ def build_report(backend_name: str, config: dict[str, Any],
865
930
  "query": parsed_args.query_filters,
866
931
  },
867
932
  "all_apps": parsed_args.all_apps,
933
+ "verbose": parsed_args.verbose,
934
+ "history_status": history_status,
868
935
  "api": api_status,
869
936
  "connections": joined,
870
937
  "proxy_owner_connections": proxy_owner_rows,
@@ -891,6 +958,84 @@ def build_report(backend_name: str, config: dict[str, Any],
891
958
  }
892
959
 
893
960
 
961
+ def _attach_history_to_rows(config: dict[str, Any],
962
+ local_rows: list[dict[str, Any]],
963
+ proxy_owner_rows: list[dict[str, Any]]
964
+ ) -> dict[str, Any]:
965
+ """Load traffic-store events and attach per-host history to each row.
966
+
967
+ Returns a small status dict that the human renderer can inspect to
968
+ decide whether to print a "history is empty — run traffic sample first"
969
+ hint. JSON consumers see the same status under report["history_status"].
970
+ """
971
+ from proxyctl import traffic_store
972
+ paths = traffic_store.store_paths(config, None)
973
+ events = traffic_store.read_events(paths["events_path"])
974
+ status: dict[str, Any] = {
975
+ "loaded": True,
976
+ "events_path": paths["events_path"],
977
+ "event_count": len(events),
978
+ "exists": os.path.exists(paths["events_path"]),
979
+ }
980
+ if not events:
981
+ return status
982
+ by_host: dict[str, list[dict[str, Any]]] = {}
983
+ for ev in events:
984
+ host_key = str(ev.get("host") or ev.get("destination_ip") or "")
985
+ if not host_key:
986
+ continue
987
+ by_host.setdefault(host_key, []).append(ev)
988
+ for row_list in (local_rows, proxy_owner_rows):
989
+ for item in row_list:
990
+ host = ""
991
+ mihomo = item.get("mihomo") or {}
992
+ host = (mihomo.get("host")
993
+ or mihomo.get("destination_ip")
994
+ or item.get("target_host") or "")
995
+ host = str(host)
996
+ if not host:
997
+ continue
998
+ matched = by_host.get(host)
999
+ if matched:
1000
+ item["history"] = _summarize_history_events(matched)
1001
+ return status
1002
+
1003
+
1004
+ def _summarize_history_events(events: list[dict[str, Any]]) -> dict[str, Any]:
1005
+ """Aggregate traffic-store events for one host into a compact summary."""
1006
+ upload_total = 0
1007
+ download_total = 0
1008
+ sample_times: list[str] = []
1009
+ owner_apps: set[str] = set()
1010
+ connection_keys: set[str] = set()
1011
+ for ev in events:
1012
+ try:
1013
+ upload_total += int(ev.get("upload") or 0)
1014
+ download_total += int(ev.get("download") or 0)
1015
+ except (TypeError, ValueError):
1016
+ pass
1017
+ ts = ev.get("sample_ts")
1018
+ if isinstance(ts, str) and ts:
1019
+ sample_times.append(ts)
1020
+ attribution = ev.get("attribution") or {}
1021
+ app_name = attribution.get("app") or attribution.get("owner_app")
1022
+ if app_name:
1023
+ owner_apps.add(str(app_name))
1024
+ key = ev.get("state_key") or ev.get("id")
1025
+ if key:
1026
+ connection_keys.add(str(key))
1027
+ sample_times.sort()
1028
+ return {
1029
+ "event_count": len(events),
1030
+ "upload_total": upload_total,
1031
+ "download_total": download_total,
1032
+ "first_seen": sample_times[0] if sample_times else "",
1033
+ "last_seen": sample_times[-1] if sample_times else "",
1034
+ "owner_apps": sorted(owner_apps),
1035
+ "connection_count": len(connection_keys),
1036
+ }
1037
+
1038
+
894
1039
  def emit_human(report: dict[str, Any]) -> None:
895
1040
  """Render a compact human-readable report via the human renderer."""
896
1041
  from proxyctl.connections_human import emit_human as render_human