from __future__ import annotations import ipaddress import re from urllib.parse import parse_qsl, unquote_to_bytes from .errors import ValidationError from .model import Hy2Endpoint _SCHEME_RE = re.compile(r"^(hysteria2|hy2)://", re.IGNORECASE) _BAD_ESCAPE_RE = re.compile(r"%(?![0-9A-Fa-f]{2})") _SUPPORTED_QUERY = {"sni", "insecure", "obfs", "obfs-password"} _KNOWN_UNSUPPORTED_QUERY = {"pinSHA256", "ech"} def _decode(value: str, label: str) -> str: if _BAD_ESCAPE_RE.search(value): raise ValidationError(f"Invalid percent encoding in {label}") try: return unquote_to_bytes(value).decode("utf-8") except UnicodeDecodeError as exc: raise ValidationError(f"Invalid UTF-8 in {label}") from exc def _normalise_host(value: str, label: str) -> str: value = _decode(value, label).strip() if not value: raise ValidationError(f"Missing {label}") try: return str(ipaddress.ip_address(value)) except ValueError: pass if value.endswith("."): value = value[:-1] try: ascii_host = value.encode("idna").decode("ascii").lower() except UnicodeError as exc: raise ValidationError(f"Invalid {label}") from exc labels = ascii_host.split(".") if ( len(ascii_host) > 253 or any(not item or len(item) > 63 for item in labels) or any(item.startswith("-") or item.endswith("-") for item in labels) or any(not re.fullmatch(r"[a-z0-9-]+", item) for item in labels) ): raise ValidationError(f"Invalid {label}") return ascii_host def _parse_port_item(item: str) -> tuple[int, int, str]: if not item: raise ValidationError("Empty port in Hysteria2 endpoint") if "-" in item: if item.count("-") != 1: raise ValidationError("Invalid Hysteria2 port range") left, right = item.split("-", 1) if not left.isdecimal() or not right.isdecimal(): raise ValidationError("Invalid Hysteria2 port range") start, end = int(left), int(right) if not 1 <= start <= end <= 65535: raise ValidationError("Hysteria2 port range is outside 1..65535") return start, end, f"{start}:{end}" if not item.isdecimal(): raise ValidationError("Invalid Hysteria2 port") port = int(item) if not 1 <= port <= 65535: raise ValidationError("Hysteria2 port is outside 1..65535") return port, port, str(port) def _parse_ports(spec: str | None) -> tuple[int | None, tuple[str, ...]]: if spec is None or spec == "": return 443, () parsed = [_parse_port_item(item) for item in spec.split(",")] ordered = sorted((start, end) for start, end, _ in parsed) for previous, current in zip(ordered, ordered[1:]): if current[0] <= previous[1]: raise ValidationError("Overlapping Hysteria2 ports are not allowed") if len(parsed) == 1 and parsed[0][0] == parsed[0][1]: return parsed[0][0], () return None, tuple(item[2] for item in parsed) def _split_authority(authority: str) -> tuple[str, str | None]: if authority.startswith("["): closing = authority.find("]") if closing < 0: raise ValidationError("Unclosed IPv6 address in Hysteria2 URI") host = authority[1:closing] tail = authority[closing + 1 :] if not tail: return host, None if not tail.startswith(":"): raise ValidationError("Invalid text after IPv6 address") return host, tail[1:] if authority.count(":") > 1: raise ValidationError("IPv6 addresses in Hysteria2 URI must use brackets") if ":" in authority: return tuple(authority.rsplit(":", 1)) # type: ignore[return-value] return authority, None def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint: uri = raw_uri.strip() scheme = _SCHEME_RE.match(uri) if not scheme: raise ValidationError("Unsupported URI scheme; expected hysteria2:// or hy2://") remainder = uri[scheme.end() :] if any(char.isspace() for char in remainder): raise ValidationError("Whitespace is not allowed in Hysteria2 URI") without_fragment, separator, raw_fragment = remainder.partition("#") if separator and "#" in raw_fragment: raise ValidationError("Invalid URI fragment") authority_path, query_separator, raw_query = without_fragment.partition("?") if not query_separator: raw_query = "" authority, slash, path_tail = authority_path.partition("/") if slash and path_tail: raise ValidationError("Client modes and non-empty paths are not supported") raw_auth, at, endpoint_authority = authority.rpartition("@") if not at: raise ValidationError("Missing Hysteria2 authentication data") if "@" in raw_auth: raise ValidationError("Special characters in Hysteria2 authentication data must be percent-encoded") password = _decode(raw_auth, "authentication data") if not password: raise ValidationError("Missing Hysteria2 authentication data") raw_host, port_spec = _split_authority(endpoint_authority) server = _normalise_host(raw_host, "server host") server_port, server_ports = _parse_ports(port_spec) if _BAD_ESCAPE_RE.search(raw_query): raise ValidationError("Invalid percent encoding in query") for component in re.split(r"[&=]", raw_query): _decode(component, "query") try: pairs = parse_qsl(raw_query, keep_blank_values=True, strict_parsing=True) except ValueError as exc: raise ValidationError("Invalid Hysteria2 URI query") from exc query: dict[str, str] = {} for key, value in pairs: if key in query: raise ValidationError(f"Duplicate Hysteria2 URI parameter: {key}") if key in _KNOWN_UNSUPPORTED_QUERY: raise ValidationError( f"Hysteria2 URI parameter {key} cannot be mapped safely to sing-box 1.13.19" ) if key not in _SUPPORTED_QUERY: raise ValidationError(f"Unsupported Hysteria2 URI parameter: {key}") query[key] = value sni = _normalise_host(query.get("sni", server), "TLS server name") insecure_raw = query.get("insecure", "0") if insecure_raw not in {"0", "1"}: raise ValidationError("insecure must be exactly 0 or 1") insecure = insecure_raw == "1" obfs_type = query.get("obfs") or None obfs_password = query.get("obfs-password") or None if obfs_type not in {None, "salamander"}: raise ValidationError("Requested obfs type is not supported by sing-box 1.13.19") if obfs_type == "salamander" and not obfs_password: raise ValidationError("obfs=salamander requires obfs-password") if obfs_type is None and obfs_password is not None: raise ValidationError("obfs-password requires obfs=salamander") display_name = _decode(raw_fragment, "fragment") if separator else None return Hy2Endpoint( server=server, password=password, server_port=server_port, server_ports=server_ports, sni=sni, insecure=insecure, obfs_type=obfs_type, obfs_password=obfs_password, display_name=display_name or None, )