agent-bypass 2.11.5 → 2.11.6
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.
- package/README.md +3 -2
- package/SKILL.md +5 -3
- package/package.json +1 -1
- package/references/mobile-fast-recon.md +102 -0
- package/scripts/__pycache__/basicauth_brute.cpython-312.pyc +0 -0
- package/scripts/__pycache__/cvefetch.cpython-312.pyc +0 -0
- package/scripts/__pycache__/deck.cpython-312.pyc +0 -0
- package/scripts/__pycache__/depscan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dirbrute.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fastrecon.cpython-312.pyc +0 -0
- package/scripts/__pycache__/jsrip.cpython-312.pyc +0 -0
- package/scripts/__pycache__/mutate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/portscan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/webprobe.cpython-312.pyc +0 -0
- package/scripts/argsmini.py +158 -0
- package/scripts/basicauth_brute.py +82 -7
- package/scripts/dirbrute.py +505 -71
- package/scripts/fastrecon.py +982 -0
- package/scripts/platf.py +44 -0
- package/scripts/portscan.py +2 -0
|
@@ -0,0 +1,982 @@
|
|
|
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
|
+
with _get_cf().ThreadPoolExecutor(n) as ex:
|
|
756
|
+
for r in ex.map(one, ports):
|
|
757
|
+
if r:
|
|
758
|
+
found.append(r)
|
|
759
|
+
return sorted(set(found)), {}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def cmd_scan(args):
|
|
763
|
+
hosts = expand_hosts(args.target)
|
|
764
|
+
if not hosts:
|
|
765
|
+
_err('未提供有效目标(主机 / CIDR / 文件)')
|
|
766
|
+
return 2
|
|
767
|
+
try:
|
|
768
|
+
ports = parse_ports(args.ports)
|
|
769
|
+
except ValueError as e:
|
|
770
|
+
_err(str(e))
|
|
771
|
+
return 2
|
|
772
|
+
if not ports:
|
|
773
|
+
_err(f'端口列表为空: {args.ports!r}(取值 1-65535 或 top100)')
|
|
774
|
+
return 2
|
|
775
|
+
workers = _clamp_int(args.workers, 1, min(MAX_SCAN_WORKERS, _fd_cap()), 500)
|
|
776
|
+
timeout = _clamp_float(args.timeout, 0.05, 60.0, 0.8)
|
|
777
|
+
if workers != args.workers:
|
|
778
|
+
_warn(f'workers 越界,已调整为 {workers}')
|
|
779
|
+
if timeout != args.timeout:
|
|
780
|
+
_warn(f'timeout 越界,已调整为 {timeout}')
|
|
781
|
+
|
|
782
|
+
rc = 0
|
|
783
|
+
for h in hosts:
|
|
784
|
+
try:
|
|
785
|
+
ip = str(ipaddress.IPv4Address(h))
|
|
786
|
+
except ValueError:
|
|
787
|
+
try:
|
|
788
|
+
ip = socket.gethostbyname(h)
|
|
789
|
+
except (OSError, UnicodeError):
|
|
790
|
+
print(f'{h}\tDNS 解析失败或非法地址')
|
|
791
|
+
rc = rc or 1
|
|
792
|
+
continue
|
|
793
|
+
t0 = time.time()
|
|
794
|
+
try:
|
|
795
|
+
found, banners, skipped = scan_host(ip, ports, timeout, workers, args.banner)
|
|
796
|
+
except Exception as e:
|
|
797
|
+
print(f'[兜底] selectors 异常({e}),切换线程池扫描', file=sys.stderr)
|
|
798
|
+
try:
|
|
799
|
+
found, banners = scan_thread_fallback(ip, ports, timeout, workers)
|
|
800
|
+
skipped = 0
|
|
801
|
+
except Exception as e2:
|
|
802
|
+
_err(f'{h} 扫描失败: {e2}')
|
|
803
|
+
rc = 1
|
|
804
|
+
continue
|
|
805
|
+
dt = time.time() - t0
|
|
806
|
+
if found:
|
|
807
|
+
plist = ','.join(str(p) for p in found)
|
|
808
|
+
print(f'{h}\t{plist}\t({len(found)}/{len(ports)} 开放, {dt:.1f}s)')
|
|
809
|
+
for p in found:
|
|
810
|
+
if p in banners:
|
|
811
|
+
print(f' {p}\t{banners[p]}')
|
|
812
|
+
else:
|
|
813
|
+
print(f'{h}\t-\t(0/{len(ports)}, {dt:.1f}s)')
|
|
814
|
+
if skipped:
|
|
815
|
+
_warn(f'{h} 资源不足,{skipped} 个端口未扫描(可降低 -w)')
|
|
816
|
+
return rc
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# ============ HTTP 探测 ============
|
|
820
|
+
|
|
821
|
+
def _normalize_url(u):
|
|
822
|
+
"""补全 scheme + 转义空格/非 ASCII(避免 curl 把带空格的 URL 拆成两条);
|
|
823
|
+
非法 URL 返回 ''。保留已存在的 %XX 转义(不二次编码)。"""
|
|
824
|
+
if not u or not isinstance(u, str):
|
|
825
|
+
return ''
|
|
826
|
+
u = u.strip()
|
|
827
|
+
if not u:
|
|
828
|
+
return ''
|
|
829
|
+
if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.\-]*://', u):
|
|
830
|
+
u = 'https://' + u
|
|
831
|
+
try:
|
|
832
|
+
sp = urllib.parse.urlsplit(u)
|
|
833
|
+
except ValueError:
|
|
834
|
+
return ''
|
|
835
|
+
host = sp.hostname or ''
|
|
836
|
+
if not host or any(c.isspace() for c in host):
|
|
837
|
+
return ''
|
|
838
|
+
netloc = sp.netloc
|
|
839
|
+
if not host.isascii():
|
|
840
|
+
try:
|
|
841
|
+
netloc = netloc.replace(host, host.encode('idna').decode('ascii'), 1)
|
|
842
|
+
except (UnicodeError, ValueError):
|
|
843
|
+
return ''
|
|
844
|
+
path = urllib.parse.quote(sp.path, safe="/%:@!$&'()*+,;=~-._")
|
|
845
|
+
query = urllib.parse.quote(sp.query, safe="=&%:?#[]@!$'()*+,;~-._/")
|
|
846
|
+
frag = urllib.parse.quote(sp.fragment, safe="%:@!$&'()*+,;=~-._/?")
|
|
847
|
+
return urllib.parse.urlunsplit((sp.scheme.lower(), netloc, path, query, frag))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _http_py(urls, timeout, workers=16):
|
|
851
|
+
"""兜底:纯 Python 并发 HTTP 探测(无 curl 或 curl 整批失败时)。
|
|
852
|
+
TLS 行为与 curl 默认一致(校验证书),保证两条路径结论一致。"""
|
|
853
|
+
import urllib.request, urllib.error, ssl as _ssl
|
|
854
|
+
timeout = _clamp_int(timeout, 1, 600, 8)
|
|
855
|
+
workers = _clamp_int(workers, 1, 64, 16)
|
|
856
|
+
ctx = _ssl.create_default_context()
|
|
857
|
+
|
|
858
|
+
def one(u):
|
|
859
|
+
t0 = time.time()
|
|
860
|
+
try:
|
|
861
|
+
req = urllib.request.Request(u, headers={'User-Agent': 'Mozilla/5.0'})
|
|
862
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
|
|
863
|
+
code = getattr(r, 'status', None) or 0
|
|
864
|
+
return f'{u} {code} {time.time()-t0:.3f}'
|
|
865
|
+
except urllib.error.HTTPError as e:
|
|
866
|
+
return f'{u} {e.code} {time.time()-t0:.3f}'
|
|
867
|
+
except Exception:
|
|
868
|
+
return f'{u} 0 {time.time()-t0:.3f}'
|
|
869
|
+
|
|
870
|
+
with _get_cf().ThreadPoolExecutor(workers) as ex:
|
|
871
|
+
for line in ex.map(one, urls):
|
|
872
|
+
print(line)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def cmd_http(args):
|
|
876
|
+
urls = read_targets(args.target)
|
|
877
|
+
if not urls:
|
|
878
|
+
_err('未提供有效 URL')
|
|
879
|
+
return 2
|
|
880
|
+
batch = _clamp_int(args.batch, 1, MAX_HTTP_BATCH, 48)
|
|
881
|
+
timeout = _clamp_int(args.timeout, 1, 600, 8)
|
|
882
|
+
if batch != args.batch:
|
|
883
|
+
_warn(f'batch 越界(1-{MAX_HTTP_BATCH}),已调整为 {batch}')
|
|
884
|
+
if timeout != args.timeout:
|
|
885
|
+
_warn(f'timeout 越界(1-600),已调整为 {timeout}')
|
|
886
|
+
|
|
887
|
+
targets = []
|
|
888
|
+
for u in urls:
|
|
889
|
+
nu = _normalize_url(u)
|
|
890
|
+
if nu:
|
|
891
|
+
targets.append(nu)
|
|
892
|
+
else:
|
|
893
|
+
_warn(f'跳过非法 URL: {u!r}')
|
|
894
|
+
if not targets:
|
|
895
|
+
_err('没有可用的 URL')
|
|
896
|
+
return 2
|
|
897
|
+
|
|
898
|
+
fmt = ('%{url_effective} %{http_code} %{time_total}\n' if not args.code
|
|
899
|
+
else '%{url_effective} %{http_code}\n')
|
|
900
|
+
use_py = False
|
|
901
|
+
done = 0
|
|
902
|
+
for i in range(0, len(targets), batch):
|
|
903
|
+
chunk = targets[i:i + batch]
|
|
904
|
+
if use_py:
|
|
905
|
+
_http_py(chunk, timeout, min(len(chunk), 32))
|
|
906
|
+
done += len(chunk)
|
|
907
|
+
continue
|
|
908
|
+
# --url 传参:URL 以 '-' 开头时不会被当成 curl 选项
|
|
909
|
+
cargs = ['curl', '-sS', '-Z', '--parallel-max', str(min(batch, len(chunk))),
|
|
910
|
+
'-m', str(timeout), '-w', fmt]
|
|
911
|
+
for u in chunk:
|
|
912
|
+
cargs += ['-o', os.devnull, '--url', u]
|
|
913
|
+
out = _run_curl(cargs)
|
|
914
|
+
lines = [l for l in (out or '').splitlines() if l.strip()]
|
|
915
|
+
if not lines and not _curl_ok():
|
|
916
|
+
print('[兜底] 未找到 curl,切换 Python urllib 并发', file=sys.stderr)
|
|
917
|
+
use_py = True
|
|
918
|
+
_http_py(chunk, timeout, min(len(chunk), 32))
|
|
919
|
+
done += len(chunk)
|
|
920
|
+
continue
|
|
921
|
+
if not lines:
|
|
922
|
+
_warn(f'批 {i // batch} 无输出(curl 被拒或网络不通)')
|
|
923
|
+
if len(lines) != len(chunk):
|
|
924
|
+
_warn(f'批 {i // batch} 输出 {len(lines)} 条 != 请求 {len(chunk)} 条'
|
|
925
|
+
f'(部分 URL 可能被 curl 直接拒绝)')
|
|
926
|
+
if lines:
|
|
927
|
+
sys.stdout.write('\n'.join(lines) + '\n')
|
|
928
|
+
done += len(chunk)
|
|
929
|
+
print(f'[完成] {done} 个 URL', file=sys.stderr)
|
|
930
|
+
return 0
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
# ============ 入口 ============
|
|
934
|
+
|
|
935
|
+
def main():
|
|
936
|
+
ap = argparse.ArgumentParser(prog='fastrecon', description='手机环境快速侦察(iSH/Termux)')
|
|
937
|
+
sub = ap.add_subparsers(dest='cmd')
|
|
938
|
+
|
|
939
|
+
d = sub.add_parser('dns', help='批量 DNS 解析(UDP 并发 + 本地缓存)')
|
|
940
|
+
d.add_argument('target', help='域名 / 逗号列表 / 文件')
|
|
941
|
+
d.add_argument('--resolver', help='指定 resolver(默认读 /etc/resolv.conf)')
|
|
942
|
+
d.add_argument('-w', '--workers', type=int, default=platf.env()['dns_workers'])
|
|
943
|
+
d.add_argument('--via', choices=['udp', 'dot', 'auto'], default='udp',
|
|
944
|
+
help='解析通道: udp(默认) / dot(DoT 853) / auto(UDP 失败自动 DoT)')
|
|
945
|
+
d.add_argument('-t', '--timeout', type=float, default=4)
|
|
946
|
+
d.add_argument('--no-cache', action='store_true')
|
|
947
|
+
|
|
948
|
+
s = sub.add_parser('scan', help='端口扫描(selectors 并发)')
|
|
949
|
+
s.add_argument('target', help='主机 / CIDR / 文件')
|
|
950
|
+
s.add_argument('-p', '--ports', default='top100',
|
|
951
|
+
help='top100 | cam(摄像头组) | 22,80 | 1-1000')
|
|
952
|
+
s.add_argument('-t', '--timeout', type=float, default=0.8, help='单个连接超时(默认 0.8s)')
|
|
953
|
+
s.add_argument('-w', '--workers', type=int, default=platf.env()['scan_workers'],
|
|
954
|
+
help='并发连接数(按平台自动适配)')
|
|
955
|
+
s.add_argument('-b', '--banner', action='store_true', help='抓取服务横幅')
|
|
956
|
+
|
|
957
|
+
h = sub.add_parser('http', help='HTTP 批量探测(curl -Z 并行)')
|
|
958
|
+
h.add_argument('target', help='URL / 逗号列表 / 文件')
|
|
959
|
+
h.add_argument('-b', '--batch', type=int, default=platf.env()['http_workers'],
|
|
960
|
+
help='并行数(按平台自动适配)')
|
|
961
|
+
h.add_argument('-t', '--timeout', type=int, default=8)
|
|
962
|
+
h.add_argument('--code', action='store_true', help='只输出 URL 和状态码')
|
|
963
|
+
|
|
964
|
+
args = ap.parse_args()
|
|
965
|
+
if not args.cmd:
|
|
966
|
+
ap.print_help()
|
|
967
|
+
return 1
|
|
968
|
+
return {'dns': cmd_dns, 'scan': cmd_scan, 'http': cmd_http}[args.cmd](args)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
if __name__ == '__main__':
|
|
972
|
+
try:
|
|
973
|
+
sys.exit(main())
|
|
974
|
+
except KeyboardInterrupt:
|
|
975
|
+
sys.stderr.write('\n[中断]\n')
|
|
976
|
+
sys.exit(130)
|
|
977
|
+
except BrokenPipeError:
|
|
978
|
+
try:
|
|
979
|
+
sys.stdout.close()
|
|
980
|
+
except Exception:
|
|
981
|
+
pass
|
|
982
|
+
sys.exit(0)
|