diff --git a/app/models/reality_scan.py b/app/models/reality_scan.py index c9a13a4c1..a1d918a36 100644 --- a/app/models/reality_scan.py +++ b/app/models/reality_scan.py @@ -3,7 +3,6 @@ class RealityScanRequest(BaseModel): target: str = Field(min_length=1, max_length=253, description="host or host:port to probe (port defaults to 443)") - sni: str | None = Field(default=None, max_length=253, description="Override the SNI sent during the handshake") timeout: float | None = Field(default=None, ge=1, le=20, description="Per-probe timeout in seconds (1-20, default 10)") @@ -15,6 +14,7 @@ class RealityScanResult(BaseModel): ip: str | None = None port: int sni: str | None = None + sni_discovered: bool = False feasible: bool tls13: bool diff --git a/app/operation/core.py b/app/operation/core.py index acdcca8fc..58bdbccf9 100644 --- a/app/operation/core.py +++ b/app/operation/core.py @@ -40,7 +40,7 @@ async def _refresh_hosts_from_db(self, db: AsyncSession) -> None: async def scan_reality_target(self, request: RealityScanRequest) -> RealityScanResult: try: - result = await scan_reality_target(target=request.target, sni=request.sni, timeout=request.timeout) + result = await scan_reality_target(target=request.target, timeout=request.timeout) except RealityScanError as e: await self.raise_error(message=str(e), code=400) except Exception as e: diff --git a/app/utils/reality_scan.py b/app/utils/reality_scan.py index dff6efeb5..44d52c51c 100644 --- a/app/utils/reality_scan.py +++ b/app/utils/reality_scan.py @@ -1,10 +1,13 @@ import asyncio import ipaddress import os +import select import socket import ssl import struct import time +import weakref +from concurrent.futures import ThreadPoolExecutor from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import x25519 as _x25519 @@ -21,14 +24,26 @@ DNS_TIMEOUT = 5.0 MAX_CONCURRENT_SCANS = 4 -_scan_semaphore: "asyncio.Semaphore | None" = None +_scan_semaphores: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = weakref.WeakKeyDictionary() + +_scan_executor: "ThreadPoolExecutor | None" = None def _get_scan_semaphore() -> "asyncio.Semaphore": - global _scan_semaphore - if _scan_semaphore is None: - _scan_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SCANS) - return _scan_semaphore + loop = asyncio.get_running_loop() + sem = _scan_semaphores.get(loop) + if sem is None: + sem = asyncio.Semaphore(MAX_CONCURRENT_SCANS) + _scan_semaphores[loop] = sem + return sem + + +def _get_scan_executor() -> "ThreadPoolExecutor": + global _scan_executor + if _scan_executor is None: + _scan_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_SCANS + 2, thread_name_prefix="reality-scan") + return _scan_executor + GROUP_X25519 = 0x001D GROUP_X25519MLKEM768 = 0x11EC @@ -56,11 +71,11 @@ def _has_control_chars(value: str) -> bool: return any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value) -def parse_target(target: str, sni_override: str | None = None) -> tuple[str, int, str | None]: +def parse_target(target: str) -> tuple[str, int, str | None]: if not target or not target.strip(): raise RealityScanError("A target host is required.") - if _has_control_chars(target) or (sni_override and _has_control_chars(sni_override)): - raise RealityScanError("Target and SNI must not contain control characters.") + if _has_control_chars(target): + raise RealityScanError("Target must not contain control characters.") value = target.strip() if "://" in value: @@ -87,12 +102,7 @@ def parse_target(target: str, sni_override: str | None = None) -> tuple[str, int if not host: raise RealityScanError("A target host is required.") - sni: str | None - if sni_override and sni_override.strip(): - sni = sni_override.strip() - else: - sni = None if _is_ip_literal(host) else host - + sni = None if _is_ip_literal(host) else host return host, port, sni @@ -240,6 +250,100 @@ def _parse_certificate(der: bytes | None) -> dict: return out +def _looks_like_hostname(name: str) -> bool: + if not name or len(name) > 253 or _has_control_chars(name): + return False + if any(ch.isspace() for ch in name): + return False + labels = name.rstrip(".").split(".") + if len(labels) < 2: + return False + return all(0 < len(label) <= 63 for label in labels) + + +def _first_usable_name(der: bytes | None) -> str | None: + if not der: + return None + try: + cert = x509.load_der_x509_certificate(der) + except Exception: + return None + try: + san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) + dns_names = list(san.value.get_values_for_type(x509.DNSName)) + except Exception: + dns_names = [] + for name in dns_names: + name = name.strip() + if name and not name.startswith("*.") and _looks_like_hostname(name): + return name + if not dns_names: + cn = _name_common_name(cert.subject) + if cn: + cn = cn.strip() + if not cn.startswith("*.") and _looks_like_hostname(cn): + return cn + return None + + +def _wait_io(tls: ssl.SSLSocket, deadline: float, *, want_read: bool) -> bool: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + if want_read: + ready, _, _ = select.select([tls], [], [], remaining) + else: + _, ready, _ = select.select([], [tls], [], remaining) + return bool(ready) + + +def _drive_handshake(tls: ssl.SSLSocket, deadline: float) -> None: + while True: + try: + tls.do_handshake() + return + except ssl.SSLWantReadError: + if not _wait_io(tls, deadline, want_read=True): + raise TimeoutError("TLS handshake timed out") + except ssl.SSLWantWriteError: + if not _wait_io(tls, deadline, want_read=False): + raise TimeoutError("TLS handshake timed out") + + +def _tls_wrap_with_deadline(ctx: ssl.SSLContext, ip: str, port: int, server_hostname: str | None, timeout: float) -> tuple[ssl.SSLSocket, float]: + started = time.monotonic() + deadline = started + timeout + sock = socket.create_connection((ip, port), timeout=timeout) + try: + sock.setblocking(False) + tls = ctx.wrap_socket(sock, server_hostname=server_hostname, do_handshake_on_connect=False) + except BaseException: + sock.close() + raise + try: + _drive_handshake(tls, deadline) + except BaseException: + tls.close() + raise + return tls, (time.monotonic() - started) * 1000.0 + + +def _make_verify_ctx() -> ssl.SSLContext: + ctx = ssl.create_default_context() + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.set_alpn_protocols(["h2", "http/1.1"]) + return ctx + + +def _make_permissive_ctx() -> ssl.SSLContext: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_alpn_protocols(["h2", "http/1.1"]) + return ctx + + def _tls_probe(ip: str, port: int, sni: str | None, timeout: float) -> dict: result: dict = { "tls13": False, @@ -253,44 +357,50 @@ def _tls_probe(ip: str, port: int, sni: str | None, timeout: float) -> dict: "server_names": [], "latency_ms": None, "reason": None, + "sni": sni, + "sni_discovered": False, } - verify_ctx = ssl.create_default_context() - verify_ctx.minimum_version = ssl.TLSVersion.TLSv1_2 - verify_ctx.set_alpn_protocols(["h2", "http/1.1"]) - if sni is None: - verify_ctx.check_hostname = False - verify_ctx.verify_mode = ssl.CERT_NONE - - permissive_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - permissive_ctx.minimum_version = ssl.TLSVersion.TLSv1_2 - permissive_ctx.check_hostname = False - permissive_ctx.verify_mode = ssl.CERT_NONE - permissive_ctx.set_alpn_protocols(["h2", "http/1.1"]) - - der: bytes | None = None version: str | None = None alpn: str | None = None + der: bytes | None = None - def _handshake(ctx: ssl.SSLContext) -> tuple[str | None, str | None, bytes | None, float]: - started = time.monotonic() - with socket.create_connection((ip, port), timeout=timeout) as sock: - with ctx.wrap_socket(sock, server_hostname=sni) as tls: - latency = (time.monotonic() - started) * 1000.0 - return tls.version(), tls.selected_alpn_protocol(), tls.getpeercert(binary_form=True), latency + def _handshake(ctx: ssl.SSLContext, server_hostname: str | None) -> tuple[str | None, str | None, bytes | None, float]: + tls, latency = _tls_wrap_with_deadline(ctx, ip, port, server_hostname, timeout) + with tls: + return tls.version(), tls.selected_alpn_protocol(), tls.getpeercert(binary_form=True), latency try: - version, alpn, der, latency = _handshake(verify_ctx) - result["cert_valid"] = sni is not None - result["latency_ms"] = round(latency) - except ssl.SSLCertVerificationError as exc: - result["reason"] = f"Certificate did not validate: {getattr(exc, 'verify_message', None) or exc}" - try: - version, alpn, der, latency = _handshake(permissive_ctx) + if sni is not None: + try: + version, alpn, der, latency = _handshake(_make_verify_ctx(), sni) + result["cert_valid"] = True + result["latency_ms"] = round(latency) + except ssl.SSLCertVerificationError as exc: + result["reason"] = f"Certificate did not validate: {getattr(exc, 'verify_message', None) or exc}" + try: + version, alpn, der, latency = _handshake(_make_permissive_ctx(), sni) + result["latency_ms"] = round(latency) + except (ssl.SSLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc2: + logger.debug("reality-scan: permissive fallback handshake failed for %s: %s", sni, exc2) + return result + else: + version, alpn, der, latency = _handshake(_make_permissive_ctx(), None) result["latency_ms"] = round(latency) - except Exception as exc2: - result["reason"] = f"TLS handshake failed: {exc2}" - return result + discovered = _first_usable_name(der) + if not discovered: + result["reason"] = "No usable domain name found in the certificate." + else: + result["sni"] = discovered + result["sni_discovered"] = True + try: + version, alpn, der, latency = _handshake(_make_verify_ctx(), discovered) + result["cert_valid"] = True + result["latency_ms"] = round(latency) + except ssl.SSLCertVerificationError as exc: + result["reason"] = f"Certificate did not validate: {getattr(exc, 'verify_message', None) or exc}" + except (ssl.SSLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc: + result["reason"] = f"Certificate re-validation failed: {exc}" except (socket.timeout, TimeoutError): result["reason"] = "Connection timed out." return result @@ -300,6 +410,12 @@ def _handshake(ctx: ssl.SSLContext) -> tuple[str | None, str | None, bytes | Non except (ConnectionRefusedError, ConnectionResetError, OSError) as exc: result["reason"] = f"Connection failed: {exc}" return result + except UnicodeError as exc: + result["reason"] = f"Server name could not be encoded for TLS: {exc}" + return result + + if version is None: + return result result["tls_version"] = _pretty_tls_version(version) result["tls13"] = version == "TLSv1.3" @@ -487,27 +603,27 @@ def _h3_probe(host: str, ip: str, port: int, sni: str | None, timeout: float) -> ctx.verify_mode = ssl.CERT_NONE ctx.set_alpn_protocols(["http/1.1"]) request_host = sni or host + tls, _ = _tls_wrap_with_deadline(ctx, ip, port, sni, timeout) deadline = time.monotonic() + timeout - with socket.create_connection((ip, port), timeout=timeout) as sock: - with ctx.wrap_socket(sock, server_hostname=sni) as tls: - tls.settimeout(timeout) - request = ( - f"GET / HTTP/1.1\r\nHost: {request_host}\r\n" - "User-Agent: PasarGuard-RealityScan/1.0\r\nAccept: */*\r\nConnection: close\r\n\r\n" - ) - tls.sendall(request.encode("ascii", "ignore")) - data = b"" - while len(data) < 32768: - remaining = deadline - time.monotonic() - if remaining <= 0: - break - tls.settimeout(remaining) - chunk = tls.recv(4096) - if not chunk: - break - data += chunk - if b"\r\n\r\n" in data: - break + with tls: + tls.settimeout(timeout) + request = ( + f"GET / HTTP/1.1\r\nHost: {request_host}\r\n" + "User-Agent: PasarGuard-RealityScan/1.0\r\nAccept: */*\r\nConnection: close\r\n\r\n" + ) + tls.sendall(request.encode("ascii", "ignore")) + data = b"" + while len(data) < 32768: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + tls.settimeout(remaining) + chunk = tls.recv(4096) + if not chunk: + break + data += chunk + if b"\r\n\r\n" in data: + break header_blob = data.split(b"\r\n\r\n", 1)[0].decode("latin-1", "ignore") for line in header_blob.split("\r\n"): lower = line.lower() @@ -521,13 +637,15 @@ def _h3_probe(host: str, ip: str, port: int, sni: str | None, timeout: float) -> def _scan_sync(host: str, ip: str, port: int, sni: str | None, timeout: float) -> dict: tls = _tls_probe(ip, port, sni, timeout) + effective_sni = tls["sni"] result: dict = { "target": f"{host}:{port}", "host": host, "ip": ip, "port": port, - "sni": sni, + "sni": effective_sni, + "sni_discovered": tls["sni_discovered"], "feasible": False, "tls13": tls["tls13"], "tls_version": tls["tls_version"], @@ -549,23 +667,33 @@ def _scan_sync(host: str, ip: str, port: int, sni: str | None, timeout: float) - if tls["tls_version"] is None: return result - group = _group_probe(ip, port, sni, timeout) + group = _group_probe(ip, port, effective_sni, timeout) result["x25519"] = group["x25519"] result["post_quantum"] = group["post_quantum"] result["curve"] = group["curve"] - result["h3"] = _h3_probe(host, ip, port, sni, timeout) - - definitely_not_x25519 = group["x25519"] is False and group["post_quantum"] is False and group["curve"] is not None - result["feasible"] = bool(result["tls13"] and result["h2"] and result["cert_valid"] and not definitely_not_x25519) + result["h3"] = _h3_probe(host, ip, port, effective_sni, timeout) + + base_ok = bool(result["tls13"] and result["h2"] and result["cert_valid"]) + result["feasible"] = base_ok and result["x25519"] is True + if base_ok and result["x25519"] is not True and not result["reason"]: + if result["x25519"] is False: + result["reason"] = f"Key exchange is {result['curve'] or 'not X25519'}; REALITY needs X25519 or X25519MLKEM768." + else: + result["reason"] = "Could not confirm an X25519 key exchange." return result -async def scan_reality_target(target: str, sni: str | None = None, timeout: float | None = None) -> dict: - host, port, resolved_sni = parse_target(target, sni) +async def scan_reality_target(target: str, timeout: float | None = None) -> dict: + host, port, sni = parse_target(target) clamped = _clamp_timeout(timeout) async with _get_scan_semaphore(): ip = await _resolve_public_ip_async(host, min(clamped, DNS_TIMEOUT)) + loop = asyncio.get_running_loop() + executor = _get_scan_executor() try: - return await asyncio.wait_for(asyncio.to_thread(_scan_sync, host, ip, port, resolved_sni, clamped), timeout=clamped * 6 + 15) + return await asyncio.wait_for( + loop.run_in_executor(executor, _scan_sync, host, ip, port, sni, clamped), + timeout=clamped * 6 + 15, + ) except TimeoutError: raise RealityScanError("Scan timed out.") diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 467b415ff..8806cfb85 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -3071,7 +3071,9 @@ "expires": "Expires", "serverNames": "Certificate server names (valid SNIs)", "unknown": "Unknown", - "other": "Other" + "other": "Other", + "sniLabel": "SNI", + "sniDiscovered": "discovered from IP" } }, "settings.cores.title": "Cores", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 02c1a37bc..efbfbbbab 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2983,7 +2983,9 @@ "expires": "انقضا", "serverNames": "نام‌های سرور گواهی (SNIهای معتبر)", "unknown": "نامشخص", - "other": "سایر" + "other": "سایر", + "sniLabel": "SNI", + "sniDiscovered": "کشف‌شده از IP" } }, "settings.cores.title": "هسته‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 9532d9ea3..06de0f1e3 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2956,7 +2956,9 @@ "expires": "Истекает", "serverNames": "Имена серверов сертификата (действительные SNI)", "unknown": "Неизвестно", - "other": "Другое" + "other": "Другое", + "sniLabel": "SNI", + "sniDiscovered": "обнаружено по IP" } }, "settings.cores.title": "Ядра", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index a7b872537..d0900cdde 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -3027,7 +3027,9 @@ "expires": "过期时间", "serverNames": "证书服务器名称(有效 SNI)", "unknown": "未知", - "other": "其他" + "other": "其他", + "sniLabel": "SNI", + "sniDiscovered": "从 IP 发现" } }, "settings.cores.title": "核心", diff --git a/dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx b/dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx index e483360c3..3d2c09637 100644 --- a/dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx +++ b/dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx @@ -17,7 +17,6 @@ interface RealityScanDialogProps { open: boolean onOpenChange: (open: boolean) => void initialTarget?: string - initialSni?: string } const MAX_TARGETS = 25 @@ -148,6 +147,20 @@ function ScanResultDetail({ result }: { result: RealityScanResult }) { + {result.sni ? ( +
+ {t('coreEditor.realityScan.sniLabel', { defaultValue: 'SNI' })}: + + {result.sni} + + {result.sni_discovered ? ( + + {t('coreEditor.realityScan.sniDiscovered', { defaultValue: 'discovered from IP' })} + + ) : null} +
+ ) : null} + {result.reason ? ( @@ -347,10 +360,9 @@ function TargetsInput({ value, onChange, disabled, max }: { value: string[]; onC ) } -export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSni }: RealityScanDialogProps) { +export function RealityScanDialog({ open, onOpenChange, initialTarget }: RealityScanDialogProps) { const { t } = useTranslation() const [targets, setTargets] = useState([]) - const [sni, setSni] = useState('') const [timeoutInput, setTimeoutInput] = useState('10') const [rows, setRows] = useState([]) const [expanded, setExpanded] = useState(null) @@ -368,12 +380,11 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn abortRef.current?.abort() abortRef.current = null setTargets(initialTarget?.trim() ? [initialTarget.trim()] : []) - setSni(initialSni?.trim() ?? '') setRows([]) setExpanded(null) setFeasibleOnly(false) setIsScanning(false) - }, [open, initialTarget, initialSni]) + }, [open, initialTarget]) useEffect(() => () => abortRef.current?.abort(), []) @@ -387,7 +398,6 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn abortRef.current = controller const parsedTimeout = Number(timeoutInput) const timeout = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : undefined - const useSni = list.length === 1 ? sni.trim() || undefined : undefined setExpanded(list.length === 1 ? list[0] : null) setFeasibleOnly(false) setRows(list.map(target => ({ target, status: 'pending' as RowStatus }))) @@ -403,7 +413,7 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn if (controller.signal.aborted) return patch(target, { status: 'scanning' }) try { - const res = await scanRealityTarget({ target, sni: useSni, timeout }, controller.signal) + const res = await scanRealityTarget({ target, timeout }, controller.signal) if (controller.signal.aborted) return patch(target, { status: 'done', result: res }) } catch (error) { @@ -467,15 +477,7 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn
- { - setTargets(next) - setSni('') - }} - disabled={isScanning} - max={MAX_TARGETS} - /> + {targets.length >= MAX_TARGETS ? (

{t('coreEditor.realityScan.maxTargets', { defaultValue: 'Up to {{max}} targets can be scanned.', max: MAX_TARGETS })}

) : null} diff --git a/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx index f09d17a3d..5e9c1a196 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx @@ -94,24 +94,6 @@ function securityFieldName(jsonKey: string): string { return `${SECURITY_FIELD_PREFIX}${jsonKey}` } -function firstConfiguredServerName(raw: unknown): string { - const s = typeof raw === 'string' ? raw : Array.isArray(raw) ? JSON.stringify(raw) : '' - const trimmed = s.trim() - if (!trimmed) return '' - let parts: string[] = [] - if (trimmed.startsWith('[')) { - try { - const parsed: unknown = JSON.parse(trimmed) - parts = Array.isArray(parsed) ? parsed.map(item => String(item ?? '').trim()) : [] - } catch { - parts = trimmed.split(/[\n,]/).map(part => part.trim()) - } - } else { - parts = trimmed.split(/[\n,]/).map(part => part.trim()) - } - return parts.find(p => p && !p.startsWith('*')) || '' -} - /** Plain English only — Xray REALITY / TLS / ECH field hints (not i18n). */ const INBOUND_SECURITY_PARITY_PLACEHOLDER: Readonly> = { dest: 'host:port for REALITY handshake target (e.g. www.microsoft.com:443)', @@ -922,7 +904,6 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo const [isGeneratingMldsa65, setIsGeneratingMldsa65] = useState(false) const [isRealityScanOpen, setIsRealityScanOpen] = useState(false) const [realityScanTarget, setRealityScanTarget] = useState('') - const [realityScanSni, setRealityScanSni] = useState('') const [echUsageOption, setEchUsageOption] = useState<'default' | 'required' | 'preferred'>('default') const [draftInbound, setDraftInbound] = useState(null) const [editOriginalInbound, setEditOriginalInbound] = useState(null) @@ -4049,7 +4030,6 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo onClick={() => { const destValue = form.getValues(securityFieldName('target')) setRealityScanTarget(typeof destValue === 'string' ? destValue : '') - setRealityScanSni(firstConfiguredServerName(form.getValues(securityFieldName('serverNames')))) setIsRealityScanOpen(true) }} className="h-10 w-full text-sm font-medium transition-all hover:shadow-md sm:h-11" @@ -5368,7 +5348,7 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo - + diff --git a/dashboard/src/service/reality-scan.ts b/dashboard/src/service/reality-scan.ts index 6e61a9aac..c692ce747 100644 --- a/dashboard/src/service/reality-scan.ts +++ b/dashboard/src/service/reality-scan.ts @@ -2,7 +2,6 @@ import { orvalFetcher } from './http' export interface RealityScanRequest { target: string - sni?: string | null timeout?: number | null } @@ -12,6 +11,7 @@ export interface RealityScanResult { ip: string | null port: number sni: string | null + sni_discovered: boolean feasible: boolean tls13: boolean tls_version: string | null diff --git a/tests/test_reality_scan_unit.py b/tests/test_reality_scan_unit.py index e6404f286..70df82d96 100644 --- a/tests/test_reality_scan_unit.py +++ b/tests/test_reality_scan_unit.py @@ -27,11 +27,6 @@ def test_parse_target_ok(target, expected): assert rs.parse_target(target) == expected -def test_parse_target_sni_override(): - host, port, sni = rs.parse_target("1.1.1.1:443", sni_override="example.org") - assert (host, port, sni) == ("1.1.1.1", 443, "example.org") - - @pytest.mark.parametrize("bad", ["", " ", "host:0", "host:70000", "host:abc"]) def test_parse_target_invalid(bad): with pytest.raises(RealityScanError): @@ -42,8 +37,6 @@ def test_parse_target_invalid(bad): def test_parse_target_rejects_control_chars(bad): with pytest.raises(RealityScanError): rs.parse_target(bad) - with pytest.raises(RealityScanError): - rs.parse_target("example.com", sni_override=bad) @pytest.mark.parametrize( @@ -178,7 +171,7 @@ def _self_signed_der(cn: str, sans: list[str]) -> bytes: key = rsa.generate_private_key(public_exponent=65537, key_size=2048) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Org")]) now = datetime.datetime.now(datetime.timezone.utc) - cert = ( + builder = ( x509.CertificateBuilder() .subject_name(name) .issuer_name(name) @@ -186,10 +179,10 @@ def _self_signed_der(cn: str, sans: list[str]) -> bytes: .serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(days=1)) .not_valid_after(now + datetime.timedelta(days=90)) - .add_extension(x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]), critical=False) - .sign(key, hashes.SHA256()) ) - return cert.public_bytes(serialization.Encoding.DER) + if sans: + builder = builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]), critical=False) + return builder.sign(key, hashes.SHA256()).public_bytes(serialization.Encoding.DER) def test_parse_certificate_extracts_sans_and_filters_wildcards(): @@ -205,6 +198,34 @@ def test_parse_certificate_handles_none(): assert rs._parse_certificate(None)["server_names"] == [] +def test_first_usable_name_prefers_san_over_cn(): + der = _self_signed_der("legacy-cn.example", ["real.example.com", "one.one.one.one"]) + assert rs._first_usable_name(der) == "real.example.com" + + +def test_first_usable_name_ignores_cn_when_san_present_but_unusable(): + der = _self_signed_der("apex.example.com", ["*.example.com"]) + assert rs._first_usable_name(der) is None + + +def test_first_usable_name_falls_back_to_cn_without_san(): + assert rs._first_usable_name(_self_signed_der("cn-only.example.com", [])) == "cn-only.example.com" + + +def test_first_usable_name_skips_wildcard_cn_uses_san(): + der = _self_signed_der("*.example.com", ["*.example.com", "www.example.com"]) + assert rs._first_usable_name(der) == "www.example.com" + + +def test_first_usable_name_none_when_all_wildcard(): + der = _self_signed_der("*.example.com", ["*.example.com"]) + assert rs._first_usable_name(der) is None + + +def test_first_usable_name_none_when_no_cert(): + assert rs._first_usable_name(None) is None + + def _patch_probes(monkeypatch, *, tls, group, h3): monkeypatch.setattr(rs, "_tls_probe", lambda *a, **k: tls) monkeypatch.setattr(rs, "_group_probe", lambda *a, **k: group) @@ -223,6 +244,8 @@ def _patch_probes(monkeypatch, *, tls, group, h3): "server_names": ["example.com"], "latency_ms": 42, "reason": None, + "sni": "example.com", + "sni_discovered": False, } @@ -234,9 +257,19 @@ def test_scan_sync_feasible_when_all_pass(monkeypatch): assert out["h3"] is True -def test_scan_sync_feasible_when_group_unknown(monkeypatch): +def test_scan_sync_not_feasible_when_group_unknown(monkeypatch): _patch_probes(monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": None, "post_quantum": None, "curve": None}, h3=False) out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5) + assert out["feasible"] is False + assert "X25519" in out["reason"] + + +def test_scan_sync_carries_discovered_sni(monkeypatch): + tls = dict(_GOOD_TLS, sni="cloudflare-dns.com", sni_discovered=True) + _patch_probes(monkeypatch, tls=tls, group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=False) + out = rs._scan_sync("1.0.0.1", "1.0.0.1", 443, None, 5) + assert out["sni"] == "cloudflare-dns.com" + assert out["sni_discovered"] is True assert out["feasible"] is True @@ -244,6 +277,7 @@ def test_scan_sync_not_feasible_when_definitely_not_x25519(monkeypatch): _patch_probes(monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": False, "post_quantum": False, "curve": "secp256r1"}, h3=False) out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5) assert out["feasible"] is False + assert "secp256r1" in out["reason"] def test_scan_sync_not_feasible_without_tls13(monkeypatch): @@ -281,6 +315,17 @@ async def test_scan_reality_target_live_example(): assert result["h2"] is True assert result["cert_valid"] is True assert result["latency_ms"] is not None + assert result["sni"] == "example.com" + assert result["sni_discovered"] is False + + +@pytest.mark.skipif(os.environ.get("REALITY_SCAN_NETWORK_TEST") != "1", reason="network test opt-in") +@pytest.mark.asyncio +async def test_scan_reality_target_bare_ip_discovers_sni(): + result = await rs.scan_reality_target("1.0.0.1:443", timeout=8) + assert result["sni_discovered"] is True + assert result["sni"] + assert result["cert_valid"] is True def _frame(payload: bytes, rtype: int = 0x16) -> bytes: @@ -360,20 +405,7 @@ def test_group_probe_non_x25519_group(monkeypatch): def test_parse_certificate_without_sans(): - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "no-san.example")]) - now = datetime.datetime.now(datetime.timezone.utc) - cert = ( - x509.CertificateBuilder() - .subject_name(name) - .issuer_name(name) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=90)) - .sign(key, hashes.SHA256()) - ) - out = rs._parse_certificate(cert.public_bytes(serialization.Encoding.DER)) + out = rs._parse_certificate(_self_signed_der("no-san.example", [])) assert out["cert_subject"] == "no-san.example" assert out["server_names"] == [] @@ -391,7 +423,8 @@ async def test_scan_concurrency_is_capped(monkeypatch): import threading import time as _time - rs._scan_semaphore = None + rs._scan_semaphores.clear() + rs._scan_executor = None lock = threading.Lock() state = {"live": 0, "peak": 0} resolver = {"live": 0, "peak": 0} @@ -418,3 +451,150 @@ def fake_sync(*a, **k): await asyncio.gather(*[rs.scan_reality_target("example.com:443") for _ in range(12)]) assert state["peak"] <= rs.MAX_CONCURRENT_SCANS assert resolver["peak"] <= rs.MAX_CONCURRENT_SCANS + + + + +class _FakeTLS: + + def __init__(self, version="TLSv1.3", alpn="h2", der=b"DER"): + self._version, self._alpn, self._der = version, alpn, der + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def version(self): + return self._version + + def selected_alpn_protocol(self): + return self._alpn + + def getpeercert(self, binary_form=False): + return self._der + + +def test_looks_like_hostname_cases(): + good = ["example.com", "www.a.b.co", "one.one.one.one", "cloudflare-dns.com", "example.com."] + bad = ["", "localhost", "a b.com", "a" * 70 + ".com", "x." + "a" * 64, "h\x01st.com"] + assert all(rs._looks_like_hostname(n) for n in good), [n for n in good if not rs._looks_like_hostname(n)] + assert not any(rs._looks_like_hostname(n) for n in bad), [n for n in bad if rs._looks_like_hostname(n)] + + +def test_first_usable_name_skips_non_hostname_cn_uses_san(): + der = _self_signed_der("localhost", ["localhost", "good.example.com"]) + assert rs._first_usable_name(der) == "good.example.com" + + +def test_first_usable_name_none_for_org_string_cn_no_san(): + assert rs._first_usable_name(_self_signed_der("Some Org CA", [])) is None + + +def test_wait_io_false_on_select_timeout(monkeypatch): + import time as _t + + monkeypatch.setattr(rs.select, "select", lambda r, w, x, t: ([], [], [])) + assert rs._wait_io(object(), deadline=_t.monotonic() + 5, want_read=True) is False + + +def test_drive_handshake_times_out_when_deadline_passed(): + import time as _t + + class T: + def do_handshake(self): + raise rs.ssl.SSLWantReadError() + + with pytest.raises(TimeoutError): + rs._drive_handshake(T(), deadline=_t.monotonic() - 1) + + +def test_drive_handshake_retries_then_succeeds(monkeypatch): + import time as _t + + calls = {"n": 0} + + class T: + def do_handshake(self): + calls["n"] += 1 + if calls["n"] == 1: + raise rs.ssl.SSLWantReadError() + + monkeypatch.setattr(rs.select, "select", lambda r, w, x, t: ([object()], [], [])) + rs._drive_handshake(T(), deadline=_t.monotonic() + 5) + assert calls["n"] == 2 + + +def test_get_scan_executor_is_bounded(): + rs._scan_executor = None + ex = rs._get_scan_executor() + assert ex._max_workers == rs.MAX_CONCURRENT_SCANS + 2 + rs._scan_executor = None + + +@pytest.mark.asyncio +async def test_get_scan_semaphore_same_within_loop(): + rs._scan_semaphores.clear() + assert rs._get_scan_semaphore() is rs._get_scan_semaphore() + + +def test_get_scan_semaphore_distinct_across_sequential_loops(): + import asyncio + + async def get(): + return rs._get_scan_semaphore() + + rs._scan_semaphores.clear() + s1 = asyncio.run(get()) + s2 = asyncio.run(get()) + assert s1 is not s2 + + +def test_get_scan_semaphore_requires_running_loop(): + with pytest.raises(RuntimeError): + rs._get_scan_semaphore() + + +def test_tls_probe_hostname_fallback_preserves_cert_reason(monkeypatch): + calls = {"n": 0} + + def fake_wrap(ctx, ip, port, server_hostname, timeout): + calls["n"] += 1 + if calls["n"] == 1: + raise rs.ssl.SSLCertVerificationError("hostname mismatch") + raise OSError("connection reset") + + monkeypatch.setattr(rs, "_tls_wrap_with_deadline", fake_wrap) + out = rs._tls_probe("1.2.3.4", 443, "example.com", 2) + assert out["cert_valid"] is False + assert out["reason"].startswith("Certificate did not validate") + + +def test_tls_probe_bare_ip_revalidation_unicodeerror_is_graceful(monkeypatch): + calls = {"n": 0} + + def fake_wrap(ctx, ip, port, server_hostname, timeout): + calls["n"] += 1 + if calls["n"] == 1: + return _FakeTLS(), 12.0 + raise UnicodeError("label empty or too long") + + monkeypatch.setattr(rs, "_tls_wrap_with_deadline", fake_wrap) + monkeypatch.setattr(rs, "_first_usable_name", lambda der: "cloudflare-dns.com") + out = rs._tls_probe("1.0.0.1", 443, None, 2) + assert out["sni"] == "cloudflare-dns.com" + assert out["sni_discovered"] is True + assert out["cert_valid"] is False + assert out["tls_version"] == "1.3" + assert out["reason"].startswith("Certificate re-validation failed") + + +def test_tls_probe_outer_unicodeerror_on_target_sni(monkeypatch): + def fake_wrap(ctx, ip, port, server_hostname, timeout): + raise UnicodeError("label too long") + + monkeypatch.setattr(rs, "_tls_wrap_with_deadline", fake_wrap) + out = rs._tls_probe("1.2.3.4", 443, "a" * 70 + ".com", 2) + assert out["tls_version"] is None + assert out["reason"].startswith("Server name could not be encoded for TLS")