From 0a94cd810441fd46fba1811a13e64c8d3297a338 Mon Sep 17 00:00:00 2001 From: parsa Date: Mon, 13 Jul 2026 02:10:54 -0700 Subject: [PATCH 1/6] feat(core): add REALITY target scanner Add a POST /api/core/reality-scan endpoint and a "Scan target" button in the Xray inbound editor to probe a candidate REALITY dest and report whether it is a good decoy: TLS 1.3, HTTP/2 (ALPN), X25519 / post-quantum (X25519MLKEM768) key exchange, HTTP/3 advertisement, certificate validity and SANs, plus handshake latency. Security: every target is resolved and pinned to a public IP, so private, loopback, link-local, reserved and cloud-metadata addresses are rejected and the pinned connection cannot be bypassed by DNS rebinding. DNS and concurrent scans are bounded, and each sub-probe is isolated so a partial failure degrades a single field to "unknown" instead of failing the scan. The key-group probe is a self-contained raw TLS 1.3 ClientHello/ServerHello parser, so it detects MLKEM even where the panel's OpenSSL predates it. Gated behind the cores:read permission. Includes backend unit tests. --- app/models/reality_scan.py | 38 ++ app/operation/core.py | 12 + app/routers/core.py | 9 + app/utils/reality_scan.py | 557 ++++++++++++++++++ .../components/xray/reality-scan-dialog.tsx | 304 ++++++++++ .../components/xray/xray-inbounds-section.tsx | 44 +- dashboard/src/service/reality-scan.ts | 35 ++ tests/test_reality_scan_unit.py | 412 +++++++++++++ 8 files changed, 1410 insertions(+), 1 deletion(-) create mode 100644 app/models/reality_scan.py create mode 100644 app/utils/reality_scan.py create mode 100644 dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx create mode 100644 dashboard/src/service/reality-scan.ts create mode 100644 tests/test_reality_scan_unit.py diff --git a/app/models/reality_scan.py b/app/models/reality_scan.py new file mode 100644 index 000000000..c9a13a4c1 --- /dev/null +++ b/app/models/reality_scan.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, ConfigDict, Field + + +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)") + + +class RealityScanResult(BaseModel): + model_config = ConfigDict(from_attributes=True) + + target: str + host: str + ip: str | None = None + port: int + sni: str | None = None + + feasible: bool + tls13: bool + tls_version: str | None = None + h2: bool + alpn: str | None = None + + x25519: bool | None = None + post_quantum: bool | None = None + curve: str | None = None + + h3: bool = False + + cert_valid: bool + cert_subject: str | None = None + cert_issuer: str | None = None + not_after: str | None = None + server_names: list[str] = Field(default_factory=list) + + latency_ms: int | None = None + reason: str | None = None diff --git a/app/operation/core.py b/app/operation/core.py index 115cf7f6b..acdcca8fc 100644 --- a/app/operation/core.py +++ b/app/operation/core.py @@ -25,8 +25,10 @@ CoresSimpleResponse, RemoveCoresResponse, ) +from app.models.reality_scan import RealityScanRequest, RealityScanResult from app.operation import BaseOperation from app.utils.logger import get_logger +from app.utils.reality_scan import RealityScanError, scan_reality_target logger = get_logger("core-operation") @@ -36,6 +38,16 @@ async def _refresh_hosts_from_db(self, db: AsyncSession) -> None: db_hosts = await get_hosts(db=db) await host_manager.add_hosts(db, db_hosts) + async def scan_reality_target(self, request: RealityScanRequest) -> RealityScanResult: + try: + result = await scan_reality_target(target=request.target, sni=request.sni, timeout=request.timeout) + except RealityScanError as e: + await self.raise_error(message=str(e), code=400) + except Exception as e: + logger.warning("reality scan failed for %r: %s", request.target, e) + await self.raise_error(message=f"Reality scan failed: {e}", code=502) + return RealityScanResult.model_validate(result) + async def create_core(self, db: AsyncSession, new_core: CoreCreate, admin: AdminDetails) -> CoreResponse: try: validated_core = core_manager.validate_core( diff --git a/app/routers/core.py b/app/routers/core.py index 26f3100da..e4b3e1fd4 100644 --- a/app/routers/core.py +++ b/app/routers/core.py @@ -10,6 +10,7 @@ CoresSimpleResponse, RemoveCoresResponse, ) +from app.models.reality_scan import RealityScanRequest, RealityScanResult from app.operation import OperatorType from app.operation.core import CoreOperation from app.operation.node import NodeOperation @@ -33,6 +34,14 @@ async def create_core_config( return await core_operator.create_core(db, new_core, admin) +@router.post("/reality-scan", response_model=RealityScanResult) +async def scan_reality_target( + request: RealityScanRequest, + _: AdminDetails = Depends(require_permission("cores", "read")), +): + return await core_operator.scan_reality_target(request) + + @router.get("/{core_id}", response_model=CoreResponse) async def get_core_config( core_id: int, _: AdminDetails = Depends(require_permission("cores", "read")), db: AsyncSession = Depends(get_db) diff --git a/app/utils/reality_scan.py b/app/utils/reality_scan.py new file mode 100644 index 000000000..0442775dc --- /dev/null +++ b/app/utils/reality_scan.py @@ -0,0 +1,557 @@ +import asyncio +import ipaddress +import os +import socket +import ssl +import struct +import time + +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import x25519 as _x25519 +from cryptography.x509.oid import ExtensionOID, NameOID + +from app.utils.logger import get_logger + +logger = get_logger("reality-scan") + +DEFAULT_PORT = 443 +DEFAULT_TIMEOUT = 10.0 +MIN_TIMEOUT = 1.0 +MAX_TIMEOUT = 20.0 +DNS_TIMEOUT = 5.0 +MAX_CONCURRENT_SCANS = 4 + +_scan_semaphore: "asyncio.Semaphore | 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 + +GROUP_X25519 = 0x001D +GROUP_X25519MLKEM768 = 0x11EC +GROUP_X25519KYBER768DRAFT00 = 0x6399 +GROUP_NAMES: dict[int, str] = { + 0x0017: "secp256r1", + 0x0018: "secp384r1", + 0x0019: "secp521r1", + 0x001D: "X25519", + 0x001E: "x448", + 0x11EC: "X25519MLKEM768", + 0x6399: "X25519Kyber768Draft00", +} +_POST_QUANTUM_GROUPS = {GROUP_X25519MLKEM768, GROUP_X25519KYBER768DRAFT00} +_X25519_GROUPS = {GROUP_X25519, GROUP_X25519MLKEM768} + +_HELLO_RETRY_REQUEST_RANDOM = bytes.fromhex("cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c") + + +class RealityScanError(ValueError): + pass + + +def parse_target(target: str, sni_override: str | None = None) -> tuple[str, int, str | None]: + if not target or not target.strip(): + raise RealityScanError("A target host is required.") + + value = target.strip() + if "://" in value: + value = value.split("://", 1)[1] + value = value.split("/", 1)[0].strip() + if not value: + raise RealityScanError("A target host is required.") + + if value.startswith("["): + close = value.find("]") + if close == -1: + raise RealityScanError("Invalid IPv6 target: missing closing bracket.") + host = value[1:close] + rest = value[close + 1 :] + port = _parse_port(rest[1:]) if rest.startswith(":") else DEFAULT_PORT + elif value.count(":") == 1: + host, port_str = value.rsplit(":", 1) + port = _parse_port(port_str) + else: + host = value + port = DEFAULT_PORT + + host = host.strip() + 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 + + return host, port, sni + + +def _parse_port(port_str: str) -> int: + port_str = port_str.strip() + if not port_str: + return DEFAULT_PORT + try: + port = int(port_str) + except ValueError: + raise RealityScanError(f"Invalid port: {port_str!r}") + if not (1 <= port <= 65535): + raise RealityScanError("Port must be between 1 and 65535.") + return port + + +def _is_ip_literal(host: str) -> bool: + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + + +def _address_is_public(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + return bool(ip_obj.is_global) and not ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_multicast + or ip_obj.is_reserved + or ip_obj.is_unspecified + ) + + +def _select_public_ip(host: str, infos: list) -> str: + candidates = sorted(infos, key=lambda info: 0 if info[0] == socket.AF_INET else 1) + saw_blocked = False + for _family, _type, _proto, _canon, sockaddr in candidates: + ip = sockaddr[0] + try: + ip_obj = ipaddress.ip_address(ip) + except ValueError: + continue + if _address_is_public(ip_obj): + return ip + saw_blocked = True + + if saw_blocked: + raise RealityScanError("Target resolves only to private or reserved addresses; only public hosts can be scanned.") + raise RealityScanError(f"Could not resolve host to a usable address: {host}") + + +def resolve_public_ip(host: str) -> str: + if _is_ip_literal(host): + ip_obj = ipaddress.ip_address(host) + if not _address_is_public(ip_obj): + raise RealityScanError("Target address is private or reserved; only public hosts can be scanned.") + return host + + try: + infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) + except socket.gaierror: + raise RealityScanError(f"Could not resolve host: {host}") + return _select_public_ip(host, infos) + + +async def _resolve_public_ip_async(host: str, timeout: float) -> str: + if _is_ip_literal(host): + return resolve_public_ip(host) + + loop = asyncio.get_running_loop() + try: + infos = await asyncio.wait_for(loop.getaddrinfo(host, None, type=socket.SOCK_STREAM), timeout=timeout) + except (asyncio.TimeoutError, TimeoutError): + raise RealityScanError(f"DNS lookup for {host} timed out.") + except socket.gaierror: + raise RealityScanError(f"Could not resolve host: {host}") + return _select_public_ip(host, infos) + + +def _clamp_timeout(timeout: float | None) -> float: + if not timeout or timeout <= 0: + return DEFAULT_TIMEOUT + return max(MIN_TIMEOUT, min(MAX_TIMEOUT, float(timeout))) + + +def _name_common_name(name: x509.Name) -> str | None: + try: + attrs = name.get_attributes_for_oid(NameOID.COMMON_NAME) + if attrs: + return str(attrs[0].value) + except Exception: + pass + return None + + +def _name_organization(name: x509.Name) -> str | None: + try: + attrs = name.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) + if attrs: + return str(attrs[0].value) + except Exception: + pass + return None + + +def _parse_certificate(der: bytes | None) -> dict: + out: dict = {"cert_subject": None, "cert_issuer": None, "not_after": None, "server_names": []} + if not der: + return out + try: + cert = x509.load_der_x509_certificate(der) + except Exception as exc: + logger.debug("reality-scan: failed to parse certificate: %s", exc) + return out + + out["cert_subject"] = _name_common_name(cert.subject) or (cert.subject.rfc4514_string() or None) + out["cert_issuer"] = _name_organization(cert.issuer) or _name_common_name(cert.issuer) or (cert.issuer.rfc4514_string() or None) + + try: + out["not_after"] = cert.not_valid_after_utc.isoformat() + except Exception: + try: + out["not_after"] = cert.not_valid_after.isoformat() + except Exception: + out["not_after"] = None + + try: + san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) + dns_names = san.value.get_values_for_type(x509.DNSName) + seen: set[str] = set() + server_names: list[str] = [] + for name in dns_names: + if name.startswith("*.") or name in seen: + continue + seen.add(name) + server_names.append(name) + out["server_names"] = server_names + except x509.ExtensionNotFound: + pass + except Exception as exc: + logger.debug("reality-scan: failed to read SANs: %s", exc) + + return out + + +def _tls_probe(ip: str, port: int, sni: str | None, timeout: float) -> dict: + result: dict = { + "tls13": False, + "tls_version": None, + "alpn": None, + "h2": False, + "cert_valid": False, + "cert_subject": None, + "cert_issuer": None, + "not_after": None, + "server_names": [], + "latency_ms": None, + "reason": None, + } + + 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 + + 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 + + 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) + result["latency_ms"] = round(latency) + except Exception as exc2: + result["reason"] = f"TLS handshake failed: {exc2}" + return result + except (socket.timeout, TimeoutError): + result["reason"] = "Connection timed out." + return result + except ssl.SSLError as exc: + result["reason"] = f"TLS handshake failed: {exc}" + return result + except (ConnectionRefusedError, ConnectionResetError, OSError) as exc: + result["reason"] = f"Connection failed: {exc}" + return result + + result["tls_version"] = _pretty_tls_version(version) + result["tls13"] = version == "TLSv1.3" + result["alpn"] = alpn + result["h2"] = alpn == "h2" + result.update(_parse_certificate(der)) + return result + + +def _pretty_tls_version(version: str | None) -> str | None: + if not version: + return None + mapping = {"TLSv1.3": "1.3", "TLSv1.2": "1.2", "TLSv1.1": "1.1", "TLSv1": "1.0"} + return mapping.get(version, version) + + +def _build_client_hello(sni: str | None) -> bytes: + priv = _x25519.X25519PrivateKey.generate() + x25519_pub = priv.public_key().public_bytes_raw() + + extensions = b"" + + if sni: + try: + sni_bytes = sni.encode("idna") + except Exception: + try: + sni_bytes = sni.encode("ascii") + except Exception: + sni_bytes = b"" + if sni_bytes: + host_entry = b"\x00" + struct.pack(">H", len(sni_bytes)) + sni_bytes + sni_list = struct.pack(">H", len(host_entry)) + host_entry + extensions += struct.pack(">HH", 0x0000, len(sni_list)) + sni_list + + sv = bytes([0x02]) + struct.pack(">H", 0x0304) + extensions += struct.pack(">HH", 0x002B, len(sv)) + sv + + groups = struct.pack(">HHHHH", GROUP_X25519MLKEM768, GROUP_X25519, 0x0017, 0x001E, 0x0018) + sg = struct.pack(">H", len(groups)) + groups + extensions += struct.pack(">HH", 0x000A, len(sg)) + sg + + ks_entry = struct.pack(">HH", GROUP_X25519, len(x25519_pub)) + x25519_pub + client_shares = struct.pack(">H", len(ks_entry)) + ks_entry + extensions += struct.pack(">HH", 0x0033, len(client_shares)) + client_shares + + algs = struct.pack(">HHHHHHHH", 0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601) + sa = struct.pack(">H", len(algs)) + algs + extensions += struct.pack(">HH", 0x000D, len(sa)) + sa + + pkem = bytes([0x01, 0x01]) + extensions += struct.pack(">HH", 0x002D, len(pkem)) + pkem + + alpn_protos = bytes([len(b"h2")]) + b"h2" + bytes([len(b"http/1.1")]) + b"http/1.1" + alpn = struct.pack(">H", len(alpn_protos)) + alpn_protos + extensions += struct.pack(">HH", 0x0010, len(alpn)) + alpn + + ext_block = struct.pack(">H", len(extensions)) + extensions + + client_random = os.urandom(32) + session_id = os.urandom(32) + cipher_suites = struct.pack(">HHH", 0x1301, 0x1302, 0x1303) + body = ( + struct.pack(">H", 0x0303) + + client_random + + bytes([len(session_id)]) + + session_id + + struct.pack(">H", len(cipher_suites)) + + cipher_suites + + bytes([0x01, 0x00]) + + ext_block + ) + + handshake = bytes([0x01]) + len(body).to_bytes(3, "big") + body + record = bytes([0x16]) + struct.pack(">H", 0x0301) + struct.pack(">H", len(handshake)) + handshake + return record + + +def _recv_exact(sock: socket.socket, count: int, deadline: float) -> bytes | None: + buf = b"" + while len(buf) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + sock.settimeout(remaining) + try: + chunk = sock.recv(count - len(buf)) + except (socket.timeout, TimeoutError): + return None + if not chunk: + return None + buf += chunk + return buf + + +def _read_first_handshake_message(sock: socket.socket, deadline: float) -> bytes | None: + hs_buf = b"" + for _ in range(16): + header = _recv_exact(sock, 5, deadline) + if not header or len(header) < 5: + return None + rtype = header[0] + rlen = (header[3] << 8) | header[4] + if rlen == 0: + continue + payload = _recv_exact(sock, rlen, deadline) + if payload is None: + return None + if rtype == 0x15: + return None + if rtype == 0x16: + hs_buf += payload + if len(hs_buf) >= 4: + msg_len = (hs_buf[1] << 16) | (hs_buf[2] << 8) | hs_buf[3] + if len(hs_buf) >= 4 + msg_len: + return hs_buf[: 4 + msg_len] + return None + + +def _parse_server_hello(msg: bytes) -> tuple[bool, int | None, bool]: + if len(msg) < 4 or msg[0] != 0x02: + return False, None, False + body = msg[4:] + if len(body) < 35: + return False, None, False + + server_random = body[2:34] + is_hrr = server_random == _HELLO_RETRY_REQUEST_RANDOM + + idx = 34 + if idx >= len(body): + return is_hrr, None, False + sid_len = body[idx] + idx += 1 + sid_len + idx += 2 + idx += 1 + if idx + 2 > len(body): + return is_hrr, None, False + ext_total = (body[idx] << 8) | body[idx + 1] + idx += 2 + end = min(idx + ext_total, len(body)) + + selected_group: int | None = None + is_tls13 = False + while idx + 4 <= end: + etype = (body[idx] << 8) | body[idx + 1] + elen = (body[idx + 2] << 8) | body[idx + 3] + edata = body[idx + 4 : idx + 4 + elen] + if etype == 0x002B and len(edata) >= 2: + if ((edata[0] << 8) | edata[1]) == 0x0304: + is_tls13 = True + elif etype == 0x0033 and len(edata) >= 2: + selected_group = (edata[0] << 8) | edata[1] + idx += 4 + elen + + return is_hrr, selected_group, is_tls13 + + +def _group_probe(ip: str, port: int, sni: str | None, timeout: float) -> dict: + result = {"x25519": None, "post_quantum": None, "curve": None} + try: + deadline = time.monotonic() + timeout + with socket.create_connection((ip, port), timeout=timeout) as sock: + sock.sendall(_build_client_hello(sni)) + msg = _read_first_handshake_message(sock, deadline) + if not msg: + return result + _is_hrr, selected_group, _is_tls13 = _parse_server_hello(msg) + if selected_group is None: + return result + result["curve"] = GROUP_NAMES.get(selected_group, f"0x{selected_group:04x}") + result["post_quantum"] = selected_group in _POST_QUANTUM_GROUPS + result["x25519"] = selected_group in _X25519_GROUPS + return result + except Exception as exc: + logger.debug("reality-scan: group probe failed for %s:%s: %s", ip, port, exc) + return result + + +def _h3_probe(host: str, ip: str, port: int, sni: str | None, timeout: float) -> bool: + try: + 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(["http/1.1"]) + request_host = sni or host + 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: + 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() + if lower.startswith("alt-svc:") and "h3" in lower: + return True + return False + except Exception as exc: + logger.debug("reality-scan: h3 probe failed for %s:%s: %s", ip, port, exc) + return False + + +def _scan_sync(host: str, ip: str, port: int, sni: str | None, timeout: float) -> dict: + tls = _tls_probe(ip, port, sni, timeout) + + result: dict = { + "target": f"{host}:{port}", + "host": host, + "ip": ip, + "port": port, + "sni": sni, + "feasible": False, + "tls13": tls["tls13"], + "tls_version": tls["tls_version"], + "h2": tls["h2"], + "alpn": tls["alpn"], + "x25519": None, + "post_quantum": None, + "curve": None, + "h3": False, + "cert_valid": tls["cert_valid"], + "cert_subject": tls["cert_subject"], + "cert_issuer": tls["cert_issuer"], + "not_after": tls["not_after"], + "server_names": tls["server_names"], + "latency_ms": tls["latency_ms"], + "reason": tls["reason"], + } + + if tls["tls_version"] is None: + return result + + group = _group_probe(ip, port, 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) + 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) + clamped = _clamp_timeout(timeout) + async with _get_scan_semaphore(): + ip = await _resolve_public_ip_async(host, min(clamped, DNS_TIMEOUT)) + return await asyncio.to_thread(_scan_sync, host, ip, port, resolved_sni, clamped) 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 new file mode 100644 index 000000000..4718bd51e --- /dev/null +++ b/dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx @@ -0,0 +1,304 @@ +import { FormEvent, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CircleCheck, CircleHelp, CircleX, Loader2, ScanSearch } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { CoreEditorFormDialog } from '@/features/core-editor/components/shared/core-editor-form-dialog' +import { RealityScanResult, scanRealityTarget } from '@/service/reality-scan' +import dayjs from '@/lib/dayjs' +import { dateUtils } from '@/utils/dateFormatter' +import { cn } from '@/lib/utils' + +interface RealityScanDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + initialTarget?: string + initialSni?: string +} + +const getErrorMessage = (error: unknown, fallback: string) => { + if (!error || typeof error !== 'object') return fallback + const maybeError = error as { data?: { detail?: string }; message?: string } + return maybeError.data?.detail || maybeError.message || fallback +} + +type TriState = boolean | null | undefined + +function CheckRow({ status, label, detail }: { status: TriState; label: string; detail?: string }) { + const icon = + status === true ? ( + + ) : status === false ? ( + + ) : ( + + ) + return ( +
+
+ {icon} + {label} +
+ {detail ? ( + + {detail} + + ) : null} +
+ ) +} + +export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSni }: RealityScanDialogProps) { + const { t } = useTranslation() + const [target, setTarget] = useState('') + const [sni, setSni] = useState('') + const [timeoutInput, setTimeoutInput] = useState('10') + const [result, setResult] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const [isScanning, setIsScanning] = useState(false) + const abortRef = useRef(null) + + useEffect(() => { + if (open) { + abortRef.current?.abort() + abortRef.current = null + setTarget(initialTarget?.trim() ?? '') + setSni(initialSni?.trim() ?? '') + setResult(null) + setErrorMessage(null) + setIsScanning(false) + } + }, [open, initialTarget, initialSni]) + + useEffect(() => () => abortRef.current?.abort(), []) + + const runScan = async () => { + const trimmed = target.trim() + if (!trimmed) return + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + setIsScanning(true) + setErrorMessage(null) + try { + const parsedTimeout = Number(timeoutInput) + const response = await scanRealityTarget( + { + target: trimmed, + sni: sni.trim() || undefined, + timeout: Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : undefined, + }, + controller.signal, + ) + if (controller.signal.aborted) return + setResult(response) + } catch (error) { + if (controller.signal.aborted) return + setResult(null) + setErrorMessage(getErrorMessage(error, t('coreEditor.realityScan.errorDescription', { defaultValue: 'Unable to scan this target.' }))) + } finally { + if (abortRef.current === controller) { + abortRef.current = null + setIsScanning(false) + } + } + } + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + runScan() + } + + const canScan = !!target.trim() && !isScanning + + const keyExchangeDetail = + result?.curve ?? + (result?.x25519 === null ? t('coreEditor.realityScan.unknown', { defaultValue: 'Unknown' }) : result?.x25519 ? 'X25519' : t('coreEditor.realityScan.other', { defaultValue: 'Other' })) + + const formatExpiry = (iso: string | null) => { + if (!iso) return null + const d = dayjs(iso) + if (!d.isValid()) return iso + return `${d.fromNow()} (${dateUtils.formatDate(d.unix())})` + } + + return ( + } + size="lg" + inlinePersistValidation={false} + footerExtra={ + + } + > +
+

+ {t('coreEditor.realityScan.description', { + defaultValue: 'Probe a target to check it works as a REALITY decoy. REALITY needs HTTP/2 and TLS 1.3.', + })} +

+ +
+
+ + { + setTarget(event.target.value) + setSni('') + }} + placeholder="www.microsoft.com:443" + disabled={isScanning} + dir="ltr" + autoComplete="off" + spellCheck={false} + /> +
+
+ + setTimeoutInput(event.target.value)} inputMode="numeric" type="number" min={1} max={20} disabled={isScanning} dir="ltr" /> +
+
+ + {errorMessage && ( + + {errorMessage} + + )} + +
+ {isScanning && !result ? ( +
+
+ + {t('coreEditor.realityScan.loading', { defaultValue: 'Scanning target...' })} +
+
+ ) : result ? ( +
+
+
+ {result.feasible ? : } + + {result.feasible + ? t('coreEditor.realityScan.feasible', { defaultValue: 'Suitable Reality target' }) + : t('coreEditor.realityScan.notFeasible', { defaultValue: 'Not a suitable Reality target' })} + +
+
+ + {result.host} + {result.ip ? ` (${result.ip})` : ''}:{result.port} + + {result.latency_ms !== null ? ( + + {result.latency_ms} ms + + ) : null} +
+
+ + {result.reason ? ( + + + {result.reason} + + + ) : null} + +
+ + + + + + + + + + + +
+ +
+ {result.cert_subject ? ( +
+ {t('coreEditor.realityScan.subject', { defaultValue: 'Subject' })}: + + {result.cert_subject} + +
+ ) : null} + {result.not_after ? ( +
+ {t('coreEditor.realityScan.expires', { defaultValue: 'Expires' })}: + + {formatExpiry(result.not_after)} + +
+ ) : null} +
+ + {result.server_names.length ? ( +
+
+ {t('coreEditor.realityScan.serverNames', { defaultValue: 'Certificate server names (valid SNIs)' })} +
+
+ {result.server_names.map(name => ( + + {name} + + ))} +
+
+ ) : null} +
+ ) : ( +
+ {t('coreEditor.realityScan.empty', { defaultValue: 'Enter a target host and run the scan.' })} +
+ )} +
+
+
+ ) +} 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 89eb4bb75..bf8556f54 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 @@ -28,6 +28,7 @@ import { remapIndexAfterArrayMove } from '@/features/core-editor/kit/remap-index import { isPlaceholderTunnelRewriteAddress, normalizeTunnelNetworkForKit } from '@/features/core-editor/kit/sanitize-inbound' import { inferParityFieldMode, outboundSettingToString, parseOutboundSettingValue, stringifyJsonFormRecord } from '@/features/core-editor/kit/xray-parity-value' import { useCoreEditorStore } from '@/features/core-editor/state/core-editor-store' +import { RealityScanDialog } from '@/features/core-editor/components/xray/reality-scan-dialog' import useDirDetection from '@/hooks/use-dir-detection' import { cn } from '@/lib/utils' import { @@ -92,6 +93,24 @@ 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('*')) || parts.find(Boolean) || '' +} + /** 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)', @@ -900,6 +919,9 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo const [isGeneratingRealityKeyPair, setIsGeneratingRealityKeyPair] = useState(false) const [isGeneratingRealityShortId, setIsGeneratingRealityShortId] = useState(false) 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) @@ -4001,6 +4023,24 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo )} + {isReality && jsonKey === 'target' && ( +
+ { + 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" + isLoading={false} + > + {t('coreConfigModal.scanRealityTarget', { defaultValue: 'Scan target' })} + +
+ )} ) })} @@ -5305,7 +5345,9 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo }} /> - + + + diff --git a/dashboard/src/service/reality-scan.ts b/dashboard/src/service/reality-scan.ts new file mode 100644 index 000000000..6e61a9aac --- /dev/null +++ b/dashboard/src/service/reality-scan.ts @@ -0,0 +1,35 @@ +import { orvalFetcher } from './http' + +export interface RealityScanRequest { + target: string + sni?: string | null + timeout?: number | null +} + +export interface RealityScanResult { + target: string + host: string + ip: string | null + port: number + sni: string | null + feasible: boolean + tls13: boolean + tls_version: string | null + h2: boolean + alpn: string | null + x25519: boolean | null + post_quantum: boolean | null + curve: string | null + h3: boolean + cert_valid: boolean + cert_subject: string | null + cert_issuer: string | null + not_after: string | null + server_names: string[] + latency_ms: number | null + reason: string | null +} + +export const scanRealityTarget = (data: RealityScanRequest, signal?: AbortSignal) => { + return orvalFetcher({ url: '/api/core/reality-scan', method: 'POST', data, signal }) +} diff --git a/tests/test_reality_scan_unit.py b/tests/test_reality_scan_unit.py new file mode 100644 index 000000000..00ae7ecf1 --- /dev/null +++ b/tests/test_reality_scan_unit.py @@ -0,0 +1,412 @@ +import datetime +import os +import struct + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from app.utils import reality_scan as rs +from app.utils.reality_scan import RealityScanError + + +@pytest.mark.parametrize( + "target,expected", + [ + ("www.microsoft.com:443", ("www.microsoft.com", 443, "www.microsoft.com")), + ("cloudflare.com", ("cloudflare.com", 443, "cloudflare.com")), + ("https://example.com/some/path", ("example.com", 443, "example.com")), + ("example.com:8443", ("example.com", 8443, "example.com")), + ("[2606:4700::1111]:443", ("2606:4700::1111", 443, None)), + ("1.1.1.1:8443", ("1.1.1.1", 8443, None)), + ], +) +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): + rs.parse_target(bad) + + +@pytest.mark.parametrize( + "ip", + ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0", "::1", "fd00::1", "224.0.0.1"], +) +def test_resolve_public_ip_blocks_internal_literals(ip): + with pytest.raises(RealityScanError): + rs.resolve_public_ip(ip) + + +@pytest.mark.parametrize("ip", ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"]) +def test_resolve_public_ip_allows_public_literals(ip): + assert rs.resolve_public_ip(ip) == ip + + +def test_resolve_public_ip_blocks_private_dns(monkeypatch): + monkeypatch.setattr( + rs.socket, + "getaddrinfo", + lambda *a, **k: [(rs.socket.AF_INET, rs.socket.SOCK_STREAM, 6, "", ("10.1.2.3", 0))], + ) + with pytest.raises(RealityScanError): + rs.resolve_public_ip("internal.example") + + +def test_resolve_public_ip_prefers_public_and_ipv4(monkeypatch): + monkeypatch.setattr( + rs.socket, + "getaddrinfo", + lambda *a, **k: [ + (rs.socket.AF_INET6, rs.socket.SOCK_STREAM, 6, "", ("fd00::5", 0, 0, 0)), + (rs.socket.AF_INET, rs.socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0)), + ], + ) + assert rs.resolve_public_ip("example.com") == "93.184.216.34" + + +def test_build_client_hello_is_well_formed(): + record = rs._build_client_hello("example.com") + assert record[0] == 0x16 + assert record[1:3] == b"\x03\x01" + rec_len = (record[3] << 8) | record[4] + assert rec_len == len(record) - 5 + handshake = record[5:] + assert handshake[0] == 0x01 + hs_len = (handshake[1] << 16) | (handshake[2] << 8) | handshake[3] + assert hs_len == len(handshake) - 4 + + +def _server_hello(selected_group: int, *, hrr: bool = False, tls13: bool = True) -> bytes: + random = rs._HELLO_RETRY_REQUEST_RANDOM if hrr else bytes(32) + exts = b"" + if tls13: + sv = struct.pack(">H", 0x0304) + exts += struct.pack(">HH", 0x002B, len(sv)) + sv + if hrr: + ks = struct.pack(">H", selected_group) + else: + key = bytes(32) + ks = struct.pack(">H", selected_group) + struct.pack(">H", len(key)) + key + exts += struct.pack(">HH", 0x0033, len(ks)) + ks + + session_id = bytes(0) + body = ( + struct.pack(">H", 0x0303) + + random + + bytes([len(session_id)]) + + session_id + + struct.pack(">H", 0x1301) + + bytes([0x00]) + + struct.pack(">H", len(exts)) + + exts + ) + return bytes([0x02]) + len(body).to_bytes(3, "big") + body + + +def test_parse_server_hello_x25519(): + is_hrr, group, tls13 = rs._parse_server_hello(_server_hello(rs.GROUP_X25519)) + assert (is_hrr, group, tls13) == (False, rs.GROUP_X25519, True) + + +def test_parse_server_hello_post_quantum_hrr(): + is_hrr, group, tls13 = rs._parse_server_hello(_server_hello(rs.GROUP_X25519MLKEM768, hrr=True)) + assert is_hrr is True + assert group == rs.GROUP_X25519MLKEM768 + + +def test_group_probe_interpretation(monkeypatch): + monkeypatch.setattr(rs.socket, "create_connection", lambda *a, **k: _FakeSock(_server_hello(rs.GROUP_X25519))) + out = rs._group_probe("1.2.3.4", 443, "example.com", 2) + assert out == {"x25519": True, "post_quantum": False, "curve": "X25519"} + + monkeypatch.setattr( + rs.socket, "create_connection", lambda *a, **k: _FakeSock(_server_hello(rs.GROUP_X25519MLKEM768, hrr=True)) + ) + out = rs._group_probe("1.2.3.4", 443, "example.com", 2) + assert out == {"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"} + + +def test_group_probe_failure_is_unknown(monkeypatch): + def boom(*a, **k): + raise OSError("refused") + + monkeypatch.setattr(rs.socket, "create_connection", boom) + assert rs._group_probe("1.2.3.4", 443, "example.com", 2) == {"x25519": None, "post_quantum": None, "curve": None} + + +class _FakeSock: + def __init__(self, server_hello: bytes): + record = bytes([0x16]) + struct.pack(">H", 0x0303) + struct.pack(">H", len(server_hello)) + server_hello + self._buf = record + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def settimeout(self, _): + pass + + def sendall(self, _): + pass + + def recv(self, n): + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + +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 = ( + 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)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]), critical=False) + .sign(key, hashes.SHA256()) + ) + return cert.public_bytes(serialization.Encoding.DER) + + +def test_parse_certificate_extracts_sans_and_filters_wildcards(): + der = _self_signed_der("example.com", ["example.com", "*.example.com", "www.example.com"]) + out = rs._parse_certificate(der) + assert out["cert_subject"] == "example.com" + assert out["cert_issuer"] == "Test Org" + assert out["server_names"] == ["example.com", "www.example.com"] + assert out["not_after"] is not None + + +def test_parse_certificate_handles_none(): + assert rs._parse_certificate(None)["server_names"] == [] + + +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) + monkeypatch.setattr(rs, "_h3_probe", lambda *a, **k: h3) + + +_GOOD_TLS = { + "tls13": True, + "tls_version": "1.3", + "alpn": "h2", + "h2": True, + "cert_valid": True, + "cert_subject": "example.com", + "cert_issuer": "CA", + "not_after": "2030-01-01T00:00:00+00:00", + "server_names": ["example.com"], + "latency_ms": 42, + "reason": None, +} + + +def test_scan_sync_feasible_when_all_pass(monkeypatch): + _patch_probes(monkeypatch, tls=dict(_GOOD_TLS), group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=True) + out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5) + assert out["feasible"] is True + assert out["post_quantum"] is True + assert out["h3"] is True + + +def test_scan_sync_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 True + + +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 + + +def test_scan_sync_not_feasible_without_tls13(monkeypatch): + tls = dict(_GOOD_TLS, tls13=False, tls_version="1.2") + _patch_probes(monkeypatch, tls=tls, group={"x25519": True, "post_quantum": False, "curve": "X25519"}, h3=False) + out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5) + assert out["feasible"] is False + + +def test_scan_sync_skips_extra_probes_when_unreachable(monkeypatch): + tls = dict(_GOOD_TLS, tls13=False, tls_version=None, h2=False, cert_valid=False, reason="Connection timed out.") + called = {"group": False, "h3": False} + + def group(*a, **k): + called["group"] = True + return {"x25519": None, "post_quantum": None, "curve": None} + + def h3(*a, **k): + called["h3"] = True + return False + + monkeypatch.setattr(rs, "_tls_probe", lambda *a, **k: tls) + monkeypatch.setattr(rs, "_group_probe", group) + monkeypatch.setattr(rs, "_h3_probe", h3) + out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5) + assert out["feasible"] is False + assert called == {"group": False, "h3": 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_live_example(): + result = await rs.scan_reality_target("example.com:443", timeout=8) + assert result["tls13"] is True + assert result["h2"] is True + assert result["cert_valid"] is True + assert result["latency_ms"] is not None + + +def _frame(payload: bytes, rtype: int = 0x16) -> bytes: + return bytes([rtype]) + struct.pack(">H", 0x0303) + struct.pack(">H", len(payload)) + payload + + +class _RawSock: + def __init__(self, raw: bytes): + self._buf = raw + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def settimeout(self, _): + pass + + def sendall(self, _): + pass + + def recv(self, n): + chunk, self._buf = self._buf[:n], self._buf[n:] + return chunk + + +def test_select_public_ip_prefers_public_v4_over_private_v6(): + infos = [ + (rs.socket.AF_INET6, rs.socket.SOCK_STREAM, 6, "", ("fd00::1", 0, 0, 0)), + (rs.socket.AF_INET, rs.socket.SOCK_STREAM, 6, "", ("1.2.3.4", 0)), + ] + assert rs._select_public_ip("h", infos) == "1.2.3.4" + + +def test_select_public_ip_all_private_raises(): + infos = [(rs.socket.AF_INET, rs.socket.SOCK_STREAM, 6, "", ("10.0.0.1", 0))] + with pytest.raises(RealityScanError): + rs._select_public_ip("h", infos) + + +@pytest.mark.asyncio +async def test_resolve_public_ip_async_times_out(monkeypatch): + import asyncio + + async def slow(*a, **k): + await asyncio.sleep(30) + + monkeypatch.setattr(asyncio.get_running_loop(), "getaddrinfo", slow) + with pytest.raises(RealityScanError, match="timed out"): + await rs._resolve_public_ip_async("slow.example", 0.2) + + +@pytest.mark.asyncio +async def test_resolve_public_ip_async_literal_skips_dns(): + assert await rs._resolve_public_ip_async("1.1.1.1", 1.0) == "1.1.1.1" + + +def test_read_first_handshake_message_reassembles_across_records(): + sh = _server_hello(rs.GROUP_X25519) + split = len(sh) // 2 + raw = _frame(sh[:split]) + _frame(sh[split:]) + msg = rs._read_first_handshake_message(_RawSock(raw), deadline=__import__("time").monotonic() + 5) + assert msg == sh + + +def test_read_first_handshake_message_alert_returns_none(): + raw = _frame(b"\x02\x28", rtype=0x15) + msg = rs._read_first_handshake_message(_RawSock(raw), deadline=__import__("time").monotonic() + 5) + assert msg is None + + +def test_group_probe_non_x25519_group(monkeypatch): + monkeypatch.setattr(rs.socket, "create_connection", lambda *a, **k: _FakeSock(_server_hello(0x0017))) + out = rs._group_probe("1.2.3.4", 443, "example.com", 2) + assert out == {"x25519": False, "post_quantum": False, "curve": "secp256r1"} + + +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)) + assert out["cert_subject"] == "no-san.example" + assert out["server_names"] == [] + + +def test_scan_sync_not_feasible_without_h2(monkeypatch): + tls = dict(_GOOD_TLS, h2=False, alpn="http/1.1") + _patch_probes(monkeypatch, tls=tls, group={"x25519": True, "post_quantum": True, "curve": "X25519MLKEM768"}, h3=False) + out = rs._scan_sync("example.com", "93.184.216.34", 443, "example.com", 5) + assert out["feasible"] is False + + +@pytest.mark.asyncio +async def test_scan_concurrency_is_capped(monkeypatch): + import asyncio + import threading + import time as _time + + rs._scan_semaphore = None + lock = threading.Lock() + state = {"live": 0, "peak": 0} + resolver = {"live": 0, "peak": 0} + + async def fake_resolve(host, timeout): + resolver["live"] += 1 + resolver["peak"] = max(resolver["peak"], resolver["live"]) + await asyncio.sleep(0.02) + resolver["live"] -= 1 + return "93.184.216.34" + + def fake_sync(*a, **k): + with lock: + state["live"] += 1 + state["peak"] = max(state["peak"], state["live"]) + _time.sleep(0.05) + with lock: + state["live"] -= 1 + return {"feasible": False} + + monkeypatch.setattr(rs, "_resolve_public_ip_async", fake_resolve) + monkeypatch.setattr(rs, "_scan_sync", fake_sync) + + 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 From 8b641db6a480e66e4ef9be99018decc128a8f325 Mon Sep 17 00:00:00 2001 From: parsa Date: Mon, 13 Jul 2026 07:22:53 -0700 Subject: [PATCH 2/6] fix(core): harden reality scanner (review follow-ups) - _h3_probe: bound the response read with an absolute deadline (was a per-recv timeout only) and cap the whole scan with asyncio.wait_for, so a slowloris target cannot pin a scan slot/thread for a long time. - parse_target: reject control characters (CR/LF/NUL/tab) in target and sni, preventing header injection into the h3 probe request. - dashboard: coerce a non-string FastAPI 422 detail (array/object) to text in getErrorMessage so a validation error cannot crash the dialog render. - dashboard: when every REALITY serverName is a wildcard, scan without an SNI instead of sending an invalid "*.host" SNI. --- app/utils/reality_scan.py | 16 +++++++++++++++- .../components/xray/reality-scan-dialog.tsx | 13 +++++++++++-- .../components/xray/xray-inbounds-section.tsx | 2 +- tests/test_reality_scan_unit.py | 8 ++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/utils/reality_scan.py b/app/utils/reality_scan.py index 0442775dc..dff6efeb5 100644 --- a/app/utils/reality_scan.py +++ b/app/utils/reality_scan.py @@ -52,9 +52,15 @@ class RealityScanError(ValueError): pass +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]: 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.") value = target.strip() if "://" in value: @@ -481,6 +487,7 @@ 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 + 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) @@ -491,6 +498,10 @@ def _h3_probe(host: str, ip: str, port: int, sni: str | None, timeout: float) -> 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 @@ -554,4 +565,7 @@ async def scan_reality_target(target: str, sni: str | None = None, timeout: floa clamped = _clamp_timeout(timeout) async with _get_scan_semaphore(): ip = await _resolve_public_ip_async(host, min(clamped, DNS_TIMEOUT)) - return await asyncio.to_thread(_scan_sync, host, ip, port, resolved_sni, clamped) + try: + return await asyncio.wait_for(asyncio.to_thread(_scan_sync, host, ip, port, resolved_sni, clamped), timeout=clamped * 6 + 15) + except TimeoutError: + raise RealityScanError("Scan timed out.") 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 4718bd51e..9456147f8 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 @@ -22,8 +22,17 @@ interface RealityScanDialogProps { const getErrorMessage = (error: unknown, fallback: string) => { if (!error || typeof error !== 'object') return fallback - const maybeError = error as { data?: { detail?: string }; message?: string } - return maybeError.data?.detail || maybeError.message || fallback + const maybeError = error as { data?: { detail?: unknown }; message?: string } + const detail = maybeError.data?.detail + if (typeof detail === 'string' && detail) return detail + if (Array.isArray(detail)) { + const joined = detail + .map(item => (item && typeof item === 'object' && 'msg' in item ? String((item as { msg?: unknown }).msg ?? '') : String(item))) + .filter(Boolean) + .join('; ') + if (joined) return joined + } + return maybeError.message || fallback } type TriState = boolean | null | undefined 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 bf8556f54..44a870426 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 @@ -108,7 +108,7 @@ function firstConfiguredServerName(raw: unknown): string { } else { parts = trimmed.split(/[\n,]/).map(part => part.trim()) } - return parts.find(p => p && !p.startsWith('*')) || parts.find(Boolean) || '' + return parts.find(p => p && !p.startsWith('*')) || '' } /** Plain English only — Xray REALITY / TLS / ECH field hints (not i18n). */ diff --git a/tests/test_reality_scan_unit.py b/tests/test_reality_scan_unit.py index 00ae7ecf1..e6404f286 100644 --- a/tests/test_reality_scan_unit.py +++ b/tests/test_reality_scan_unit.py @@ -38,6 +38,14 @@ def test_parse_target_invalid(bad): rs.parse_target(bad) +@pytest.mark.parametrize("bad", ["a\r\nX-Smuggled: 1", "host\nfoo", "h\x00st", "a\tb"]) +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( "ip", ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0", "::1", "fd00::1", "224.0.0.1"], From 21c75b8be20f2d887a61ee799d09e3cc8e7ddad6 Mon Sep 17 00:00:00 2001 From: parsa Date: Mon, 13 Jul 2026 04:51:17 -0700 Subject: [PATCH 3/6] feat(core): scan multiple REALITY targets at once Turn the REALITY scanner into a batch tool: add targets as chips (type and press space, comma, or enter; paste a whitespace/comma/newline list) and each is probed against the existing /api/core/reality-scan endpoint, bounded to a small client-side concurrency pool (the backend semaphore already caps real concurrency). Results stream in as compact per-target rows (verdict, TLS 1.3 / HTTP/2 badges, latency) that expand to the full detail card, with a "suitable only" filter and a running suitable/total count. A single target keeps the original detailed view. Frontend-only; no new endpoint or backend change, and reuses the abort/request-owner guard. --- .../components/xray/reality-scan-dialog.tsx | 555 ++++++++++++------ 1 file changed, 380 insertions(+), 175 deletions(-) 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 9456147f8..28d6a7669 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 @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { CircleCheck, CircleHelp, CircleX, Loader2, ScanSearch } from 'lucide-react' +import { ChevronDown, CircleCheck, CircleHelp, CircleX, Loader2, ScanSearch, X } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -20,6 +20,36 @@ interface RealityScanDialogProps { initialSni?: string } +const MAX_TARGETS = 25 +const SCAN_CONCURRENCY = 5 + +type RowStatus = 'pending' | 'scanning' | 'done' | 'error' + +interface ScanRow { + target: string + status: RowStatus + result?: RealityScanResult + error?: string +} + +function splitTokens(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map(part => part.trim()) + .filter(Boolean) +} + +async function runPool(items: T[], limit: number, worker: (item: T) => Promise) { + let cursor = 0 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const item = items[cursor++] + await worker(item) + } + }) + await Promise.all(workers) +} + const getErrorMessage = (error: unknown, fallback: string) => { if (!error || typeof error !== 'object') return fallback const maybeError = error as { data?: { detail?: unknown }; message?: string } @@ -61,13 +91,270 @@ function CheckRow({ status, label, detail }: { status: TriState; label: string; ) } +function MiniBadge({ ok, label }: { ok: boolean; label: string }) { + return ( + + {label} + + ) +} + +function RowStatusIcon({ row }: { row: ScanRow }) { + if (row.status === 'scanning') return + if (row.status === 'pending') return + if (row.status === 'error') return + return row.result?.feasible ? : +} + +function ScanResultDetail({ result }: { result: RealityScanResult }) { + const { t } = useTranslation() + const keyExchangeDetail = + result.curve ?? + (result.x25519 === null ? t('coreEditor.realityScan.unknown', { defaultValue: 'Unknown' }) : result.x25519 ? 'X25519' : t('coreEditor.realityScan.other', { defaultValue: 'Other' })) + + const formatExpiry = (iso: string | null) => { + if (!iso) return null + const d = dayjs(iso) + if (!d.isValid()) return iso + return `${d.fromNow()} (${dateUtils.formatDate(d.unix())})` + } + + return ( +
+
+
+ {result.feasible ? : } + + {result.feasible + ? t('coreEditor.realityScan.feasible', { defaultValue: 'Suitable Reality target' }) + : t('coreEditor.realityScan.notFeasible', { defaultValue: 'Not a suitable Reality target' })} + +
+
+ + {result.host} + {result.ip ? ` (${result.ip})` : ''}:{result.port} + + {result.latency_ms !== null ? ( + + {result.latency_ms} ms + + ) : null} +
+
+ + {result.reason ? ( + + + {result.reason} + + + ) : null} + +
+ + + + + + + + + + + +
+ +
+ {result.cert_subject ? ( +
+ {t('coreEditor.realityScan.subject', { defaultValue: 'Subject' })}: + + {result.cert_subject} + +
+ ) : null} + {result.not_after ? ( +
+ {t('coreEditor.realityScan.expires', { defaultValue: 'Expires' })}: + + {formatExpiry(result.not_after)} + +
+ ) : null} +
+ + {result.server_names.length ? ( +
+
{t('coreEditor.realityScan.serverNames', { defaultValue: 'Certificate server names (valid SNIs)' })}
+
+ {result.server_names.map(name => ( + + {name} + + ))} +
+
+ ) : null} +
+ ) +} + +function ScanRowItem({ row, expanded, onToggle }: { row: ScanRow; expanded: boolean; onToggle: () => void }) { + const { t } = useTranslation() + const canExpand = row.status === 'done' || row.status === 'error' + return ( +
+ + {expanded && row.status === 'done' && row.result ? ( +
+ +
+ ) : expanded && row.status === 'error' ? ( +
+ + + {row.error} + + +
+ ) : null} +
+ ) +} + +function TargetsInput({ value, onChange, disabled, max }: { value: string[]; onChange: (next: string[]) => void; disabled?: boolean; max: number }) { + const { t } = useTranslation() + const [draft, setDraft] = useState('') + const inputRef = useRef(null) + const atMax = value.length >= max + + const addTokens = (raw: string) => { + const next = [...value] + for (const part of splitTokens(raw)) { + if (next.length >= max) break + if (!next.includes(part)) next.push(part) + } + if (next.length !== value.length) onChange(next) + } + + const commitDraft = () => { + if (!draft.trim()) return + addTokens(draft) + setDraft('') + } + + return ( +
{ + if (event.target === event.currentTarget) inputRef.current?.focus() + }} + > + {value.map(tag => ( + + {tag} + + + ))} + { + const raw = event.target.value + if (/[\s,]/.test(raw)) { + addTokens(raw) + setDraft('') + } else { + setDraft(raw) + } + }} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + commitDraft() + } else if (event.key === 'Backspace' && draft === '' && value.length) { + onChange(value.slice(0, -1)) + } + }} + onPaste={event => { + const text = event.clipboardData.getData('text') + if (/[\s,\n]/.test(text)) { + event.preventDefault() + addTokens(text) + } + }} + onBlur={commitDraft} + /> +
+ ) +} + export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSni }: RealityScanDialogProps) { const { t } = useTranslation() - const [target, setTarget] = useState('') + const [targets, setTargets] = useState([]) const [sni, setSni] = useState('') const [timeoutInput, setTimeoutInput] = useState('10') - const [result, setResult] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) + const [rows, setRows] = useState([]) + const [expanded, setExpanded] = useState(null) + const [feasibleOnly, setFeasibleOnly] = useState(false) const [isScanning, setIsScanning] = useState(false) const abortRef = useRef(null) @@ -75,40 +362,51 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn if (open) { abortRef.current?.abort() abortRef.current = null - setTarget(initialTarget?.trim() ?? '') + setTargets(initialTarget?.trim() ? [initialTarget.trim()] : []) setSni(initialSni?.trim() ?? '') - setResult(null) - setErrorMessage(null) + setRows([]) + setExpanded(null) + setFeasibleOnly(false) setIsScanning(false) } }, [open, initialTarget, initialSni]) useEffect(() => () => abortRef.current?.abort(), []) + const canScan = targets.length > 0 && !isScanning + const runScan = async () => { - const trimmed = target.trim() - if (!trimmed) return + const list = targets + if (!list.length) return abortRef.current?.abort() const controller = new AbortController() 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 }))) setIsScanning(true) - setErrorMessage(null) - try { - const parsedTimeout = Number(timeoutInput) - const response = await scanRealityTarget( - { - target: trimmed, - sni: sni.trim() || undefined, - timeout: Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : undefined, - }, - controller.signal, - ) - if (controller.signal.aborted) return - setResult(response) - } catch (error) { + + const patch = (target: string, next: Partial) => { if (controller.signal.aborted) return - setResult(null) - setErrorMessage(getErrorMessage(error, t('coreEditor.realityScan.errorDescription', { defaultValue: 'Unable to scan this target.' }))) + setRows(prev => prev.map(row => (row.target === target ? { ...row, ...next } : row))) + } + + try { + await runPool(list, SCAN_CONCURRENCY, async target => { + if (controller.signal.aborted) return + patch(target, { status: 'scanning' }) + try { + const res = await scanRealityTarget({ target, sni: useSni, timeout }, controller.signal) + if (controller.signal.aborted) return + patch(target, { status: 'done', result: res }) + } catch (error) { + if (controller.signal.aborted) return + patch(target, { status: 'error', error: getErrorMessage(error, t('coreEditor.realityScan.errorDescription', { defaultValue: 'Unable to scan this target.' })) }) + } + }) } finally { if (abortRef.current === controller) { abortRef.current = null @@ -122,18 +420,9 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn runScan() } - const canScan = !!target.trim() && !isScanning - - const keyExchangeDetail = - result?.curve ?? - (result?.x25519 === null ? t('coreEditor.realityScan.unknown', { defaultValue: 'Unknown' }) : result?.x25519 ? 'X25519' : t('coreEditor.realityScan.other', { defaultValue: 'Other' })) - - const formatExpiry = (iso: string | null) => { - if (!iso) return null - const d = dayjs(iso) - if (!d.isValid()) return iso - return `${d.fromNow()} (${dateUtils.formatDate(d.unix())})` - } + const single = rows.length === 1 ? rows[0] : null + const feasibleCount = rows.filter(row => row.status === 'done' && row.result?.feasible).length + const displayedRows = feasibleOnly ? rows.filter(row => row.status === 'done' && row.result?.feasible) : rows return ( - {isScanning ? : } - {t('coreEditor.realityScan.scan', { defaultValue: 'Scan' })} - + isScanning ? ( + + ) : ( + + ) } >
-

- {t('coreEditor.realityScan.description', { - defaultValue: 'Probe a target to check it works as a REALITY decoy. REALITY needs HTTP/2 and TLS 1.3.', - })} -

+

{t('coreEditor.realityScan.description', { defaultValue: 'Probe a target to check it works as a REALITY decoy. REALITY needs HTTP/2 and TLS 1.3.' })}

-
+
- - { - setTarget(event.target.value) + + { + setTargets(next) setSni('') }} - placeholder="www.microsoft.com:443" disabled={isScanning} - dir="ltr" - autoComplete="off" - spellCheck={false} + max={MAX_TARGETS} />
@@ -180,130 +471,44 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn
- {errorMessage && ( - - {errorMessage} - - )} -
- {isScanning && !result ? ( -
-
- - {t('coreEditor.realityScan.loading', { defaultValue: 'Scanning target...' })} -
+ {!rows.length ? ( +
+ {t('coreEditor.realityScan.empty', { defaultValue: 'Enter one or more targets and run the scan.' })}
- ) : result ? ( -
-
-
- {result.feasible ? : } - - {result.feasible - ? t('coreEditor.realityScan.feasible', { defaultValue: 'Suitable Reality target' }) - : t('coreEditor.realityScan.notFeasible', { defaultValue: 'Not a suitable Reality target' })} - -
-
- - {result.host} - {result.ip ? ` (${result.ip})` : ''}:{result.port} - - {result.latency_ms !== null ? ( - - {result.latency_ms} ms - - ) : null} + ) : single ? ( + single.status === 'done' && single.result ? ( + + ) : single.status === 'error' ? ( + + + {single.error} + + + ) : ( +
+
+ + {t('coreEditor.realityScan.loading', { defaultValue: 'Scanning target...' })}
- - {result.reason ? ( - - - {result.reason} - - - ) : null} - -
- - - - - - - - - - - + ) + ) : ( +
+
+ + {t('coreEditor.realityScan.summary', { defaultValue: '{{feasible}} / {{total}} suitable', feasible: feasibleCount, total: rows.length })} + {isScanning ? : null} + +
- -
- {result.cert_subject ? ( -
- {t('coreEditor.realityScan.subject', { defaultValue: 'Subject' })}: - - {result.cert_subject} - -
- ) : null} - {result.not_after ? ( -
- {t('coreEditor.realityScan.expires', { defaultValue: 'Expires' })}: - - {formatExpiry(result.not_after)} - -
- ) : null} +
+ {displayedRows.map(row => ( + setExpanded(prev => (prev === row.target ? null : row.target))} /> + ))}
- - {result.server_names.length ? ( -
-
- {t('coreEditor.realityScan.serverNames', { defaultValue: 'Certificate server names (valid SNIs)' })} -
-
- {result.server_names.map(name => ( - - {name} - - ))} -
-
- ) : null} -
- ) : ( -
- {t('coreEditor.realityScan.empty', { defaultValue: 'Enter a target host and run the scan.' })}
)}
From 3ce1f211beae54937e599b13f9fb52703e62bde8 Mon Sep 17 00:00:00 2001 From: parsa Date: Mon, 13 Jul 2026 07:28:36 -0700 Subject: [PATCH 4/6] fix(core): batch scanner review follow-ups - Stop now optimistically clears the scanning state (and detaches the run) so control returns immediately even though the shared fetch client drops the abort signal; the request-owner guard still ignores any late results. - The targets chip input is fully inert during a scan: the text input and each chip's remove button are disabled, not just pointer-events, so a chip cannot be removed by keyboard mid-scan. - The input stays editable at the target cap (no frozen draft) and a hint shows when the maximum is reached. --- .../components/xray/reality-scan-dialog.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 28d6a7669..05869ee78 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 @@ -267,7 +267,6 @@ function TargetsInput({ value, onChange, disabled, max }: { value: string[]; onC const { t } = useTranslation() const [draft, setDraft] = useState('') const inputRef = useRef(null) - const atMax = value.length >= max const addTokens = (raw: string) => { const next = [...value] @@ -300,6 +299,7 @@ function TargetsInput({ value, onChange, disabled, max }: { value: string[]; onC {tag} @@ -450,7 +458,7 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn } >
-

{t('coreEditor.realityScan.description', { defaultValue: 'Probe a target to check it works as a REALITY decoy. REALITY needs HTTP/2 and TLS 1.3.' })}

+

{t('coreEditor.realityScan.description', { defaultValue: 'Probe one or more targets to check they work as REALITY decoys. REALITY needs HTTP/2 and TLS 1.3.' })}

@@ -464,6 +472,9 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn 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}
From 7cdfa5e26a6f6983df5a7f428ad247e79a109a18 Mon Sep 17 00:00:00 2001 From: parsa Date: Mon, 13 Jul 2026 10:05:51 -0700 Subject: [PATCH 5/6] fix(core): abort in-flight reality scans when the dialog closes The reset effect only aborted the running scan when the dialog opened; on close the parent keeps the component mounted, so the unmount cleanup never ran and probes kept hitting targets and patching state in the background. Abort and clear the scanning state on open -> false too. --- .../components/xray/reality-scan-dialog.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 05869ee78..e483360c3 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 @@ -359,16 +359,20 @@ export function RealityScanDialog({ open, onOpenChange, initialTarget, initialSn const abortRef = useRef(null) useEffect(() => { - if (open) { + if (!open) { abortRef.current?.abort() abortRef.current = null - setTargets(initialTarget?.trim() ? [initialTarget.trim()] : []) - setSni(initialSni?.trim() ?? '') - setRows([]) - setExpanded(null) - setFeasibleOnly(false) setIsScanning(false) + return } + abortRef.current?.abort() + abortRef.current = null + setTargets(initialTarget?.trim() ? [initialTarget.trim()] : []) + setSni(initialSni?.trim() ?? '') + setRows([]) + setExpanded(null) + setFeasibleOnly(false) + setIsScanning(false) }, [open, initialTarget, initialSni]) useEffect(() => () => abortRef.current?.abort(), []) From 08ceea784c9320b22dcb0782c5b2902b2a643254 Mon Sep 17 00:00:00 2001 From: parsa Date: Tue, 14 Jul 2026 22:19:17 -0700 Subject: [PATCH 6/6] i18n(core): translate REALITY scanner UI (en/fa/zh/ru) The scanner strings previously relied on t() defaultValue fallbacks and were English-only. Add proper locale keys for all 31 coreEditor.realityScan.* strings plus coreConfigModal.scanRealityTarget ("Scan target") to en/fa/ru/zh. Placeholders ({{max}}, {{tag}}, {{feasible}}, {{total}}) and protocol tokens (TLS 1.3, HTTP/2, Alt-Svc, X25519, X25519MLKEM768) are kept verbatim across locales. --- dashboard/public/statics/locales/en.json | 36 +++++++++++++++++++++++- dashboard/public/statics/locales/fa.json | 36 +++++++++++++++++++++++- dashboard/public/statics/locales/ru.json | 36 +++++++++++++++++++++++- dashboard/public/statics/locales/zh.json | 36 +++++++++++++++++++++++- 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 227a10eba..529f08f35 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2233,7 +2233,8 @@ "shadowsocksPasswordGenerated": "Password generated successfully", "shadowsocksPasswordGenerationFailed": "Failed to generate password", "discardChangesTitle": "Discard changes?", - "discardChangesDescription": "Your unsaved kernel configuration changes will be lost if you close this editor." + "discardChangesDescription": "Your unsaved kernel configuration changes will be lost if you close this editor.", + "scanRealityTarget": "Scan target" }, "coreEditor": { "nameRequired": "Name is required", @@ -3038,6 +3039,39 @@ "bulkRemove": { "title": "Remove selected entries", "description": "Remove {{count}} selected items? They will be dropped from configuration." + }, + "realityScan": { + "title": "Scan Reality target", + "description": "Probe one or more targets to check they work as REALITY decoys. REALITY needs HTTP/2 and TLS 1.3.", + "targets": "Targets", + "maxTargets": "Up to {{max}} targets can be scanned.", + "timeout": "Timeout (s)", + "scan": "Scan", + "stop": "Stop", + "empty": "Enter one or more targets and run the scan.", + "loading": "Scanning target...", + "summary": "{{feasible}} / {{total}} suitable", + "suitableOnly": "Suitable only", + "removeTarget": "Remove {{tag}}", + "scanningShort": "Scanning", + "scanFailed": "Failed", + "errorDescription": "Unable to scan this target.", + "feasible": "Suitable Reality target", + "notFeasible": "Not a suitable Reality target", + "tls13": "TLS 1.3", + "h2": "HTTP/2 (ALPN)", + "keyExchange": "X25519 key exchange", + "postQuantum": "Post-quantum (X25519MLKEM768)", + "h3": "HTTP/3 (advertised)", + "advertised": "Alt-Svc", + "notAdvertised": "Not advertised", + "supported": "Supported", + "certificate": "Valid certificate", + "subject": "Subject", + "expires": "Expires", + "serverNames": "Certificate server names (valid SNIs)", + "unknown": "Unknown", + "other": "Other" } }, "settings.cores.title": "Cores", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 5217e27f5..02c1a37bc 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2148,7 +2148,8 @@ "shadowsocksPasswordGenerated": "رمز عبور با موفقیت تولید شد", "shadowsocksPasswordGenerationFailed": "تولید رمز عبور ناموفق بود", "discardChangesTitle": "لغو تغییرات؟", - "discardChangesDescription": "اگر این ویرایشگر را ببندید، تغییرات ذخیره‌نشدهٔ پیکربندی هسته از بین خواهد رفت." + "discardChangesDescription": "اگر این ویرایشگر را ببندید، تغییرات ذخیره‌نشدهٔ پیکربندی هسته از بین خواهد رفت.", + "scanRealityTarget": "اسکن هدف" }, "coreEditor": { "nameRequired": "نام الزامی است", @@ -2950,6 +2951,39 @@ "bulkRemove": { "title": "حذف موارد انتخاب‌شده", "description": "{{count}} مورد انتخاب‌شده حذف شود؟ از پیکربندی حذف خواهند شد." + }, + "realityScan": { + "title": "اسکن هدف Reality", + "description": "یک یا چند هدف را بررسی کنید تا مطمئن شوید به عنوان استتار REALITY کار می‌کنند. REALITY به HTTP/2 و TLS 1.3 نیاز دارد.", + "targets": "هدف‌ها", + "maxTargets": "حداکثر {{max}} هدف قابل اسکن است.", + "timeout": "مهلت (ثانیه)", + "scan": "اسکن", + "stop": "توقف", + "empty": "یک یا چند هدف وارد کنید و اسکن را اجرا کنید.", + "loading": "در حال اسکن هدف...", + "summary": "{{feasible}} / {{total}} مناسب", + "suitableOnly": "فقط موارد مناسب", + "removeTarget": "حذف {{tag}}", + "scanningShort": "در حال اسکن", + "scanFailed": "ناموفق", + "errorDescription": "اسکن این هدف امکان‌پذیر نیست.", + "feasible": "هدف مناسب Reality", + "notFeasible": "هدف نامناسب برای Reality", + "tls13": "TLS 1.3", + "h2": "HTTP/2 (ALPN)", + "keyExchange": "تبادل کلید X25519", + "postQuantum": "پساکوانتومی (X25519MLKEM768)", + "h3": "HTTP/3 (اعلام‌شده)", + "advertised": "Alt-Svc", + "notAdvertised": "اعلام‌نشده", + "supported": "پشتیبانی می‌شود", + "certificate": "گواهی معتبر", + "subject": "موضوع", + "expires": "انقضا", + "serverNames": "نام‌های سرور گواهی (SNIهای معتبر)", + "unknown": "نامشخص", + "other": "سایر" } }, "settings.cores.title": "هسته‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index c656e5465..9532d9ea3 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2121,7 +2121,8 @@ "shadowsocksPasswordGenerated": "Пароль успешно сгенерирован", "shadowsocksPasswordGenerationFailed": "Не удалось сгенерировать пароль", "discardChangesTitle": "Отменить изменения?", - "discardChangesDescription": "Несохранённые изменения конфигурации ядра будут потеряны, если закрыть редактор." + "discardChangesDescription": "Несохранённые изменения конфигурации ядра будут потеряны, если закрыть редактор.", + "scanRealityTarget": "Сканировать цель" }, "coreEditor": { "nameRequired": "Укажите имя", @@ -2923,6 +2924,39 @@ "bulkRemove": { "title": "Удалить выбранные записи", "description": "Удалить {{count}} выбранных элементов? Они будут исключены из конфигурации." + }, + "realityScan": { + "title": "Сканирование цели Reality", + "description": "Проверьте одну или несколько целей, чтобы убедиться, что они подходят как маскировочные сайты REALITY. Для REALITY требуются HTTP/2 и TLS 1.3.", + "targets": "Цели", + "maxTargets": "Можно просканировать до {{max}} целей.", + "timeout": "Тайм-аут (с)", + "scan": "Сканировать", + "stop": "Остановить", + "empty": "Введите одну или несколько целей и запустите сканирование.", + "loading": "Сканирование цели...", + "summary": "{{feasible}} / {{total}} подходят", + "suitableOnly": "Только подходящие", + "removeTarget": "Удалить {{tag}}", + "scanningShort": "Сканирование", + "scanFailed": "Ошибка", + "errorDescription": "Не удалось просканировать эту цель.", + "feasible": "Подходящая цель Reality", + "notFeasible": "Неподходящая цель Reality", + "tls13": "TLS 1.3", + "h2": "HTTP/2 (ALPN)", + "keyExchange": "Обмен ключами X25519", + "postQuantum": "Постквантовый (X25519MLKEM768)", + "h3": "HTTP/3 (объявлен)", + "advertised": "Alt-Svc", + "notAdvertised": "Не объявлен", + "supported": "Поддерживается", + "certificate": "Действительный сертификат", + "subject": "Субъект", + "expires": "Истекает", + "serverNames": "Имена серверов сертификата (действительные SNI)", + "unknown": "Неизвестно", + "other": "Другое" } }, "settings.cores.title": "Ядра", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 400a76062..a7b872537 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2192,7 +2192,8 @@ "shadowsocksPasswordGenerated": "密码生成成功", "shadowsocksPasswordGenerationFailed": "密码生成失败", "discardChangesTitle": "放弃更改?", - "discardChangesDescription": "如果关闭此编辑器,未保存的内核配置更改将会丢失。" + "discardChangesDescription": "如果关闭此编辑器,未保存的内核配置更改将会丢失。", + "scanRealityTarget": "扫描目标" }, "coreEditor": { "nameRequired": "名称为必填项", @@ -2994,6 +2995,39 @@ "bulkRemove": { "title": "删除所选条目", "description": "删除所选的 {{count}} 项?它们将从配置中移除。" + }, + "realityScan": { + "title": "扫描 Reality 目标", + "description": "探测一个或多个目标,检查它们是否适合用作 REALITY 伪装。REALITY 需要 HTTP/2 和 TLS 1.3。", + "targets": "目标", + "maxTargets": "最多可扫描 {{max}} 个目标。", + "timeout": "超时(秒)", + "scan": "扫描", + "stop": "停止", + "empty": "输入一个或多个目标并运行扫描。", + "loading": "正在扫描目标...", + "summary": "{{feasible}} / {{total}} 个适合", + "suitableOnly": "仅显示适合的", + "removeTarget": "删除 {{tag}}", + "scanningShort": "扫描中", + "scanFailed": "失败", + "errorDescription": "无法扫描此目标。", + "feasible": "适合的 Reality 目标", + "notFeasible": "不适合的 Reality 目标", + "tls13": "TLS 1.3", + "h2": "HTTP/2 (ALPN)", + "keyExchange": "X25519 密钥交换", + "postQuantum": "后量子 (X25519MLKEM768)", + "h3": "HTTP/3 (已通告)", + "advertised": "Alt-Svc", + "notAdvertised": "未通告", + "supported": "支持", + "certificate": "有效证书", + "subject": "主题", + "expires": "过期时间", + "serverNames": "证书服务器名称(有效 SNI)", + "unknown": "未知", + "other": "其他" } }, "settings.cores.title": "核心",