88"""
99import asyncio
1010import logging
11+ import re
1112import socket
12- import struct
1313import time
1414from typing import Any , Dict , Optional
1515
1616import aiohttp
17+ import socks
1718from aiohttp_socks import ProxyConnector
1819
1920logger = logging .getLogger (__name__ )
2021
2122
23+ def _format_error (exc : Exception ) -> str :
24+ """Format exception as error message, truncated to 250 chars."""
25+ msg = str (exc )
26+ return msg [:250 ] if msg else f"{ type (exc ).__name__ } "
27+
28+
2229class ProxyChecker :
2330 def __init__ (self , config : Dict ) -> None :
2431 self .config = config
2532 self ._tcp_test_url : Optional [str ] = None
2633 self ._timeout : Optional [float ] = None
27- self ._connector : Optional [aiohttp .TCPConnector ] = None
28-
29- async def _get_connector (self ) -> aiohttp .TCPConnector :
30- """Get or create a shared TCP connector for connection pooling."""
31- if self ._connector is None or self ._connector .closed :
32- self ._connector = aiohttp .TCPConnector (
33- limit = 100 , # Total connection pool size
34- limit_per_host = 10 , # Per-host limit
35- ttl_dns_cache = 300 , # DNS cache TTL
36- enable_cleanup_closed = True ,
37- )
38- return self ._connector
39-
40- async def close (self ) -> None :
41- """Close the connector and cleanup resources."""
42- if self ._connector and not self ._connector .closed :
43- await self ._connector .close ()
44- self ._connector = None
4534
4635 # ------------------------------------------------------------------ #
4736 # Helpers (cached) #
@@ -62,7 +51,8 @@ def _get_tcp_test_url(self) -> str:
6251 )
6352 return self ._tcp_test_url
6453
65- def _proxy_url (self , proxy : Dict ) -> str :
54+ @staticmethod
55+ def _proxy_url (proxy : Dict ) -> str :
6656 host = proxy ["host" ]
6757 port = proxy ["port" ]
6858 user = proxy .get ("username" , "" ) or ""
@@ -71,9 +61,6 @@ def _proxy_url(self, proxy: Dict) -> str:
7161 return f"socks5://{ user } :{ pwd } @{ host } :{ port } "
7262 return f"socks5://{ host } :{ port } "
7363
74- # ------------------------------------------------------------------ #
75- # TCP check #
76- # ------------------------------------------------------------------ #
7764 async def check_tcp (self , proxy : Dict ) -> Dict [str , Any ]:
7865 """Check TCP connectivity through the SOCKS5 proxy."""
7966 timeout = self ._get_timeout ()
@@ -126,164 +113,59 @@ async def check_tcp(self, proxy: Dict) -> Dict[str, Any]:
126113 "success" : False ,
127114 "latency_ms" : round (latency , 2 ),
128115 "external_ip" : None ,
129- "error" : str (exc )[: 250 ] ,
116+ "error" : _format_error (exc ),
130117 }
131118
132- # ------------------------------------------------------------------ #
133- # UDP check (SOCKS5 UDP ASSOCIATE + DNS query) #
134- # ------------------------------------------------------------------ #
135- async def check_udp (self , proxy : Dict ) -> Dict [str , Any ]:
136- """Check UDP connectivity through the SOCKS5 proxy via DNS query."""
119+ def _check_udp_sync (self , proxy : Dict ) -> Dict [str , Any ]:
120+ """Synchronous UDP check using socks library (run in executor)."""
137121 host = proxy ["host" ]
138122 port = proxy ["port" ]
139- user = proxy .get ("username" , "" ) or ""
140- pwd = proxy .get ("password" , "" ) or ""
123+ user = proxy .get ("username" , "" ) or None
124+ pwd = proxy .get ("password" , "" ) or None
141125 timeout = self ._get_timeout ()
142- start = time .monotonic ()
143- reader : Optional [asyncio .StreamReader ] = None
144- writer : Optional [asyncio .StreamWriter ] = None
145- udp_sock : Optional [socket .socket ] = None
146-
147- def remaining () -> float :
148- return max (0.5 , timeout - (time .monotonic () - start ))
149126
127+ # DNS TXT query: CH whoami.cloudflare
128+ dns_query = (
129+ b"\x12 \x34 \x01 \x00 \x00 \x01 \x00 \x00 \x00 \x00 \x00 \x00 "
130+ b"\x06 whoami\x0a cloudflare\x00 "
131+ b"\x00 \x10 \x00 \x03 "
132+ )
133+ result = {
134+ "success" : False ,
135+ "latency_ms" : None ,
136+ "external_ip" : None ,
137+ "error" : None ,
138+ }
139+ sock = socks .socksocket (socket .AF_INET , socket .SOCK_DGRAM )
150140 try :
151- # 1 ── Open TCP connection to proxy ──────────────────────────
152- reader , writer = await asyncio .wait_for (
153- asyncio .open_connection (host , port ), timeout = timeout
154- )
155-
156- # 2 ── SOCKS5 greeting ────────────────────────────────────────
157- writer .write (b"\x05 \x02 \x00 \x02 " if (user and pwd ) else b"\x05 \x01 \x00 " )
158- await writer .drain ()
159-
160- greeting = await asyncio .wait_for (reader .read (2 ), timeout = remaining ())
161- if len (greeting ) < 2 or greeting [0 ] != 0x05 :
162- raise ValueError (f"Bad SOCKS5 greeting: { greeting !r} " )
163- method = greeting [1 ]
164-
165- if method == 0xFF :
166- raise ValueError ("No acceptable auth methods offered by proxy" )
167-
168- if method == 0x02 :
169- # Username / password sub-negotiation (RFC 1929)
170- auth_payload = (
171- bytes ([0x01 , len (user )])
172- + user .encode ()
173- + bytes ([len (pwd )])
174- + pwd .encode ()
175- )
176- writer .write (auth_payload )
177- await writer .drain ()
178- auth_resp = await asyncio .wait_for (reader .read (2 ), timeout = remaining ())
179- if len (auth_resp ) < 2 or auth_resp [1 ] != 0x00 :
180- raise ValueError ("SOCKS5 username/password authentication failed" )
181-
182- # 3 ── UDP ASSOCIATE request ──────────────────────────────────
183- # CMD=0x03, ATYP=0x01 (IPv4), DST.ADDR/PORT = 0 (let proxy pick)
184- writer .write (b"\x05 \x03 \x00 \x01 \x00 \x00 \x00 \x00 \x00 \x00 " )
185- await writer .drain ()
186-
187- bound = await asyncio .wait_for (reader .read (10 ), timeout = remaining ())
188- if len (bound ) < 10 :
189- raise ValueError (f"Truncated UDP ASSOCIATE response: { bound !r} " )
190- if bound [1 ] != 0x00 :
191- err_codes = {
192- 1 : "general SOCKS failure" ,
193- 2 : "connection not allowed" ,
194- 3 : "network unreachable" ,
195- 4 : "host unreachable" ,
196- 5 : "connection refused" ,
197- 7 : "command not supported" ,
198- }
199- raise ValueError (
200- f"UDP ASSOCIATE rejected: { err_codes .get (bound [1 ], bound [1 ])} "
201- )
202-
203- # BND.ADDR / BND.PORT – where we send UDP frames
204- relay_ip = socket .inet_ntoa (bound [4 :8 ])
205- relay_port = struct .unpack ("!H" , bound [8 :10 ])[0 ]
206- if relay_ip == "0.0.0.0" :
207- relay_ip = host # proxy said "use my address"
208-
209- # 4 ── Create local UDP socket ────────────────────────────────
210- # Use a blocking socket with a timeout – run_in_executor already
211- # runs it in a thread pool, so the event loop is not blocked.
212- # setblocking(False) causes WSAEWOULDBLOCK on Windows immediately
213- # because the OS can't complete the send/recv synchronously.
214- udp_sock = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
215- udp_sock .settimeout (remaining ())
216-
217- # Minimal DNS A-query for www.google.com
218- dns_query = (
219- b"\xab \xcd " # Transaction ID
220- b"\x01 \x00 " # Flags: RD=1 (recursion desired)
221- b"\x00 \x01 " # QDCOUNT=1
222- b"\x00 \x00 \x00 \x00 \x00 \x00 " # AN/NS/AR = 0
223- b"\x03 www\x06 google\x03 com\x00 " # QNAME
224- b"\x00 \x01 " # QTYPE A
225- b"\x00 \x01 " # QCLASS IN
226- )
227-
228- # SOCKS5 UDP request header: RSV(2) FRAG(1) ATYP(1) DST.ADDR(4) DST.PORT(2)
229- udp_frame = (
230- b"\x00 \x00 " # RSV
231- b"\x00 " # FRAG
232- b"\x01 " # ATYP IPv4
233- + socket .inet_aton ("8.8.8.8" ) # DST.ADDR (Google DNS)
234- + struct .pack ("!H" , 53 ) # DST.PORT
235- + dns_query
236- )
237-
238- loop = asyncio .get_event_loop ()
239- await loop .run_in_executor (
240- None , udp_sock .sendto , udp_frame , (relay_ip , relay_port )
241- )
242- # Refresh socket timeout to reflect remaining budget after sendto
243- udp_sock .settimeout (remaining ())
244- resp_data : bytes = await asyncio .wait_for (
245- loop .run_in_executor (None , udp_sock .recv , 4096 ),
246- timeout = remaining () + 0.5 , # outer guard: socket timeout fires first
247- )
248-
249- # A valid SOCKS5 UDP response starts with 4 header bytes + at least
250- # a minimal DNS reply (12 bytes header)
251- if len (resp_data ) < 16 :
252- raise ValueError (
253- f"UDP relay response too short ({ len (resp_data )} bytes)"
254- )
255-
256- latency = (time .monotonic () - start ) * 1000
257- return {
258- "success" : True ,
259- "latency_ms" : round (latency , 2 ),
260- "external_ip" : None ,
261- "error" : None ,
262- }
263-
264- except asyncio .TimeoutError :
265- return {
266- "success" : False ,
267- "latency_ms" : round ((time .monotonic () - start ) * 1000 , 2 ),
268- "external_ip" : None ,
269- "error" : "Timeout" ,
270- }
141+ sock .set_proxy (socks .SOCKS5 , host , port , username = user , password = pwd )
142+ sock .settimeout (timeout )
143+ start_time = time .perf_counter ()
144+ try :
145+ sock .sendto (dns_query , ("1.1.1.1" , 53 ))
146+ data , _ = sock .recvfrom (512 )
147+ finally :
148+ result ["latency_ms" ] = round ((time .perf_counter () - start_time ) * 1000 , 2 )
149+ result ["success" ] = True
150+
151+ # DNS TXT record format: <length byte><text data>
152+ # Search for IPv4 address pattern directly in the response
153+ match = re .search (rb'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' , data )
154+ result ["external_ip" ] = match .group (1 ).decode ('utf-8' ) if match else None
155+ except socket .timeout :
156+ result ["error" ] = "Timeout"
271157 except Exception as exc :
272- return {
273- "success" : False ,
274- "latency_ms" : round ((time .monotonic () - start ) * 1000 , 2 ),
275- "external_ip" : None ,
276- "error" : str (exc )[:250 ],
277- }
158+ result ["error" ] = _format_error (exc )
278159 finally :
279- if writer :
280- try :
281- writer .close ()
282- await asyncio .wait_for (writer .wait_closed (), timeout = 1.0 )
283- except Exception :
284- pass
285- if udp_sock :
286- udp_sock .close ()
160+ sock .close ()
161+
162+ return result
163+
164+ async def check_udp (self , proxy : Dict ) -> Dict [str , Any ]:
165+ """Check UDP connectivity through the SOCKS5 proxy via DNS query."""
166+ loop = asyncio .get_event_loop ()
167+ result = await loop .run_in_executor (None , self ._check_udp_sync , proxy )
168+ return result
287169
288170 # ------------------------------------------------------------------ #
289171 # Run all configured checks concurrently #
@@ -310,7 +192,7 @@ async def check_proxy(self, proxy: Dict) -> Dict[str, Dict]:
310192 "success" : False ,
311193 "latency_ms" : 0.0 ,
312194 "external_ip" : None ,
313- "error" : str (res )[: 250 ] ,
195+ "error" : _format_error (res ),
314196 }
315197 else :
316198 results [ct ] = res # type: ignore[assignment]
0 commit comments