agent-bypass 2.16.0 → 2.17.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,369 @@
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
+ DOH_PROVIDERS="https://1.1.1.1/dns-query" python3 origin_trace.py example.com
16
+
17
+ 输出: 候选源站表(ip / 分数 / 来源 / 标题 / Server / CDN 头),--json 输出机器可读结构。
18
+ 退出码: 0 正常(有候选)/ 1 无候选或全部失败 / 2 用法错误。
19
+ """
20
+ import concurrent.futures
21
+ import hashlib
22
+ import json
23
+ import re
24
+ import socket
25
+ import ssl
26
+ import sys
27
+ import time
28
+ import urllib.parse
29
+ import urllib.request
30
+
31
+ try:
32
+ import netdns
33
+ except Exception: # 单文件拷贝场景
34
+ netdns = None
35
+
36
+ UA = "Mozilla/5.0 (compatible; origin-trace/1.0; +agent-bypass)"
37
+ SUBS = [
38
+ "direct", "origin", "origin-www", "mail", "mx", "smtp", "pop", "imap",
39
+ "webmail", "autodiscover", "autoconfig", "cpanel", "whm", "ftp",
40
+ "dev", "test", "staging", "stage", "pre", "prod", "beta", "old",
41
+ "legacy", "backup", "admin", "api", "gateway", "vpn", "oa", "portal",
42
+ "m", "wap", "static", "img", "cdn", "assets", "uploads",
43
+ ]
44
+ CDN_HEADERS = (
45
+ "cf-ray", "cf-cache-status", "x-cache", "via", "x-amz-cf-id", "x-amz-cf-pop",
46
+ "x-fastly-request-id", "x-akamai-transformed", "x-cdn", "x-served-by",
47
+ "x-cache-hits", "x-timer", "server-timing",
48
+ )
49
+
50
+
51
+ def _err(msg):
52
+ print(msg, file=sys.stderr)
53
+
54
+
55
+ def is_ipv4(s):
56
+ if netdns is not None:
57
+ return netdns.is_ipv4(s)
58
+ return bool(re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", str(s)))
59
+
60
+
61
+ def doh_resolve(name, timeout=6.0):
62
+ """DoH 真 IP;无 netdns 时退回系统解析。返回 [(ip, 'doh'|'system')]"""
63
+ if netdns is not None:
64
+ ips = netdns.doh_query(name, "A", timeout)
65
+ if ips:
66
+ return [(ip, "doh") for ip in ips]
67
+ ips = netdns.system_resolve(name, timeout)
68
+ return [(ip, "system") for ip in ips if not netdns.is_fake_ip(ip)]
69
+ try:
70
+ return [(socket.gethostbyname(name), "system")]
71
+ except OSError:
72
+ return []
73
+
74
+
75
+ def fetch_ct_names(domain, timeout=8.0):
76
+ """证书透明度(crt.sh)里的 DNS 名;返回去重主机名列表。
77
+ crt.sh 偶发 502/超时:重试一次,仍失败则跳过(不阻塞主流程)。"""
78
+ url = "https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json"
79
+ last = None
80
+ for attempt in (1, 2):
81
+ try:
82
+ req = urllib.request.Request(url, headers={"user-agent": UA})
83
+ with urllib.request.urlopen(req, timeout=timeout) as r:
84
+ text = r.read(8 << 20).decode("utf-8", "ignore")
85
+ return parse_crtsh(text, domain)
86
+ except Exception as e:
87
+ last = e
88
+ if attempt == 1:
89
+ time.sleep(1.0)
90
+ _err("[crt.sh] 查询失败(已重试): %s —— 跳过 CT,继续漏质子域" % last)
91
+ return []
92
+
93
+
94
+ def parse_crtsh(text, domain):
95
+ """解析 crt.sh JSON → 该域名下的主机名(去通配符、去重、保序)。"""
96
+ try:
97
+ rows = json.loads(text)
98
+ except (ValueError, TypeError):
99
+ return []
100
+ if not isinstance(rows, list):
101
+ return []
102
+ out, seen = [], set()
103
+ for row in rows:
104
+ if not isinstance(row, dict):
105
+ continue
106
+ for raw in str(row.get("name_value") or "").split("\n"):
107
+ name = raw.strip().lower().lstrip("*.").rstrip(".")
108
+ if not name or name in seen:
109
+ continue
110
+ if name == domain or name.endswith("." + domain):
111
+ seen.add(name)
112
+ out.append(name)
113
+ return out
114
+
115
+
116
+ def cdn_markers(headers):
117
+ return [h for h in CDN_HEADERS if h in headers]
118
+
119
+
120
+ def extract_title(body):
121
+ m = re.search(r"<title[^>]*>(.*?)</title>", body, re.I | re.S)
122
+ if not m:
123
+ return ""
124
+ t = re.sub(r"\s+", " ", m.group(1)).strip()
125
+ return t[:120]
126
+
127
+
128
+ def parse_response(data):
129
+ """原始 HTTP 响应 → dict(status/title/server/headers/cdn/location)。"""
130
+ head, _, body = data.partition(b"\r\n\r\n")
131
+ lines = head.decode("iso-8859-1", "ignore").split("\r\n")
132
+ status = lines[0].strip() if lines else ""
133
+ headers = {}
134
+ for line in lines[1:]:
135
+ if ":" in line:
136
+ k, v = line.split(":", 1)
137
+ headers[k.strip().lower()] = v.strip()
138
+ return {
139
+ "status": status,
140
+ "title": extract_title(body.decode("utf-8", "ignore")),
141
+ "server": headers.get("server", ""),
142
+ "location": headers.get("location", ""),
143
+ "headers": headers,
144
+ "cdn": cdn_markers(headers),
145
+ "body_sha1": hashlib.sha1(body).hexdigest()[:12] if body else "",
146
+ }
147
+
148
+
149
+ def http_probe(ip, domain, timeout=8.0, method="GET"):
150
+ """直连 IP + 正确 SNI/Host 的 HTTPS 探测(不校验证书,仅采集指纹)。"""
151
+ ctx = ssl.create_default_context()
152
+ ctx.check_hostname = False
153
+ ctx.verify_mode = ssl.CERT_NONE
154
+ raw = socket.create_connection((ip, 443), timeout=timeout)
155
+ try:
156
+ sock = ctx.wrap_socket(raw, server_hostname=domain) # SSLContext.wrap_socket 不接受 timeout 参数
157
+ sock.settimeout(timeout)
158
+ try:
159
+ cert = sock.getpeercert() or {}
160
+ sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
161
+ req = ("%s / HTTP/1.1\r\nHost: %s\r\nUser-Agent: %s\r\n"
162
+ "Accept: */*\r\nConnection: close\r\n\r\n") % (method, domain, UA)
163
+ sock.sendall(req.encode("utf-8", "ignore"))
164
+ data = b""
165
+ end = time.monotonic() + max(timeout, 2.0) # 读取总预算,防慢连接挂住
166
+ while len(data) < 131072 and time.monotonic() < end:
167
+ try:
168
+ chunk = sock.recv(16384)
169
+ except (socket.timeout, OSError):
170
+ break
171
+ if not chunk:
172
+ break
173
+ data += chunk
174
+ finally:
175
+ sock.close()
176
+ finally:
177
+ try:
178
+ raw.close()
179
+ except Exception:
180
+ pass
181
+ info = parse_response(data)
182
+ info["cert_sans"] = sans
183
+ info["cert_domain_ok"] = any(
184
+ domain == x or domain.endswith("." + x.lstrip("*.")) for x in sans
185
+ )
186
+ return info
187
+
188
+
189
+ def score_candidate(base, cand):
190
+ """候选 vs 基线指纹打分:同标题/同 Server/无 CDN 头/证书匹配加分。"""
191
+ score, reasons = 0, []
192
+ if base.get("title") and cand.get("title") and cand["title"] == base["title"]:
193
+ score += 3
194
+ reasons.append("title")
195
+ if base.get("status") and cand.get("status") == base.get("status"):
196
+ score += 1
197
+ if base.get("server") and cand.get("server") == base.get("server"):
198
+ score += 1
199
+ reasons.append("server")
200
+ if base.get("cdn") and not cand.get("cdn"):
201
+ score += 2
202
+ reasons.append("no-cdn-headers")
203
+ if cand.get("cert_domain_ok"):
204
+ score += 1
205
+ reasons.append("cert-san")
206
+ if cand.get("cdn"):
207
+ if base.get("cdn") and set(cand["cdn"]) & set(base["cdn"]):
208
+ score -= 2 # 与基线同一 CDN = 还是 CDN 边缘,不是源站
209
+ reasons.append("same-cdn")
210
+ else:
211
+ score -= 1
212
+ reasons.append("cdn")
213
+ return score, reasons
214
+
215
+
216
+ def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True):
217
+ """收集候选 (name, ip, sources)。"""
218
+ names = ["www." + domain, domain] + ["%s.%s" % (s, domain) for s in SUBS]
219
+ if use_ct:
220
+ ct = fetch_ct_names(domain, timeout)
221
+ if ct:
222
+ _err("[CT] crt.sh 返回 %d 个主机名" % len(ct))
223
+ names.extend(ct)
224
+ uniq, seen = [], set()
225
+ for n in names:
226
+ n = n.strip().lower().rstrip(".")
227
+ if n and n not in seen and (n == domain or n.endswith("." + domain)):
228
+ seen.add(n)
229
+ uniq.append(n)
230
+ uniq = uniq[:max_candidates]
231
+ _err("[候选] 解析 %d 个主机(DoH)" % len(uniq))
232
+
233
+ def one(name):
234
+ return name, doh_resolve(name, timeout)
235
+
236
+ by_ip = {}
237
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
238
+ for name, pairs in ex.map(one, uniq):
239
+ for ip, src in pairs:
240
+ srcs = by_ip.setdefault(ip, {"ips": set(), "sources": set()})
241
+ srcs["sources"].add(name)
242
+ return by_ip
243
+
244
+
245
+ def main(argv=None):
246
+ args = list(sys.argv[1:] if argv is None else argv)
247
+ domains, as_json, no_verify, no_ct = [], False, False, False
248
+ max_c, timeout, budget = 40, 8.0, 120.0
249
+ i = 0
250
+ while i < len(args):
251
+ a = args[i]
252
+ if a == "--json":
253
+ as_json = True
254
+ elif a == "--no-verify":
255
+ no_verify = True
256
+ elif a == "--no-ct":
257
+ no_ct = True
258
+ elif a in ("--max-candidates", "--timeout", "--budget"):
259
+ if i + 1 >= len(args):
260
+ _err("%s 缺少取值" % a)
261
+ return 2
262
+ try:
263
+ if a == "--max-candidates":
264
+ max_c = max(1, int(args[i + 1]))
265
+ elif a == "--budget":
266
+ budget = max(10.0, float(args[i + 1]))
267
+ else:
268
+ timeout = max(1.0, min(float(args[i + 1]), 30.0))
269
+ except (TypeError, ValueError):
270
+ _err("%s 取值非法: %s" % (a, args[i + 1]))
271
+ return 2
272
+ i += 1
273
+ elif a.startswith("-"):
274
+ _err("未知参数: %s" % a)
275
+ return 2
276
+ else:
277
+ domains.append(a)
278
+ i += 1
279
+ if not domains:
280
+ _err(__doc__)
281
+ return 2
282
+
283
+ rc = 0
284
+ report = {}
285
+ for domain in domains:
286
+ _err("\n[*] %s" % domain)
287
+ root_ips = doh_resolve(domain, timeout)
288
+ real_root = [ip for ip, _s in root_ips]
289
+ if not real_root:
290
+ _err("[!] %s DoH 解析失败(检查出网/DoH 端点)" % domain)
291
+ rc = 1
292
+ continue
293
+ _err("[根] %s → %s" % (domain, ",".join(real_root)))
294
+
295
+ base = None
296
+ if not no_verify:
297
+ try:
298
+ base = http_probe(real_root[0], domain, timeout)
299
+ _err("[基线] %s | server=%s | cdn=%s | title=%r" % (
300
+ base["status"], base["server"], ",".join(base["cdn"]) or "-", base["title"][:40]))
301
+ except Exception as e:
302
+ _err("[基线] 探测失败: %s(继续)" % e)
303
+
304
+ by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct)
305
+ for ip in real_root: # 根 IP 纳入候选
306
+ by_ip.setdefault(ip, {"sources": set()})["sources"].add(domain)
307
+
308
+ rows = []
309
+ end = time.monotonic() + budget
310
+ pending = [(ip, meta) for ip, meta in by_ip.items()]
311
+
312
+ def verify(item):
313
+ ip, meta = item
314
+ row = {"ip": ip, "sources": sorted(meta["sources"]),
315
+ "score": 0, "reasons": [], "cdn": [], "title": "", "server": "",
316
+ "status": "", "cert_sans": []}
317
+ if not no_verify and time.monotonic() < end:
318
+ try:
319
+ cand = http_probe(ip, domain, timeout)
320
+ row.update({k: cand.get(k) for k in
321
+ ("status", "title", "server", "cdn", "cert_sans")})
322
+ row["score"], row["reasons"] = score_candidate(base or {}, cand)
323
+ except Exception as e:
324
+ row["error"] = str(e)[:80]
325
+ return row
326
+
327
+ if no_verify or not pending:
328
+ rows = [verify(it) for it in pending]
329
+ else:
330
+ with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex:
331
+ futs = {ex.submit(verify, it): it for it in pending}
332
+ try:
333
+ for f in concurrent.futures.as_completed(futs, timeout=max(5.0, budget)):
334
+ try:
335
+ rows.append(f.result())
336
+ except Exception:
337
+ pass
338
+ except concurrent.futures.TimeoutError:
339
+ _err("[预算] 验证超过 %.0fs,先返回已完成的候选" % budget)
340
+ finally:
341
+ ex.shutdown(wait=False, cancel_futures=True)
342
+ rows.sort(key=lambda r: (-r["score"], r["ip"]))
343
+ report[domain] = rows
344
+
345
+ if as_json:
346
+ continue
347
+ print("\n%s 候选源站(分数高=更像源站)" % domain)
348
+ print("%-16s %5s %-28s %-10s %s" % ("ip", "score", "sources", "server", "title"))
349
+ for r in rows[:max_c]:
350
+ print("%-16s %5d %-28s %-10s %s%s" % (
351
+ r["ip"], r["score"], ",".join(r["sources"])[:27] or "-",
352
+ (r["server"] or "-")[:10], (r["title"] or "")[:40],
353
+ (" [cdn:%s]" % ",".join(r["cdn"])) if r["cdn"] else ""))
354
+ strong = [r for r in rows if r["score"] >= 4 and not r["cdn"]]
355
+ if strong:
356
+ print("[结论] 高置信候选(无 CDN 头): %s" % ", ".join(r["ip"] for r in strong[:5]))
357
+ elif rows:
358
+ print("[结论] 无高置信候选:可能源站未暴露/全站 CDN/TUN 干扰(用 --json 看细节)")
359
+ rc = rc or 1
360
+ else:
361
+ rc = 1
362
+
363
+ if as_json:
364
+ print(json.dumps(report, ensure_ascii=False, indent=2))
365
+ return rc
366
+
367
+
368
+ if __name__ == "__main__":
369
+ sys.exit(main())
@@ -1,19 +1,32 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- 并发端口扫描(无需 nmap)
4
+ 并发端口扫描(无需 nmap)——iSH / TUN / fake-IP 友好版
5
5
 
6
6
  提示: 手机环境(iSH/Termux)优先用 fastrecon.py scan —— selectors 并发实测快 5-45 倍
7
7
 
8
+ iSH + TUN(Clash/mihomo/sing-box)常见问题:系统 DNS 被劫持成 fake-IP(198.18.0.0/15),
9
+ 直接扫域名会得到不可路由假地址 → 全部超时/误判。本脚本默认自动检测并用 DoH 拿真实 IP。
10
+
8
11
  用法:
9
12
  python3 portscan.py <host> [--ports 1-65535] [--conc 500] [--timeout 2]
10
13
  python3 portscan.py 1.2.3.4 --ports top100
11
- python3 portscan.py 1.2.3.4 --ports 22,80,443,3306,6379
14
+ python3 portscan.py example.com --resolve doh # 强制 DoH 解析
15
+ python3 portscan.py example.com --resolve none # 只扫 IP,不解析
16
+ python3 portscan.py example.com --allow-fake # 允许扫 fake-IP(一般不要)
12
17
 
13
- 输出: jsonl(每行一个开放端口)
18
+ 输出: jsonl(每行一个开放端口,含 ip / host / port / service)
19
+ 退出码: 0 正常 / 2 参数错误 / 3 目标解析失败或只剩 fake-IP
14
20
  """
15
21
  import argsmini as argparse
16
- import asyncio, json, re, sys
22
+ import asyncio, json, os, re, sys, time
23
+
24
+ try: # 共享 DNS 工具(同目录 scripts/netdns.py)
25
+ import netdns
26
+ except Exception: # 单文件拷贝场景:退化为基础 socket 解析
27
+ netdns = None
28
+
29
+ ISH = os.path.isdir('/var/minis') or bool(os.environ.get('MINIS_ROOT'))
17
30
 
18
31
  # 100 端口 = 40 高价值服务 + 60 经典(对齐 nmap top100 覆盖面)
19
32
  TOP100 = [21,22,23,25,53,80,110,111,135,139,143,443,445,993,995,1433,1521,1723,
@@ -40,6 +53,11 @@ COMMON = {
40
53
  50000:"db2"
41
54
  }
42
55
 
56
+ FAKE_HINT = ("[fake-IP] 目标解析到 fake-IP(TUN 劫持 DNS),该地址不可信:\n"
57
+ " 1) 用 DoH 真 IP:python3 portscan.py <真实IP> 或 --resolve doh\n"
58
+ " 2) 关掉代理的 TUN 全局/DNS 劫持,或把目标加 DIRECT/fake-ip-filter\n"
59
+ " 3) 溯源同理:先 DoH 拿真 IP,再扫 IP(nmap -n -Pn <ip>)")
60
+
43
61
 
44
62
  def parse_ports(s):
45
63
  s = (s or "").strip().lower()
@@ -74,6 +92,53 @@ def parse_ports(s):
74
92
  return sorted(out)
75
93
 
76
94
 
95
+ def split_host(raw):
96
+ """'https://example.com:8443/x' / 'example.com' / '1.2.3.4' → ('example.com', 8443|None)"""
97
+ s = (raw or "").strip()
98
+ s = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', s)
99
+ s = s.split('/', 1)[0]
100
+ m = re.match(r'^(\[[^\]]+\]|[^:\s/]+)(?::(\d{1,5}))?$', s)
101
+ if not m:
102
+ return None, None
103
+ host = m.group(1).strip('[]')
104
+ port = int(m.group(2)) if m.group(2) else None
105
+ if port is not None and not (1 <= port <= 65535):
106
+ return None, None
107
+ return host, port
108
+
109
+
110
+ def is_ipv4_literal(s):
111
+ if netdns is not None:
112
+ return netdns.is_ipv4(s)
113
+ return bool(re.match(r'^\d{1,3}(?:\.\d{1,3}){3}$', str(s)))
114
+
115
+
116
+ def is_fake_ip(ip):
117
+ if netdns is not None:
118
+ return netdns.is_fake_ip(ip)
119
+ return str(ip).startswith(('198.18.', '198.19.', '240.', '198.51.100.', '203.0.113.'))
120
+
121
+
122
+ def resolve_target(host, mode="auto", timeout=6.0):
123
+ """返回 (ip, source, fake_list)。mode: auto/system/doh/none。"""
124
+ if is_ipv4_literal(host):
125
+ return host, "ip", []
126
+ if mode == "none":
127
+ return None, "none", []
128
+ if netdns is not None:
129
+ ips, source = netdns.resolve_real(host, prefer=("system" if mode == "system" else "doh" if mode == "doh" else "auto"),
130
+ timeout=timeout)
131
+ else:
132
+ try:
133
+ import socket as _s
134
+ ips, source = [_s.gethostbyname(host)], "system"
135
+ except Exception:
136
+ ips, source = [], "fail"
137
+ fake = [ip for ip in ips if is_fake_ip(ip)]
138
+ real = [ip for ip in ips if not is_fake_ip(ip)]
139
+ return (real[0] if real else (ips[0] if ips else None)), source, fake
140
+
141
+
77
142
  async def probe(host, port, timeout=2.0):
78
143
  try:
79
144
  r, w = await asyncio.wait_for(
@@ -88,31 +153,45 @@ async def probe(host, port, timeout=2.0):
88
153
  return None
89
154
 
90
155
 
91
- async def scan(host, ports, conc=500, timeout=2.0):
156
+ async def scan(host, ports, conc=500, timeout=2.0, deadline=None):
157
+ """deadline(time.monotonic 秒)用于总时限:到点停发新探测,返回已扫到的部分结果。
158
+ 返回 (open_ports, timed_out)。"""
92
159
  sem = asyncio.Semaphore(conc)
93
160
  open_ports = []
161
+ timed_out = False
94
162
 
95
163
  async def one(p):
164
+ nonlocal timed_out
165
+ if deadline is not None and time.monotonic() > deadline:
166
+ timed_out = True
167
+ return
96
168
  async with sem:
97
169
  r = await probe(host, p, timeout)
98
170
  if r:
99
171
  open_ports.append(r)
100
172
 
101
173
  await asyncio.gather(*[one(p) for p in ports])
102
- return sorted(open_ports)
174
+ return sorted(open_ports), timed_out
103
175
 
104
176
 
105
177
  def main():
106
- ap = argparse.ArgumentParser(description="并发端口扫描(无需 nmap)")
178
+ ap = argparse.ArgumentParser(description="并发端口扫描(无需 nmap;自动绕 fake-IP)")
107
179
  ap.add_argument("host")
108
180
  ap.add_argument("--ports", default="top20",
109
181
  help="1-65535 / top100 / top20 / 22,80,443")
110
182
  ap.add_argument("--conc", type=int, default=500, help="并发数")
111
183
  ap.add_argument("--timeout", type=float, default=2.0)
112
184
  ap.add_argument("--json", action="store_true", help="只输出 JSONL")
185
+ ap.add_argument("--resolve", choices=["auto", "system", "doh", "none"], default="auto",
186
+ help="DNS 解析方式: auto(系统→DoH) / system / doh / none(只接受 IP)")
187
+ ap.add_argument("--allow-fake", action="store_true",
188
+ help="允许扫描 fake-IP 地址(默认拒绝,防误判)")
189
+ ap.add_argument("--deadline", type=float, default=0,
190
+ help="总时限秒(0=自动:按端口数/并发估算,最长 600s;到点返回部分结果)")
113
191
  a = ap.parse_args()
114
192
 
115
- if not a.host or re.search(r"[\s/]", a.host):
193
+ name, inline_port = split_host(a.host)
194
+ if not name:
116
195
  print("[错误] host 只接受域名或 IP(可带 :端口)", file=sys.stderr)
117
196
  sys.exit(2)
118
197
  try:
@@ -124,20 +203,47 @@ def main():
124
203
  if conc != a.conc:
125
204
  print(f"[提示] 并发钳制为 {conc}(允许 1-2000)", file=sys.stderr)
126
205
  timeout = max(0.1, min(a.timeout, 30.0))
127
- if timeout != a.timeout:
128
- print(f"[提示] 超时钳制为 {timeout}s(允许 0.1-30)", file=sys.stderr)
206
+ if ISH and conc > 200:
207
+ conc = 200
208
+ print("[提示] iSH 环境并发降至 200(CPU 模拟 + 弱网更稳)", file=sys.stderr)
209
+ if ISH and timeout <= 2.0:
210
+ timeout = 3.0
211
+ print("[提示] iSH 环境超时上调至 3.0s", file=sys.stderr)
212
+ if inline_port and inline_port not in ports:
213
+ ports = sorted(set(ports) | {inline_port})
214
+
215
+ ip, source, fake = resolve_target(name, mode=a.resolve, timeout=max(timeout, 6.0))
216
+ if not ip:
217
+ print(f"[错误] 无法解析 {name}(--resolve {a.resolve})", file=sys.stderr)
218
+ print(FAKE_HINT, file=sys.stderr)
219
+ sys.exit(3)
220
+ if fake and not any(not is_fake_ip(x) for x in [ip]):
221
+ if not a.allow_fake:
222
+ print(f"[错误] {name} 只解析到 fake-IP:{','.join(fake)}", file=sys.stderr)
223
+ print(FAKE_HINT, file=sys.stderr)
224
+ sys.exit(3)
225
+ print(f"[警告] 正在扫描 fake-IP(--allow-fake):{ip}(结果不可信)", file=sys.stderr)
226
+ elif source == "doh":
227
+ print(f"[提示] 系统解析为 fake/IP 不可信,已用 DoH 真 IP: {ip}", file=sys.stderr)
228
+
129
229
  if not a.json:
130
- print(f"# 扫描 {a.host} 共 {len(ports)} 端口 (并发 {conc})",
230
+ print(f"# 扫描 {name} → {ip}({source})共 {len(ports)} 端口 (并发 {conc})",
131
231
  file=sys.stderr)
132
232
 
133
- open_ports = asyncio.run(scan(a.host, ports, conc, timeout))
233
+ overall = a.deadline if a.deadline > 0 else min(600.0, max(20.0, len(ports) * timeout / conc * 3 + 10))
234
+ deadline = time.monotonic() + max(5.0, min(overall, 3600.0))
235
+ open_ports, timed_out = asyncio.run(scan(ip, ports, conc, timeout, deadline))
236
+ if timed_out:
237
+ print(f"# 达到总时限 {overall:.0f}s:返回部分结果(可加大 --deadline 或降低端口数)", file=sys.stderr)
134
238
 
135
239
  for p in open_ports:
136
- rec = {"host": a.host, "port": p, "service": COMMON.get(p, "?")}
240
+ rec = {"host": name, "ip": ip, "port": p, "service": COMMON.get(p, "?")}
137
241
  print(json.dumps(rec, ensure_ascii=False))
138
242
 
139
243
  if not a.json:
140
244
  print(f"# 开放 {len(open_ports)} 个: {open_ports}", file=sys.stderr)
245
+ if ISH:
246
+ print("# iSH 提示:并发已限制、超时已上调;长扫描用 --deadline 控制,配合 nohup 后台跑", file=sys.stderr)
141
247
 
142
248
 
143
249
  if __name__ == "__main__":