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/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)