feat: add declarative sing-box egress control plane
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Declarative control plane for the sing-box VPN egress gateway."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)}"
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user