feat: add declarative sing-box egress control plane

This commit is contained in:
2026-08-27 00:58:52 +05:00
commit b8d19b2c3e
54 changed files with 3337 additions and 0 deletions
+179
View File
@@ -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,
)