feat: add declarative sing-box egress control plane
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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")
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user