proxyctl 0.5.8__tar.gz → 0.5.10__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.8 → proxyctl-0.5.10}/PKG-INFO +1 -1
  2. {proxyctl-0.5.8 → proxyctl-0.5.10}/pyproject.toml +1 -1
  3. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/audit.py +28 -11
  4. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/builtin_plugins/connectivity_basic.py +12 -6
  5. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/check.py +200 -31
  6. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/connections.py +159 -14
  7. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/connections_filters.py +86 -13
  8. proxyctl-0.5.10/src/proxyctl/connections_human.py +526 -0
  9. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/explain.py +7 -1
  10. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/traffic.py +12 -1
  11. proxyctl-0.5.8/src/proxyctl/connections_human.py +0 -239
  12. {proxyctl-0.5.8 → proxyctl-0.5.10}/.gitignore +0 -0
  13. {proxyctl-0.5.8 → proxyctl-0.5.10}/LICENSE +0 -0
  14. {proxyctl-0.5.8 → proxyctl-0.5.10}/README.md +0 -0
  15. {proxyctl-0.5.8 → proxyctl-0.5.10}/man/proxyctl.1 +0 -0
  16. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/__init__.py +0 -0
  17. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/_io.py +0 -0
  18. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/autostart.py +0 -0
  19. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  20. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  21. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/cli.py +0 -0
  22. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/completion.py +0 -0
  23. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/core/__init__.py +0 -0
  24. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/core/plugin.py +0 -0
  25. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/engine/__init__.py +0 -0
  26. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/engine/base.py +0 -0
  27. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/engine/mihomo.py +0 -0
  28. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/engine/singbox.py +0 -0
  29. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/status.py +0 -0
  30. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/subscription.py +0 -0
  31. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/suggest.py +0 -0
  32. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/suggest_rules.py +0 -0
  33. {proxyctl-0.5.8 → proxyctl-0.5.10}/src/proxyctl/trace.py +0 -0
  34. {proxyctl-0.5.8 → proxyctl-0.5.10}/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.8
3
+ Version: 0.5.10
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.8"
3
+ version = "0.5.10"
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 线路: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
@@ -24,17 +29,18 @@ class ConnectivityBasic(Plugin):
24
29
  return [
25
30
  CheckTarget(name="google", url="https://www.google.com", mode="proxy"),
26
31
  CheckTarget(name="github", url="https://github.com", mode="proxy"),
32
+ CheckTarget(name="openai",
33
+ url="https://api.openai.com/v1/models",
34
+ mode="proxy"),
27
35
  CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
28
36
  CheckTarget(name="anthropic",
29
- url="https://api.anthropic.com",
30
- mode="proxy",
31
- expected_proxy="claude"),
37
+ url="https://api.anthropic.com/v1/models",
38
+ mode="proxy"),
32
39
  ]
33
40
 
34
41
  def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
35
42
  return [
36
43
  OutboundProbe(name="proxy", mode="proxy", url="https://api.ipify.org"),
37
- OutboundProbe(name="claude", mode="proxy", url="https://ipinfo.io/ip"),
38
44
  OutboundProbe(name="direct",
39
45
  mode="direct",
40
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):
@@ -54,7 +59,7 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
54
59
  env = {k: v for k, v in os.environ.items()
55
60
  if k not in ("http_proxy", "https_proxy", "all_proxy",
56
61
  "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
57
- cmd = ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
62
+ cmd = ["curl", "--http1.1", "-s", "-o", "/dev/null", "-w", "%{http_code}",
58
63
  "--max-time", str(timeout)]
59
64
  if mode == "proxy":
60
65
  cmd += ["--proxy", f"socks5h://127.0.0.1:{proxy_port}"]
@@ -66,7 +71,8 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
66
71
  code = r.stdout.strip()
67
72
 
68
73
  if code == "000" or not code:
69
- return False, f" {RED}✗{NC} {desc:<18s} {url:<44s} {RED}timeout{NC}"
74
+ reason = _curl_failure_reason(r.stderr)
75
+ return False, f" {RED}✗{NC} {desc:<18s} {url:<44s} {RED}{reason}{NC}"
70
76
  elif code.startswith(("2", "3", "4")):
71
77
  return True, f" {GREEN}✓{NC} {desc:<18s} {url:<44s} {GREEN}{code}{NC}"
72
78
  elif code.startswith("5"):
@@ -76,6 +82,16 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
76
82
  return False, f" {YELLOW}?{NC} {desc:<18s} {url:<44s} {YELLOW}{code}{NC}"
77
83
 
78
84
 
85
+ def _curl_failure_reason(stderr: str) -> str:
86
+ """Return a concise curl failure reason for HTTP code 000."""
87
+ text = (stderr or "").strip()
88
+ if not text:
89
+ return "timeout"
90
+ first = text.splitlines()[0].strip()
91
+ first = first.removeprefix("curl: ").strip()
92
+ return first[:120] if first else "timeout"
93
+
94
+
79
95
  def _api_connections(api_base: str, api_secret: str) -> list[dict]:
80
96
  r = subprocess.run(
81
97
  ["curl", "-s", "--noproxy", "*",
@@ -91,26 +107,130 @@ def _api_connections(api_base: str, api_secret: str) -> list[dict]:
91
107
  return conns if isinstance(conns, list) else []
92
108
 
93
109
 
94
- def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
95
- expected_proxy: str) -> tuple[bool, str]:
110
+ def _route_from_connections(conns: list[dict], host: str) -> dict:
111
+ """Find one Mihomo route for host and expose leaf line + policy group.
112
+
113
+ Mihomo reports chains from leaf node to policy group, for example
114
+ ``["TW-Residential-01", "residential-tw", "claude"]``. The human
115
+ "line" is the leaf node, not the policy group.
116
+ """
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]
123
+ return {
124
+ "found": True,
125
+ "line": chains[0] if chains else "?",
126
+ "group": chains[-1] if chains else "",
127
+ "chain": " → ".join(reversed(chains)) if chains else "?",
128
+ }
129
+ return {"found": False, "line": "?", "group": "", "chain": ""}
130
+
131
+
132
+ def _target_route(api_base: str, api_secret: str, url: str) -> dict:
133
+ """Return the observed route for a URL from local Mihomo connections."""
96
134
  host = urllib.parse.urlparse(url).hostname or ""
97
- if not host or not expected_proxy:
98
- return True, ""
135
+ if not host:
136
+ return {"found": False, "line": "?", "group": "", "chain": ""}
99
137
 
100
138
  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 '?'}"
139
+ route = _route_from_connections(_api_connections(api_base, api_secret),
140
+ host)
141
+ if route.get("found"):
142
+ return route
112
143
  time.sleep(0.2)
113
- return False, f" expected {expected_proxy}, no active connection found"
144
+ return {"found": False, "line": "?", "group": "", "chain": ""}
145
+
146
+
147
+ def _route_matches_expected_proxy(route: dict,
148
+ expected_proxy: str) -> tuple[bool, str]:
149
+ """Validate an observed route's terminal policy group."""
150
+ if not expected_proxy:
151
+ return True, ""
152
+ if not route.get("found"):
153
+ return False, f" expected {expected_proxy}, no active connection found"
154
+
155
+ actual = str(route.get("group") or "")
156
+ if actual.lower() == expected_proxy.lower():
157
+ return True, ""
158
+ return False, f" expected {expected_proxy}, got {actual or '?'}"
159
+
160
+
161
+ def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
162
+ expected_proxy: str) -> tuple[bool, str]:
163
+ if not urllib.parse.urlparse(url).hostname or not expected_proxy:
164
+ return True, ""
165
+ return _route_matches_expected_proxy(
166
+ _target_route(api_base, api_secret, url), expected_proxy)
167
+
168
+
169
+ def _connectivity_line_value(mode: str) -> str:
170
+ """Return the default route-line label for one check target mode.
171
+
172
+ proxy 模式默认值是 em dash ("—") 而非问号——含义是"反查不到活动连接",
173
+ 比裸 "?" 信息密度更高、视觉更安静。真实反查命中时会被实际链路覆盖。
174
+ """
175
+ if mode == "direct":
176
+ return "direct"
177
+ if mode == "proxy":
178
+ return "—"
179
+ return "-"
180
+
181
+
182
+ def _append_route_column(line: str, route_line: str,
183
+ route_chain: str = "") -> str:
184
+ """Append the observed route in the same style as Mihomo chains."""
185
+ value = route_chain or route_line or "-"
186
+ pad = max(2, 74 - _visible_len(line))
187
+ return f"{line}{' ' * pad}{DIM}via{NC} {value:<32s}"
188
+
189
+
190
+ def _dedupe_check_targets(targets: list) -> list:
191
+ """Drop exact duplicate connectivity targets from multiple plugins.
192
+
193
+ Builtin plugins load before user plugins. Keep the first target's display
194
+ order, but merge an expected_proxy from a duplicate if the first one lacks
195
+ it, so a stale user plugin cannot remove route validation by accident.
196
+ """
197
+ out = []
198
+ seen = {}
199
+ for target in targets:
200
+ key = (getattr(target, "name", ""),
201
+ getattr(target, "url", ""),
202
+ getattr(target, "mode", ""))
203
+ if key in seen:
204
+ kept = seen[key]
205
+ expected = getattr(target, "expected_proxy", "")
206
+ if expected and not getattr(kept, "expected_proxy", ""):
207
+ kept.expected_proxy = expected
208
+ continue
209
+ seen[key] = target
210
+ out.append(target)
211
+ return out
212
+
213
+
214
+ def _dedupe_outbound_probes(probes: list) -> list:
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
226
+ for probe in probes:
227
+ key = (getattr(probe, "name", ""), getattr(probe, "mode", ""))
228
+ if key in index:
229
+ out[index[key]] = probe # later (user) probe overrides earlier
230
+ continue
231
+ index[key] = len(out)
232
+ out.append(probe)
233
+ return out
114
234
 
115
235
 
116
236
  def _test_dns(desc: str, server: str, domain: str) -> tuple:
@@ -716,7 +836,7 @@ def _collect_fail_hints(collector: dict, *, dns_bad: bool, failed: bool) -> list
716
836
  if basic.get("daemon_up") is False:
717
837
  hints.append("engine not running")
718
838
 
719
- # connectivity: 列表形式,每项 {name, url, ok, message}
839
+ # connectivity: 列表形式,每项 {name, url, mode, line, route_chain, ok, message}
720
840
  conn = stages.get("connectivity") or []
721
841
  failed_conns = [c.get("name", "?") for c in conn
722
842
  if isinstance(c, dict) and not c.get("ok")]
@@ -794,7 +914,8 @@ def cmd_check(engine, api: str, api_secret: str,
794
914
  # [4/4] 出口 IP 探测:从插件收集到的 probes 决定有哪些出口
795
915
  probes = []
796
916
  if registry is not None:
797
- probes = registry.collect("check_outbound_probes", ctx={})
917
+ probes = _dedupe_outbound_probes(
918
+ registry.collect("check_outbound_probes", ctx={}))
798
919
  probe_ips: dict[str, str] = {p.name: "" for p in probes}
799
920
 
800
921
  # 临时常量:probes 在端口解析之前启动,所以这里用 config 直接拿
@@ -1051,7 +1172,8 @@ def cmd_check(engine, api: str, api_secret: str,
1051
1172
  ctx = {"corp_net": corp_net, "mode": mode, "engine": engine.name}
1052
1173
  targets = []
1053
1174
  if registry is not None:
1054
- targets = registry.collect("check_targets", ctx=ctx)
1175
+ targets = _dedupe_check_targets(
1176
+ registry.collect("check_targets", ctx=ctx))
1055
1177
 
1056
1178
  # 应用 only_when 过滤
1057
1179
  def _target_enabled(t):
@@ -1074,6 +1196,9 @@ def cmd_check(engine, api: str, api_secret: str,
1074
1196
  ready = [threading.Event() for _ in targets]
1075
1197
 
1076
1198
  def _run_test(idx, target):
1199
+ route_line = _connectivity_line_value(target.mode)
1200
+ route_chain = ""
1201
+ route_note = ""
1077
1202
  try:
1078
1203
  if target.mode == "dns":
1079
1204
  # url 格式: dns:<server>:<domain>
@@ -1083,21 +1208,63 @@ def cmd_check(engine, api: str, api_secret: str,
1083
1208
  parts = target.url.removeprefix("tcp:").rsplit(":", 1)
1084
1209
  ok, line = _test_tcp(parts[0], int(parts[1]), target.name)
1085
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
+
1086
1234
  ok, line = _test_url(target.url, target.name,
1087
1235
  target.mode, target.timeout,
1088
1236
  proxy_port=proxy_port)
1237
+
1238
+ if poller is not None:
1239
+ stop_evt.set()
1240
+ poller.join(timeout=0.5)
1241
+
1089
1242
  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}"
1243
+ if target.mode == "proxy" and ok:
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 "")
1254
+ if expected:
1255
+ route_ok, route_msg = _route_matches_expected_proxy(
1256
+ route, expected)
1257
+ ok = route_ok
1258
+ if route_ok:
1259
+ route_note = f" {GREEN}{route_msg}{NC}"
1260
+ else:
1261
+ route_note = f" {RED}{route_msg}{NC}"
1262
+ line = _append_route_column(line, route_line, route_chain)
1263
+ line += route_note
1098
1264
  except Exception as e:
1099
1265
  ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
1100
- results[idx] = (line, ok)
1266
+ line = _append_route_column(line, route_line, route_chain)
1267
+ results[idx] = (line, ok, route_line, route_chain)
1101
1268
  ready[idx].set()
1102
1269
 
1103
1270
  if targets:
@@ -1106,7 +1273,7 @@ def cmd_check(engine, api: str, api_secret: str,
1106
1273
  pool.submit(_run_test, idx, target)
1107
1274
  for idx in range(len(targets)):
1108
1275
  ready[idx].wait()
1109
- line, ok = results[idx]
1276
+ line, ok, route_line, route_chain = results[idx]
1110
1277
  print(line)
1111
1278
  if not ok:
1112
1279
  fail = True
@@ -1114,6 +1281,8 @@ def cmd_check(engine, api: str, api_secret: str,
1114
1281
  "name": targets[idx].name,
1115
1282
  "url": targets[idx].url,
1116
1283
  "mode": targets[idx].mode,
1284
+ "line": route_line,
1285
+ "route_chain": route_chain,
1117
1286
  "ok": bool(ok),
1118
1287
  "message": _strip_ansi(line),
1119
1288
  })