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..dff6efeb5 --- /dev/null +++ b/app/utils/reality_scan.py @@ -0,0 +1,571 @@ +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 _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: + 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 + 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 + 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)) + 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/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 96ad3db7e..467b415ff 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": "核心", 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..e483360c3 --- /dev/null +++ b/dashboard/src/features/core-editor/components/xray/reality-scan-dialog.tsx @@ -0,0 +1,533 @@ +import { FormEvent, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +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' +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 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 } + 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 + +function CheckRow({ status, label, detail }: { status: TriState; label: string; detail?: string }) { + const icon = + status === true ? ( + + ) : status === false ? ( + + ) : ( + + ) + return ( +
+
+ {icon} + {label} +
+ {detail ? ( + + {detail} + + ) : null} +
+ ) +} + +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 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 [targets, setTargets] = useState([]) + const [sni, setSni] = useState('') + const [timeoutInput, setTimeoutInput] = useState('10') + const [rows, setRows] = useState([]) + const [expanded, setExpanded] = useState(null) + const [feasibleOnly, setFeasibleOnly] = useState(false) + const [isScanning, setIsScanning] = useState(false) + const abortRef = useRef(null) + + useEffect(() => { + if (!open) { + abortRef.current?.abort() + abortRef.current = null + 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(), []) + + const canScan = targets.length > 0 && !isScanning + + const runScan = async () => { + 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) + + const patch = (target: string, next: Partial) => { + if (controller.signal.aborted) return + 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 + setIsScanning(false) + } + } + } + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + runScan() + } + + 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 ( + } + size="lg" + inlinePersistValidation={false} + footerExtra={ + isScanning ? ( + + ) : ( + + ) + } + > +
+

{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.' })}

+ +
+
+ + { + 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} +
+
+ + setTimeoutInput(event.target.value)} inputMode="numeric" type="number" min={1} max={20} disabled={isScanning} dir="ltr" /> +
+
+ +
+ {!rows.length ? ( +
+ {t('coreEditor.realityScan.empty', { defaultValue: 'Enter one or more targets and run the scan.' })} +
+ ) : single ? ( + single.status === 'done' && single.result ? ( + + ) : single.status === 'error' ? ( + + + {single.error} + + + ) : ( +
+
+ + {t('coreEditor.realityScan.loading', { defaultValue: 'Scanning target...' })} +
+
+ ) + ) : ( +
+
+ + {t('coreEditor.realityScan.summary', { defaultValue: '{{feasible}} / {{total}} suitable', feasible: feasibleCount, total: rows.length })} + {isScanning ? : null} + + +
+
+ {displayedRows.map(row => ( + setExpanded(prev => (prev === row.target ? null : row.target))} /> + ))} +
+
+ )} +
+
+
+ ) +} 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 e2f7e4c53..f09d17a3d 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 @@ -29,6 +29,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 { @@ -93,6 +94,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('*')) || '' +} + /** 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)', @@ -901,6 +920,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) @@ -4019,6 +4041,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' })} + +
+ )} ) })} @@ -5326,6 +5366,10 @@ 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..e6404f286 --- /dev/null +++ b/tests/test_reality_scan_unit.py @@ -0,0 +1,420 @@ +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("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"], +) +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