agent-bypass 2.16.0 → 2.18.0

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.
@@ -0,0 +1,535 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """origin_trace — 源站溯源(绕 CDN/WAF):DoH 真 IP + 证书透明度 + 漏质子域 + 直连指纹比对
4
+
5
+ 为什么需要:开了 TUN / fake-IP(iSH 常见 198.18.0.0/15)时,系统 DNS 拿不到真实 IP,
6
+ 历史解析、子域、CT 查询都会被污染;本工具统一走 DoH 拿真 IP,再逐个直连比对指纹。
7
+
8
+ 只读:仅做 DNS / HTTPS GET / CT 查询,不修改目标任何状态。授权范围内使用。
9
+
10
+ 用法:
11
+ python3 origin_trace.py example.com
12
+ python3 origin_trace.py example.com --json
13
+ python3 origin_trace.py example.com --max-candidates 60 --timeout 8
14
+ python3 origin_trace.py example.com --no-ct --max-candidates 8 --budget 60 # iSH/fake-IP:跳过 CT、限时防卡
15
+ python3 origin_trace.py example.com --ct-sources crt.sh,certspotter,otx # 指定/兜底 CT 源(all=全部)
16
+ python3 origin_trace.py example.com --max-ct 120 --ct-budget 20 # CT 主机上限/多源总预算
17
+ DOH_PROVIDERS="https://1.1.1.1/dns-query" python3 origin_trace.py example.com
18
+
19
+ 输出: 候选源站表(ip / 分数 / 来源 / 标题 / Server / CDN 头),--json 输出机器可读结构。
20
+ 退出码: 0 正常(有候选)/ 1 无候选或全部失败 / 2 用法错误。
21
+ """
22
+ import concurrent.futures
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+ import socket
28
+ import ssl
29
+ import sys
30
+ import time
31
+ import urllib.parse
32
+ import urllib.request
33
+
34
+ try:
35
+ import netdns
36
+ except Exception: # 单文件拷贝场景
37
+ netdns = None
38
+
39
+ UA = "Mozilla/5.0 (compatible; origin-trace/1.0; +agent-bypass)"
40
+ SUBS = [
41
+ "direct", "origin", "origin-www", "mail", "mx", "smtp", "pop", "imap",
42
+ "webmail", "autodiscover", "autoconfig", "cpanel", "whm", "ftp",
43
+ "dev", "test", "staging", "stage", "pre", "prod", "beta", "old",
44
+ "legacy", "backup", "admin", "api", "gateway", "vpn", "oa", "portal",
45
+ "m", "wap", "static", "img", "cdn", "assets", "uploads",
46
+ ]
47
+ ISH = os.path.isdir("/var/minis") or bool(os.environ.get("MINIS_ROOT"))
48
+
49
+ # 多源证书透明度 / 被动 DNS(无 Key 优先;单个源 502/超时不影响其他源)
50
+ CT_SOURCES = ("crt.sh", "certspotter", "otx", "hackertarget", "anubis", "rapiddns")
51
+
52
+ CDN_HEADERS = (
53
+ "cf-ray", "cf-cache-status", "x-cache", "via", "x-amz-cf-id", "x-amz-cf-pop",
54
+ "x-fastly-request-id", "x-akamai-transformed", "x-cdn", "x-served-by",
55
+ "x-cache-hits", "x-timer", "server-timing",
56
+ )
57
+
58
+
59
+ def _err(msg):
60
+ print(msg, file=sys.stderr)
61
+
62
+
63
+ def is_ipv4(s):
64
+ if netdns is not None:
65
+ return netdns.is_ipv4(s)
66
+ return bool(re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", str(s)))
67
+
68
+
69
+ def doh_resolve(name, timeout=6.0):
70
+ """DoH 真 IP;无 netdns 时退回系统解析。返回 [(ip, 'doh'|'system')]"""
71
+ if netdns is not None:
72
+ ips = netdns.doh_query(name, "A", timeout)
73
+ if ips:
74
+ return [(ip, "doh") for ip in ips]
75
+ ips = netdns.system_resolve(name, timeout)
76
+ return [(ip, "system") for ip in ips if not netdns.is_fake_ip(ip)]
77
+ try:
78
+ return [(socket.gethostbyname(name), "system")]
79
+ except OSError:
80
+ return []
81
+
82
+
83
+ def _host_in_domain(name, domain):
84
+ """规范化主机名并校验属于目标域;不合法返回 None。"""
85
+ name = str(name or "").strip().lower().lstrip("*.").rstrip(".")
86
+ if name and (name == domain or name.endswith("." + domain)):
87
+ return name
88
+ return None
89
+
90
+
91
+ def _dedup_hosts(cands, domain, seen, out):
92
+ for raw in cands:
93
+ n = _host_in_domain(raw, domain)
94
+ if n and n not in seen:
95
+ seen.add(n)
96
+ out.append(n)
97
+
98
+
99
+ def http_get_text(url, timeout=8.0, retry=1):
100
+ """GET 文本(带 UA + 重试);失败抛异常由调用方兜底。"""
101
+ last = None
102
+ for attempt in range(retry + 1):
103
+ try:
104
+ req = urllib.request.Request(url, headers={"user-agent": UA})
105
+ with urllib.request.urlopen(req, timeout=timeout) as r:
106
+ return r.read(8 << 20).decode("utf-8", "ignore")
107
+ except Exception as e:
108
+ last = e
109
+ if attempt < retry:
110
+ time.sleep(0.8)
111
+ raise last
112
+
113
+
114
+ def parse_crtsh(text, domain):
115
+ """解析 crt.sh JSON → 该域名下的主机名(去通配符、去重、保序)。"""
116
+ try:
117
+ rows = json.loads(text)
118
+ except (ValueError, TypeError):
119
+ return []
120
+ if not isinstance(rows, list):
121
+ return []
122
+ out, seen = [], set()
123
+ for row in rows:
124
+ if isinstance(row, dict):
125
+ _dedup_hosts(str(row.get("name_value") or "").split("\n"), domain, seen, out)
126
+ return out
127
+
128
+
129
+ def parse_certspotter(text, domain):
130
+ """CertSpotter issuances JSON → dns_names 里的主机名。"""
131
+ try:
132
+ rows = json.loads(text)
133
+ except (ValueError, TypeError):
134
+ return []
135
+ out, seen = [], set()
136
+ for row in rows if isinstance(rows, list) else []:
137
+ if isinstance(row, dict):
138
+ _dedup_hosts(row.get("dns_names") or [], domain, seen, out)
139
+ return out
140
+
141
+
142
+ def parse_otx(text, domain):
143
+ """OTX 被动 DNS → (主机名列表, IP 列表)。"""
144
+ try:
145
+ d = json.loads(text)
146
+ except (ValueError, TypeError):
147
+ return [], []
148
+ rows = d.get("passive_dns") if isinstance(d, dict) else None
149
+ out, seen, ips = [], set(), []
150
+ for row in rows if isinstance(rows, list) else []:
151
+ if not isinstance(row, dict):
152
+ continue
153
+ host = _host_in_domain(row.get("hostname") or "", domain)
154
+ if not host:
155
+ continue # 只收目标域相关记录,避免无关 IP 污染候选
156
+ _dedup_hosts([host], domain, seen, out)
157
+ ip = str(row.get("address") or "").strip()
158
+ if netdns is not None and netdns.is_ipv4(ip) and not netdns.is_fake_ip(ip) and ip not in ips:
159
+ ips.append(ip)
160
+ return out, ips
161
+
162
+
163
+ def parse_hackertarget(text, domain):
164
+ """HackerTarget hostsearch CSV(host,ip)→ (主机名, IP)。"""
165
+ out, seen, ips = [], set(), []
166
+ for line in str(text or "").splitlines():
167
+ parts = [x.strip() for x in line.split(",")]
168
+ if len(parts) != 2:
169
+ continue
170
+ host = _host_in_domain(parts[0], domain)
171
+ if not host:
172
+ continue # 只收目标域相关主机/IP
173
+ _dedup_hosts([host], domain, seen, out)
174
+ if netdns is not None and netdns.is_ipv4(parts[1]) and not netdns.is_fake_ip(parts[1]) and parts[1] not in ips:
175
+ ips.append(parts[1])
176
+ return out, ips
177
+
178
+
179
+ def parse_anubis(text, domain):
180
+ """Anubis(jldc.me) 子域 JSON 数组 → 主机名。"""
181
+ try:
182
+ rows = json.loads(text)
183
+ except (ValueError, TypeError):
184
+ return []
185
+ out, seen = [], set()
186
+ _dedup_hosts(rows if isinstance(rows, list) else [], domain, seen, out)
187
+ return out
188
+
189
+
190
+ def parse_rapiddns(text, domain):
191
+ """RapidDNS HTML → 页面中的子域文本(宽松匹配)。"""
192
+ out, seen = [], set()
193
+ for m in re.finditer(r"([a-zA-Z0-9][a-zA-Z0-9._-]*\." + re.escape(domain) + r")", str(text or "")):
194
+ _dedup_hosts([m.group(1)], domain, seen, out)
195
+ return out
196
+
197
+
198
+ def _ct_fetch(name, domain, timeout):
199
+ """单个 CT/被动 DNS 源 → (hosts, ips)。"""
200
+ q = urllib.parse.quote(domain, safe="")
201
+ if name == "crt.sh":
202
+ text = http_get_text("https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json",
203
+ timeout, retry=1)
204
+ return parse_crtsh(text, domain), []
205
+ if name == "certspotter":
206
+ text = http_get_text("https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names" % q,
207
+ timeout)
208
+ return parse_certspotter(text, domain), []
209
+ if name == "otx":
210
+ text = http_get_text("https://otx.alienvault.com/api/v1/indicators/domain/%s/passive_dns" % q, timeout)
211
+ return parse_otx(text, domain)
212
+ if name == "hackertarget":
213
+ text = http_get_text("https://api.hackertarget.com/hostsearch/?q=%s" % q, timeout)
214
+ return parse_hackertarget(text, domain)
215
+ if name == "anubis":
216
+ text = http_get_text("https://jldc.me/anubis/subdomains/%s" % q, timeout)
217
+ return parse_anubis(text, domain), []
218
+ if name == "rapiddns":
219
+ text = http_get_text("https://rapiddns.io/subdomain/%s?full=1" % q, timeout)
220
+ return parse_rapiddns(text, domain), []
221
+ return [], []
222
+
223
+
224
+ def fetch_passive_names(domain, timeout=8.0, sources=None, max_hosts=80, budget=None):
225
+ """多源并发查询 CT/被动 DNS:单源失败不影响其他;总预算到期返回部分结果。
226
+ 返回 (hosts, passive_ips, per_source_status)。"""
227
+ sources = list(sources or CT_SOURCES)
228
+ budget = budget if budget and budget > 0 else (10.0 if ISH else 15.0)
229
+ workers = min(4 if ISH else 6, max(1, len(sources)))
230
+ hosts, seen, ips, status = [], set(), [], {}
231
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
232
+ futs = {ex.submit(_ct_fetch, name, domain, timeout): name for name in sources}
233
+ try:
234
+ for fut in concurrent.futures.as_completed(futs, timeout=budget):
235
+ src = futs[fut]
236
+ try:
237
+ h, ip = fut.result()
238
+ except Exception as e:
239
+ status[src] = "失败: %s" % str(e)[:60]
240
+ _err("[CT] %-12s 失败,跳过: %s" % (src, str(e)[:80]))
241
+ continue
242
+ status[src] = len(h) + (len(ip) and len(ip) or 0) * 0 # 仅主机数用于展示
243
+ for n in h:
244
+ if n not in seen and len(hosts) < max_hosts:
245
+ seen.add(n)
246
+ hosts.append(n)
247
+ for ipv in ip:
248
+ if ipv not in ips:
249
+ ips.append(ipv)
250
+ _err("[CT] %-12s %d 主机%s" % (src, len(h), (" / %d IP" % len(ip)) if ip else ""))
251
+ except concurrent.futures.TimeoutError:
252
+ status["_budget"] = "总预算 %.0fs 超时(返回部分)" % budget
253
+ _err("[CT] 总预算 %.0fs 到期,使用已返回的 %d 个主机" % (budget, len(hosts)))
254
+ finally:
255
+ ex.shutdown(wait=False, cancel_futures=True)
256
+ return hosts, ips, status
257
+
258
+
259
+ def cdn_markers(headers):
260
+ return [h for h in CDN_HEADERS if h in headers]
261
+
262
+
263
+ def extract_title(body):
264
+ m = re.search(r"<title[^>]*>(.*?)</title>", body, re.I | re.S)
265
+ if not m:
266
+ return ""
267
+ t = re.sub(r"\s+", " ", m.group(1)).strip()
268
+ return t[:120]
269
+
270
+
271
+ def parse_response(data):
272
+ """原始 HTTP 响应 → dict(status/title/server/headers/cdn/location)。"""
273
+ head, _, body = data.partition(b"\r\n\r\n")
274
+ lines = head.decode("iso-8859-1", "ignore").split("\r\n")
275
+ status = lines[0].strip() if lines else ""
276
+ headers = {}
277
+ for line in lines[1:]:
278
+ if ":" in line:
279
+ k, v = line.split(":", 1)
280
+ headers[k.strip().lower()] = v.strip()
281
+ return {
282
+ "status": status,
283
+ "title": extract_title(body.decode("utf-8", "ignore")),
284
+ "server": headers.get("server", ""),
285
+ "location": headers.get("location", ""),
286
+ "headers": headers,
287
+ "cdn": cdn_markers(headers),
288
+ "body_sha1": hashlib.sha1(body).hexdigest()[:12] if body else "",
289
+ }
290
+
291
+
292
+ def http_probe(ip, domain, timeout=8.0, method="GET"):
293
+ """直连 IP + 正确 SNI/Host 的 HTTPS 探测(不校验证书,仅采集指纹)。"""
294
+ ctx = ssl.create_default_context()
295
+ ctx.check_hostname = False
296
+ ctx.verify_mode = ssl.CERT_NONE
297
+ raw = socket.create_connection((ip, 443), timeout=timeout)
298
+ try:
299
+ sock = ctx.wrap_socket(raw, server_hostname=domain) # SSLContext.wrap_socket 不接受 timeout 参数
300
+ sock.settimeout(timeout)
301
+ try:
302
+ cert = sock.getpeercert() or {}
303
+ sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
304
+ req = ("%s / HTTP/1.1\r\nHost: %s\r\nUser-Agent: %s\r\n"
305
+ "Accept: */*\r\nConnection: close\r\n\r\n") % (method, domain, UA)
306
+ sock.sendall(req.encode("utf-8", "ignore"))
307
+ data = b""
308
+ end = time.monotonic() + max(timeout, 2.0) # 读取总预算,防慢连接挂住
309
+ while len(data) < 131072 and time.monotonic() < end:
310
+ try:
311
+ chunk = sock.recv(16384)
312
+ except (socket.timeout, OSError):
313
+ break
314
+ if not chunk:
315
+ break
316
+ data += chunk
317
+ finally:
318
+ sock.close()
319
+ finally:
320
+ try:
321
+ raw.close()
322
+ except Exception:
323
+ pass
324
+ info = parse_response(data)
325
+ info["cert_sans"] = sans
326
+ info["cert_domain_ok"] = any(
327
+ domain == x or domain.endswith("." + x.lstrip("*.")) for x in sans
328
+ )
329
+ return info
330
+
331
+
332
+ def score_candidate(base, cand):
333
+ """候选 vs 基线指纹打分:同标题/同 Server/无 CDN 头/证书匹配加分。"""
334
+ score, reasons = 0, []
335
+ if base.get("title") and cand.get("title") and cand["title"] == base["title"]:
336
+ score += 3
337
+ reasons.append("title")
338
+ if base.get("status") and cand.get("status") == base.get("status"):
339
+ score += 1
340
+ if base.get("server") and cand.get("server") == base.get("server"):
341
+ score += 1
342
+ reasons.append("server")
343
+ if base.get("cdn") and not cand.get("cdn"):
344
+ score += 2
345
+ reasons.append("no-cdn-headers")
346
+ if cand.get("cert_domain_ok"):
347
+ score += 1
348
+ reasons.append("cert-san")
349
+ if cand.get("cdn"):
350
+ if base.get("cdn") and set(cand["cdn"]) & set(base["cdn"]):
351
+ score -= 2 # 与基线同一 CDN = 还是 CDN 边缘,不是源站
352
+ reasons.append("same-cdn")
353
+ else:
354
+ score -= 1
355
+ reasons.append("cdn")
356
+ return score, reasons
357
+
358
+
359
+ def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True,
360
+ ct_sources=None, max_ct=80, ct_budget=0.0):
361
+ """收集候选 (name, ip, sources):漏质子域 + 多源 CT/被动 DNS。"""
362
+ names = ["www." + domain, domain] + ["%s.%s" % (s, domain) for s in SUBS]
363
+ passive_ips = []
364
+ if use_ct:
365
+ ct, passive_ips, _status = fetch_passive_names(
366
+ domain, timeout, sources=ct_sources, max_hosts=max_ct, budget=ct_budget)
367
+ if ct:
368
+ _err("[CT] 合计 %d 个主机名" % len(ct))
369
+ names.extend(ct)
370
+ if passive_ips:
371
+ _err("[被动DNS] 合计 %d 个 IP 并入候选" % len(passive_ips))
372
+ uniq, seen = [], set()
373
+ for n in names:
374
+ n = n.strip().lower().rstrip(".")
375
+ if n and n not in seen and (n == domain or n.endswith("." + domain)):
376
+ seen.add(n)
377
+ uniq.append(n)
378
+ uniq = uniq[:max_candidates]
379
+ _err("[候选] 解析 %d 个主机(DoH)" % len(uniq))
380
+
381
+ def one(name):
382
+ return name, doh_resolve(name, timeout)
383
+
384
+ by_ip = {}
385
+ with concurrent.futures.ThreadPoolExecutor(max_workers=(4 if ISH else 8)) as ex:
386
+ for name, pairs in ex.map(one, uniq):
387
+ for ip, src in pairs:
388
+ srcs = by_ip.setdefault(ip, {"ips": set(), "sources": set()})
389
+ srcs["sources"].add(name)
390
+ for ip in passive_ips:
391
+ by_ip.setdefault(ip, {"ips": set(), "sources": set()})["sources"].add("passive-dns")
392
+ return by_ip
393
+
394
+
395
+ def main(argv=None):
396
+ args = list(sys.argv[1:] if argv is None else argv)
397
+ domains, as_json, no_verify, no_ct = [], False, False, False
398
+ max_c, timeout, budget = 40, 8.0, 120.0
399
+ ct_sources, max_ct, ct_budget = None, 80, 0.0
400
+ i = 0
401
+ while i < len(args):
402
+ a = args[i]
403
+ if a == "--json":
404
+ as_json = True
405
+ elif a == "--no-verify":
406
+ no_verify = True
407
+ elif a == "--no-ct":
408
+ no_ct = True
409
+ elif a in ("--max-candidates", "--timeout", "--budget", "--max-ct", "--ct-budget", "--ct-sources"):
410
+ if i + 1 >= len(args):
411
+ _err("%s 缺少取值" % a)
412
+ return 2
413
+ try:
414
+ if a == "--max-candidates":
415
+ max_c = max(1, int(args[i + 1]))
416
+ elif a == "--budget":
417
+ budget = max(10.0, float(args[i + 1]))
418
+ elif a == "--max-ct":
419
+ max_ct = max(1, int(args[i + 1]))
420
+ elif a == "--ct-budget":
421
+ ct_budget = max(3.0, float(args[i + 1]))
422
+ elif a == "--ct-sources":
423
+ val = args[i + 1].strip()
424
+ if val.lower() == "all":
425
+ ct_sources = None
426
+ else:
427
+ picked = [x.strip() for x in val.split(",") if x.strip()]
428
+ if not picked or any(x not in CT_SOURCES for x in picked):
429
+ _err("--ct-sources 取值非法(可选: %s 或 all)" % ",".join(CT_SOURCES))
430
+ return 2
431
+ ct_sources = picked
432
+ else:
433
+ timeout = max(1.0, min(float(args[i + 1]), 30.0))
434
+ except (TypeError, ValueError):
435
+ _err("%s 取值非法: %s" % (a, args[i + 1]))
436
+ return 2
437
+ i += 1
438
+ elif a.startswith("-"):
439
+ _err("未知参数: %s" % a)
440
+ return 2
441
+ else:
442
+ domains.append(a)
443
+ i += 1
444
+ if not domains:
445
+ _err(__doc__)
446
+ return 2
447
+
448
+ rc = 0
449
+ report = {}
450
+ for domain in domains:
451
+ _err("\n[*] %s" % domain)
452
+ root_ips = doh_resolve(domain, timeout)
453
+ real_root = [ip for ip, _s in root_ips]
454
+ if not real_root:
455
+ _err("[!] %s DoH 解析失败(检查出网/DoH 端点)" % domain)
456
+ rc = 1
457
+ continue
458
+ _err("[根] %s → %s" % (domain, ",".join(real_root)))
459
+
460
+ base = None
461
+ if not no_verify:
462
+ try:
463
+ base = http_probe(real_root[0], domain, timeout)
464
+ _err("[基线] %s | server=%s | cdn=%s | title=%r" % (
465
+ base["status"], base["server"], ",".join(base["cdn"]) or "-", base["title"][:40]))
466
+ except Exception as e:
467
+ _err("[基线] 探测失败: %s(继续)" % e)
468
+
469
+ by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct,
470
+ ct_sources=ct_sources, max_ct=max_ct, ct_budget=ct_budget)
471
+ for ip in real_root: # 根 IP 纳入候选
472
+ by_ip.setdefault(ip, {"sources": set()})["sources"].add(domain)
473
+
474
+ rows = []
475
+ end = time.monotonic() + budget
476
+ pending = [(ip, meta) for ip, meta in by_ip.items()]
477
+
478
+ def verify(item):
479
+ ip, meta = item
480
+ row = {"ip": ip, "sources": sorted(meta["sources"]),
481
+ "score": 0, "reasons": [], "cdn": [], "title": "", "server": "",
482
+ "status": "", "cert_sans": []}
483
+ if not no_verify and time.monotonic() < end:
484
+ try:
485
+ cand = http_probe(ip, domain, timeout)
486
+ row.update({k: cand.get(k) for k in
487
+ ("status", "title", "server", "cdn", "cert_sans")})
488
+ row["score"], row["reasons"] = score_candidate(base or {}, cand)
489
+ except Exception as e:
490
+ row["error"] = str(e)[:80]
491
+ return row
492
+
493
+ if no_verify or not pending:
494
+ rows = [verify(it) for it in pending]
495
+ else:
496
+ with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex:
497
+ futs = {ex.submit(verify, it): it for it in pending}
498
+ try:
499
+ for f in concurrent.futures.as_completed(futs, timeout=max(5.0, budget)):
500
+ try:
501
+ rows.append(f.result())
502
+ except Exception:
503
+ pass
504
+ except concurrent.futures.TimeoutError:
505
+ _err("[预算] 验证超过 %.0fs,先返回已完成的候选" % budget)
506
+ finally:
507
+ ex.shutdown(wait=False, cancel_futures=True)
508
+ rows.sort(key=lambda r: (-r["score"], r["ip"]))
509
+ report[domain] = rows
510
+
511
+ if as_json:
512
+ continue
513
+ print("\n%s 候选源站(分数高=更像源站)" % domain)
514
+ print("%-16s %5s %-28s %-10s %s" % ("ip", "score", "sources", "server", "title"))
515
+ for r in rows[:max_c]:
516
+ print("%-16s %5d %-28s %-10s %s%s" % (
517
+ r["ip"], r["score"], ",".join(r["sources"])[:27] or "-",
518
+ (r["server"] or "-")[:10], (r["title"] or "")[:40],
519
+ (" [cdn:%s]" % ",".join(r["cdn"])) if r["cdn"] else ""))
520
+ strong = [r for r in rows if r["score"] >= 4 and not r["cdn"]]
521
+ if strong:
522
+ print("[结论] 高置信候选(无 CDN 头): %s" % ", ".join(r["ip"] for r in strong[:5]))
523
+ elif rows:
524
+ print("[结论] 无高置信候选:可能源站未暴露/全站 CDN/TUN 干扰(用 --json 看细节)")
525
+ rc = rc or 1
526
+ else:
527
+ rc = 1
528
+
529
+ if as_json:
530
+ print(json.dumps(report, ensure_ascii=False, indent=2))
531
+ return rc
532
+
533
+
534
+ if __name__ == "__main__":
535
+ sys.exit(main())