nonebot-plugin-proxy-probe 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_proxy_probe/__init__.py +26 -0
- nonebot_plugin_proxy_probe/assets//321/205/320/236/320/257/321/207/320/265/320/256/321/205/320/275/320/247/321/204/342/225/234/320/243.ttf +0 -0
- nonebot_plugin_proxy_probe/cache.py +82 -0
- nonebot_plugin_proxy_probe/commands.py +130 -0
- nonebot_plugin_proxy_probe/config.py +29 -0
- nonebot_plugin_proxy_probe/manager.py +360 -0
- nonebot_plugin_proxy_probe/models.py +145 -0
- nonebot_plugin_proxy_probe/probe.py +1184 -0
- nonebot_plugin_proxy_probe/render.py +266 -0
- nonebot_plugin_proxy_probe-0.2.2.dist-info/METADATA +269 -0
- nonebot_plugin_proxy_probe-0.2.2.dist-info/RECORD +14 -0
- nonebot_plugin_proxy_probe-0.2.2.dist-info/WHEEL +5 -0
- nonebot_plugin_proxy_probe-0.2.2.dist-info/licenses/LICENSE +674 -0
- nonebot_plugin_proxy_probe-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import queue
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
import urllib3
|
|
20
|
+
from requests.adapters import HTTPAdapter
|
|
21
|
+
|
|
22
|
+
from .models import (
|
|
23
|
+
NOT_PROBED,
|
|
24
|
+
UNAVAILABLE,
|
|
25
|
+
PipelineProgress,
|
|
26
|
+
ProxyRecord,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
STOP = object()
|
|
31
|
+
UpdateCallback = Callable[
|
|
32
|
+
[PipelineProgress, list[ProxyRecord], int, int], None
|
|
33
|
+
]
|
|
34
|
+
RefreshCallback = Callable[
|
|
35
|
+
[list[ProxyRecord], "RefreshProgress"], None
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SourceAddressAdapter(HTTPAdapter):
|
|
40
|
+
"""让直连及代理连接均从指定本机 IP 发出。"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, source_ip: str, *args: Any, **kwargs: Any) -> None:
|
|
43
|
+
self.source_ip = source_ip
|
|
44
|
+
super().__init__(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
def init_poolmanager(
|
|
47
|
+
self,
|
|
48
|
+
connections: int,
|
|
49
|
+
maxsize: int,
|
|
50
|
+
block: bool = False,
|
|
51
|
+
**pool_kwargs: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
pool_kwargs["source_address"] = (self.source_ip, 0)
|
|
54
|
+
super().init_poolmanager(
|
|
55
|
+
connections, maxsize, block=block, **pool_kwargs
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any:
|
|
59
|
+
proxy_kwargs["source_address"] = (self.source_ip, 0)
|
|
60
|
+
return super().proxy_manager_for(proxy, **proxy_kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ProbeConfig:
|
|
65
|
+
local_ip: str
|
|
66
|
+
target_ip: str
|
|
67
|
+
prefix_length: int
|
|
68
|
+
proxy_ports: tuple[int, ...]
|
|
69
|
+
connect_timeout: float
|
|
70
|
+
proxy_timeout: float
|
|
71
|
+
geo_timeout: float
|
|
72
|
+
workers: int
|
|
73
|
+
proxy_workers: int
|
|
74
|
+
geo_workers: int
|
|
75
|
+
bind_source_ip: bool
|
|
76
|
+
proxy_test_urls: tuple[str, ...]
|
|
77
|
+
exclude_ips: tuple[str, ...]
|
|
78
|
+
|
|
79
|
+
def validate(self) -> None:
|
|
80
|
+
ipaddress.IPv4Address(self.local_ip)
|
|
81
|
+
ipaddress.IPv4Address(self.target_ip)
|
|
82
|
+
if not 0 <= self.prefix_length <= 32:
|
|
83
|
+
raise ValueError("prefix_length 必须在 0 到 32 之间")
|
|
84
|
+
if not self.proxy_ports:
|
|
85
|
+
raise ValueError("proxy_ports 不能为空")
|
|
86
|
+
if len(set(self.proxy_ports)) != len(self.proxy_ports):
|
|
87
|
+
raise ValueError("proxy_ports 不能包含重复端口")
|
|
88
|
+
for port in self.proxy_ports:
|
|
89
|
+
if not 1 <= port <= 65535:
|
|
90
|
+
raise ValueError("代理端口必须在 1 到 65535 之间")
|
|
91
|
+
if min(self.connect_timeout, self.proxy_timeout, self.geo_timeout) <= 0:
|
|
92
|
+
raise ValueError("超时时间必须大于 0")
|
|
93
|
+
for name, count, maximum in (
|
|
94
|
+
("workers", self.workers, 1024),
|
|
95
|
+
("proxy_workers", self.proxy_workers, 512),
|
|
96
|
+
("geo_workers", self.geo_workers, 512),
|
|
97
|
+
):
|
|
98
|
+
if not 1 <= count <= maximum:
|
|
99
|
+
raise ValueError(f"{name} 必须在 1 到 {maximum} 之间")
|
|
100
|
+
if not self.proxy_test_urls:
|
|
101
|
+
raise ValueError("proxy_test_urls 不能为空")
|
|
102
|
+
for url in self.proxy_test_urls:
|
|
103
|
+
if urlsplit(url).scheme != "https":
|
|
104
|
+
raise ValueError("代理验证地址必须使用 https://")
|
|
105
|
+
for item in self.exclude_ips:
|
|
106
|
+
ipaddress.IPv4Address(item)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ProbeRunResult:
|
|
111
|
+
progress: PipelineProgress
|
|
112
|
+
results: list[ProxyRecord]
|
|
113
|
+
interrupted: bool
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class RefreshRunResult:
|
|
118
|
+
results: list[ProxyRecord]
|
|
119
|
+
progress: "RefreshProgress"
|
|
120
|
+
interrupted: bool
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class RefreshProgress:
|
|
125
|
+
total: int = 0
|
|
126
|
+
completed: int = 0
|
|
127
|
+
proxy_tested: int = 0
|
|
128
|
+
proxy_count: int = 0
|
|
129
|
+
geo_tested: int = 0
|
|
130
|
+
geo_success: int = 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def source_address(config: ProbeConfig) -> tuple[str, int] | None:
|
|
134
|
+
return (config.local_ip, 0) if config.bind_source_ip else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class LocalNetwork:
|
|
139
|
+
local_ip: str
|
|
140
|
+
dns_servers: tuple[str, ...] = ()
|
|
141
|
+
interface_name: str = ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_VIRTUAL_INTERFACE_WORDS = (
|
|
145
|
+
"meta",
|
|
146
|
+
"tailscale",
|
|
147
|
+
"zerotier",
|
|
148
|
+
"tunnel",
|
|
149
|
+
"virtual",
|
|
150
|
+
"hyper-v",
|
|
151
|
+
"vmware",
|
|
152
|
+
"virtualbox",
|
|
153
|
+
"loopback",
|
|
154
|
+
"docker",
|
|
155
|
+
"veth",
|
|
156
|
+
"virbr",
|
|
157
|
+
"wsl",
|
|
158
|
+
"wireguard",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _valid_local_ipv4(value: str) -> str | None:
|
|
163
|
+
try:
|
|
164
|
+
address = ipaddress.IPv4Address(value)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return None
|
|
167
|
+
if address.is_loopback or address.is_link_local or address.is_unspecified:
|
|
168
|
+
return None
|
|
169
|
+
return str(address)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read_resolv_conf() -> list[str]:
|
|
173
|
+
result: list[str] = []
|
|
174
|
+
try:
|
|
175
|
+
lines = Path("/etc/resolv.conf").read_text(
|
|
176
|
+
encoding="utf-8",
|
|
177
|
+
errors="replace",
|
|
178
|
+
).splitlines()
|
|
179
|
+
except OSError:
|
|
180
|
+
return result
|
|
181
|
+
for line in lines:
|
|
182
|
+
fields = line.split()
|
|
183
|
+
if len(fields) < 2 or fields[0].lower() != "nameserver":
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
server = str(ipaddress.IPv4Address(fields[1]))
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue
|
|
189
|
+
if server not in result:
|
|
190
|
+
result.append(server)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _windows_network_candidates() -> list[dict[str, Any]]:
|
|
195
|
+
script = r"""
|
|
196
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
197
|
+
Get-NetIPConfiguration |
|
|
198
|
+
Where-Object { $_.IPv4Address -and $_.IPv4DefaultGateway } |
|
|
199
|
+
ForEach-Object {
|
|
200
|
+
$adapter = Get-NetAdapter -InterfaceIndex $_.InterfaceIndex -ErrorAction SilentlyContinue
|
|
201
|
+
$route = Get-NetRoute -InterfaceIndex $_.InterfaceIndex -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
|
|
202
|
+
Sort-Object RouteMetric, InterfaceMetric |
|
|
203
|
+
Select-Object -First 1
|
|
204
|
+
$routeMetric = if ($route) { [int]$route.RouteMetric } else { 999999 }
|
|
205
|
+
$interfaceMetric = if ($route) { [int]$route.InterfaceMetric } else { 999999 }
|
|
206
|
+
[PSCustomObject]@{
|
|
207
|
+
ip = [string](@($_.IPv4Address)[0].IPAddress)
|
|
208
|
+
alias = [string]$_.InterfaceAlias
|
|
209
|
+
description = [string]$_.InterfaceDescription
|
|
210
|
+
virtual = [bool]$adapter.Virtual
|
|
211
|
+
status = [string]$adapter.Status
|
|
212
|
+
route_metric = $routeMetric
|
|
213
|
+
interface_metric = $interfaceMetric
|
|
214
|
+
gateway = [string]$_.IPv4DefaultGateway.NextHop
|
|
215
|
+
dns_servers = @($_.DNSServer.ServerAddresses | Where-Object { $_ -match '^\d+\.' })
|
|
216
|
+
}
|
|
217
|
+
} |
|
|
218
|
+
ConvertTo-Json -Depth 4 -Compress
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
completed = subprocess.run(
|
|
222
|
+
[
|
|
223
|
+
"powershell.exe",
|
|
224
|
+
"-NoProfile",
|
|
225
|
+
"-NonInteractive",
|
|
226
|
+
"-Command",
|
|
227
|
+
script,
|
|
228
|
+
],
|
|
229
|
+
check=True,
|
|
230
|
+
capture_output=True,
|
|
231
|
+
encoding="utf-8",
|
|
232
|
+
errors="replace",
|
|
233
|
+
timeout=12,
|
|
234
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
235
|
+
)
|
|
236
|
+
text = completed.stdout.lstrip("\ufeff").strip()
|
|
237
|
+
if not text:
|
|
238
|
+
return []
|
|
239
|
+
raw = json.loads(text)
|
|
240
|
+
except (
|
|
241
|
+
OSError,
|
|
242
|
+
subprocess.SubprocessError,
|
|
243
|
+
json.JSONDecodeError,
|
|
244
|
+
):
|
|
245
|
+
return []
|
|
246
|
+
rows = raw if isinstance(raw, list) else [raw]
|
|
247
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _linux_interface_ipv4(interface: str) -> str | None:
|
|
251
|
+
try:
|
|
252
|
+
import fcntl
|
|
253
|
+
except ImportError:
|
|
254
|
+
return None
|
|
255
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
256
|
+
try:
|
|
257
|
+
request = struct.pack(
|
|
258
|
+
"256s",
|
|
259
|
+
interface[:15].encode("utf-8"),
|
|
260
|
+
)
|
|
261
|
+
response = fcntl.ioctl(sock.fileno(), 0x8915, request)
|
|
262
|
+
return _valid_local_ipv4(socket.inet_ntoa(response[20:24]))
|
|
263
|
+
except OSError:
|
|
264
|
+
return None
|
|
265
|
+
finally:
|
|
266
|
+
sock.close()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _linux_network_candidates() -> list[dict[str, Any]]:
|
|
270
|
+
try:
|
|
271
|
+
lines = Path("/proc/net/route").read_text(
|
|
272
|
+
encoding="ascii",
|
|
273
|
+
errors="replace",
|
|
274
|
+
).splitlines()[1:]
|
|
275
|
+
except OSError:
|
|
276
|
+
return []
|
|
277
|
+
dns_servers = _read_resolv_conf()
|
|
278
|
+
rows: list[dict[str, Any]] = []
|
|
279
|
+
for line in lines:
|
|
280
|
+
fields = line.split()
|
|
281
|
+
if len(fields) < 8 or fields[1] != "00000000":
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
flags = int(fields[3], 16)
|
|
285
|
+
metric = int(fields[6])
|
|
286
|
+
gateway = socket.inet_ntoa(
|
|
287
|
+
struct.pack("<L", int(fields[2], 16))
|
|
288
|
+
)
|
|
289
|
+
except (ValueError, OSError, struct.error):
|
|
290
|
+
continue
|
|
291
|
+
if not flags & 0x1:
|
|
292
|
+
continue
|
|
293
|
+
interface = fields[0]
|
|
294
|
+
local_ip = _linux_interface_ipv4(interface)
|
|
295
|
+
if not local_ip:
|
|
296
|
+
continue
|
|
297
|
+
rows.append(
|
|
298
|
+
{
|
|
299
|
+
"ip": local_ip,
|
|
300
|
+
"alias": interface,
|
|
301
|
+
"description": interface,
|
|
302
|
+
"virtual": False,
|
|
303
|
+
"status": "Up",
|
|
304
|
+
"route_metric": metric,
|
|
305
|
+
"interface_metric": 0,
|
|
306
|
+
"gateway": gateway,
|
|
307
|
+
"dns_servers": dns_servers,
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
return rows
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _fallback_network() -> LocalNetwork:
|
|
314
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
315
|
+
try:
|
|
316
|
+
sock.connect(("8.8.8.8", 80))
|
|
317
|
+
local_ip = _valid_local_ipv4(str(sock.getsockname()[0]))
|
|
318
|
+
if not local_ip:
|
|
319
|
+
raise ValueError
|
|
320
|
+
except (OSError, ValueError) as exc:
|
|
321
|
+
raise ValueError("无法自动获取当前出站网卡 IPv4") from exc
|
|
322
|
+
finally:
|
|
323
|
+
sock.close()
|
|
324
|
+
return LocalNetwork(
|
|
325
|
+
local_ip=local_ip,
|
|
326
|
+
dns_servers=tuple(_read_resolv_conf()),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def detect_local_network(preferred_ip: str = "") -> LocalNetwork:
|
|
331
|
+
"""跨平台选择有默认网关的实体 IPv4 网卡。"""
|
|
332
|
+
system = platform.system()
|
|
333
|
+
if system == "Windows":
|
|
334
|
+
candidates = _windows_network_candidates()
|
|
335
|
+
elif system == "Linux":
|
|
336
|
+
candidates = _linux_network_candidates()
|
|
337
|
+
else:
|
|
338
|
+
candidates = []
|
|
339
|
+
|
|
340
|
+
normalized_preferred = (
|
|
341
|
+
_valid_local_ipv4(preferred_ip) if preferred_ip else None
|
|
342
|
+
)
|
|
343
|
+
if preferred_ip and not normalized_preferred:
|
|
344
|
+
raise ValueError(f"配置的 local_ip 不是有效 IPv4:{preferred_ip}")
|
|
345
|
+
|
|
346
|
+
prepared: list[tuple[int, bool, dict[str, Any]]] = []
|
|
347
|
+
for row in candidates:
|
|
348
|
+
local_ip = _valid_local_ipv4(str(row.get("ip") or ""))
|
|
349
|
+
if not local_ip:
|
|
350
|
+
continue
|
|
351
|
+
name = " ".join(
|
|
352
|
+
(
|
|
353
|
+
str(row.get("alias") or ""),
|
|
354
|
+
str(row.get("description") or ""),
|
|
355
|
+
)
|
|
356
|
+
).lower()
|
|
357
|
+
is_virtual = bool(row.get("virtual")) or any(
|
|
358
|
+
word in name for word in _VIRTUAL_INTERFACE_WORDS
|
|
359
|
+
)
|
|
360
|
+
if str(row.get("status") or "").lower() not in ("", "up"):
|
|
361
|
+
continue
|
|
362
|
+
try:
|
|
363
|
+
score = int(row.get("route_metric", 999999)) + int(
|
|
364
|
+
row.get("interface_metric", 999999)
|
|
365
|
+
)
|
|
366
|
+
except (TypeError, ValueError):
|
|
367
|
+
score = 999999
|
|
368
|
+
prepared.append((score, is_virtual, {**row, "ip": local_ip}))
|
|
369
|
+
|
|
370
|
+
selected: dict[str, Any] | None = None
|
|
371
|
+
if normalized_preferred:
|
|
372
|
+
for _, _, row in prepared:
|
|
373
|
+
if row["ip"] == normalized_preferred:
|
|
374
|
+
selected = row
|
|
375
|
+
break
|
|
376
|
+
if selected is None:
|
|
377
|
+
return LocalNetwork(
|
|
378
|
+
local_ip=normalized_preferred,
|
|
379
|
+
dns_servers=tuple(_read_resolv_conf()),
|
|
380
|
+
)
|
|
381
|
+
elif prepared:
|
|
382
|
+
physical = [item for item in prepared if not item[1]]
|
|
383
|
+
pool = physical or prepared
|
|
384
|
+
selected = min(pool, key=lambda item: item[0])[2]
|
|
385
|
+
|
|
386
|
+
if selected is None:
|
|
387
|
+
return _fallback_network()
|
|
388
|
+
|
|
389
|
+
dns_servers: list[str] = []
|
|
390
|
+
raw_dns = selected.get("dns_servers") or []
|
|
391
|
+
if isinstance(raw_dns, str):
|
|
392
|
+
raw_dns = [raw_dns]
|
|
393
|
+
for value in [*raw_dns, selected.get("gateway")]:
|
|
394
|
+
try:
|
|
395
|
+
server = str(ipaddress.IPv4Address(str(value or "")))
|
|
396
|
+
except ValueError:
|
|
397
|
+
continue
|
|
398
|
+
if server not in dns_servers:
|
|
399
|
+
dns_servers.append(server)
|
|
400
|
+
return LocalNetwork(
|
|
401
|
+
local_ip=str(selected["ip"]),
|
|
402
|
+
dns_servers=tuple(dns_servers),
|
|
403
|
+
interface_name=str(selected.get("alias") or ""),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def detect_local_ip() -> str:
|
|
408
|
+
return detect_local_network().local_ip
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _skip_dns_name(data: bytes, offset: int) -> int:
|
|
412
|
+
while offset < len(data):
|
|
413
|
+
length = data[offset]
|
|
414
|
+
if length & 0xC0 == 0xC0:
|
|
415
|
+
return offset + 2
|
|
416
|
+
offset += 1
|
|
417
|
+
if length == 0:
|
|
418
|
+
return offset
|
|
419
|
+
offset += length
|
|
420
|
+
raise ValueError("DNS 响应中的名称格式错误")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _resolve_a_via_server(
|
|
424
|
+
hostname: str,
|
|
425
|
+
server: str,
|
|
426
|
+
local_ip: str,
|
|
427
|
+
timeout: float,
|
|
428
|
+
) -> list[str]:
|
|
429
|
+
transaction_id = random.randint(0, 65535)
|
|
430
|
+
labels = hostname.rstrip(".").split(".")
|
|
431
|
+
question = b"".join(
|
|
432
|
+
bytes((len(label.encode("idna")),)) + label.encode("idna")
|
|
433
|
+
for label in labels
|
|
434
|
+
) + b"\x00"
|
|
435
|
+
packet = (
|
|
436
|
+
struct.pack("!HHHHHH", transaction_id, 0x0100, 1, 0, 0, 0)
|
|
437
|
+
+ question
|
|
438
|
+
+ struct.pack("!HH", 1, 1)
|
|
439
|
+
)
|
|
440
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
441
|
+
try:
|
|
442
|
+
sock.settimeout(min(timeout, 3.0))
|
|
443
|
+
if not ipaddress.IPv4Address(server).is_loopback:
|
|
444
|
+
sock.bind((local_ip, 0))
|
|
445
|
+
sock.sendto(packet, (server, 53))
|
|
446
|
+
data, _ = sock.recvfrom(4096)
|
|
447
|
+
finally:
|
|
448
|
+
sock.close()
|
|
449
|
+
if len(data) < 12:
|
|
450
|
+
return []
|
|
451
|
+
response_id, flags, qd_count, answer_count, _, _ = struct.unpack(
|
|
452
|
+
"!HHHHHH",
|
|
453
|
+
data[:12],
|
|
454
|
+
)
|
|
455
|
+
if response_id != transaction_id or flags & 0x000F:
|
|
456
|
+
return []
|
|
457
|
+
offset = 12
|
|
458
|
+
for _ in range(qd_count):
|
|
459
|
+
offset = _skip_dns_name(data, offset) + 4
|
|
460
|
+
result: list[str] = []
|
|
461
|
+
for _ in range(answer_count):
|
|
462
|
+
offset = _skip_dns_name(data, offset)
|
|
463
|
+
if offset + 10 > len(data):
|
|
464
|
+
break
|
|
465
|
+
record_type, record_class, _, data_length = struct.unpack(
|
|
466
|
+
"!HHIH",
|
|
467
|
+
data[offset : offset + 10],
|
|
468
|
+
)
|
|
469
|
+
offset += 10
|
|
470
|
+
record_data = data[offset : offset + data_length]
|
|
471
|
+
offset += data_length
|
|
472
|
+
if record_type == 1 and record_class == 1 and data_length == 4:
|
|
473
|
+
address = socket.inet_ntoa(record_data)
|
|
474
|
+
if address not in result:
|
|
475
|
+
result.append(address)
|
|
476
|
+
return result
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _resolve_direct_ipv4(
|
|
480
|
+
hostname: str,
|
|
481
|
+
dns_servers: tuple[str, ...],
|
|
482
|
+
local_ip: str,
|
|
483
|
+
timeout: float,
|
|
484
|
+
) -> list[str]:
|
|
485
|
+
for server in dns_servers:
|
|
486
|
+
try:
|
|
487
|
+
addresses = _resolve_a_via_server(
|
|
488
|
+
hostname,
|
|
489
|
+
server,
|
|
490
|
+
local_ip,
|
|
491
|
+
timeout,
|
|
492
|
+
)
|
|
493
|
+
except OSError:
|
|
494
|
+
continue
|
|
495
|
+
if addresses:
|
|
496
|
+
return addresses
|
|
497
|
+
if not dns_servers:
|
|
498
|
+
try:
|
|
499
|
+
return list(
|
|
500
|
+
dict.fromkeys(
|
|
501
|
+
socket.gethostbyname_ex(hostname)[2]
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
except OSError:
|
|
505
|
+
pass
|
|
506
|
+
return []
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _direct_https_get(
|
|
510
|
+
hostname: str,
|
|
511
|
+
path: str,
|
|
512
|
+
local_ip: str,
|
|
513
|
+
dns_servers: tuple[str, ...],
|
|
514
|
+
timeout: float,
|
|
515
|
+
) -> bytes:
|
|
516
|
+
addresses = _resolve_direct_ipv4(
|
|
517
|
+
hostname,
|
|
518
|
+
dns_servers,
|
|
519
|
+
local_ip,
|
|
520
|
+
timeout,
|
|
521
|
+
)
|
|
522
|
+
last_error: Exception | None = None
|
|
523
|
+
for address in addresses:
|
|
524
|
+
pool = urllib3.HTTPSConnectionPool(
|
|
525
|
+
address,
|
|
526
|
+
port=443,
|
|
527
|
+
server_hostname=hostname,
|
|
528
|
+
assert_hostname=hostname,
|
|
529
|
+
source_address=(local_ip, 0),
|
|
530
|
+
timeout=urllib3.Timeout(connect=timeout, read=timeout),
|
|
531
|
+
retries=False,
|
|
532
|
+
)
|
|
533
|
+
try:
|
|
534
|
+
response = pool.request(
|
|
535
|
+
"GET",
|
|
536
|
+
path,
|
|
537
|
+
headers={
|
|
538
|
+
"Host": hostname,
|
|
539
|
+
"User-Agent": "nonebot-plugin-proxy-probe/0.1",
|
|
540
|
+
},
|
|
541
|
+
redirect=False,
|
|
542
|
+
)
|
|
543
|
+
if 200 <= response.status < 400:
|
|
544
|
+
return bytes(response.data)
|
|
545
|
+
except Exception as exc:
|
|
546
|
+
last_error = exc
|
|
547
|
+
finally:
|
|
548
|
+
pool.close()
|
|
549
|
+
if last_error:
|
|
550
|
+
raise last_error
|
|
551
|
+
raise OSError(f"无法直连解析或访问 {hostname}")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def detect_direct_public_ip(
|
|
555
|
+
local_ip: str,
|
|
556
|
+
timeout: float,
|
|
557
|
+
bind_source_ip: bool = True,
|
|
558
|
+
dns_servers: tuple[str, ...] = (),
|
|
559
|
+
) -> str:
|
|
560
|
+
"""绕过环境代理和透明代理 DNS,取得实体网卡出口 IPv4。"""
|
|
561
|
+
def normalize(value: Any) -> str | None:
|
|
562
|
+
text = str(value or "").strip()
|
|
563
|
+
try:
|
|
564
|
+
address = ipaddress.IPv4Address(text)
|
|
565
|
+
except ValueError:
|
|
566
|
+
return None
|
|
567
|
+
return str(address) if address.is_global else None
|
|
568
|
+
|
|
569
|
+
if bind_source_ip:
|
|
570
|
+
try:
|
|
571
|
+
payload = _direct_https_get(
|
|
572
|
+
"api.myip.la",
|
|
573
|
+
"/cn?json",
|
|
574
|
+
local_ip,
|
|
575
|
+
dns_servers,
|
|
576
|
+
timeout=timeout,
|
|
577
|
+
)
|
|
578
|
+
result = normalize(
|
|
579
|
+
json.loads(payload.decode("utf-8")).get("ip")
|
|
580
|
+
)
|
|
581
|
+
if result:
|
|
582
|
+
return result
|
|
583
|
+
except (OSError, ValueError, TypeError, AttributeError, urllib3.HTTPError):
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
payload = _direct_https_get(
|
|
588
|
+
"api.ip.sb",
|
|
589
|
+
"/ip",
|
|
590
|
+
local_ip,
|
|
591
|
+
dns_servers,
|
|
592
|
+
timeout=timeout,
|
|
593
|
+
)
|
|
594
|
+
result = normalize(payload.decode("utf-8", errors="replace"))
|
|
595
|
+
if result:
|
|
596
|
+
return result
|
|
597
|
+
except (OSError, urllib3.HTTPError):
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
if bind_source_ip and dns_servers:
|
|
601
|
+
raise ValueError("无法通过实体网卡直连接口获取当前出口 IPv4")
|
|
602
|
+
|
|
603
|
+
session = requests.Session()
|
|
604
|
+
session.trust_env = False
|
|
605
|
+
session.headers["User-Agent"] = "nonebot-plugin-proxy-probe/0.1"
|
|
606
|
+
if bind_source_ip:
|
|
607
|
+
adapter: HTTPAdapter = SourceAddressAdapter(
|
|
608
|
+
local_ip,
|
|
609
|
+
max_retries=0,
|
|
610
|
+
)
|
|
611
|
+
session.mount("http://", adapter)
|
|
612
|
+
session.mount("https://", adapter)
|
|
613
|
+
try:
|
|
614
|
+
try:
|
|
615
|
+
response = session.get(
|
|
616
|
+
"https://api.ip.sb/ip",
|
|
617
|
+
timeout=timeout,
|
|
618
|
+
)
|
|
619
|
+
response.raise_for_status()
|
|
620
|
+
result = normalize(response.text)
|
|
621
|
+
if result:
|
|
622
|
+
return result
|
|
623
|
+
except requests.RequestException:
|
|
624
|
+
pass
|
|
625
|
+
finally:
|
|
626
|
+
session.close()
|
|
627
|
+
raise ValueError("无法通过直连回退接口获取当前出口 IPv4")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def check_source_binding(config: ProbeConfig) -> None:
|
|
631
|
+
config.validate()
|
|
632
|
+
if not config.bind_source_ip:
|
|
633
|
+
return
|
|
634
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
635
|
+
try:
|
|
636
|
+
sock.bind((config.local_ip, 0))
|
|
637
|
+
except OSError as exc:
|
|
638
|
+
raise ValueError(
|
|
639
|
+
f"无法绑定本机 IP {config.local_ip},请检查插件配置"
|
|
640
|
+
) from exc
|
|
641
|
+
finally:
|
|
642
|
+
sock.close()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def make_proxy_session(
|
|
646
|
+
ip: str,
|
|
647
|
+
port: int,
|
|
648
|
+
config: ProbeConfig,
|
|
649
|
+
) -> requests.Session:
|
|
650
|
+
session = requests.Session()
|
|
651
|
+
session.trust_env = False
|
|
652
|
+
adapter: HTTPAdapter
|
|
653
|
+
if config.bind_source_ip:
|
|
654
|
+
adapter = SourceAddressAdapter(config.local_ip, max_retries=0)
|
|
655
|
+
else:
|
|
656
|
+
adapter = HTTPAdapter(max_retries=0)
|
|
657
|
+
session.mount("http://", adapter)
|
|
658
|
+
session.mount("https://", adapter)
|
|
659
|
+
proxy_url = f"http://{ip}:{port}"
|
|
660
|
+
session.proxies = {"http": proxy_url, "https": proxy_url}
|
|
661
|
+
session.headers["User-Agent"] = "nonebot-plugin-proxy-probe/0.1"
|
|
662
|
+
return session
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def is_port_open(ip: str, port: int, config: ProbeConfig) -> bool:
|
|
666
|
+
try:
|
|
667
|
+
with socket.create_connection(
|
|
668
|
+
(ip, port),
|
|
669
|
+
timeout=config.connect_timeout,
|
|
670
|
+
source_address=source_address(config),
|
|
671
|
+
):
|
|
672
|
+
return True
|
|
673
|
+
except OSError:
|
|
674
|
+
return False
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def verify_proxy(
|
|
678
|
+
ip: str,
|
|
679
|
+
port: int,
|
|
680
|
+
config: ProbeConfig,
|
|
681
|
+
stop_event: threading.Event,
|
|
682
|
+
) -> str | None:
|
|
683
|
+
session = make_proxy_session(ip, port, config)
|
|
684
|
+
try:
|
|
685
|
+
for url in config.proxy_test_urls:
|
|
686
|
+
if stop_event.is_set():
|
|
687
|
+
return None
|
|
688
|
+
try:
|
|
689
|
+
response = session.get(
|
|
690
|
+
url,
|
|
691
|
+
timeout=config.proxy_timeout,
|
|
692
|
+
allow_redirects=False,
|
|
693
|
+
)
|
|
694
|
+
if 200 <= response.status_code < 500:
|
|
695
|
+
host = urlsplit(url).hostname or url
|
|
696
|
+
return f"{host} HTTP {response.status_code}"
|
|
697
|
+
except requests.RequestException:
|
|
698
|
+
continue
|
|
699
|
+
finally:
|
|
700
|
+
session.close()
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def join_location(*parts: Any) -> str:
|
|
705
|
+
values: list[str] = []
|
|
706
|
+
for part in parts:
|
|
707
|
+
text = str(part or "").strip()
|
|
708
|
+
if text and text not in values:
|
|
709
|
+
values.append(text)
|
|
710
|
+
return " / ".join(values) or "未知属地"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def parse_myip_la(data: dict[str, Any]) -> tuple[str, str]:
|
|
714
|
+
location = data.get("location") or {}
|
|
715
|
+
return (
|
|
716
|
+
str(data.get("ip") or "").strip(),
|
|
717
|
+
join_location(
|
|
718
|
+
location.get("country_name"),
|
|
719
|
+
location.get("province"),
|
|
720
|
+
location.get("city"),
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def parse_ip_sb(data: dict[str, Any]) -> tuple[str, str]:
|
|
726
|
+
return (
|
|
727
|
+
str(data.get("ip") or "").strip(),
|
|
728
|
+
join_location(
|
|
729
|
+
data.get("country"),
|
|
730
|
+
data.get("region"),
|
|
731
|
+
data.get("city"),
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def probe_proxy_location(
|
|
737
|
+
ip: str,
|
|
738
|
+
port: int,
|
|
739
|
+
config: ProbeConfig,
|
|
740
|
+
stop_event: threading.Event,
|
|
741
|
+
) -> tuple[str, str]:
|
|
742
|
+
session = make_proxy_session(ip, port, config)
|
|
743
|
+
json_apis: tuple[
|
|
744
|
+
tuple[str, Callable[[dict[str, Any]], tuple[str, str]]], ...
|
|
745
|
+
] = (
|
|
746
|
+
("https://api.myip.la/cn?json", parse_myip_la),
|
|
747
|
+
("https://api.ip.sb/geoip", parse_ip_sb),
|
|
748
|
+
)
|
|
749
|
+
try:
|
|
750
|
+
for url, parser in json_apis:
|
|
751
|
+
if stop_event.is_set():
|
|
752
|
+
return NOT_PROBED, NOT_PROBED
|
|
753
|
+
try:
|
|
754
|
+
response = session.get(url, timeout=config.geo_timeout)
|
|
755
|
+
response.raise_for_status()
|
|
756
|
+
public_ip, location = parser(response.json())
|
|
757
|
+
if public_ip:
|
|
758
|
+
return public_ip, location
|
|
759
|
+
except (
|
|
760
|
+
requests.RequestException,
|
|
761
|
+
ValueError,
|
|
762
|
+
TypeError,
|
|
763
|
+
KeyError,
|
|
764
|
+
AttributeError,
|
|
765
|
+
):
|
|
766
|
+
continue
|
|
767
|
+
|
|
768
|
+
if stop_event.is_set():
|
|
769
|
+
return NOT_PROBED, NOT_PROBED
|
|
770
|
+
try:
|
|
771
|
+
response = session.get(
|
|
772
|
+
"https://myip.ipip.net",
|
|
773
|
+
timeout=config.geo_timeout,
|
|
774
|
+
)
|
|
775
|
+
response.raise_for_status()
|
|
776
|
+
response.encoding = "utf-8"
|
|
777
|
+
match = re.search(
|
|
778
|
+
r"当前\s*IP\s*[::]\s*(\S+)\s+来自于\s*[::]\s*(.+)",
|
|
779
|
+
response.text.strip(),
|
|
780
|
+
)
|
|
781
|
+
if match:
|
|
782
|
+
return (
|
|
783
|
+
match.group(1).strip(),
|
|
784
|
+
match.group(2).strip() or "未知属地",
|
|
785
|
+
)
|
|
786
|
+
except requests.RequestException:
|
|
787
|
+
pass
|
|
788
|
+
finally:
|
|
789
|
+
session.close()
|
|
790
|
+
return UNAVAILABLE, UNAVAILABLE
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _sorted_records(
|
|
794
|
+
records: dict[tuple[str, int], ProxyRecord],
|
|
795
|
+
port_order: dict[int, int],
|
|
796
|
+
) -> list[ProxyRecord]:
|
|
797
|
+
return sorted(
|
|
798
|
+
records.values(),
|
|
799
|
+
key=lambda item: (
|
|
800
|
+
ipaddress.IPv4Address(item.ip),
|
|
801
|
+
port_order.get(item.port, 999999),
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def run_probe(
|
|
807
|
+
config: ProbeConfig,
|
|
808
|
+
stop_event: threading.Event,
|
|
809
|
+
callback: UpdateCallback | None = None,
|
|
810
|
+
) -> ProbeRunResult:
|
|
811
|
+
check_source_binding(config)
|
|
812
|
+
network = ipaddress.ip_network(
|
|
813
|
+
f"{config.target_ip}/{config.prefix_length}",
|
|
814
|
+
strict=False,
|
|
815
|
+
)
|
|
816
|
+
targets = [str(ip) for ip in network]
|
|
817
|
+
total = len(targets)
|
|
818
|
+
excluded = {str(ipaddress.IPv4Address(ip)) for ip in config.exclude_ips}
|
|
819
|
+
port_order = {port: index for index, port in enumerate(config.proxy_ports)}
|
|
820
|
+
|
|
821
|
+
scan_queue: queue.Queue[Any] = queue.Queue()
|
|
822
|
+
proxy_queue: queue.Queue[Any] = queue.Queue()
|
|
823
|
+
geo_queue: queue.Queue[Any] = queue.Queue()
|
|
824
|
+
state_lock = threading.RLock()
|
|
825
|
+
results: dict[tuple[str, int], ProxyRecord] = {}
|
|
826
|
+
counters = {
|
|
827
|
+
"scan_completed": 0,
|
|
828
|
+
"open_count": 0,
|
|
829
|
+
"proxy_tested": 0,
|
|
830
|
+
"proxy_count": 0,
|
|
831
|
+
"geo_tested": 0,
|
|
832
|
+
"geo_success": 0,
|
|
833
|
+
}
|
|
834
|
+
notify_counter = {"scan": 0}
|
|
835
|
+
|
|
836
|
+
def snapshot(task_current: int | None = None) -> None:
|
|
837
|
+
if callback is None:
|
|
838
|
+
return
|
|
839
|
+
with state_lock:
|
|
840
|
+
progress = PipelineProgress(
|
|
841
|
+
total=total,
|
|
842
|
+
scan_completed=counters["scan_completed"],
|
|
843
|
+
open_count=counters["open_count"],
|
|
844
|
+
proxy_tested=counters["proxy_tested"],
|
|
845
|
+
proxy_count=counters["proxy_count"],
|
|
846
|
+
geo_tested=counters["geo_tested"],
|
|
847
|
+
geo_success=counters["geo_success"],
|
|
848
|
+
)
|
|
849
|
+
records = _sorted_records(results, port_order)
|
|
850
|
+
callback(
|
|
851
|
+
progress,
|
|
852
|
+
records,
|
|
853
|
+
progress.scan_completed if task_current is None else task_current,
|
|
854
|
+
total,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def complete_proxy_endpoint(
|
|
858
|
+
verified: ProxyRecord | None,
|
|
859
|
+
) -> None:
|
|
860
|
+
with state_lock:
|
|
861
|
+
counters["proxy_tested"] += 1
|
|
862
|
+
if verified is not None:
|
|
863
|
+
results[(verified.ip, verified.port)] = verified
|
|
864
|
+
counters["proxy_count"] += 1
|
|
865
|
+
if verified is not None and not stop_event.is_set():
|
|
866
|
+
geo_queue.put(verified)
|
|
867
|
+
snapshot()
|
|
868
|
+
|
|
869
|
+
def scan_worker() -> None:
|
|
870
|
+
while True:
|
|
871
|
+
item = scan_queue.get()
|
|
872
|
+
try:
|
|
873
|
+
if item is STOP:
|
|
874
|
+
return
|
|
875
|
+
ip = str(item)
|
|
876
|
+
if stop_event.is_set():
|
|
877
|
+
continue
|
|
878
|
+
opened: list[int] = []
|
|
879
|
+
if ip not in excluded:
|
|
880
|
+
for port in config.proxy_ports:
|
|
881
|
+
if stop_event.is_set():
|
|
882
|
+
break
|
|
883
|
+
if is_port_open(ip, port, config):
|
|
884
|
+
opened.append(port)
|
|
885
|
+
if stop_event.is_set():
|
|
886
|
+
continue
|
|
887
|
+
|
|
888
|
+
with state_lock:
|
|
889
|
+
counters["scan_completed"] += 1
|
|
890
|
+
counters["open_count"] += len(opened)
|
|
891
|
+
notify_counter["scan"] += 1
|
|
892
|
+
should_notify = (
|
|
893
|
+
notify_counter["scan"] >= 64
|
|
894
|
+
or counters["scan_completed"] == total
|
|
895
|
+
)
|
|
896
|
+
if should_notify:
|
|
897
|
+
notify_counter["scan"] = 0
|
|
898
|
+
for port in opened:
|
|
899
|
+
proxy_queue.put((ip, port))
|
|
900
|
+
if should_notify or opened:
|
|
901
|
+
snapshot()
|
|
902
|
+
finally:
|
|
903
|
+
scan_queue.task_done()
|
|
904
|
+
|
|
905
|
+
def proxy_worker() -> None:
|
|
906
|
+
while True:
|
|
907
|
+
item = proxy_queue.get()
|
|
908
|
+
try:
|
|
909
|
+
if item is STOP:
|
|
910
|
+
return
|
|
911
|
+
if stop_event.is_set():
|
|
912
|
+
continue
|
|
913
|
+
ip, port = item
|
|
914
|
+
status = verify_proxy(ip, port, config, stop_event)
|
|
915
|
+
if stop_event.is_set():
|
|
916
|
+
continue
|
|
917
|
+
record = None
|
|
918
|
+
if status:
|
|
919
|
+
record = ProxyRecord(
|
|
920
|
+
ip=ip,
|
|
921
|
+
port=port,
|
|
922
|
+
proxy_status=status,
|
|
923
|
+
public_ip=NOT_PROBED,
|
|
924
|
+
location=NOT_PROBED,
|
|
925
|
+
)
|
|
926
|
+
complete_proxy_endpoint(record)
|
|
927
|
+
finally:
|
|
928
|
+
proxy_queue.task_done()
|
|
929
|
+
|
|
930
|
+
def geo_worker() -> None:
|
|
931
|
+
while True:
|
|
932
|
+
item = geo_queue.get()
|
|
933
|
+
try:
|
|
934
|
+
if item is STOP:
|
|
935
|
+
return
|
|
936
|
+
if stop_event.is_set():
|
|
937
|
+
continue
|
|
938
|
+
proxy: ProxyRecord = item
|
|
939
|
+
public_ip, location = probe_proxy_location(
|
|
940
|
+
proxy.ip,
|
|
941
|
+
proxy.port,
|
|
942
|
+
config,
|
|
943
|
+
stop_event,
|
|
944
|
+
)
|
|
945
|
+
if stop_event.is_set():
|
|
946
|
+
continue
|
|
947
|
+
updated = ProxyRecord(
|
|
948
|
+
ip=proxy.ip,
|
|
949
|
+
port=proxy.port,
|
|
950
|
+
proxy_status=proxy.proxy_status,
|
|
951
|
+
public_ip=public_ip,
|
|
952
|
+
location=location,
|
|
953
|
+
)
|
|
954
|
+
with state_lock:
|
|
955
|
+
results[(proxy.ip, proxy.port)] = updated
|
|
956
|
+
counters["geo_tested"] += 1
|
|
957
|
+
if public_ip not in (UNAVAILABLE, NOT_PROBED):
|
|
958
|
+
counters["geo_success"] += 1
|
|
959
|
+
snapshot()
|
|
960
|
+
finally:
|
|
961
|
+
geo_queue.task_done()
|
|
962
|
+
|
|
963
|
+
def start_workers(
|
|
964
|
+
count: int,
|
|
965
|
+
target: Callable[[], None],
|
|
966
|
+
name: str,
|
|
967
|
+
) -> list[threading.Thread]:
|
|
968
|
+
threads = [
|
|
969
|
+
threading.Thread(
|
|
970
|
+
target=target,
|
|
971
|
+
name=f"proxy-probe-{name}-{index + 1}",
|
|
972
|
+
daemon=True,
|
|
973
|
+
)
|
|
974
|
+
for index in range(count)
|
|
975
|
+
]
|
|
976
|
+
for thread in threads:
|
|
977
|
+
thread.start()
|
|
978
|
+
return threads
|
|
979
|
+
|
|
980
|
+
geo_threads = start_workers(config.geo_workers, geo_worker, "geo")
|
|
981
|
+
proxy_threads = start_workers(
|
|
982
|
+
config.proxy_workers, proxy_worker, "verify"
|
|
983
|
+
)
|
|
984
|
+
scan_threads = start_workers(config.workers, scan_worker, "scan")
|
|
985
|
+
|
|
986
|
+
for target in targets:
|
|
987
|
+
scan_queue.put(target)
|
|
988
|
+
for _ in scan_threads:
|
|
989
|
+
scan_queue.put(STOP)
|
|
990
|
+
for thread in scan_threads:
|
|
991
|
+
thread.join()
|
|
992
|
+
|
|
993
|
+
for _ in proxy_threads:
|
|
994
|
+
proxy_queue.put(STOP)
|
|
995
|
+
for thread in proxy_threads:
|
|
996
|
+
thread.join()
|
|
997
|
+
|
|
998
|
+
for _ in geo_threads:
|
|
999
|
+
geo_queue.put(STOP)
|
|
1000
|
+
for thread in geo_threads:
|
|
1001
|
+
thread.join()
|
|
1002
|
+
|
|
1003
|
+
snapshot()
|
|
1004
|
+
with state_lock:
|
|
1005
|
+
final_progress = PipelineProgress(
|
|
1006
|
+
total=total,
|
|
1007
|
+
scan_completed=counters["scan_completed"],
|
|
1008
|
+
open_count=counters["open_count"],
|
|
1009
|
+
proxy_tested=counters["proxy_tested"],
|
|
1010
|
+
proxy_count=counters["proxy_count"],
|
|
1011
|
+
geo_tested=counters["geo_tested"],
|
|
1012
|
+
geo_success=counters["geo_success"],
|
|
1013
|
+
)
|
|
1014
|
+
final_results = _sorted_records(results, port_order)
|
|
1015
|
+
return ProbeRunResult(
|
|
1016
|
+
progress=final_progress,
|
|
1017
|
+
results=final_results,
|
|
1018
|
+
interrupted=stop_event.is_set(),
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def run_refresh(
|
|
1023
|
+
config: ProbeConfig,
|
|
1024
|
+
cached_results: list[ProxyRecord],
|
|
1025
|
+
stop_event: threading.Event,
|
|
1026
|
+
callback: RefreshCallback | None = None,
|
|
1027
|
+
) -> RefreshRunResult:
|
|
1028
|
+
check_source_binding(config)
|
|
1029
|
+
total = len(cached_results)
|
|
1030
|
+
port_order = {port: index for index, port in enumerate(config.proxy_ports)}
|
|
1031
|
+
results = {
|
|
1032
|
+
(item.ip, item.port): item
|
|
1033
|
+
for item in cached_results
|
|
1034
|
+
}
|
|
1035
|
+
verify_queue: queue.Queue[Any] = queue.Queue()
|
|
1036
|
+
geo_queue: queue.Queue[Any] = queue.Queue()
|
|
1037
|
+
lock = threading.RLock()
|
|
1038
|
+
counters = {
|
|
1039
|
+
"completed": 0,
|
|
1040
|
+
"proxy_tested": 0,
|
|
1041
|
+
"proxy_count": 0,
|
|
1042
|
+
"geo_tested": 0,
|
|
1043
|
+
"geo_success": 0,
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
def snapshot() -> None:
|
|
1047
|
+
if callback is None:
|
|
1048
|
+
return
|
|
1049
|
+
with lock:
|
|
1050
|
+
records = _sorted_records(results, port_order)
|
|
1051
|
+
progress = RefreshProgress(
|
|
1052
|
+
total=total,
|
|
1053
|
+
completed=counters["completed"],
|
|
1054
|
+
proxy_tested=counters["proxy_tested"],
|
|
1055
|
+
proxy_count=counters["proxy_count"],
|
|
1056
|
+
geo_tested=counters["geo_tested"],
|
|
1057
|
+
geo_success=counters["geo_success"],
|
|
1058
|
+
)
|
|
1059
|
+
callback(records, progress)
|
|
1060
|
+
|
|
1061
|
+
def verify_worker() -> None:
|
|
1062
|
+
while True:
|
|
1063
|
+
item = verify_queue.get()
|
|
1064
|
+
try:
|
|
1065
|
+
if item is STOP:
|
|
1066
|
+
return
|
|
1067
|
+
if stop_event.is_set():
|
|
1068
|
+
continue
|
|
1069
|
+
proxy: ProxyRecord = item
|
|
1070
|
+
status = verify_proxy(
|
|
1071
|
+
proxy.ip,
|
|
1072
|
+
proxy.port,
|
|
1073
|
+
config,
|
|
1074
|
+
stop_event,
|
|
1075
|
+
)
|
|
1076
|
+
if stop_event.is_set():
|
|
1077
|
+
continue
|
|
1078
|
+
key = (proxy.ip, proxy.port)
|
|
1079
|
+
if not status:
|
|
1080
|
+
with lock:
|
|
1081
|
+
results.pop(key, None)
|
|
1082
|
+
counters["proxy_tested"] += 1
|
|
1083
|
+
counters["completed"] += 1
|
|
1084
|
+
snapshot()
|
|
1085
|
+
continue
|
|
1086
|
+
verified = ProxyRecord(
|
|
1087
|
+
ip=proxy.ip,
|
|
1088
|
+
port=proxy.port,
|
|
1089
|
+
proxy_status=status,
|
|
1090
|
+
public_ip=NOT_PROBED,
|
|
1091
|
+
location=NOT_PROBED,
|
|
1092
|
+
)
|
|
1093
|
+
with lock:
|
|
1094
|
+
results[key] = verified
|
|
1095
|
+
counters["proxy_tested"] += 1
|
|
1096
|
+
counters["proxy_count"] += 1
|
|
1097
|
+
geo_queue.put(verified)
|
|
1098
|
+
snapshot()
|
|
1099
|
+
finally:
|
|
1100
|
+
verify_queue.task_done()
|
|
1101
|
+
|
|
1102
|
+
def geo_worker() -> None:
|
|
1103
|
+
while True:
|
|
1104
|
+
item = geo_queue.get()
|
|
1105
|
+
try:
|
|
1106
|
+
if item is STOP:
|
|
1107
|
+
return
|
|
1108
|
+
if stop_event.is_set():
|
|
1109
|
+
continue
|
|
1110
|
+
proxy: ProxyRecord = item
|
|
1111
|
+
public_ip, location = probe_proxy_location(
|
|
1112
|
+
proxy.ip,
|
|
1113
|
+
proxy.port,
|
|
1114
|
+
config,
|
|
1115
|
+
stop_event,
|
|
1116
|
+
)
|
|
1117
|
+
if stop_event.is_set():
|
|
1118
|
+
continue
|
|
1119
|
+
updated = ProxyRecord(
|
|
1120
|
+
ip=proxy.ip,
|
|
1121
|
+
port=proxy.port,
|
|
1122
|
+
proxy_status=proxy.proxy_status,
|
|
1123
|
+
public_ip=public_ip,
|
|
1124
|
+
location=location,
|
|
1125
|
+
)
|
|
1126
|
+
with lock:
|
|
1127
|
+
results[(proxy.ip, proxy.port)] = updated
|
|
1128
|
+
counters["geo_tested"] += 1
|
|
1129
|
+
if public_ip not in (UNAVAILABLE, NOT_PROBED):
|
|
1130
|
+
counters["geo_success"] += 1
|
|
1131
|
+
counters["completed"] += 1
|
|
1132
|
+
snapshot()
|
|
1133
|
+
finally:
|
|
1134
|
+
geo_queue.task_done()
|
|
1135
|
+
|
|
1136
|
+
def start_workers(
|
|
1137
|
+
count: int,
|
|
1138
|
+
target: Callable[[], None],
|
|
1139
|
+
name: str,
|
|
1140
|
+
) -> list[threading.Thread]:
|
|
1141
|
+
threads = [
|
|
1142
|
+
threading.Thread(
|
|
1143
|
+
target=target,
|
|
1144
|
+
name=f"proxy-refresh-{name}-{index + 1}",
|
|
1145
|
+
daemon=True,
|
|
1146
|
+
)
|
|
1147
|
+
for index in range(count)
|
|
1148
|
+
]
|
|
1149
|
+
for thread in threads:
|
|
1150
|
+
thread.start()
|
|
1151
|
+
return threads
|
|
1152
|
+
|
|
1153
|
+
geo_threads = start_workers(config.geo_workers, geo_worker, "geo")
|
|
1154
|
+
verify_threads = start_workers(
|
|
1155
|
+
config.proxy_workers, verify_worker, "verify"
|
|
1156
|
+
)
|
|
1157
|
+
for record in cached_results:
|
|
1158
|
+
verify_queue.put(record)
|
|
1159
|
+
for _ in verify_threads:
|
|
1160
|
+
verify_queue.put(STOP)
|
|
1161
|
+
for thread in verify_threads:
|
|
1162
|
+
thread.join()
|
|
1163
|
+
|
|
1164
|
+
for _ in geo_threads:
|
|
1165
|
+
geo_queue.put(STOP)
|
|
1166
|
+
for thread in geo_threads:
|
|
1167
|
+
thread.join()
|
|
1168
|
+
|
|
1169
|
+
snapshot()
|
|
1170
|
+
with lock:
|
|
1171
|
+
final_results = _sorted_records(results, port_order)
|
|
1172
|
+
final_progress = RefreshProgress(
|
|
1173
|
+
total=total,
|
|
1174
|
+
completed=counters["completed"],
|
|
1175
|
+
proxy_tested=counters["proxy_tested"],
|
|
1176
|
+
proxy_count=counters["proxy_count"],
|
|
1177
|
+
geo_tested=counters["geo_tested"],
|
|
1178
|
+
geo_success=counters["geo_success"],
|
|
1179
|
+
)
|
|
1180
|
+
return RefreshRunResult(
|
|
1181
|
+
results=final_results,
|
|
1182
|
+
progress=final_progress,
|
|
1183
|
+
interrupted=stop_event.is_set(),
|
|
1184
|
+
)
|