Skip to content

Commit f707f80

Browse files
committed
feat(proxy-monitor): refactor UDP checker and improve UI feedback
- Replace async UDP check with synchronous socks library implementation run in executor for better reliability - Add protocol-specific status display in detail modal - Implement time-window-based error filtering for dashboard - Add ESC key handler for closing modals - Fix status logic for disabled protocols in card view - Remove unused code.
1 parent e38c215 commit f707f80

12 files changed

Lines changed: 351 additions & 246 deletions

File tree

proxy-monitor/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "Proxy Monitor"
2-
version: "1.1.2"
2+
version: "1.2.0"
33
slug: "proxy-monitor"
44
description: "Real-time SOCKS5 proxy monitoring dashboard with TCP and UDP checks"
55
url: "https://github.com/Aculeasis/ha_addons/tree/main/proxy-monitor"

proxy-monitor/src/checker.py

Lines changed: 55 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,29 @@
88
"""
99
import asyncio
1010
import logging
11+
import re
1112
import socket
12-
import struct
1313
import time
1414
from typing import Any, Dict, Optional
1515

1616
import aiohttp
17+
import socks
1718
from aiohttp_socks import ProxyConnector
1819

1920
logger = 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+
2229
class 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"\x06whoami\x0acloudflare\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"\x03www\x06google\x03com\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]

proxy-monitor/src/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ aiohttp>=3.9.0
44
aiohttp-socks>=0.8.0
55
aiosqlite>=0.19.0
66
pyyaml>=6.0.1
7+
PySocks>=1.7.1

0 commit comments

Comments
 (0)