proxyctl 0.5.8__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.
- {proxyctl-0.5.8 → proxyctl-0.5.9}/PKG-INFO +1 -1
- {proxyctl-0.5.8 → proxyctl-0.5.9}/pyproject.toml +1 -1
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/builtin_plugins/connectivity_basic.py +5 -2
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/check.py +149 -31
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/connections.py +159 -14
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/connections_filters.py +86 -13
- proxyctl-0.5.9/src/proxyctl/connections_human.py +526 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/explain.py +7 -1
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/traffic.py +12 -1
- proxyctl-0.5.8/src/proxyctl/connections_human.py +0 -239
- {proxyctl-0.5.8 → proxyctl-0.5.9}/.gitignore +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/LICENSE +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/README.md +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/man/proxyctl.1 +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/autostart.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/cli.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/suggest_rules.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/trace.py +0 -0
- {proxyctl-0.5.8 → proxyctl-0.5.9}/src/proxyctl/traffic_store.py +0 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
提供一组跨所有用户都通用的连通性测试点:
|
|
4
4
|
- 海外(走代理):google / github
|
|
5
5
|
- 国内(直连):baidu
|
|
6
|
-
- AI 线路:anthropic
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
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
|
|
98
|
-
return
|
|
130
|
+
if not host:
|
|
131
|
+
return {"found": False, "line": "?", "group": "", "chain": ""}
|
|
99
132
|
|
|
100
133
|
for _ in range(3):
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
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:
|
|
@@ -716,7 +820,7 @@ def _collect_fail_hints(collector: dict, *, dns_bad: bool, failed: bool) -> list
|
|
|
716
820
|
if basic.get("daemon_up") is False:
|
|
717
821
|
hints.append("engine not running")
|
|
718
822
|
|
|
719
|
-
# connectivity: 列表形式,每项 {name, url, ok, message}
|
|
823
|
+
# connectivity: 列表形式,每项 {name, url, mode, line, route_chain, ok, message}
|
|
720
824
|
conn = stages.get("connectivity") or []
|
|
721
825
|
failed_conns = [c.get("name", "?") for c in conn
|
|
722
826
|
if isinstance(c, dict) and not c.get("ok")]
|
|
@@ -794,7 +898,8 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
794
898
|
# [4/4] 出口 IP 探测:从插件收集到的 probes 决定有哪些出口
|
|
795
899
|
probes = []
|
|
796
900
|
if registry is not None:
|
|
797
|
-
probes =
|
|
901
|
+
probes = _dedupe_outbound_probes(
|
|
902
|
+
registry.collect("check_outbound_probes", ctx={}))
|
|
798
903
|
probe_ips: dict[str, str] = {p.name: "" for p in probes}
|
|
799
904
|
|
|
800
905
|
# 临时常量:probes 在端口解析之前启动,所以这里用 config 直接拿
|
|
@@ -1051,7 +1156,8 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1051
1156
|
ctx = {"corp_net": corp_net, "mode": mode, "engine": engine.name}
|
|
1052
1157
|
targets = []
|
|
1053
1158
|
if registry is not None:
|
|
1054
|
-
targets =
|
|
1159
|
+
targets = _dedupe_check_targets(
|
|
1160
|
+
registry.collect("check_targets", ctx=ctx))
|
|
1055
1161
|
|
|
1056
1162
|
# 应用 only_when 过滤
|
|
1057
1163
|
def _target_enabled(t):
|
|
@@ -1074,6 +1180,9 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1074
1180
|
ready = [threading.Event() for _ in targets]
|
|
1075
1181
|
|
|
1076
1182
|
def _run_test(idx, target):
|
|
1183
|
+
route_line = _connectivity_line_value(target.mode)
|
|
1184
|
+
route_chain = ""
|
|
1185
|
+
route_note = ""
|
|
1077
1186
|
try:
|
|
1078
1187
|
if target.mode == "dns":
|
|
1079
1188
|
# url 格式: dns:<server>:<domain>
|
|
@@ -1087,17 +1196,24 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1087
1196
|
target.mode, target.timeout,
|
|
1088
1197
|
proxy_port=proxy_port)
|
|
1089
1198
|
expected = getattr(target, "expected_proxy", "")
|
|
1090
|
-
if
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
if
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
|
1098
1213
|
except Exception as e:
|
|
1099
1214
|
ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
|
|
1100
|
-
|
|
1215
|
+
line = _append_route_column(line, route_line, route_chain)
|
|
1216
|
+
results[idx] = (line, ok, route_line, route_chain)
|
|
1101
1217
|
ready[idx].set()
|
|
1102
1218
|
|
|
1103
1219
|
if targets:
|
|
@@ -1106,7 +1222,7 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1106
1222
|
pool.submit(_run_test, idx, target)
|
|
1107
1223
|
for idx in range(len(targets)):
|
|
1108
1224
|
ready[idx].wait()
|
|
1109
|
-
line, ok = results[idx]
|
|
1225
|
+
line, ok, route_line, route_chain = results[idx]
|
|
1110
1226
|
print(line)
|
|
1111
1227
|
if not ok:
|
|
1112
1228
|
fail = True
|
|
@@ -1114,6 +1230,8 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1114
1230
|
"name": targets[idx].name,
|
|
1115
1231
|
"url": targets[idx].url,
|
|
1116
1232
|
"mode": targets[idx].mode,
|
|
1233
|
+
"line": route_line,
|
|
1234
|
+
"route_chain": route_chain,
|
|
1117
1235
|
"ok": bool(ok),
|
|
1118
1236
|
"message": _strip_ansi(line),
|
|
1119
1237
|
})
|
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|