agent-bypass 2.11.5 → 2.11.7

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,980 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """fastrecon — 手机环境快速侦察(iSH / Termux 优化)
4
+
5
+ 实测基准(iSH on iOS):
6
+ scan 50 端口超时场景:串行 40s → selectors 并发 0.9s(45x)
7
+ dns 批量 30 域名:串行 0.2s → UDP 并发 0.1s;缓存命中 0ms(DoH 1102ms 不可用)
8
+ http 10 URL:串行 15.2s → curl -Z 并行 2.7s(5.6x)
9
+
10
+ 用法:
11
+ fastrecon.py dns example.com github.com # 批量解析(UDP 并发 + 缓存)
12
+ fastrecon.py dns subs.txt -w 50 # 文件批量(每行一个域名)
13
+ fastrecon.py scan 192.168.1.1 -p top100 # 端口扫描(selectors 并发)
14
+ fastrecon.py scan 10.0.0.0/24 -p 22,80,443 -t 0.6
15
+ fastrecon.py http urls.txt -b 48 # HTTP 批量探测(curl -Z 并行)
16
+ fastrecon.py http https://a.com,https://b.com --code
17
+
18
+ 纯标准库(socket/selectors/struct/subprocess/urllib);无 root 依赖;Termux/iSH 通用。
19
+ Python 3.8+。退出码:0 正常 / 1 用法错误 / 2 参数或目标非法。
20
+
21
+ 健壮性要点(2026-09 审查修订):
22
+ * 域名先做合法性/IDNA 校验(尾部点、空标签、>63 字节标签、>253 字节全名 → 明确报错)
23
+ * DNS 应答按事务 ID 校验、按 RCODE 判定终局(NOERROR/NXDOMAIN 为终局;SERVFAIL 换下一台)
24
+ * 带 EDNS0(4096),服务器返回 FORMERR/NOTIMP 时自动去掉 OPT 重试
25
+ * TC 截断且未取到 A 记录 → 自动 TCP 重发;仍失败则保留 UDP 已解析出的部分结果
26
+ * 端口/workers/timeout/batch 全部夹取到合法区间(0/负数不再死循环或抛栈)
27
+ * scan_host 任何异常路径都保证 close 全部 socket 与 selector;EMFILE 时优雅降级
28
+ * 缓存文件容忍损坏行/多余列/CRLF,写入用单次 O_APPEND write 降低并发交错
29
+ * http 走 --url 传参(URL 前导 '-' 不会被当选项)、URL 规范化转义空格与非 ASCII
30
+ * -w 输出按行解析并与期望条数比对,不一致时告警
31
+ """
32
+ import argsmini as argparse
33
+ import ipaddress, os, random, re, selectors, socket, struct, sys, time
34
+ import platf
35
+
36
+ # 懒加载:iSH 上 concurrent.futures/subprocess import 各耗 2-3s,仅首次使用时载入
37
+ _cf_mod = None
38
+ def _get_cf():
39
+ global _cf_mod
40
+ if _cf_mod is None:
41
+ import concurrent.futures as _c
42
+ _cf_mod = _c
43
+ return _cf_mod
44
+
45
+ def _sp():
46
+ import subprocess
47
+ return subprocess
48
+ _SH_OK = None
49
+ def _curl_ok():
50
+ global _SH_OK
51
+ if _SH_OK is None:
52
+ _SH_OK = bool(os.popen("curl --version 2>/dev/null").read().strip())
53
+ return _SH_OK
54
+ _SAFE = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%_+=:,./-")
55
+ def _shq(s):
56
+ if s and all(c in _SAFE for c in s): return s
57
+ return "'%s'" % s.replace("'", "'" + chr(92) + "'" + "'")
58
+ def _run_curl(cargs):
59
+ p = os.popen(" ".join(_shq(a) for a in cargs) + " 2>/dev/null")
60
+ out = p.read(); p.close(); return out
61
+ import urllib.parse
62
+
63
+ CACHE_FILE = os.path.expanduser('~/.fastrecon_cache.txt')
64
+ FALLBACK_RESOLVERS = ['1.1.1.1', '8.8.8.8']
65
+ MAX_DNS_WORKERS = 256
66
+ MAX_SCAN_WORKERS = 1024
67
+ MAX_HTTP_BATCH = 256
68
+ DNS_UDP_BUF = 4096
69
+ TOP100 = [21, 22, 23, 25, 53, 80, 81, 88, 110, 111, 135, 139, 143, 161, 389, 443, 445, 465, 502, 554,
70
+ 587, 631, 636, 873, 902, 993, 995, 1025, 1080, 1099, 1433, 1521, 1723, 1883, 2049, 2082,
71
+ 2083, 2181, 2222, 2375, 2376, 3000, 3128, 3260, 3306, 3389, 3690, 4000, 4369, 4443, 4848,
72
+ 5000, 5222, 5432, 5555, 5601, 5672, 5900, 5901, 5984, 6000, 6379, 6443, 6667, 7001, 7443,
73
+ 7474, 8000, 8008, 8080, 8081, 8088, 8443, 8880, 8888, 9000, 9001, 9042, 9090, 9092, 9200,
74
+ 9300, 9443, 9999, 10000, 10250, 11211, 15672, 16379, 27017, 27018, 30000, 50000, 50070,
75
+ 50090, 55555, 61616, 8086, 8883, 9093]
76
+
77
+
78
+ # ============ 通用 ============
79
+
80
+ def _warn(msg):
81
+ print(f'[警告] {msg}', file=sys.stderr)
82
+
83
+
84
+ def _err(msg):
85
+ print(f'[错误] {msg}', file=sys.stderr)
86
+
87
+
88
+ def _clamp_int(v, lo, hi, default):
89
+ """整数参数夹取到 [lo, hi];非法/不可转换时用 default"""
90
+ try:
91
+ v = int(v)
92
+ except (TypeError, ValueError):
93
+ return default
94
+ return max(lo, min(v, hi))
95
+
96
+
97
+ def _clamp_float(v, lo, hi, default):
98
+ """浮点参数夹取到 [lo, hi];NaN/非法时用 default(NaN 会让 settimeout 抛栈)"""
99
+ try:
100
+ v = float(v)
101
+ except (TypeError, ValueError):
102
+ return default
103
+ if v != v:
104
+ return default
105
+ return max(lo, min(v, hi))
106
+
107
+
108
+ def _dedup(seq):
109
+ """保序去重"""
110
+ seen, out = set(), []
111
+ for x in seq:
112
+ if x not in seen:
113
+ seen.add(x)
114
+ out.append(x)
115
+ return out
116
+
117
+
118
+ def _is_ipv4(s):
119
+ if not isinstance(s, str):
120
+ return False
121
+ try:
122
+ return ipaddress.ip_address(s.strip()).version == 4
123
+ except ValueError:
124
+ return False
125
+
126
+
127
+ def _is_addr_list(v):
128
+ """缓存值合法性:逗号分隔的 IP 列表"""
129
+ parts = [x for x in v.split(',') if x]
130
+ if not parts:
131
+ return False
132
+ try:
133
+ for p in parts:
134
+ ipaddress.ip_address(p)
135
+ except ValueError:
136
+ return False
137
+ return True
138
+
139
+
140
+ def read_targets(t):
141
+ """目标:文件(每行一项,支持 # 注释)或逗号/空格分隔列表;空输入返回 []"""
142
+ if not t:
143
+ return []
144
+ if os.path.isfile(t):
145
+ try:
146
+ with open(t, encoding='utf-8', errors='ignore') as f:
147
+ return [l.strip() for l in f if l.strip() and not l.strip().startswith('#')]
148
+ except OSError:
149
+ return []
150
+ return [x for x in re.split(r'[,\s]+', t) if x]
151
+
152
+
153
+ def load_resolvers(override=None):
154
+ """resolver 列表;只接受 IPv4 字面量(UDP 查询走 AF_INET),去重保序"""
155
+ if override:
156
+ o = override.strip().strip('[]').split('%')[0]
157
+ return [o] if _is_ipv4(o) else []
158
+ rs = []
159
+ try:
160
+ with open('/etc/resolv.conf', encoding='utf-8', errors='ignore') as f:
161
+ for line in f:
162
+ m = re.match(r'\s*nameserver\s+(\S+)', line)
163
+ if not m:
164
+ continue
165
+ a = m.group(1).strip('[]').split('%')[0]
166
+ if _is_ipv4(a) and a not in rs:
167
+ rs.append(a)
168
+ except OSError:
169
+ pass
170
+ return rs or list(FALLBACK_RESOLVERS)
171
+
172
+
173
+ # ============ DNS ============
174
+
175
+ def _encode_qname(name):
176
+ """域名 → DNS 线格式;非法域名返回 None(不抛异常)"""
177
+ if not name or not isinstance(name, str):
178
+ return None
179
+ n = name.strip().rstrip('.') # 容忍 FQDN 尾部点
180
+ if not n or len(n) > 253:
181
+ return None
182
+ out = b''
183
+ for part in n.split('.'):
184
+ if not part: # 空标签(a..b / 首尾点)
185
+ return None
186
+ try:
187
+ pb = part.encode('ascii') if part.isascii() else part.encode('idna')
188
+ except (UnicodeError, ValueError):
189
+ return None
190
+ if not 0 < len(pb) < 64: # 标签长度 1..63
191
+ return None
192
+ out += bytes([len(pb)]) + pb
193
+ return out + b'\x00'
194
+
195
+
196
+ def _skip_name(d, o):
197
+ """跳过域名,返回新偏移;越界/异常结构抛 IndexError"""
198
+ n = len(d)
199
+ hops = 0
200
+ while True:
201
+ if o >= n:
202
+ raise IndexError('truncated name')
203
+ l = d[o]
204
+ if l == 0:
205
+ return o + 1
206
+ if l & 0xC0 == 0xC0: # 压缩指针
207
+ if o + 1 >= n:
208
+ raise IndexError('truncated pointer')
209
+ return o + 2
210
+ o += 1 + l
211
+ hops += 1
212
+ if hops > 128: # 防御畸形报文导致的长循环
213
+ raise IndexError('too many labels')
214
+
215
+
216
+ def _parse_answers(data):
217
+ """从应答中提取 ANSWER 段的 A 记录(其余类型跳过);异常报文返回已解析部分"""
218
+ ips = []
219
+ if len(data) < 12:
220
+ return ips
221
+ ancount = struct.unpack('>H', data[6:8])[0]
222
+ o = 12
223
+ try:
224
+ o = _skip_name(data, o) + 4 # 跳过 question(name + qtype + qclass)
225
+ for _ in range(min(ancount, 128)):
226
+ o = _skip_name(data, o)
227
+ if o + 10 > len(data):
228
+ break
229
+ t, _cl, _ttl, ln = struct.unpack('>HHIH', data[o:o + 10])
230
+ o += 10
231
+ if o + ln > len(data):
232
+ break
233
+ if t == 1 and ln == 4:
234
+ ips.append('.'.join(str(b) for b in data[o:o + 4]))
235
+ o += ln
236
+ except (IndexError, struct.error):
237
+ pass
238
+ return ips
239
+
240
+
241
+ def _recv_exact(s, want):
242
+ buf = b''
243
+ while len(buf) < want:
244
+ try:
245
+ chunk = s.recv(want - len(buf))
246
+ except OSError:
247
+ break
248
+ if not chunk:
249
+ break
250
+ buf += chunk
251
+ return buf
252
+
253
+
254
+ def _dns_tcp_query(q, srv, timeout):
255
+ """UDP 应答被截断(TC 位)时的 TCP 重发;失败返回 [](调用方保留部分结果)"""
256
+ try:
257
+ with socket.create_connection((srv, 53), timeout=timeout) as s:
258
+ s.settimeout(timeout)
259
+ s.sendall(struct.pack('>H', len(q)) + q)
260
+ hdr = _recv_exact(s, 2)
261
+ if len(hdr) < 2:
262
+ return []
263
+ n = struct.unpack('>H', hdr)[0]
264
+ if not 0 < n <= 65535:
265
+ return []
266
+ body = _recv_exact(s, n)
267
+ if len(body) < 12:
268
+ return []
269
+ return _parse_answers(body)
270
+ except OSError:
271
+ return []
272
+
273
+
274
+ def _dns_dot_query(q, srv, timeout):
275
+ """DNS over TLS(RFC 7858):TCP 853 + TLS 包装,帧格式同 TCP。失败返回 []。"""
276
+ try:
277
+ import ssl
278
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
279
+ ctx.check_hostname = False
280
+ ctx.verify_mode = ssl.CERT_NONE
281
+ with socket.create_connection((srv, 853), timeout=timeout) as raw:
282
+ with ctx.wrap_socket(raw, server_hostname=srv) as s:
283
+ s.settimeout(timeout)
284
+ s.sendall(struct.pack('>H', len(q)) + q)
285
+ hdr = _recv_exact(s, 2)
286
+ if len(hdr) < 2:
287
+ return []
288
+ n = struct.unpack('>H', hdr)[0]
289
+ if not 0 < n <= 65535:
290
+ return []
291
+ body = _recv_exact(s, n)
292
+ if len(body) < 12:
293
+ return []
294
+ return _parse_answers(body)
295
+ except OSError:
296
+ return []
297
+
298
+
299
+ def _dns_exchange(sock, q, qidb, srv, timeout):
300
+ """发送一次查询并等待 ID 匹配的应答;超时/无应答返回 None"""
301
+ # 先清掉上一步可能残留的迟到报文,避免串包
302
+ try:
303
+ sock.settimeout(0)
304
+ while True:
305
+ sock.recvfrom(DNS_UDP_BUF)
306
+ except OSError:
307
+ pass
308
+ try:
309
+ sock.sendto(q, (srv, 53))
310
+ except OSError:
311
+ return None
312
+ deadline = time.time() + timeout
313
+ while True:
314
+ remain = deadline - time.time()
315
+ if remain <= 0:
316
+ return None
317
+ try:
318
+ sock.settimeout(remain)
319
+ resp, _src = sock.recvfrom(DNS_UDP_BUF)
320
+ except (socket.timeout, OSError):
321
+ return None
322
+ if len(resp) >= 12 and resp[:2] == qidb:
323
+ return resp
324
+ # ID 不匹配 → 丢弃并继续等(防串包)
325
+
326
+
327
+ def dns_query(name, servers, timeout=4.0, via="udp"):
328
+ """查询 A 记录。
329
+
330
+ 返回 None:域名非法(未发查询);返回 []:查询失败或确无 A 记录。
331
+ """
332
+ qn = _encode_qname(name)
333
+ if qn is None:
334
+ return None
335
+ timeout = _clamp_float(timeout, 0.2, 60.0, 4.0)
336
+ servers = [s for s in (servers or []) if _is_ipv4(s)] or list(FALLBACK_RESOLVERS)
337
+ qid = random.randint(0, 65535)
338
+ qidb = struct.pack('>H', qid)
339
+ head = struct.pack('>HHHHHH', qid, 0x0100, 1, 0, 0, 0) + qn + struct.pack('>HH', 1, 1)
340
+ # EDNS0:通告 4096 字节 UDP 缓冲,显著降低 TC 截断概率
341
+ q_edns = (struct.pack('>HHHHHH', qid, 0x0100, 1, 0, 0, 1) + qn + struct.pack('>HH', 1, 1)
342
+ + b'\x00' + struct.pack('>HHIH', 41, DNS_UDP_BUF, 0, 0))
343
+ if via == 'dot':
344
+ for srv in servers:
345
+ r = _dns_dot_query(head, srv, timeout)
346
+ if r:
347
+ return r
348
+ return []
349
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
350
+ try:
351
+ for srv in servers:
352
+ for q in (q_edns, head): # EDNS0 被拒(FORMERR/NOTIMP)时去掉 OPT 重试
353
+ data = _dns_exchange(sock, q, qidb, srv, timeout)
354
+ if data is None:
355
+ break # 该 resolver 超时/无应答 → 换下一台
356
+ rcode = data[3] & 0x0F
357
+ if rcode in (1, 4) and q is q_edns:
358
+ continue
359
+ ips = _parse_answers(data)
360
+ if (data[2] & 0x02) and not ips: # TC 截断且没拿到 A → TCP
361
+ tcp = _dns_tcp_query(q, srv, timeout)
362
+ if tcp:
363
+ return tcp
364
+ if rcode in (0, 3) or ips: # NOERROR / NXDOMAIN / 有 A
365
+ return ips
366
+ break # SERVFAIL / REFUSED → 换下一台
367
+ finally:
368
+ sock.close()
369
+ if via == 'auto':
370
+ for srv in servers: # UDP 全挂 → DoT 853 兜底
371
+ r = _dns_dot_query(head, srv, timeout)
372
+ if r:
373
+ return r
374
+ return []
375
+
376
+
377
+ def cache_load():
378
+ """读取缓存:容忍损坏行、多余列、CRLF、空行、# 注释;同名后者覆盖前者"""
379
+ c = {}
380
+ try:
381
+ with open(CACHE_FILE, encoding='utf-8', errors='ignore') as f:
382
+ for line in f:
383
+ line = line.strip()
384
+ if not line or line.startswith('#'):
385
+ continue
386
+ parts = line.split('\t')
387
+ if len(parts) < 2:
388
+ continue
389
+ k, v = parts[0].strip(), parts[1].strip()
390
+ if k and v and _is_addr_list(v):
391
+ c[k] = v
392
+ except OSError:
393
+ pass
394
+ return c
395
+
396
+
397
+ def cache_append(entries):
398
+ """追加写缓存。单次 O_APPEND write:多进程并发时把交错窗口压到最小"""
399
+ if not entries:
400
+ return
401
+ payload = ''.join(f'{n}\t{v}\n' for n, v in entries)
402
+ try:
403
+ fd = os.open(CACHE_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
404
+ except OSError:
405
+ return
406
+ try:
407
+ os.write(fd, payload.encode('utf-8'))
408
+ except OSError:
409
+ pass
410
+ finally:
411
+ os.close(fd)
412
+
413
+
414
+ def _norm_addr(a):
415
+ """地址归一:::ffff:1.2.3.4 → 1.2.3.4,非法返回 None(UDP/TCP 与 getent 输出保持一致)"""
416
+ try:
417
+ ip = ipaddress.ip_address(a)
418
+ except (ValueError, TypeError):
419
+ return None
420
+ if ip.version == 6 and ip.ipv4_mapped is not None:
421
+ return str(ip.ipv4_mapped)
422
+ return str(ip)
423
+
424
+
425
+ def _getent_one(n):
426
+ """兜底解析:系统 getent 优先(走 libc/TCP),失败再退回 getaddrinfo"""
427
+ addrs = []
428
+ try:
429
+ r = _sp().run(['getent', 'hosts', n], capture_output=True,
430
+ encoding='utf-8', errors='replace', timeout=6)
431
+ for line in (r.stdout or '').splitlines():
432
+ p = line.split()
433
+ if not p:
434
+ continue
435
+ a = _norm_addr(p[0])
436
+ if a and a not in addrs:
437
+ addrs.append(a)
438
+ except Exception:
439
+ addrs = []
440
+ if not addrs:
441
+ try:
442
+ for info in socket.getaddrinfo(n, None):
443
+ a = _norm_addr(info[4][0])
444
+ if a and a not in addrs:
445
+ addrs.append(a)
446
+ except Exception:
447
+ pass
448
+ return n, addrs
449
+
450
+
451
+ def _cache_key(n):
452
+ return n.strip().rstrip('.').lower()
453
+
454
+
455
+ def _run_pool(items, fn, workers):
456
+ items = list(items)
457
+ if len(items) <= 1:
458
+ return [fn(x) for x in items]
459
+ import threading
460
+ out = [None] * len(items)
461
+ nxt = [0]
462
+ lock = threading.Lock()
463
+ def worker():
464
+ while True:
465
+ with lock:
466
+ if nxt[0] >= len(items):
467
+ return
468
+ i = nxt[0]
469
+ nxt[0] += 1
470
+ try:
471
+ out[i] = fn(items[i])
472
+ except Exception:
473
+ out[i] = (items[i], [])
474
+ ts = [threading.Thread(target=worker, daemon=True)
475
+ for _ in range(max(1, min(workers, len(items))))]
476
+ for t in ts:
477
+ t.start()
478
+ for t in ts:
479
+ t.join()
480
+ return out
481
+
482
+
483
+ def cmd_dns(args):
484
+ names = _dedup(read_targets(args.target))
485
+ if not names:
486
+ _err('未提供有效域名')
487
+ return 2
488
+ workers = _clamp_int(args.workers, 1, MAX_DNS_WORKERS, 50)
489
+ timeout = _clamp_float(args.timeout, 0.2, 60.0, 4.0)
490
+ if workers != args.workers:
491
+ _warn(f'workers 越界,已调整为 {workers}')
492
+ if timeout != args.timeout:
493
+ _warn(f'timeout 越界,已调整为 {timeout}')
494
+ servers = load_resolvers(args.resolver)
495
+ if not servers:
496
+ _warn(f'resolver 非法({args.resolver!r}),回退 {FALLBACK_RESOLVERS[0]}')
497
+ servers = list(FALLBACK_RESOLVERS)
498
+
499
+ cache = {} if args.no_cache else cache_load()
500
+ todo, keys = [], {}
501
+ for n in names:
502
+ k = _cache_key(n)
503
+ keys[n] = k
504
+ hit = cache.get(k)
505
+ if hit:
506
+ print(f'{n}\t{hit}\t[缓存]')
507
+ else:
508
+ todo.append(n)
509
+ if not todo:
510
+ return 0
511
+
512
+ def one(n):
513
+ try:
514
+ return n, dns_query(n, servers, timeout=timeout, via=args.via)
515
+ except Exception as e: # 单点失败不影响整批
516
+ print(f'[警告] {n} 解析异常: {e}', file=sys.stderr)
517
+ return n, []
518
+
519
+ t0 = time.time()
520
+ results = {}
521
+ for n, ips in _run_pool(todo, one, workers):
522
+ results[n] = ips
523
+ # 兜底:UDP 大批失败(网络过滤 UDP:53)→ getent / getaddrinfo 并发重试
524
+ failed = [n for n in todo if results.get(n) == []]
525
+ if len(failed) >= 2 and len(failed) > 0.6 * len(todo):
526
+ print(f'[兜底] UDP 失败 {len(failed)}/{len(todo)},切换 getent 并发重试', file=sys.stderr)
527
+ for n, ips in _run_pool(failed, _getent_one, _clamp_int(workers, 1, 20, 20)):
528
+ if ips:
529
+ results[n] = ips
530
+ new_cache, written = [], set()
531
+ for n in todo:
532
+ ips = results.get(n)
533
+ if ips is None:
534
+ print(f'{n}\t-\t[非法域名]')
535
+ elif ips:
536
+ print(f'{n}\t{",".join(ips)}')
537
+ k = keys[n]
538
+ val = ','.join(ips)
539
+ # 同名(含大小写/尾部点差异)只写一行,避免缓存重复行堆积
540
+ if not cache.get(k) and k not in written:
541
+ written.add(k)
542
+ new_cache.append((k, val))
543
+ else:
544
+ print(f'{n}\t-')
545
+ if not args.no_cache:
546
+ cache_append(new_cache)
547
+ print(f'[完成] {len(todo)} 个解析 / {time.time()-t0:.1f}s', file=sys.stderr)
548
+ return 0
549
+
550
+
551
+ # ============ 端口扫描 ============
552
+
553
+ TOP_CAM = [80, 81, 88, 443, 554, 8554, 8000, 8080, 8081, 8899, 34567, 37777,
554
+ 9527, 2000, 2020, 1935, 5000, 5001, 5540, 7070, 7447, 4520, 8787]
555
+
556
+
557
+ def parse_ports(spec):
558
+ """端口规格 → 升序去重列表。非端口语法抛 ValueError;越界端口忽略;空结果返回 []"""
559
+ if not spec or not spec.strip():
560
+ return []
561
+ if spec.strip().lower() == 'top100':
562
+ return sorted(set(TOP100))
563
+ if spec.strip().lower() == 'cam':
564
+ return sorted(set(TOP_CAM))
565
+ ports = set()
566
+ for part in spec.split(','):
567
+ part = part.strip()
568
+ if not part:
569
+ continue
570
+ if '-' in part:
571
+ a, b = part.split('-', 1)
572
+ a, b = a.strip(), b.strip()
573
+ if not (a.isdigit() and b.isdigit()):
574
+ raise ValueError(f'非法端口区间: {part!r}')
575
+ lo, hi = int(a), int(b)
576
+ if lo > hi:
577
+ lo, hi = hi, lo
578
+ lo = max(lo, 1)
579
+ hi = min(hi, 65535)
580
+ if lo > hi:
581
+ raise ValueError(f'端口区间越界(1-65535): {part!r}')
582
+ ports.update(range(lo, hi + 1))
583
+ else:
584
+ if not part.isdigit():
585
+ raise ValueError(f'非法端口: {part!r}')
586
+ p = int(part)
587
+ if 1 <= p <= 65535:
588
+ ports.add(p)
589
+ else:
590
+ _warn(f'端口越界已忽略: {p}')
591
+ return sorted(ports)
592
+
593
+
594
+ def expand_hosts(spec, limit=256):
595
+ """目标 → IPv4 列表;非法网段/超大网段跳过并提示"""
596
+ hosts = []
597
+ for item in read_targets(spec):
598
+ if '/' in item:
599
+ try:
600
+ net = ipaddress.ip_network(item, strict=False)
601
+ except ValueError:
602
+ print(f'[跳过] 非法网段: {item}', file=sys.stderr)
603
+ continue
604
+ if net.version == 6:
605
+ print(f'[跳过] {item}: 暂不支持 IPv6', file=sys.stderr)
606
+ continue
607
+ if net.num_addresses > limit:
608
+ print(f'[跳过] {item} 超过 {limit} 台上限(请拆成更小网段)', file=sys.stderr)
609
+ continue
610
+ hs = [str(h) for h in net.hosts()] or [str(net.network_address)]
611
+ hosts += hs
612
+ else:
613
+ hosts.append(item)
614
+ return _dedup(hosts)
615
+
616
+
617
+ def _unregister(sel, s):
618
+ try:
619
+ sel.unregister(s)
620
+ except (KeyError, ValueError, OSError):
621
+ pass
622
+
623
+
624
+ def _fd_cap():
625
+ """按 RLIMIT_NOFILE 约束并发连接数(iSH/Termux 默认 1024 或更小)"""
626
+ try:
627
+ import resource
628
+ soft = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
629
+ if isinstance(soft, int) and soft > 0:
630
+ return max(16, min(soft - 32, MAX_SCAN_WORKERS))
631
+ except Exception:
632
+ pass
633
+ return MAX_SCAN_WORKERS
634
+
635
+
636
+ def scan_host(host, ports, timeout, workers, banner_mode):
637
+ """selectors 并发非阻塞 connect + 逐 sock 精确超时清理。
638
+
639
+ 任何异常路径都会 close 所有已开 socket 与 selector。
640
+ 返回 (open_ports, banners, skipped_ports);skipped>0 表示 fd 资源不足未能扫完。
641
+ """
642
+ workers = max(1, int(workers))
643
+ timeout = max(0.05, float(timeout))
644
+ sel = selectors.DefaultSelector()
645
+ opened = {} # sock -> (port, connect_ts)
646
+ pending = list(ports)
647
+ found, banners = [], {}
648
+ exhausted = False # socket() 失败(EMFILE/ENFILE)→ 停止新建
649
+
650
+ def launch():
651
+ nonlocal exhausted
652
+ if exhausted:
653
+ return
654
+ while pending and len(opened) < workers:
655
+ p = pending[0]
656
+ try:
657
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
658
+ except OSError as e:
659
+ _warn(f'创建 socket 失败({e}),剩余端口可能未扫描')
660
+ exhausted = True
661
+ return
662
+ s.setblocking(False)
663
+ try:
664
+ s.connect_ex((host, p)) # 非阻塞:立即返回 EINPROGRESS
665
+ sel.register(s, selectors.EVENT_WRITE)
666
+ except (OSError, ValueError):
667
+ try:
668
+ s.close()
669
+ except OSError:
670
+ pass
671
+ pending.pop(0) # 建连/注册失败 → 丢弃该端口,不留悬挂 sock
672
+ continue
673
+ pending.pop(0)
674
+ opened[s] = (p, time.time())
675
+
676
+ try:
677
+ launch()
678
+ while opened or pending:
679
+ now = time.time()
680
+ # 精确超时清理:非阻塞 connect 超时不会触发 select 事件,需逐 sock 计时
681
+ for s in [s for s, (_p, t) in opened.items() if now - t > timeout]:
682
+ opened.pop(s, None)
683
+ _unregister(sel, s)
684
+ try:
685
+ s.close()
686
+ except OSError:
687
+ pass
688
+ if not opened:
689
+ if pending and not exhausted:
690
+ launch()
691
+ continue
692
+ break
693
+ for key, _ev in sel.select(0.05):
694
+ s = key.fileobj
695
+ if s not in opened:
696
+ continue
697
+ p, _t = opened.pop(s)
698
+ _unregister(sel, s)
699
+ try:
700
+ err = s.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
701
+ except OSError:
702
+ err = 1
703
+ if err == 0:
704
+ found.append(p)
705
+ if banner_mode:
706
+ try:
707
+ s.settimeout(1.0)
708
+ b = s.recv(160)
709
+ if b:
710
+ banners[p] = b.decode('utf-8', 'replace').strip().split('\n')[0][:80]
711
+ except OSError:
712
+ pass
713
+ try:
714
+ s.close()
715
+ except OSError:
716
+ pass
717
+ launch()
718
+ return sorted(set(found)), banners, len(pending)
719
+ finally:
720
+ for s in list(opened):
721
+ _unregister(sel, s)
722
+ try:
723
+ s.close()
724
+ except OSError:
725
+ pass
726
+ opened.clear()
727
+ try:
728
+ sel.close()
729
+ except Exception:
730
+ pass
731
+
732
+
733
+ def scan_thread_fallback(host, ports, timeout, workers):
734
+ """兜底:线程池 connect_ex(selectors 不可用时)"""
735
+ timeout = max(0.05, float(timeout))
736
+ n = max(1, min(int(workers), 200))
737
+
738
+ def one(p):
739
+ try:
740
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
741
+ except OSError:
742
+ return None
743
+ s.settimeout(timeout)
744
+ try:
745
+ return p if s.connect_ex((host, p)) == 0 else None
746
+ except OSError:
747
+ return None
748
+ finally:
749
+ try:
750
+ s.close()
751
+ except OSError:
752
+ pass
753
+
754
+ found = []
755
+ for r in _run_pool(ports, one, n):
756
+ if r:
757
+ found.append(r)
758
+ return sorted(set(found)), {}
759
+
760
+
761
+ def cmd_scan(args):
762
+ hosts = expand_hosts(args.target)
763
+ if not hosts:
764
+ _err('未提供有效目标(主机 / CIDR / 文件)')
765
+ return 2
766
+ try:
767
+ ports = parse_ports(args.ports)
768
+ except ValueError as e:
769
+ _err(str(e))
770
+ return 2
771
+ if not ports:
772
+ _err(f'端口列表为空: {args.ports!r}(取值 1-65535 或 top100)')
773
+ return 2
774
+ workers = _clamp_int(args.workers, 1, min(MAX_SCAN_WORKERS, _fd_cap()), 500)
775
+ timeout = _clamp_float(args.timeout, 0.05, 60.0, 0.8)
776
+ if workers != args.workers:
777
+ _warn(f'workers 越界,已调整为 {workers}')
778
+ if timeout != args.timeout:
779
+ _warn(f'timeout 越界,已调整为 {timeout}')
780
+
781
+ rc = 0
782
+ for h in hosts:
783
+ try:
784
+ ip = str(ipaddress.IPv4Address(h))
785
+ except ValueError:
786
+ try:
787
+ ip = socket.gethostbyname(h)
788
+ except (OSError, UnicodeError):
789
+ print(f'{h}\tDNS 解析失败或非法地址')
790
+ rc = rc or 1
791
+ continue
792
+ t0 = time.time()
793
+ try:
794
+ found, banners, skipped = scan_host(ip, ports, timeout, workers, args.banner)
795
+ except Exception as e:
796
+ print(f'[兜底] selectors 异常({e}),切换线程池扫描', file=sys.stderr)
797
+ try:
798
+ found, banners = scan_thread_fallback(ip, ports, timeout, workers)
799
+ skipped = 0
800
+ except Exception as e2:
801
+ _err(f'{h} 扫描失败: {e2}')
802
+ rc = 1
803
+ continue
804
+ dt = time.time() - t0
805
+ if found:
806
+ plist = ','.join(str(p) for p in found)
807
+ print(f'{h}\t{plist}\t({len(found)}/{len(ports)} 开放, {dt:.1f}s)')
808
+ for p in found:
809
+ if p in banners:
810
+ print(f' {p}\t{banners[p]}')
811
+ else:
812
+ print(f'{h}\t-\t(0/{len(ports)}, {dt:.1f}s)')
813
+ if skipped:
814
+ _warn(f'{h} 资源不足,{skipped} 个端口未扫描(可降低 -w)')
815
+ return rc
816
+
817
+
818
+ # ============ HTTP 探测 ============
819
+
820
+ def _normalize_url(u):
821
+ """补全 scheme + 转义空格/非 ASCII(避免 curl 把带空格的 URL 拆成两条);
822
+ 非法 URL 返回 ''。保留已存在的 %XX 转义(不二次编码)。"""
823
+ if not u or not isinstance(u, str):
824
+ return ''
825
+ u = u.strip()
826
+ if not u:
827
+ return ''
828
+ if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.\-]*://', u):
829
+ u = 'https://' + u
830
+ try:
831
+ sp = urllib.parse.urlsplit(u)
832
+ except ValueError:
833
+ return ''
834
+ host = sp.hostname or ''
835
+ if not host or any(c.isspace() for c in host):
836
+ return ''
837
+ netloc = sp.netloc
838
+ if not host.isascii():
839
+ try:
840
+ netloc = netloc.replace(host, host.encode('idna').decode('ascii'), 1)
841
+ except (UnicodeError, ValueError):
842
+ return ''
843
+ path = urllib.parse.quote(sp.path, safe="/%:@!$&'()*+,;=~-._")
844
+ query = urllib.parse.quote(sp.query, safe="=&%:?#[]@!$'()*+,;~-._/")
845
+ frag = urllib.parse.quote(sp.fragment, safe="%:@!$&'()*+,;=~-._/?")
846
+ return urllib.parse.urlunsplit((sp.scheme.lower(), netloc, path, query, frag))
847
+
848
+
849
+ def _http_py(urls, timeout, workers=16):
850
+ """兜底:纯 Python 并发 HTTP 探测(无 curl 或 curl 整批失败时)。
851
+ TLS 行为与 curl 默认一致(校验证书),保证两条路径结论一致。"""
852
+ import urllib.request, urllib.error, ssl as _ssl
853
+ timeout = _clamp_int(timeout, 1, 600, 8)
854
+ workers = _clamp_int(workers, 1, 64, 16)
855
+ ctx = _ssl.create_default_context()
856
+
857
+ def one(u):
858
+ t0 = time.time()
859
+ try:
860
+ req = urllib.request.Request(u, headers={'User-Agent': 'Mozilla/5.0'})
861
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
862
+ code = getattr(r, 'status', None) or 0
863
+ return f'{u} {code} {time.time()-t0:.3f}'
864
+ except urllib.error.HTTPError as e:
865
+ return f'{u} {e.code} {time.time()-t0:.3f}'
866
+ except Exception:
867
+ return f'{u} 0 {time.time()-t0:.3f}'
868
+
869
+ for line in _run_pool(urls, one, workers):
870
+ print(line)
871
+
872
+
873
+ def cmd_http(args):
874
+ urls = read_targets(args.target)
875
+ if not urls:
876
+ _err('未提供有效 URL')
877
+ return 2
878
+ batch = _clamp_int(args.batch, 1, MAX_HTTP_BATCH, 48)
879
+ timeout = _clamp_int(args.timeout, 1, 600, 8)
880
+ if batch != args.batch:
881
+ _warn(f'batch 越界(1-{MAX_HTTP_BATCH}),已调整为 {batch}')
882
+ if timeout != args.timeout:
883
+ _warn(f'timeout 越界(1-600),已调整为 {timeout}')
884
+
885
+ targets = []
886
+ for u in urls:
887
+ nu = _normalize_url(u)
888
+ if nu:
889
+ targets.append(nu)
890
+ else:
891
+ _warn(f'跳过非法 URL: {u!r}')
892
+ if not targets:
893
+ _err('没有可用的 URL')
894
+ return 2
895
+
896
+ fmt = ('%{url_effective} %{http_code} %{time_total}\n' if not args.code
897
+ else '%{url_effective} %{http_code}\n')
898
+ use_py = False
899
+ done = 0
900
+ for i in range(0, len(targets), batch):
901
+ chunk = targets[i:i + batch]
902
+ if use_py:
903
+ _http_py(chunk, timeout, min(len(chunk), 32))
904
+ done += len(chunk)
905
+ continue
906
+ # --url 传参:URL 以 '-' 开头时不会被当成 curl 选项
907
+ cargs = ['curl', '-sS', '-Z', '--parallel-max', str(min(batch, len(chunk))),
908
+ '-m', str(timeout), '-w', fmt]
909
+ for u in chunk:
910
+ cargs += ['-o', os.devnull, '--url', u]
911
+ out = _run_curl(cargs)
912
+ lines = [l for l in (out or '').splitlines() if l.strip()]
913
+ if not lines and not _curl_ok():
914
+ print('[兜底] 未找到 curl,切换 Python urllib 并发', file=sys.stderr)
915
+ use_py = True
916
+ _http_py(chunk, timeout, min(len(chunk), 32))
917
+ done += len(chunk)
918
+ continue
919
+ if not lines:
920
+ _warn(f'批 {i // batch} 无输出(curl 被拒或网络不通)')
921
+ if len(lines) != len(chunk):
922
+ _warn(f'批 {i // batch} 输出 {len(lines)} 条 != 请求 {len(chunk)} 条'
923
+ f'(部分 URL 可能被 curl 直接拒绝)')
924
+ if lines:
925
+ sys.stdout.write('\n'.join(lines) + '\n')
926
+ done += len(chunk)
927
+ print(f'[完成] {done} 个 URL', file=sys.stderr)
928
+ return 0
929
+
930
+
931
+ # ============ 入口 ============
932
+
933
+ def main():
934
+ ap = argparse.ArgumentParser(prog='fastrecon', description='手机环境快速侦察(iSH/Termux)')
935
+ sub = ap.add_subparsers(dest='cmd')
936
+
937
+ d = sub.add_parser('dns', help='批量 DNS 解析(UDP 并发 + 本地缓存)')
938
+ d.add_argument('target', help='域名 / 逗号列表 / 文件')
939
+ d.add_argument('--resolver', help='指定 resolver(默认读 /etc/resolv.conf)')
940
+ d.add_argument('-w', '--workers', type=int, default=platf.env()['dns_workers'])
941
+ d.add_argument('--via', choices=['udp', 'dot', 'auto'], default='udp',
942
+ help='解析通道: udp(默认) / dot(DoT 853) / auto(UDP 失败自动 DoT)')
943
+ d.add_argument('-t', '--timeout', type=float, default=4)
944
+ d.add_argument('--no-cache', action='store_true')
945
+
946
+ s = sub.add_parser('scan', help='端口扫描(selectors 并发)')
947
+ s.add_argument('target', help='主机 / CIDR / 文件')
948
+ s.add_argument('-p', '--ports', default='top100',
949
+ help='top100 | cam(摄像头组) | 22,80 | 1-1000')
950
+ s.add_argument('-t', '--timeout', type=float, default=0.8, help='单个连接超时(默认 0.8s)')
951
+ s.add_argument('-w', '--workers', type=int, default=platf.env()['scan_workers'],
952
+ help='并发连接数(按平台自动适配)')
953
+ s.add_argument('-b', '--banner', action='store_true', help='抓取服务横幅')
954
+
955
+ h = sub.add_parser('http', help='HTTP 批量探测(curl -Z 并行)')
956
+ h.add_argument('target', help='URL / 逗号列表 / 文件')
957
+ h.add_argument('-b', '--batch', type=int, default=platf.env()['http_workers'],
958
+ help='并行数(按平台自动适配)')
959
+ h.add_argument('-t', '--timeout', type=int, default=8)
960
+ h.add_argument('--code', action='store_true', help='只输出 URL 和状态码')
961
+
962
+ args = ap.parse_args()
963
+ if not args.cmd:
964
+ ap.print_help()
965
+ return 1
966
+ return {'dns': cmd_dns, 'scan': cmd_scan, 'http': cmd_http}[args.cmd](args)
967
+
968
+
969
+ if __name__ == '__main__':
970
+ try:
971
+ sys.exit(main())
972
+ except KeyboardInterrupt:
973
+ sys.stderr.write('\n[中断]\n')
974
+ sys.exit(130)
975
+ except BrokenPipeError:
976
+ try:
977
+ sys.stdout.close()
978
+ except Exception:
979
+ pass
980
+ sys.exit(0)