zapret-kvn/xray_fluent/engines/socks_probe.py
loop-uh 3196d5b7a9
Some checks failed
Windows project source guards / test (push) Failing after 1m52s
Отказ сервера по адресу назначения не рвёт рабочий туннель; проверка без DoH
Узлы VPnBot политикой отклоняют публичные DoH-резолверы и сервисы
определения IP. Проверка HTTPS стучалась именно в cloudflare-dns.com,
dns.google и dns.quad9.net, поэтому всегда проваливалась через наши узлы.
Официальный клиент Hysteria сообщает такой отказ как "dial error:", и
он классифицировался как TARGET_CONNECTION_REFUSED: запускалось
автовосстановление с остановкой sing-box и всех живых соединений.

- Общий каталог runtime-errors.json: правило TARGET_DESTINATION_UNREACHABLE
  (record_only) первым в списке. Покрывает "dial error:" официального
  клиента и "remote error:" sing-quic hysteria2, кроме TLS-алертов.
  Каталог общий с Android, значит правило действует для всех ядер.
- Проверка HTTPS (Hysteria и AWG, общий список): connectivity-check
  адреса generate_204, SOCKS5-запрос по домену с удалённым резолвом.
- Строки stage=destination не пишутся в журнал сбоев ядра.
- "[core-switch] fallback ... not loaded in pool" переименован в
  плановый полный переход с явной причиной: sidecar-сессия (Hysteria,
  AWG) обслуживает один сервер и горячо не переключается.
- Регрессионные тесты: tests/test_destination_scope.py.
2026-09-25 15:48:26 +03:00

84 lines
3.8 KiB
Python

"""Small authenticated SOCKS/HTTPS probe shared by protocol transports."""
from __future__ import annotations
import ipaddress
import socket
import ssl
# Egress probes: (SOCKS destination, TLS name, path).
#
# Destinations are hostnames sent as SOCKS5 domain requests, so the remote
# side resolves them. The probe therefore needs no local/front DNS and proves
# real egress, including the node resolver. Connectivity-check URLs are used
# on purpose: node network policy (VPnBot network-policy-v3) rejects public
# DoH resolvers (blocked_doh_domain_suffixes) and IP-discovery services
# (public-ip-discovery), so probing those measures the policy, not the path.
# tests/test_destination_scope.py keeps this list outside those classes.
HTTPS_ENDPOINTS = (("www.google.com", "www.google.com", "/generate_204"),
("connectivitycheck.gstatic.com", "connectivitycheck.gstatic.com", "/generate_204"),
("cp.cloudflare.com", "cp.cloudflare.com", "/generate_204"))
def recv_exact(sock: socket.socket, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise OSError("SOCKS connection closed")
data.extend(chunk)
return bytes(data)
def open_socks_connection(relay_port: int, *, username: str, password: str,
timeout: float, target_host: str, target_port: int = 443) -> socket.socket:
sock = socket.create_connection(("127.0.0.1", relay_port), timeout=timeout)
try:
sock.settimeout(timeout)
method = b"\x02" if username or password else b"\x00"
sock.sendall(b"\x05\x01" + method)
if recv_exact(sock, 2) != b"\x05" + method:
raise OSError("SOCKS authentication method rejected")
if method == b"\x02":
user, secret = username.encode(), password.encode()
if not 0 < len(user) <= 255 or not 0 < len(secret) <= 255:
raise ValueError("Invalid SOCKS credential length")
sock.sendall(b"\x01" + bytes([len(user)]) + user + bytes([len(secret)]) + secret)
if recv_exact(sock, 2) != b"\x01\x00":
raise OSError("SOCKS authentication rejected")
try:
ip = ipaddress.ip_address(target_host)
except ValueError:
host = target_host.encode("idna")
if not 0 < len(host) <= 255:
raise ValueError("Invalid SOCKS destination")
address = b"\x03" + bytes([len(host)]) + host
else:
address = (b"\x01" if ip.version == 4 else b"\x04") + ip.packed
sock.sendall(b"\x05\x01\x00" + address + target_port.to_bytes(2, "big"))
header = recv_exact(sock, 4)
if header[:3] != b"\x05\x00\x00":
raise OSError(f"SOCKS CONNECT rejected (reply={header[1]})")
length = {1: 4, 4: 16}.get(header[3])
if header[3] == 3:
length = recv_exact(sock, 1)[0]
if length is None:
raise OSError("Invalid SOCKS response address")
recv_exact(sock, length + 2)
return sock
except BaseException:
sock.close()
raise
def probe_https(relay_port: int, *, username: str, password: str,
endpoint: tuple[str, str, str], timeout: float) -> None:
address, name, path = endpoint
raw = open_socks_connection(relay_port, username=username, password=password,
timeout=timeout, target_host=address)
try:
with ssl.create_default_context().wrap_socket(raw, server_hostname=name) as tls:
tls.sendall(f"HEAD {path} HTTP/1.1\r\nHost: {name}\r\nConnection: close\r\n\r\n".encode("ascii"))
if not recv_exact(tls, 5).startswith(b"HTTP/"):
raise OSError("HTTPS endpoint returned no HTTP response")
finally:
raw.close()