proxyctl 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- proxyctl/audit.py +385 -0
- proxyctl/builtin_plugins/__init__.py +5 -0
- proxyctl/builtin_plugins/connectivity_basic.py +35 -0
- proxyctl/builtin_plugins/corp_network.py +57 -0
- proxyctl/check.py +761 -0
- proxyctl/cli.py +1355 -0
- proxyctl/core/__init__.py +1 -0
- proxyctl/core/plugin.py +287 -0
- proxyctl/engine/__init__.py +12 -0
- proxyctl/engine/base.py +85 -0
- proxyctl/engine/mihomo.py +127 -0
- proxyctl/engine/singbox.py +135 -0
- proxyctl/status.py +523 -0
- proxyctl/trace.py +558 -0
- proxyctl-0.1.0.dist-info/METADATA +218 -0
- proxyctl-0.1.0.dist-info/RECORD +20 -0
- proxyctl-0.1.0.dist-info/WHEEL +4 -0
- proxyctl-0.1.0.dist-info/entry_points.txt +2 -0
- proxyctl-0.1.0.dist-info/licenses/LICENSE +21 -0
proxyctl/trace.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""proxyctl trace — 域名链路诊断 (DNS → 规则预测 → 连通性 → 实际连接)"""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
RED = "\033[0;31m"
|
|
13
|
+
GREEN = "\033[0;32m"
|
|
14
|
+
YELLOW = "\033[0;33m"
|
|
15
|
+
CYAN = "\033[0;36m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
DIM = "\033[2m"
|
|
18
|
+
NC = "\033[0m"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _detect_mode() -> dict:
|
|
22
|
+
"""
|
|
23
|
+
读取 mihomo 配置文件,返回当前代理模式信息。
|
|
24
|
+
返回: {"tun_enabled": bool, "enhanced_mode": str, "mixed_port": int}
|
|
25
|
+
"""
|
|
26
|
+
import os, re
|
|
27
|
+
config_path = os.path.expanduser("~/.config/mihomo/config.yaml")
|
|
28
|
+
result = {"tun_enabled": False, "enhanced_mode": "redir-host", "mixed_port": 7890}
|
|
29
|
+
try:
|
|
30
|
+
text = open(config_path).read()
|
|
31
|
+
m = re.search(r'tun:\s*\n(?:\s*#[^\n]*\n)*\s*enable:\s*(true|false)', text)
|
|
32
|
+
if m:
|
|
33
|
+
result["tun_enabled"] = (m.group(1) == "true")
|
|
34
|
+
m = re.search(r'enhanced-mode:\s*(\S+)', text)
|
|
35
|
+
if m:
|
|
36
|
+
result["enhanced_mode"] = m.group(1)
|
|
37
|
+
m = re.search(r'mixed-port:\s*(\d+)', text)
|
|
38
|
+
if m:
|
|
39
|
+
result["mixed_port"] = int(m.group(1))
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _api_get(api: str, path: str, secret: str) -> dict:
|
|
46
|
+
"""通过 Clash API 获取数据(绕过系统代理)。"""
|
|
47
|
+
req = urllib.request.Request(f"{api}{path}")
|
|
48
|
+
req.add_header("Authorization", f"Bearer {secret}")
|
|
49
|
+
try:
|
|
50
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
51
|
+
with opener.open(req, timeout=5) as r:
|
|
52
|
+
return json.loads(r.read())
|
|
53
|
+
except Exception:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_input(raw: str) -> tuple:
|
|
58
|
+
"""
|
|
59
|
+
从输入解析 (scheme, domain, port, path)。
|
|
60
|
+
支持: domain / domain:port / scheme://domain:port/path
|
|
61
|
+
"""
|
|
62
|
+
scheme = "https"
|
|
63
|
+
port = None
|
|
64
|
+
path = "/"
|
|
65
|
+
|
|
66
|
+
# ws/wss 与 http/https 同端口同 TLS,统一映射
|
|
67
|
+
_SCHEME_MAP = {"ws": "http", "wss": "https"}
|
|
68
|
+
m = re.match(r'^(https?|wss?)://(.*)', raw)
|
|
69
|
+
if m:
|
|
70
|
+
scheme = _SCHEME_MAP.get(m.group(1), m.group(1))
|
|
71
|
+
raw = m.group(2)
|
|
72
|
+
if "/" in raw:
|
|
73
|
+
raw, rest = raw.split("/", 1)
|
|
74
|
+
path = "/" + rest
|
|
75
|
+
if ":" in raw:
|
|
76
|
+
raw, port_str = raw.rsplit(":", 1)
|
|
77
|
+
try:
|
|
78
|
+
port = int(port_str)
|
|
79
|
+
except ValueError:
|
|
80
|
+
pass
|
|
81
|
+
return scheme, raw, port, path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_ip(s: str) -> bool:
|
|
85
|
+
"""判断字符串是否为 IP 地址(IPv4 或 IPv6)。"""
|
|
86
|
+
try:
|
|
87
|
+
ipaddress.ip_address(s)
|
|
88
|
+
return True
|
|
89
|
+
except ValueError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _section_dns(domain: str, api: str, secret: str,
|
|
94
|
+
fakeip_active: bool = False, corp_dns: dict = None) -> list:
|
|
95
|
+
"""
|
|
96
|
+
[1/4] DNS 解析 — 通过 Clash API 查询域名 A 记录。
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
fakeip_active: 当前是否处于 fake-ip 模式,影响 IP 标签显示。
|
|
100
|
+
返回已解析的 IP 列表。
|
|
101
|
+
"""
|
|
102
|
+
print(f"{BOLD}[1/4] DNS 解析{NC} {domain}")
|
|
103
|
+
resolved_ips = []
|
|
104
|
+
|
|
105
|
+
data = _api_get(api, f"/dns/query?name={urllib.parse.quote(domain)}&type=A", secret)
|
|
106
|
+
if data and data.get("Answer"):
|
|
107
|
+
for a in data["Answer"]:
|
|
108
|
+
if a.get("type") == 5: # CNAME
|
|
109
|
+
print(f" CNAME → {a['data']}")
|
|
110
|
+
for a in data["Answer"]:
|
|
111
|
+
if a.get("type") == 1: # A
|
|
112
|
+
ip = a["data"]
|
|
113
|
+
resolved_ips.append(ip)
|
|
114
|
+
# fakeip 标签只在 fake-ip 模式下才有意义,redir-host 返回的是真实 IP
|
|
115
|
+
is_fake = fakeip_active and (ip.startswith("198.18.") or ip.startswith("198.19."))
|
|
116
|
+
tag = f"{RED}fakeip{NC}" if is_fake else f"{GREEN}real{NC}"
|
|
117
|
+
print(f" A → {ip} [{tag}] TTL={a.get('TTL', '?')}")
|
|
118
|
+
elif data and data.get("message"):
|
|
119
|
+
print(f" {YELLOW}API 超时 (可能是 fakeip 域名),尝试系统 DNS...{NC}")
|
|
120
|
+
try:
|
|
121
|
+
r = subprocess.run(["nslookup", domain, "127.0.0.1"],
|
|
122
|
+
capture_output=True, text=True, timeout=5)
|
|
123
|
+
for line in r.stdout.splitlines():
|
|
124
|
+
if "Address:" in line and "127.0.0.1" not in line:
|
|
125
|
+
ip = line.split("Address:")[-1].strip()
|
|
126
|
+
resolved_ips.append(ip)
|
|
127
|
+
is_fake = ip.startswith("198.18.") or ip.startswith("198.19.")
|
|
128
|
+
tag = f"{RED}fakeip{NC}" if is_fake else f"{GREEN}real{NC}"
|
|
129
|
+
print(f" A → {ip} [{tag}]")
|
|
130
|
+
except Exception:
|
|
131
|
+
print(f" {RED}DNS 查询失败{NC}")
|
|
132
|
+
elif data is not None:
|
|
133
|
+
# Clash API /dns/query 无结果,尝试其他 DNS 解析路径
|
|
134
|
+
fallback_ips = []
|
|
135
|
+
fallback_src = ""
|
|
136
|
+
|
|
137
|
+
# 1. 尝试 mihomo DNS listener(仅当 53 端口在监听时)
|
|
138
|
+
import socket as _sock
|
|
139
|
+
dns_listening = False
|
|
140
|
+
try:
|
|
141
|
+
with _sock.create_connection(("127.0.0.1", 53), timeout=0.3):
|
|
142
|
+
dns_listening = True
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if dns_listening:
|
|
147
|
+
try:
|
|
148
|
+
r = subprocess.run(
|
|
149
|
+
["dig", "@127.0.0.1", "+short", "+timeout=5", domain, "A"],
|
|
150
|
+
capture_output=True, text=True, timeout=7
|
|
151
|
+
)
|
|
152
|
+
fallback_ips = [l.strip() for l in r.stdout.splitlines()
|
|
153
|
+
if l.strip() and not l.strip().endswith(".")]
|
|
154
|
+
if fallback_ips:
|
|
155
|
+
fallback_src = "mihomo DNS"
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# 2. mihomo DNS 不可用或无结果,用系统 DNS
|
|
160
|
+
if not fallback_ips:
|
|
161
|
+
try:
|
|
162
|
+
r = subprocess.run(
|
|
163
|
+
["dig", "+short", "+timeout=3", domain, "A"],
|
|
164
|
+
capture_output=True, text=True, timeout=5
|
|
165
|
+
)
|
|
166
|
+
fallback_ips = [l.strip() for l in r.stdout.splitlines()
|
|
167
|
+
if l.strip() and not l.strip().endswith(".")]
|
|
168
|
+
if fallback_ips:
|
|
169
|
+
fallback_src = "系统 DNS"
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
if fallback_ips:
|
|
174
|
+
print(f" {DIM}(Clash API 无结果,{fallback_src} 解析到:){NC}")
|
|
175
|
+
for ip in fallback_ips:
|
|
176
|
+
resolved_ips.append(ip)
|
|
177
|
+
is_fake = ip.startswith("198.18.") or ip.startswith("198.19.")
|
|
178
|
+
tag = f"{RED}fakeip{NC}" if is_fake else f"{GREEN}real{NC}"
|
|
179
|
+
print(f" A → {ip} [{tag}]")
|
|
180
|
+
else:
|
|
181
|
+
print(f" {YELLOW}无 A 记录{NC}")
|
|
182
|
+
# 探 corp-dns,判断是否需要内网/VPN(仅当配置了企业 DNS 时)
|
|
183
|
+
_corp = corp_dns or {}
|
|
184
|
+
_corp_server = _corp.get("server", "")
|
|
185
|
+
if _corp_server:
|
|
186
|
+
try:
|
|
187
|
+
r = subprocess.run(
|
|
188
|
+
["dig", f"@{_corp_server}", "+short", "+timeout=2", domain],
|
|
189
|
+
capture_output=True, text=True, timeout=4
|
|
190
|
+
)
|
|
191
|
+
corp_ips = [l.strip() for l in r.stdout.splitlines()
|
|
192
|
+
if l.strip() and not l.strip().endswith(".")]
|
|
193
|
+
if corp_ips:
|
|
194
|
+
print(f" {YELLOW}⚠ 内网域名{NC}:corp-dns 可解析 → "
|
|
195
|
+
f"{', '.join(corp_ips[:2])}")
|
|
196
|
+
print(f" {YELLOW}需连接内网 / VPN 才可访问{NC}")
|
|
197
|
+
resolved_ips.extend(corp_ips)
|
|
198
|
+
else:
|
|
199
|
+
print(f" corp-dns 也无记录 (域名不存在或 DNS 故障)")
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
else:
|
|
203
|
+
print(f" {RED}DNS 查询失败 (API 不可达){NC}")
|
|
204
|
+
|
|
205
|
+
return resolved_ips
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _section_rules(domain: str, resolved_ips: list, api: str, secret: str) -> tuple:
|
|
209
|
+
"""
|
|
210
|
+
[2/4] 规则匹配 — 按引擎规则顺序逐条预测。
|
|
211
|
+
返回 (predicted_rule, predicted_proxy)。
|
|
212
|
+
"""
|
|
213
|
+
print(f"\n{BOLD}[2/4] 规则匹配{NC}")
|
|
214
|
+
rules_data = _api_get(api, "/rules", secret)
|
|
215
|
+
if not rules_data:
|
|
216
|
+
print(f" {RED}无法获取规则列表{NC}")
|
|
217
|
+
return None, None
|
|
218
|
+
|
|
219
|
+
predicted_rule = predicted_proxy = None
|
|
220
|
+
for rule in rules_data.get("rules", []):
|
|
221
|
+
rtype = rule.get("type", "")
|
|
222
|
+
payload = rule.get("payload", "")
|
|
223
|
+
proxy = rule.get("proxy", "")
|
|
224
|
+
|
|
225
|
+
matched = False
|
|
226
|
+
detail = ""
|
|
227
|
+
|
|
228
|
+
if rtype == "DomainSuffix":
|
|
229
|
+
if domain == payload or domain.endswith("." + payload):
|
|
230
|
+
matched = True
|
|
231
|
+
detail = f"域名后缀 {payload}"
|
|
232
|
+
elif rtype == "DomainKeyword":
|
|
233
|
+
if payload in domain:
|
|
234
|
+
matched = True
|
|
235
|
+
detail = f"域名关键词 {payload}"
|
|
236
|
+
elif rtype == "Domain":
|
|
237
|
+
if domain == payload:
|
|
238
|
+
matched = True
|
|
239
|
+
detail = f"精确域名 {payload}"
|
|
240
|
+
elif rtype == "IPCIDR" and resolved_ips:
|
|
241
|
+
try:
|
|
242
|
+
net = ipaddress.ip_network(payload, strict=False)
|
|
243
|
+
for ip in resolved_ips:
|
|
244
|
+
if ipaddress.ip_address(ip) in net:
|
|
245
|
+
matched = True
|
|
246
|
+
detail = f"IP {ip} ∈ {payload}"
|
|
247
|
+
break
|
|
248
|
+
except ValueError:
|
|
249
|
+
pass
|
|
250
|
+
elif rtype == "Match":
|
|
251
|
+
matched = True
|
|
252
|
+
detail = "兜底规则"
|
|
253
|
+
# GeoSite / GeoIP 无法客户端精确匹配,跳过
|
|
254
|
+
|
|
255
|
+
if matched:
|
|
256
|
+
predicted_rule = rule
|
|
257
|
+
predicted_proxy = proxy
|
|
258
|
+
idx = rule.get("index", "?")
|
|
259
|
+
hit_count = rule.get("extra", {}).get("hitCount", 0)
|
|
260
|
+
print(f" 规则 #{idx}: {CYAN}{rtype}({payload}){NC} → {BOLD}{proxy}{NC}")
|
|
261
|
+
print(f" 匹配原因: {detail}")
|
|
262
|
+
print(f" 历史命中: {hit_count} 次")
|
|
263
|
+
|
|
264
|
+
# 提示跳过了哪些不确定的 GeoSite/GeoIP 规则
|
|
265
|
+
uncertain = [
|
|
266
|
+
r2 for r2 in rules_data["rules"][:rule.get("index", 0)]
|
|
267
|
+
if r2.get("type") in ("GeoSite", "GeoIP") and r2.get("proxy") != proxy
|
|
268
|
+
]
|
|
269
|
+
if uncertain:
|
|
270
|
+
types_str = ", ".join(
|
|
271
|
+
f"{r2['type']}({r2['payload']})→{r2['proxy']}"
|
|
272
|
+
for r2 in uncertain[-3:]
|
|
273
|
+
)
|
|
274
|
+
print(f" {DIM}注: 跳过了 {len(uncertain)} 条 GeoSite/GeoIP 规则 "
|
|
275
|
+
f"(无法客户端匹配): {types_str}{NC}")
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
if not predicted_rule:
|
|
279
|
+
print(f" {YELLOW}未匹配任何规则{NC}")
|
|
280
|
+
|
|
281
|
+
return predicted_rule, predicted_proxy
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _section_connectivity(scheme: str, domain: str, port, path: str,
|
|
285
|
+
mode: dict | None = None) -> tuple:
|
|
286
|
+
"""[3/4] 连通性测试 — 走真实路径测试(经系统代理或 TUN)。
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
mode: _detect_mode() 的返回值,用于标注流量实际走向。
|
|
290
|
+
返回 (lines, remote_ip),由调用方负责打印,支持后台并发执行。
|
|
291
|
+
"""
|
|
292
|
+
if port:
|
|
293
|
+
test_url = f"{scheme}://{domain}:{port}{path}"
|
|
294
|
+
else:
|
|
295
|
+
test_url = f"{scheme}://{domain}{path}"
|
|
296
|
+
lines = [f"\n{BOLD}[3/4] 连通性测试{NC} {test_url}"]
|
|
297
|
+
|
|
298
|
+
cmd = ["curl", "-sS", "-o", "/dev/null", "-w",
|
|
299
|
+
"%{http_code} %{time_connect} %{time_total} %{remote_ip}",
|
|
300
|
+
"--max-time", "8", "--connect-timeout", "5"]
|
|
301
|
+
if scheme == "https":
|
|
302
|
+
cmd.append("-k") # 内网自签证书常见
|
|
303
|
+
cmd.append(test_url)
|
|
304
|
+
|
|
305
|
+
remote_ip = ""
|
|
306
|
+
try:
|
|
307
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=12)
|
|
308
|
+
parts = r.stdout.strip().split()
|
|
309
|
+
if len(parts) >= 4:
|
|
310
|
+
http_code, t_conn, t_total, remote_ip = parts
|
|
311
|
+
cc = GREEN if http_code[0] in "23" else RED
|
|
312
|
+
|
|
313
|
+
# remote_ip == 127.0.0.1 说明 curl 连到了本地代理/TUN,不是目标服务器
|
|
314
|
+
# 根据当前模式标注实际走向,避免误导
|
|
315
|
+
if remote_ip == "127.0.0.1" and mode:
|
|
316
|
+
if mode["tun_enabled"]:
|
|
317
|
+
via = f"{DIM}via TUN{NC}"
|
|
318
|
+
else:
|
|
319
|
+
via = f"{DIM}via HTTP 代理 :{mode['mixed_port']}{NC}"
|
|
320
|
+
lines.append(f" HTTP {cc}{http_code}{NC} "
|
|
321
|
+
f"连接 {float(t_conn)*1000:.0f}ms "
|
|
322
|
+
f"总计 {float(t_total)*1000:.0f}ms "
|
|
323
|
+
f"[{via}]")
|
|
324
|
+
else:
|
|
325
|
+
lines.append(f" HTTP {cc}{http_code}{NC} "
|
|
326
|
+
f"连接 {float(t_conn)*1000:.0f}ms "
|
|
327
|
+
f"总计 {float(t_total)*1000:.0f}ms "
|
|
328
|
+
f"目标IP {remote_ip}")
|
|
329
|
+
elif r.returncode != 0:
|
|
330
|
+
lines.append(f" {RED}✗ 连接失败{NC} (exit {r.returncode})")
|
|
331
|
+
for line in r.stderr.strip().splitlines():
|
|
332
|
+
if line.strip():
|
|
333
|
+
lines.append(f" {RED}{line.strip()}{NC}")
|
|
334
|
+
else:
|
|
335
|
+
lines.append(f" {YELLOW}? 无输出 (curl exit {r.returncode}){NC}")
|
|
336
|
+
except subprocess.TimeoutExpired:
|
|
337
|
+
lines.append(f" {RED}✗ 超时 (>8s){NC}")
|
|
338
|
+
except Exception as e:
|
|
339
|
+
lines.append(f" {RED}✗ {e}{NC}")
|
|
340
|
+
|
|
341
|
+
return lines, remote_ip
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _grep_log_connections(domain: str, max_entries: int = 5) -> list:
|
|
345
|
+
"""从 mihomo/sing-box 日志中 grep 域名的最近连接记录。
|
|
346
|
+
|
|
347
|
+
解析格式:[TCP] src --> domain:port match Rule(payload) using group[node]
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
domain: 目标域名
|
|
351
|
+
max_entries: 最多返回几条(去重后)
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
[{"time": "07:42:13", "proto": "TCP", "rule": "DomainSuffix(github.com)",
|
|
355
|
+
"chain": "proxy[日本4(IP)(直连)]"}, ...]
|
|
356
|
+
"""
|
|
357
|
+
import os as _os
|
|
358
|
+
home = _os.path.expanduser("~")
|
|
359
|
+
log_candidates = [
|
|
360
|
+
f"{home}/.config/mihomo/mihomo.log",
|
|
361
|
+
f"{home}/.config/sing-box/sing-box.log",
|
|
362
|
+
]
|
|
363
|
+
# 尝试从日志文件 grep
|
|
364
|
+
lines = []
|
|
365
|
+
for f in log_candidates:
|
|
366
|
+
if not _os.path.isfile(f):
|
|
367
|
+
continue
|
|
368
|
+
try:
|
|
369
|
+
r = subprocess.run(
|
|
370
|
+
["grep", "--text", "--", f"--> {domain}:", f],
|
|
371
|
+
capture_output=True, text=True, timeout=5
|
|
372
|
+
)
|
|
373
|
+
if r.stdout.strip():
|
|
374
|
+
lines = r.stdout.strip().splitlines()
|
|
375
|
+
break
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
# 日志文件没结果(可能被 systemd journal 截走),尝试 journalctl
|
|
380
|
+
if not lines:
|
|
381
|
+
import platform as _plat
|
|
382
|
+
if _plat.system() == "Linux":
|
|
383
|
+
try:
|
|
384
|
+
r = subprocess.run(
|
|
385
|
+
["journalctl", "--user", "--no-pager", "-u", "mihomo.service",
|
|
386
|
+
"-u", "sing-box.service", "--since", "24 hours ago",
|
|
387
|
+
"--grep", f"-- {domain}:"],
|
|
388
|
+
capture_output=True, text=True, timeout=5
|
|
389
|
+
)
|
|
390
|
+
if r.stdout.strip():
|
|
391
|
+
lines = r.stdout.strip().splitlines()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
if not lines:
|
|
396
|
+
return []
|
|
397
|
+
|
|
398
|
+
if not lines:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
# 解析并去重(按 rule+chain 去重,保留最近的)
|
|
402
|
+
results = []
|
|
403
|
+
seen = set()
|
|
404
|
+
for line in reversed(lines):
|
|
405
|
+
# time="2026-04-25T07:42:13..." ... [TCP] ... match Rule using chain
|
|
406
|
+
t_match = re.search(r'T(\d{2}:\d{2}:\d{2})', line)
|
|
407
|
+
p_match = re.search(r'\[(TCP|UDP)\]', line)
|
|
408
|
+
r_match = re.search(r'match\s+(\S+)', line)
|
|
409
|
+
c_match = re.search(r'using\s+(.+?)(?:\s*$|")', line)
|
|
410
|
+
|
|
411
|
+
if r_match and c_match:
|
|
412
|
+
entry_time = t_match.group(1) if t_match else "?"
|
|
413
|
+
proto = p_match.group(1) if p_match else "?"
|
|
414
|
+
rule = r_match.group(1)
|
|
415
|
+
chain = c_match.group(1)
|
|
416
|
+
key = f"{rule}|{chain}"
|
|
417
|
+
if key not in seen:
|
|
418
|
+
seen.add(key)
|
|
419
|
+
results.append({
|
|
420
|
+
"time": entry_time, "proto": proto,
|
|
421
|
+
"rule": rule, "chain": chain,
|
|
422
|
+
})
|
|
423
|
+
if len(results) >= max_entries:
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
results.reverse() # 时间正序
|
|
427
|
+
return results
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _section_connections(domain: str, resolved_ips: list,
|
|
431
|
+
predicted_proxy: str, api: str, secret: str):
|
|
432
|
+
"""[4/4] 实际连接验证 — 从 Clash API 活跃连接 + 日志历史中取路由信息。"""
|
|
433
|
+
print(f"\n{BOLD}[4/4] 实际连接{NC}")
|
|
434
|
+
|
|
435
|
+
domain_conns = []
|
|
436
|
+
for _ in range(3):
|
|
437
|
+
data = _api_get(api, "/connections", secret)
|
|
438
|
+
if data and isinstance(data, dict):
|
|
439
|
+
for c in (data.get("connections") or []):
|
|
440
|
+
m = c.get("metadata", {})
|
|
441
|
+
h = m.get("host", "")
|
|
442
|
+
di = m.get("destinationIP", "")
|
|
443
|
+
if (h == domain or h.endswith("." + domain)
|
|
444
|
+
or (di and di in resolved_ips)):
|
|
445
|
+
domain_conns.append(c)
|
|
446
|
+
if domain_conns:
|
|
447
|
+
break
|
|
448
|
+
time.sleep(0.3)
|
|
449
|
+
|
|
450
|
+
if not domain_conns:
|
|
451
|
+
# 没有活跃连接,从引擎日志中捞历史记录
|
|
452
|
+
log_conns = _grep_log_connections(domain)
|
|
453
|
+
if log_conns:
|
|
454
|
+
print(f" {DIM}(无活跃连接,以下为日志中最近的记录){NC}")
|
|
455
|
+
for entry in log_conns:
|
|
456
|
+
print(f" {entry['time']} {entry['proto']} → "
|
|
457
|
+
f"{CYAN}{entry['rule']}{NC} using "
|
|
458
|
+
f"{GREEN}{entry['chain']}{NC}")
|
|
459
|
+
else:
|
|
460
|
+
print(f" {DIM}无活跃连接,日志中也无记录{NC}")
|
|
461
|
+
if predicted_proxy:
|
|
462
|
+
print(f" {DIM}基于规则预测,该域名应走: {BOLD}{predicted_proxy}{NC}")
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
# 去重:按 rule+chains
|
|
466
|
+
seen: set = set()
|
|
467
|
+
deduped = []
|
|
468
|
+
for c in domain_conns:
|
|
469
|
+
rule = c.get("rule", "?")
|
|
470
|
+
rp = c.get("rulePayload", "")
|
|
471
|
+
chains = c.get("chains", [])
|
|
472
|
+
key = f"{rule}|{rp}|{','.join(chains)}"
|
|
473
|
+
if key not in seen:
|
|
474
|
+
seen.add(key)
|
|
475
|
+
deduped.append(c)
|
|
476
|
+
|
|
477
|
+
for c in deduped:
|
|
478
|
+
rule = c.get("rule", "?")
|
|
479
|
+
rp = c.get("rulePayload", "")
|
|
480
|
+
chains = c.get("chains", [])
|
|
481
|
+
chain_str = " → ".join(reversed(chains)) if chains else "?"
|
|
482
|
+
rp_str = f"({rp})" if rp else ""
|
|
483
|
+
print(f" 规则: {CYAN}{rule}{rp_str}{NC}")
|
|
484
|
+
print(f" 链路: {chain_str}")
|
|
485
|
+
|
|
486
|
+
# 与预测对比
|
|
487
|
+
if predicted_proxy:
|
|
488
|
+
actual_chains = domain_conns[0].get("chains", [])
|
|
489
|
+
actual_outbound = actual_chains[-1] if actual_chains else "?"
|
|
490
|
+
if actual_outbound.lower() != predicted_proxy.lower():
|
|
491
|
+
print(f" {YELLOW}⚠ 预测出口 {predicted_proxy},实际出口 {actual_outbound}{NC}")
|
|
492
|
+
else:
|
|
493
|
+
print(f" {GREEN}✓ 与规则预测一致{NC}")
|
|
494
|
+
|
|
495
|
+
# 连接详情
|
|
496
|
+
print(f" {DIM}---{NC}")
|
|
497
|
+
domain_conns.sort(key=lambda x: x.get("start", ""), reverse=True)
|
|
498
|
+
for c in domain_conns[:5]:
|
|
499
|
+
m = c.get("metadata", {})
|
|
500
|
+
host = m.get("host", "?")
|
|
501
|
+
dport = m.get("destinationPort", "?")
|
|
502
|
+
up = c.get("upload", 0)
|
|
503
|
+
down = c.get("download", 0)
|
|
504
|
+
chains = c.get("chains", [])
|
|
505
|
+
outbound = chains[0] if chains else "?"
|
|
506
|
+
|
|
507
|
+
def fmt_bytes(b: int) -> str:
|
|
508
|
+
if b < 1024: return f"{b}B"
|
|
509
|
+
if b < 1024 * 1024: return f"{b/1024:.1f}K"
|
|
510
|
+
return f"{b/1024/1024:.1f}M"
|
|
511
|
+
|
|
512
|
+
print(f" {host}:{dport} {outbound} ↑{fmt_bytes(up)} ↓{fmt_bytes(down)}")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def cmd_trace(raw_input: str, api: str, secret: str, config: dict = None):
|
|
516
|
+
"""proxyctl trace — 诊断域名的完整访问链路。
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
raw_input: 用户输入的域名或 URL
|
|
520
|
+
api: Clash API 基础 URL
|
|
521
|
+
secret: Clash API Bearer token
|
|
522
|
+
config: 全局配置字典(可选,用于 corp_dns 探测)
|
|
523
|
+
"""
|
|
524
|
+
import threading
|
|
525
|
+
scheme, domain, port, path = _parse_input(raw_input)
|
|
526
|
+
|
|
527
|
+
# 读取当前代理模式,影响 DNS 标签和连通性测试的输出注释
|
|
528
|
+
mode = _detect_mode()
|
|
529
|
+
fakeip_active = mode["enhanced_mode"] == "fake-ip"
|
|
530
|
+
tun_label = f"{GREEN}TUN on{NC}" if mode["tun_enabled"] else f"{DIM}TUN off{NC}"
|
|
531
|
+
dns_label = f"{CYAN}{mode['enhanced_mode']}{NC}"
|
|
532
|
+
print(f"{DIM}模式: {tun_label} DNS: {dns_label} 代理端口: {mode['mixed_port']}{NC}\n")
|
|
533
|
+
|
|
534
|
+
# [3/4] 连通性与 [1/4][2/4] 完全无依赖,提前并发发出
|
|
535
|
+
connectivity_result = [None]
|
|
536
|
+
|
|
537
|
+
def _run_connectivity():
|
|
538
|
+
connectivity_result[0] = _section_connectivity(scheme, domain, port, path, mode)
|
|
539
|
+
|
|
540
|
+
t = threading.Thread(target=_run_connectivity)
|
|
541
|
+
t.start()
|
|
542
|
+
|
|
543
|
+
# 输入直接是 IP 地址时跳过 DNS,直接进规则匹配
|
|
544
|
+
if _is_ip(domain):
|
|
545
|
+
print(f"{BOLD}[1/4] DNS 解析{NC} {domain} {DIM}(IP 地址,跳过){NC}")
|
|
546
|
+
resolved_ips = [domain]
|
|
547
|
+
else:
|
|
548
|
+
corp_dns = (config or {}).get("corp_dns", {}) or {}
|
|
549
|
+
resolved_ips = _section_dns(domain, api, secret, fakeip_active, corp_dns)
|
|
550
|
+
predicted_rule, predicted_proxy = _section_rules(domain, resolved_ips, api, secret)
|
|
551
|
+
|
|
552
|
+
# 等连通性测试结束(此时大概率已完成),按顺序打印
|
|
553
|
+
t.join()
|
|
554
|
+
lines, _ = connectivity_result[0]
|
|
555
|
+
for line in lines:
|
|
556
|
+
print(line)
|
|
557
|
+
|
|
558
|
+
_section_connections(domain, resolved_ips, predicted_proxy, api, secret)
|