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.
@@ -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
+ )