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
+3
View File
@@ -0,0 +1,3 @@
"""Declarative control plane for the sing-box VPN egress gateway."""
__version__ = "0.1.0"
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
from pathlib import Path
from .doctor import Doctor
from .errors import ApplyError, UnsupportedVersionError, ValidationError, VpnEgressError
from .fsutil import atomic_write
from .guard import apply_guard
from .policy import load_policy
from .transaction import Controller
DEFAULT_POLICY = "/etc/vpn-egress/policy.json"
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="vpn-egressctl",
description="Declarative control plane for sing-box 1.13.19",
)
parser.add_argument("--policy", default=DEFAULT_POLICY, help="path to policy.json")
commands = parser.add_subparsers(dest="command", required=True)
import_parser = commands.add_parser("import", help="validate, store and apply a Hysteria2 URI")
source = import_parser.add_mutually_exclusive_group()
source.add_argument("--stdin", action="store_true", help="read URI from stdin or a hidden TTY prompt")
source.add_argument("--file", metavar="PATH", help="read URI from a protected file")
commands.add_parser("check", help="validate source, renderer and generated config")
commands.add_parser("diff", help="show a redacted desired-state diff")
render = commands.add_parser("render", help="write a validated generated config to a protected file")
render.add_argument("--output", required=True, help="output path; stdout is deliberately unsupported")
commands.add_parser("sync", help="reconcile the current URI and policy")
status = commands.add_parser("status", help="show safe state summary")
status.add_argument("--json", action="store_true")
doctor = commands.add_parser("doctor", help="run complete runtime diagnostics")
doctor.add_argument("--json", action="store_true")
commands.add_parser("rollback", help="atomically swap to the last-good configuration")
commands.add_parser("guard-apply", help=argparse.SUPPRESS)
return parser
def _read_import_source(args: argparse.Namespace) -> str:
if args.file:
path = Path(args.file)
if os.name == "posix" and path.exists() and (path.stat().st_mode & 0o077):
raise ValidationError("URI source file must not be accessible by group or others")
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError("Cannot read URI source file") from exc
if not args.stdin:
raise ValidationError("Use --stdin or --file; URI positional arguments are intentionally disabled")
if sys.stdin.isatty():
return getpass.getpass("Hysteria2 URI: ")
return sys.stdin.read()
def _print_status(state: dict[str, object]) -> None:
for key in sorted(state):
value = state[key]
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False)
print(f"{key}: {value}")
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
policy = load_policy(args.policy)
controller = Controller(policy)
if args.command == "import":
changed = controller.import_uri(_read_import_source(args))
print("Configuration applied." if changed else "Desired state is already installed.")
elif args.command == "check":
endpoint = controller.check()
print(f"OK: {endpoint.endpoint_label()}, sing-box 1.13.19")
elif args.command == "diff":
changes = controller.diff()
print("\n".join(changes) if changes else "No changes.")
elif args.command == "render":
atomic_write(args.output, controller.render())
print(f"Validated configuration written to {args.output}")
elif args.command == "sync":
changed = controller.sync()
print("Configuration applied." if changed else "Desired state is already installed.")
elif args.command == "status":
state = controller.status()
if args.json:
print(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True))
else:
_print_status(state)
elif args.command == "doctor":
doctor = Doctor(policy)
checks = doctor.run()
if args.json:
print(doctor.as_json())
else:
for check in checks:
print(f"[{check.level}] {check.name}: {check.message}")
return doctor.exit_code()
elif args.command == "rollback":
controller.rollback()
print("Rollback completed and validated.")
elif args.command == "guard-apply":
apply_guard(policy)
print("VPN egress guard installed.")
return 0
except ApplyError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 4 if exc.rollback_ok else 5
except UnsupportedVersionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 3
except (ValidationError, VpnEgressError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
except OSError:
print("ERROR: filesystem operation failed", file=sys.stderr)
return 2
except KeyboardInterrupt:
print("ERROR: interrupted", file=sys.stderr)
return 130
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import ipaddress
import json
import os
import socket
import stat
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
from .errors import CommandError, ValidationError, VpnEgressError
from .policy import Policy
from .renderer_1_13_19 import render_bytes
from .uri import parse_hysteria2_uri
from .version import probe_version, require_supported
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
@dataclass(frozen=True, slots=True)
class Check:
level: str
name: str
message: str
class Doctor:
def __init__(
self,
policy: Policy,
*,
runner: RunFunction = subprocess.run,
resolver: Callable[..., Any] = socket.getaddrinfo,
) -> None:
self.policy = policy
self.runner = runner
self.resolver = resolver
self.checks: list[Check] = []
def _add(self, level: str, name: str, message: str) -> None:
self.checks.append(Check(level, name, message))
def _run(self, args: list[str], timeout: float = 15) -> subprocess.CompletedProcess[str] | None:
try:
return self.runner(args, text=True, capture_output=True, timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired):
return None
def _command_contains(self, name: str, args: list[str], required: list[str]) -> None:
result = self._run(args)
if result is None or result.returncode != 0:
self._add("ERROR", name, "command failed")
return
output = result.stdout + result.stderr
missing = [item for item in required if item not in output]
if missing:
self._add("ERROR", name, "expected runtime state is missing")
else:
self._add("OK", name, "runtime state matches policy")
def _permissions(self, path: str, expected: int, required: bool = True) -> None:
target = Path(path)
if not target.exists():
self._add("ERROR" if required else "WARN", f"permissions:{path}", "file does not exist")
return
actual = stat.S_IMODE(target.stat().st_mode)
if actual != expected:
self._add("ERROR", f"permissions:{path}", f"mode is {actual:04o}, expected {expected:04o}")
else:
self._add("OK", f"permissions:{path}", f"mode is {actual:04o}")
def run(self) -> list[Check]:
if os.name == "posix" and os.geteuid() != 0:
self._add("WARN", "privileges", "doctor is not running as root; some checks may fail")
else:
self._add("OK", "privileges", "sufficient privileges")
try:
version = probe_version(self.policy.sing_box.binary, self.runner)
require_supported(version, self.policy.sing_box.required_version)
self._add("OK", "sing-box-version", f"exact version {version.version} with QUIC")
except VpnEgressError as exc:
self._add("ERROR", "sing-box-version", str(exc))
endpoint = None
source = None
try:
source = Path(self.policy.runtime.uri_path).read_text(encoding="utf-8").strip()
endpoint = parse_hysteria2_uri(source)
self._add("OK", "hysteria2-uri", f"valid endpoint {endpoint.endpoint_label()}")
if endpoint.insecure:
self._add("WARN", "tls-insecure", "TLS certificate verification is disabled by the URI")
else:
self._add("OK", "tls-insecure", "TLS certificate verification is enabled")
except (OSError, ValidationError) as exc:
self._add("ERROR", "hysteria2-uri", str(exc))
self._permissions(self.policy.runtime.uri_path, 0o600)
self._permissions(self.policy.sing_box.config_path, 0o600)
state_path = Path(self.policy.runtime.state_dir) / "state.json"
self._permissions(str(state_path), 0o600, required=False)
if endpoint is not None:
try:
expected = render_bytes(self.policy, endpoint)
installed = Path(self.policy.sing_box.config_path).read_bytes()
if expected == installed:
self._add("OK", "config-drift", "installed configuration matches desired state")
else:
self._add("ERROR", "config-drift", "installed configuration differs from desired state")
except OSError:
self._add("ERROR", "config-drift", "cannot read installed configuration")
exclusions = [ipaddress.ip_network(item) for item in self.policy.network.route_exclude_address]
for network in exclusions:
if network.version == 4 and network.prefixlen == 32 and network.is_global:
self._add("WARN", "public-route-exclusion", f"public host route is configured: {network}")
try:
answers = self.resolver(endpoint.server, endpoint.server_port or 443, socket.AF_INET)
addresses = sorted({item[4][0] for item in answers})
conflict = [address for address in addresses if any(ipaddress.ip_address(address) in network for network in exclusions)]
if conflict:
self._add("ERROR", "endpoint-exclusion", "resolved endpoint is present in TUN exclusions")
else:
self._add("OK", "endpoint-exclusion", f"{len(addresses)} resolved IPv4 address(es), none excluded")
except OSError:
self._add("ERROR", "endpoint-resolution", "cannot resolve endpoint through system resolver")
check = self._run([
self.policy.sing_box.binary,
"check",
"-c",
self.policy.sing_box.config_path,
])
if check is not None and check.returncode == 0:
self._add("OK", "sing-box-check", "installed configuration is accepted")
else:
self._add("ERROR", "sing-box-check", "installed configuration is rejected")
if os.name == "posix":
for interface in (
self.policy.network.upstream_interface,
self.policy.network.vpn_lan_interface,
self.policy.network.tun_name,
):
result = self._run(["/usr/sbin/ip", "link", "show", interface])
self._add(
"OK" if result is not None and result.returncode == 0 else "ERROR",
f"interface:{interface}",
"interface exists" if result is not None and result.returncode == 0 else "interface is missing",
)
self._add(
"OK" if Path("/dev/net/tun").exists() else "ERROR",
"tun-device",
"/dev/net/tun exists" if Path("/dev/net/tun").exists() else "/dev/net/tun is missing",
)
self._command_contains(
"ip-forward",
["/usr/sbin/sysctl", "net.ipv4.ip_forward"],
["= 1"],
)
self._command_contains(
"ip-rules",
["/usr/sbin/ip", "-4", "rule", "show"],
[
str(self.policy.network.iproute2_rule_index),
self.policy.network.auto_redirect_input_mark,
self.policy.network.auto_redirect_output_mark,
str(self.policy.network.auto_redirect_fallback_rule_index),
],
)
self._command_contains(
"route-table",
["/usr/sbin/ip", "-4", "route", "show", "table", str(self.policy.network.iproute2_table_index)],
[self.policy.network.tun_name],
)
self._command_contains(
"anti-leak-nft",
["/usr/sbin/nft", "list", "table", "inet", "vpn_egress_guard"],
[
f'iifname "{self.policy.network.vpn_lan_interface}"',
f'oifname "{self.policy.network.upstream_interface}"',
"reject",
],
)
for unit in (self.policy.sing_box.service, "vpn-egress-sync.path", "vpn-egress-guard.service"):
result = self._run(["/usr/bin/systemctl", "is-active", "--quiet", unit])
self._add(
"OK" if result is not None and result.returncode == 0 else "ERROR",
f"systemd:{unit}",
"active" if result is not None and result.returncode == 0 else "not active",
)
if endpoint is not None:
journal = self._run(["/usr/bin/journalctl", "-u", self.policy.sing_box.service, "-n", "500", "--no-pager"])
if journal is None:
self._add("WARN", "journal-secrets", "journal could not be inspected")
else:
text = journal.stdout + journal.stderr
secrets = [endpoint.password]
if endpoint.obfs_password:
secrets.append(endpoint.obfs_password)
if any(secret and secret in text for secret in secrets):
self._add("ERROR", "journal-secrets", "a current credential was found in journal")
else:
self._add("OK", "journal-secrets", "current credentials were not found in journal")
legacy = Path("/root/render-singbox-hy2.sh")
if legacy.exists():
self._add("WARN", "legacy-renderer", "legacy renderer still exists and must not be used")
else:
self._add("OK", "legacy-renderer", "legacy renderer is absent")
return self.checks
def exit_code(self) -> int:
return 1 if any(check.level == "ERROR" for check in self.checks) else 0
def as_json(self) -> str:
return json.dumps([asdict(item) for item in self.checks], ensure_ascii=False, indent=2)
+20
View File
@@ -0,0 +1,20 @@
class VpnEgressError(Exception):
"""Expected, user-facing failure which must not include secrets."""
class ValidationError(VpnEgressError):
pass
class UnsupportedVersionError(VpnEgressError):
pass
class CommandError(VpnEgressError):
pass
class ApplyError(VpnEgressError):
def __init__(self, message: str, *, rollback_ok: bool | None = None) -> None:
super().__init__(message)
self.rollback_ok = rollback_ok
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def ensure_private_dir(path: str | Path, mode: int = 0o700) -> Path:
target = Path(path)
target.mkdir(mode=mode, parents=True, exist_ok=True)
if os.name == "posix":
os.chmod(target, mode)
return target
def fsync_directory(path: str | Path) -> None:
if os.name != "posix":
return
descriptor = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def atomic_write(path: str | Path, data: bytes, mode: int = 0o600) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
owner: tuple[int, int] | None = None
if target.exists() and os.name == "posix":
stat_result = target.stat()
owner = stat_result.st_uid, stat_result.st_gid
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
temporary = Path(temporary_name)
try:
os.chmod(temporary, mode)
if owner is not None:
os.chown(temporary, *owner)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
fsync_directory(target.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temporary.unlink(missing_ok=True)
def atomic_write_text(path: str | Path, text: str, mode: int = 0o600) -> None:
atomic_write(path, text.encode("utf-8"), mode)
def atomic_write_json(path: str | Path, value: Any, mode: int = 0o600) -> None:
data = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
atomic_write(path, data, mode)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import subprocess
from typing import Callable
from .errors import CommandError
from .policy import Policy
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
def _run(runner: RunFunction, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
try:
return runner(
args,
text=True,
capture_output=True,
timeout=15,
check=False,
**kwargs,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandError(f"Cannot execute {args[0].rsplit('/', 1)[-1]}") from exc
def apply_guard(policy: Policy, runner: RunFunction = subprocess.run) -> None:
network = policy.network
for interface in (network.upstream_interface, network.vpn_lan_interface):
result = _run(runner, ["/usr/sbin/ip", "link", "show", interface])
if result.returncode != 0:
raise CommandError(f"Required interface is missing: {interface}")
existing = _run(runner, ["/usr/sbin/nft", "list", "table", "inet", "vpn_egress_guard"])
prefix = "delete table inet vpn_egress_guard\n" if existing.returncode == 0 else ""
ruleset = prefix + f'''table inet vpn_egress_guard {{
chain forward {{
type filter hook forward priority filter; policy accept;
iifname "{network.vpn_lan_interface}" oifname "{network.upstream_interface}" counter reject
}}
}}
'''
result = _run(runner, ["/usr/sbin/nft", "-f", "-"], input=ruleset)
if result.returncode != 0:
raise CommandError("Cannot atomically install the VPN egress guard")
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Hy2Endpoint:
server: str
password: str = field(repr=False)
server_port: int | None = None
server_ports: tuple[str, ...] = ()
sni: str = ""
insecure: bool = False
obfs_type: str | None = None
obfs_password: str | None = field(default=None, repr=False)
display_name: str | None = None
def endpoint_label(self) -> str:
host = f"[{self.server}]" if ":" in self.server else self.server
if self.server_port is not None:
return f"{host}:{self.server_port}"
return f"{host}:{','.join(self.server_ports)}"
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import ipaddress
import json
import re
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlsplit
from .errors import ValidationError
@dataclass(frozen=True, slots=True)
class SingBoxPolicy:
binary: str
config_path: str
service: str
required_version: str
@dataclass(frozen=True, slots=True)
class RuntimePolicy:
uri_path: str
state_dir: str
lock_path: str
backup_keep: int
@dataclass(frozen=True, slots=True)
class NetworkPolicy:
upstream_interface: str
vpn_lan_interface: str
tun_name: str
tun_address: str
mtu: int
route_exclude_address: tuple[str, ...]
iproute2_table_index: int
iproute2_rule_index: int
auto_redirect_input_mark: str
auto_redirect_output_mark: str
auto_redirect_reset_mark: str
auto_redirect_nfqueue: int
auto_redirect_fallback_rule_index: int
@dataclass(frozen=True, slots=True)
class DnsPolicy:
bootstrap_server: str
bootstrap_port: int
remote_server: str
remote_port: int
remote_path: str
remote_tls_server_name: str
strategy: str
@dataclass(frozen=True, slots=True)
class BandwidthPolicy:
up_mbps: int
down_mbps: int
@dataclass(frozen=True, slots=True)
class HealthcheckPolicy:
url: str | None
timeout_seconds: float
settle_seconds: float
expected_status: int
body_contains: str | None
@dataclass(frozen=True, slots=True)
class Policy:
schema_version: int
sing_box: SingBoxPolicy
runtime: RuntimePolicy
network: NetworkPolicy
dns: DnsPolicy
bandwidth: BandwidthPolicy
healthcheck: HealthcheckPolicy
_EXPECTED: dict[str, set[str]] = {
"root": {"schema_version", "sing_box", "runtime", "network", "dns", "bandwidth", "healthcheck"},
"sing_box": {"binary", "config_path", "service", "required_version"},
"runtime": {"uri_path", "state_dir", "lock_path", "backup_keep"},
"network": {
"upstream_interface", "vpn_lan_interface", "tun_name", "tun_address", "mtu",
"route_exclude_address", "iproute2_table_index", "iproute2_rule_index",
"auto_redirect_input_mark", "auto_redirect_output_mark", "auto_redirect_reset_mark",
"auto_redirect_nfqueue", "auto_redirect_fallback_rule_index",
},
"dns": {"bootstrap_server", "bootstrap_port", "remote_server", "remote_port", "remote_path", "remote_tls_server_name", "strategy"},
"bandwidth": {"up_mbps", "down_mbps"},
"healthcheck": {"url", "timeout_seconds", "settle_seconds", "expected_status", "body_contains"},
}
def _mapping(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValidationError(f"Policy section {label} must be an object")
unknown = set(value) - _EXPECTED[label]
missing = _EXPECTED[label] - set(value)
if unknown:
raise ValidationError(f"Unknown policy keys in {label}: {', '.join(sorted(unknown))}")
if missing:
raise ValidationError(f"Missing policy keys in {label}: {', '.join(sorted(missing))}")
return value
def _string(mapping: dict[str, Any], key: str, *, nonempty: bool = True) -> str:
value = mapping[key]
if not isinstance(value, str) or (nonempty and not value):
raise ValidationError(f"Policy value {key} must be a non-empty string")
return value
def _integer(mapping: dict[str, Any], key: str, minimum: int, maximum: int) -> int:
value = mapping[key]
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
raise ValidationError(f"Policy value {key} must be in {minimum}..{maximum}")
return value
def _absolute(value: str, label: str) -> str:
path = PurePosixPath(value)
if not path.is_absolute() or ".." in path.parts:
raise ValidationError(f"Policy path {label} must be an absolute normalised POSIX path")
return str(path)
def _interface(value: str, label: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,15}", value):
raise ValidationError(f"Invalid interface name in {label}")
return value
def _mark(value: str, label: str) -> str:
if not re.fullmatch(r"0x[0-9a-fA-F]{1,8}", value):
raise ValidationError(f"Invalid hexadecimal mark in {label}")
return "0x" + value[2:].lower()
def load_policy(path: str | Path) -> Policy:
try:
with Path(path).open("r", encoding="utf-8") as stream:
raw = json.load(stream)
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(f"Cannot read policy file: {path}") from exc
root = _mapping(raw, "root")
if root["schema_version"] != 1:
raise ValidationError("Only policy schema_version=1 is supported")
sb = _mapping(root["sing_box"], "sing_box")
runtime = _mapping(root["runtime"], "runtime")
network = _mapping(root["network"], "network")
dns = _mapping(root["dns"], "dns")
bandwidth = _mapping(root["bandwidth"], "bandwidth")
health = _mapping(root["healthcheck"], "healthcheck")
required_version = _string(sb, "required_version")
if required_version != "1.13.19":
raise ValidationError("This release requires sing-box version exactly 1.13.19")
service = _string(sb, "service")
if not re.fullmatch(r"[A-Za-z0-9@_.:-]+\.service", service):
raise ValidationError("Invalid sing-box systemd service name")
exclusions_raw = network["route_exclude_address"]
if not isinstance(exclusions_raw, list) or not exclusions_raw:
raise ValidationError("route_exclude_address must be a non-empty array")
exclusions: list[str] = []
for value in exclusions_raw:
if not isinstance(value, str):
raise ValidationError("route_exclude_address entries must be strings")
try:
parsed = ipaddress.ip_network(value, strict=True)
except ValueError as exc:
raise ValidationError(f"Invalid route exclusion: {value}") from exc
exclusions.append(str(parsed))
tun_address = _string(network, "tun_address")
try:
ipaddress.ip_interface(tun_address)
except ValueError as exc:
raise ValidationError("Invalid tun_address") from exc
bootstrap = _string(dns, "bootstrap_server")
remote = _string(dns, "remote_server")
try:
ipaddress.ip_address(bootstrap)
ipaddress.ip_address(remote)
except ValueError as exc:
raise ValidationError("DNS bootstrap and remote servers must be IP addresses") from exc
if dns["strategy"] != "ipv4_only":
raise ValidationError("Only DNS strategy ipv4_only is supported")
url = health["url"]
body_contains = health["body_contains"]
if url is not None and (not isinstance(url, str) or not url.startswith("https://")):
raise ValidationError("healthcheck.url must be null or an https:// URL")
if url is not None:
parsed_url = urlsplit(url)
if not parsed_url.hostname or parsed_url.username is not None or parsed_url.password is not None or parsed_url.fragment:
raise ValidationError("healthcheck.url must not contain credentials or a fragment")
if not _string(dns, "remote_path").startswith("/"):
raise ValidationError("dns.remote_path must start with /")
if body_contains is not None and not isinstance(body_contains, str):
raise ValidationError("healthcheck.body_contains must be null or a string")
for float_key in ("timeout_seconds", "settle_seconds"):
value = health[float_key]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 120:
raise ValidationError(f"healthcheck.{float_key} must be in 0..120")
return Policy(
schema_version=1,
sing_box=SingBoxPolicy(
binary=_absolute(_string(sb, "binary"), "sing_box.binary"),
config_path=_absolute(_string(sb, "config_path"), "sing_box.config_path"),
service=service,
required_version=required_version,
),
runtime=RuntimePolicy(
uri_path=_absolute(_string(runtime, "uri_path"), "runtime.uri_path"),
state_dir=_absolute(_string(runtime, "state_dir"), "runtime.state_dir"),
lock_path=_absolute(_string(runtime, "lock_path"), "runtime.lock_path"),
backup_keep=_integer(runtime, "backup_keep", 1, 100),
),
network=NetworkPolicy(
upstream_interface=_interface(_string(network, "upstream_interface"), "upstream_interface"),
vpn_lan_interface=_interface(_string(network, "vpn_lan_interface"), "vpn_lan_interface"),
tun_name=_interface(_string(network, "tun_name"), "tun_name"),
tun_address=tun_address,
mtu=_integer(network, "mtu", 576, 9000),
route_exclude_address=tuple(exclusions),
iproute2_table_index=_integer(network, "iproute2_table_index", 1, 2**31 - 1),
iproute2_rule_index=_integer(network, "iproute2_rule_index", 1, 32765),
auto_redirect_input_mark=_mark(_string(network, "auto_redirect_input_mark"), "input mark"),
auto_redirect_output_mark=_mark(_string(network, "auto_redirect_output_mark"), "output mark"),
auto_redirect_reset_mark=_mark(_string(network, "auto_redirect_reset_mark"), "reset mark"),
auto_redirect_nfqueue=_integer(network, "auto_redirect_nfqueue", 0, 65535),
auto_redirect_fallback_rule_index=_integer(network, "auto_redirect_fallback_rule_index", 32766, 2**31 - 1),
),
dns=DnsPolicy(
bootstrap_server=bootstrap,
bootstrap_port=_integer(dns, "bootstrap_port", 1, 65535),
remote_server=remote,
remote_port=_integer(dns, "remote_port", 1, 65535),
remote_path=_string(dns, "remote_path"),
remote_tls_server_name=_string(dns, "remote_tls_server_name"),
strategy=_string(dns, "strategy"),
),
bandwidth=BandwidthPolicy(
up_mbps=_integer(bandwidth, "up_mbps", 0, 1_000_000),
down_mbps=_integer(bandwidth, "down_mbps", 0, 1_000_000),
),
healthcheck=HealthcheckPolicy(
url=url,
timeout_seconds=float(health["timeout_seconds"]),
settle_seconds=float(health["settle_seconds"]),
expected_status=_integer(health, "expected_status", 100, 599),
body_contains=body_contains,
),
)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from typing import Any
_SECRET_RE = re.compile(
r"password|passwd|passphrase|token|secret|credential|private[_-]?key|"
r"pre[_-]?shared[_-]?key|psk|authorization|api[_-]?key|auth$",
re.IGNORECASE,
)
def is_secret_key(key: str) -> bool:
return bool(_SECRET_RE.search(key))
def redact(value: Any, key: str = "") -> Any:
if key and is_secret_key(key):
return "<REDACTED>"
if isinstance(value, Mapping):
return {str(item_key): redact(item_value, str(item_key)) for item_key, item_value in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [redact(item) for item in value]
return value
def secret_fingerprint(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
def _flatten(value: Any, prefix: str = "") -> dict[str, Any]:
if isinstance(value, dict):
flattened: dict[str, Any] = {}
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
flattened.update(_flatten(item, path))
return flattened
if isinstance(value, list):
flattened = {}
for index, item in enumerate(value):
flattened.update(_flatten(item, f"{prefix}[{index}]"))
return flattened
return {prefix: value}
def redacted_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> list[str]:
old_flat = _flatten(old or {})
new_flat = _flatten(new)
changes: list[str] = []
for path in sorted(set(old_flat) | set(new_flat)):
before = old_flat.get(path, "<MISSING>")
after = new_flat.get(path, "<MISSING>")
if before == after:
continue
leaf = re.split(r"[.[]", path)[-1].rstrip("]")
if is_secret_key(leaf):
changes.append(f"~ {path}: <REDACTED> -> <REDACTED>")
elif before == "<MISSING>":
changes.append(f"+ {path}: {json.dumps(after, ensure_ascii=False)}")
elif after == "<MISSING>":
changes.append(f"- {path}: {json.dumps(before, ensure_ascii=False)}")
else:
changes.append(
f"~ {path}: {json.dumps(before, ensure_ascii=False)} -> {json.dumps(after, ensure_ascii=False)}"
)
return changes
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
from typing import Any
from .model import Hy2Endpoint
from .policy import Policy
TARGET_VERSION = "1.13.19"
def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
network = policy.network
dns = policy.dns
outbound: dict[str, Any] = {
"type": "hysteria2",
"tag": "hy2-out",
"server": endpoint.server,
"up_mbps": policy.bandwidth.up_mbps,
"down_mbps": policy.bandwidth.down_mbps,
"password": endpoint.password,
"tls": {
"enabled": True,
"server_name": endpoint.sni,
"insecure": endpoint.insecure,
},
"bind_interface": network.upstream_interface,
"domain_resolver": {"server": "bootstrap-dns", "strategy": dns.strategy},
}
if endpoint.server_port is not None:
outbound["server_port"] = endpoint.server_port
else:
outbound["server_ports"] = list(endpoint.server_ports)
if endpoint.obfs_type == "salamander":
outbound["obfs"] = {
"type": "salamander",
"password": endpoint.obfs_password,
}
return {
"log": {"level": "info", "timestamp": True},
"dns": {
"servers": [
{
"type": "udp",
"tag": "bootstrap-dns",
"server": dns.bootstrap_server,
"server_port": dns.bootstrap_port,
"bind_interface": network.upstream_interface,
},
{
"type": "https",
"tag": "remote-dns",
"server": dns.remote_server,
"server_port": dns.remote_port,
"path": dns.remote_path,
"tls": {
"enabled": True,
"server_name": dns.remote_tls_server_name,
},
"detour": "hy2-out",
},
],
"final": "remote-dns",
"strategy": dns.strategy,
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": network.tun_name,
"address": [network.tun_address],
"mtu": network.mtu,
"auto_route": True,
"iproute2_table_index": network.iproute2_table_index,
"iproute2_rule_index": network.iproute2_rule_index,
"auto_redirect": True,
"auto_redirect_input_mark": network.auto_redirect_input_mark,
"auto_redirect_output_mark": network.auto_redirect_output_mark,
"auto_redirect_reset_mark": network.auto_redirect_reset_mark,
"auto_redirect_nfqueue": network.auto_redirect_nfqueue,
"auto_redirect_iproute2_fallback_rule_index": network.auto_redirect_fallback_rule_index,
"strict_route": True,
"stack": "mixed",
"route_exclude_address": list(network.route_exclude_address),
}
],
"outbounds": [
outbound,
{
"type": "direct",
"tag": "direct",
"bind_interface": network.upstream_interface,
},
],
"route": {
"auto_detect_interface": True,
"default_domain_resolver": {
"server": "bootstrap-dns",
"strategy": dns.strategy,
},
"final": "hy2-out",
"rules": [
{
"network": ["tcp", "udp"],
"port": 53,
"action": "hijack-dns",
}
],
},
}
def render_bytes(policy: Policy, endpoint: Hy2Endpoint) -> bytes:
return (json.dumps(render_config(policy, endpoint), ensure_ascii=False, indent=2) + "\n").encode("utf-8")
+490
View File
@@ -0,0 +1,490 @@
from __future__ import annotations
import hashlib
import json
import os
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from contextlib import AbstractContextManager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
from .errors import ApplyError, CommandError, ValidationError, VpnEgressError
from .fsutil import atomic_write, atomic_write_json, atomic_write_text, ensure_private_dir, fsync_directory
from .model import Hy2Endpoint
from .policy import Policy
from .redact import redacted_diff
from .renderer_1_13_19 import render_bytes, render_config
from .uri import parse_hysteria2_uri
from .version import SingBoxVersion, probe_version, require_supported
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
class FileLock(AbstractContextManager["FileLock"]):
def __init__(self, path: str) -> None:
self.path = Path(path)
self._stream: Any = None
def __enter__(self) -> "FileLock":
self.path.parent.mkdir(parents=True, exist_ok=True)
self._stream = self.path.open("a+b")
if os.name == "posix":
import fcntl
fcntl.flock(self._stream.fileno(), fcntl.LOCK_EX)
else:
import msvcrt
self._stream.seek(0)
if not self._stream.read(1):
self._stream.seek(0)
self._stream.write(b"0")
self._stream.flush()
self._stream.seek(0)
msvcrt.locking(self._stream.fileno(), msvcrt.LK_LOCK, 1)
return self
def __exit__(self, *args: object) -> None:
if self._stream is None:
return
if os.name == "posix":
import fcntl
fcntl.flock(self._stream.fileno(), fcntl.LOCK_UN)
else:
import msvcrt
self._stream.seek(0)
msvcrt.locking(self._stream.fileno(), msvcrt.LK_UNLCK, 1)
self._stream.close()
self._stream = None
class Controller:
def __init__(
self,
policy: Policy,
*,
runner: RunFunction = subprocess.run,
urlopen: Callable[..., Any] = urllib.request.urlopen,
sleeper: Callable[[float], None] = time.sleep,
) -> None:
self.policy = policy
self.runner = runner
self.urlopen = urlopen
self.sleeper = sleeper
@property
def state_dir(self) -> Path:
return Path(self.policy.runtime.state_dir)
@property
def state_path(self) -> Path:
return self.state_dir / "state.json"
@property
def backup_dir(self) -> Path:
return self.state_dir / "backups"
@property
def last_good_path(self) -> Path:
return self.state_dir / "last-good.json"
def _run(self, args: list[str], timeout: float = 30) -> subprocess.CompletedProcess[str]:
try:
return self.runner(
args,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandError(f"Command failed to execute: {Path(args[0]).name}") from exc
def _probe(self) -> SingBoxVersion:
version = probe_version(self.policy.sing_box.binary, self.runner)
require_supported(version, self.policy.sing_box.required_version)
return version
def _read_uri(self) -> tuple[str, Hy2Endpoint]:
path = Path(self.policy.runtime.uri_path)
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise ValidationError(f"Cannot read Hysteria2 URI source: {path}") from exc
return raw, parse_hysteria2_uri(raw)
def _installed_bytes(self) -> bytes | None:
try:
return Path(self.policy.sing_box.config_path).read_bytes()
except FileNotFoundError:
return None
except OSError as exc:
raise CommandError("Cannot read installed sing-box configuration") from exc
def _installed_json(self) -> dict[str, Any] | None:
data = self._installed_bytes()
if data is None:
return None
try:
value = json.loads(data)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValidationError("Installed sing-box configuration is not valid JSON") from exc
if not isinstance(value, dict):
raise ValidationError("Installed sing-box configuration must be an object")
return value
def _candidate(self, endpoint: Hy2Endpoint) -> tuple[bytes, Path]:
data = render_bytes(self.policy, endpoint)
target = Path(self.policy.sing_box.config_path)
target.parent.mkdir(parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(prefix=f".{target.name}.candidate.", dir=target.parent)
candidate = Path(name)
try:
os.chmod(candidate, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
result = self._run([self.policy.sing_box.binary, "check", "-c", str(candidate)])
if result.returncode != 0:
raise ValidationError("sing-box rejected the generated configuration")
return data, candidate
except Exception:
candidate.unlink(missing_ok=True)
raise
finally:
if descriptor >= 0:
os.close(descriptor)
def _service_active(self) -> bool:
result = self._run(
["/usr/bin/systemctl", "is-active", "--quiet", self.policy.sing_box.service],
timeout=10,
)
return result.returncode == 0
def _restart(self) -> None:
result = self._run(
["/usr/bin/systemctl", "restart", self.policy.sing_box.service],
timeout=45,
)
if result.returncode != 0:
raise CommandError("sing-box service restart failed")
def _healthcheck(self) -> None:
health = self.policy.healthcheck
if health.settle_seconds:
self.sleeper(health.settle_seconds)
deadline = time.monotonic() + health.timeout_seconds
last_error = "healthcheck failed"
while True:
if not self._service_active():
last_error = "sing-box service is not active"
elif health.url is None:
return
else:
try:
remaining = max(0.1, deadline - time.monotonic())
with self.urlopen(health.url, timeout=remaining) as response:
body = response.read(1_048_576).decode("utf-8", errors="replace")
status = getattr(response, "status", response.getcode())
if status != health.expected_status:
last_error = "healthcheck returned an unexpected HTTP status"
elif health.body_contains is not None and health.body_contains not in body:
last_error = "healthcheck response did not contain the expected marker"
else:
return
except (OSError, urllib.error.URLError, TimeoutError, socket.timeout):
last_error = "healthcheck request failed"
if time.monotonic() >= deadline:
raise CommandError(last_error)
self.sleeper(min(0.5, max(0.0, deadline - time.monotonic())))
def _write_state(
self,
*,
status: str,
version: SingBoxVersion,
config_data: bytes,
source_data: str | None,
endpoint: Hy2Endpoint | None,
changed: bool,
message: str | None = None,
) -> None:
ensure_private_dir(self.state_dir)
state: dict[str, Any] = {
"schema_version": 1,
"status": status,
"updated_at": _now(),
"sing_box_version": version.version,
"config_sha256": _sha256(config_data),
"changed": changed,
}
if source_data is not None:
state["source_sha256"] = _sha256(source_data.encode("utf-8"))
if endpoint is not None:
state["endpoint"] = endpoint.endpoint_label()
if message is not None:
state["message"] = message
atomic_write_json(self.state_path, state)
def _save_backup(self, current: bytes) -> None:
ensure_private_dir(self.backup_dir)
ensure_private_dir(self.state_dir)
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
name = self.backup_dir / f"config.{stamp}.{_sha256(current)[:12]}.json"
atomic_write(name, current)
atomic_write(self.last_good_path, current)
backups = sorted(self.backup_dir.glob("config.*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
for old in backups[self.policy.runtime.backup_keep :]:
old.unlink(missing_ok=True)
fsync_directory(self.backup_dir)
def _restore(self, previous: bytes | None) -> bool:
target = Path(self.policy.sing_box.config_path)
try:
if previous is None:
target.unlink(missing_ok=True)
return False
atomic_write(target, previous)
check = self._run([self.policy.sing_box.binary, "check", "-c", str(target)])
if check.returncode != 0:
return False
self._restart()
self._healthcheck()
return True
except (CommandError, OSError):
return False
def _install_candidate(
self,
*,
source: str,
endpoint: Hy2Endpoint,
candidate_data: bytes,
candidate_path: Path,
version: SingBoxVersion,
) -> bool:
target = Path(self.policy.sing_box.config_path)
previous = self._installed_bytes()
if previous == candidate_data:
candidate_path.unlink(missing_ok=True)
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=False,
)
return False
if previous is not None:
try:
self._save_backup(previous)
except OSError as exc:
candidate_path.unlink(missing_ok=True)
raise CommandError("Cannot create a protected last-good backup") from exc
try:
if os.name == "posix":
os.chmod(candidate_path, 0o600)
if target.exists():
stat_result = target.stat()
os.chown(candidate_path, stat_result.st_uid, stat_result.st_gid)
os.replace(candidate_path, target)
fsync_directory(target.parent)
self._restart()
self._healthcheck()
except (CommandError, OSError) as exc:
candidate_path.unlink(missing_ok=True)
rollback_ok = self._restore(previous)
self._write_state(
status="apply_failed_rolled_back" if rollback_ok else "critical_rollback_failed",
version=version,
config_data=previous or b"",
source_data=source,
endpoint=endpoint,
changed=False,
message="Generated configuration was not activated",
)
raise ApplyError(
"Generated configuration failed health validation; previous configuration restored"
if rollback_ok
else "Generated configuration failed and automatic rollback also failed",
rollback_ok=rollback_ok,
) from exc
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=True,
)
return True
def check(self) -> Hy2Endpoint:
with FileLock(self.policy.runtime.lock_path):
self._probe()
_, endpoint = self._read_uri()
_, candidate = self._candidate(endpoint)
candidate.unlink(missing_ok=True)
return endpoint
def render(self) -> bytes:
with FileLock(self.policy.runtime.lock_path):
self._probe()
_, endpoint = self._read_uri()
data, candidate = self._candidate(endpoint)
candidate.unlink(missing_ok=True)
return data
def diff(self) -> list[str]:
with FileLock(self.policy.runtime.lock_path):
self._probe()
_, endpoint = self._read_uri()
_, candidate = self._candidate(endpoint)
candidate.unlink(missing_ok=True)
return redacted_diff(self._installed_json(), render_config(self.policy, endpoint))
def sync(self) -> bool:
with FileLock(self.policy.runtime.lock_path):
version = self._probe()
source, endpoint = self._read_uri()
data, candidate = self._candidate(endpoint)
return self._install_candidate(
source=source,
endpoint=endpoint,
candidate_data=data,
candidate_path=candidate,
version=version,
)
def import_uri(self, source: str) -> bool:
source = source.strip()
endpoint = parse_hysteria2_uri(source)
uri_path = Path(self.policy.runtime.uri_path)
with FileLock(self.policy.runtime.lock_path):
version = self._probe()
data, candidate = self._candidate(endpoint)
old_uri = uri_path.read_bytes() if uri_path.exists() else None
atomic_write_text(uri_path, source + "\n")
try:
return self._install_candidate(
source=source,
endpoint=endpoint,
candidate_data=data,
candidate_path=candidate,
version=version,
)
except Exception:
if old_uri is None:
uri_path.unlink(missing_ok=True)
else:
atomic_write(uri_path, old_uri)
raise
def rollback(self) -> None:
with FileLock(self.policy.runtime.lock_path):
version = self._probe()
if not self.last_good_path.exists():
raise ValidationError("No last-good configuration is available")
previous = self.last_good_path.read_bytes()
current = self._installed_bytes()
if current is None:
raise ValidationError("Installed sing-box configuration does not exist")
descriptor, name = tempfile.mkstemp(
prefix=".rollback.", dir=Path(self.policy.sing_box.config_path).parent
)
candidate = Path(name)
try:
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(previous)
stream.flush()
os.fsync(stream.fileno())
os.chmod(candidate, 0o600)
check = self._run([self.policy.sing_box.binary, "check", "-c", str(candidate)])
if check.returncode != 0:
raise ValidationError("last-good configuration is rejected by sing-box")
os.replace(candidate, self.policy.sing_box.config_path)
fsync_directory(Path(self.policy.sing_box.config_path).parent)
self._restart()
self._healthcheck()
atomic_write(self.last_good_path, current)
self._write_state(
status="rolled_back",
version=version,
config_data=previous,
source_data=None,
endpoint=None,
changed=True,
)
except Exception as exc:
candidate.unlink(missing_ok=True)
rollback_ok = self._restore(current)
raise ApplyError(
"Manual rollback failed; original configuration restored"
if rollback_ok
else "Manual rollback and recovery both failed",
rollback_ok=rollback_ok,
) from exc
finally:
if descriptor >= 0:
os.close(descriptor)
def status(self) -> dict[str, Any]:
state: dict[str, Any] = {"state": "not_applied"}
try:
if self.state_path.exists():
loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
state = loaded
except (OSError, json.JSONDecodeError):
state = {"state": "invalid_state_file"}
installed = self._installed_bytes()
state["installed_config_sha256"] = _sha256(installed) if installed is not None else None
state["config_drift"] = bool(
installed is not None
and state.get("config_sha256")
and state.get("config_sha256") != _sha256(installed)
)
try:
source = Path(self.policy.runtime.uri_path).read_text(encoding="utf-8").strip()
source_hash = _sha256(source.encode("utf-8"))
state["installed_source_sha256"] = source_hash
state["source_drift"] = bool(
state.get("source_sha256") and state.get("source_sha256") != source_hash
)
except OSError:
state["installed_source_sha256"] = None
state["source_drift"] = True
try:
version = probe_version(self.policy.sing_box.binary, self.runner)
state["detected_sing_box_version"] = version.version
state["version_supported"] = version.version == self.policy.sing_box.required_version
except (CommandError, ValidationError, VpnEgressError):
state["detected_sing_box_version"] = None
state["version_supported"] = False
try:
state["service_active"] = self._service_active()
except CommandError:
state["service_active"] = False
return state
+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,
)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
from typing import Callable
from .errors import CommandError, UnsupportedVersionError
Runner = Callable[..., subprocess.CompletedProcess[str]]
@dataclass(frozen=True, slots=True)
class SingBoxVersion:
version: str
environment: str
tags: frozenset[str]
revision: str | None
def parse_version_output(output: str) -> SingBoxVersion:
first = re.search(r"(?m)^sing-box version ([^\s]+)\s*$", output)
if not first:
raise UnsupportedVersionError("Cannot parse sing-box version output")
environment = re.search(r"(?m)^Environment:\s*(.+)$", output)
tags = re.search(r"(?m)^Tags:\s*(.*)$", output)
revision = re.search(r"(?m)^Revision:\s*(\S+)$", output)
return SingBoxVersion(
version=first.group(1),
environment=environment.group(1).strip() if environment else "",
tags=frozenset(filter(None, re.split(r"[\s,]+", tags.group(1)))) if tags else frozenset(),
revision=revision.group(1) if revision else None,
)
def probe_version(binary: str, runner: Runner = subprocess.run) -> SingBoxVersion:
try:
result = runner(
[binary, "version"],
text=True,
capture_output=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandError("Cannot execute sing-box binary") from exc
if result.returncode != 0:
raise CommandError("sing-box version command failed")
return parse_version_output(result.stdout + result.stderr)
def require_supported(version: SingBoxVersion, required: str = "1.13.19") -> None:
if version.version != required:
raise UnsupportedVersionError(
f"Unsupported sing-box version {version.version}; required exactly {required}"
)
if "linux/" not in version.environment:
raise UnsupportedVersionError("sing-box must be a Linux build")
if "with_quic" not in version.tags:
raise UnsupportedVersionError("sing-box build does not include with_quic")