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
+1
View File
@@ -0,0 +1 @@
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import subprocess
from dataclasses import replace
from pathlib import Path
from vpn_egressctl.policy import (
BandwidthPolicy,
DnsPolicy,
HealthcheckPolicy,
NetworkPolicy,
Policy,
RuntimePolicy,
SingBoxPolicy,
)
VERSION_OUTPUT = """sing-box version 1.13.19
Environment: go1.25.9 linux/amd64
Tags: with_quic,with_gvisor,with_utls
Revision: testrevision
CGO: enabled
"""
def make_policy(root: Path, *, health_url: str | None = "https://health.invalid/") -> Policy:
etc = root / "etc"
state = root / "state"
return Policy(
schema_version=1,
sing_box=SingBoxPolicy(
binary=str(root / "sing-box"),
config_path=str(etc / "config.json"),
service="sing-box.service",
required_version="1.13.19",
),
runtime=RuntimePolicy(
uri_path=str(etc / "hysteria2.uri"),
state_dir=str(state),
lock_path=str(root / "run" / "controller.lock"),
backup_keep=3,
),
network=NetworkPolicy(
upstream_interface="eth0",
vpn_lan_interface="eth1",
tun_name="tun-sb0",
tun_address="172.19.0.1/30",
mtu=1400,
route_exclude_address=("10.20.0.0/24", "10.30.0.0/24", "127.0.0.0/8"),
iproute2_table_index=2022,
iproute2_rule_index=9000,
auto_redirect_input_mark="0x2023",
auto_redirect_output_mark="0x2024",
auto_redirect_reset_mark="0x2025",
auto_redirect_nfqueue=100,
auto_redirect_fallback_rule_index=32768,
),
dns=DnsPolicy(
bootstrap_server="1.1.1.1",
bootstrap_port=53,
remote_server="1.1.1.1",
remote_port=443,
remote_path="/dns-query",
remote_tls_server_name="cloudflare-dns.com",
strategy="ipv4_only",
),
bandwidth=BandwidthPolicy(up_mbps=50, down_mbps=200),
healthcheck=HealthcheckPolicy(
url=health_url,
timeout_seconds=0.1,
settle_seconds=0,
expected_status=200,
body_contains="ip=",
),
)
class FakeResponse:
status = 200
def __init__(self, body: bytes = b"ip=203.0.113.10\n") -> None:
self.body = body
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
def read(self, limit: int = -1) -> bytes:
return self.body[:limit]
def getcode(self) -> int:
return self.status
class FakeRunner:
def __init__(self, *, version: str = VERSION_OUTPUT, restart_results: list[int] | None = None, check_result: int = 0) -> None:
self.version = version
self.restart_results = list(restart_results or [0])
self.check_result = check_result
self.calls: list[list[str]] = []
def __call__(self, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
self.calls.append(list(args))
if len(args) > 1 and args[1] == "version":
return subprocess.CompletedProcess(args, 0, self.version, "")
if len(args) > 1 and args[1] == "check":
return subprocess.CompletedProcess(args, self.check_result, "", "")
if "restart" in args:
result = self.restart_results.pop(0) if self.restart_results else 0
return subprocess.CompletedProcess(args, result, "", "")
if "is-active" in args:
return subprocess.CompletedProcess(args, 0, "active\n", "")
return subprocess.CompletedProcess(args, 0, "", "")
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import contextlib
import io
import unittest
from vpn_egressctl.cli import _parser
class CliTests(unittest.TestCase):
def test_uri_positional_argument_is_rejected(self) -> None:
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["import", "hysteria2://secret@example.com"])
def test_render_requires_output(self) -> None:
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["render"])
def test_all_commands_parse(self) -> None:
for command in ("check", "diff", "sync", "status", "doctor", "rollback"):
with self.subTest(command=command):
args = _parser().parse_args([command])
self.assertEqual(args.command, command)
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from vpn_egressctl.doctor import Doctor
from vpn_egressctl.renderer_1_13_19 import render_bytes
from vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import FakeRunner, make_policy
class DoctorTests(unittest.TestCase):
def prepare(self, directory: str, uri: str):
root = Path(directory)
policy = make_policy(root)
endpoint = parse_hysteria2_uri(uri)
Path(policy.runtime.uri_path).parent.mkdir(parents=True)
Path(policy.runtime.uri_path).write_text(uri + "\n", encoding="utf-8")
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint))
return policy
def test_endpoint_exclusion_is_an_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = self.prepare(directory, "hy2://auth@example.com")
policy = replace(
policy,
network=replace(
policy.network,
route_exclude_address=policy.network.route_exclude_address + ("8.8.8.8/32",),
),
)
doctor = Doctor(
policy,
runner=FakeRunner(),
resolver=lambda *args: [(None, None, None, None, ("8.8.8.8", 443))],
)
checks = doctor.run()
selected = [check for check in checks if check.name == "endpoint-exclusion"]
self.assertEqual(selected[0].level, "ERROR")
def test_insecure_tls_is_reported_without_secret(self) -> None:
secret = "NEVER-LOG-ME"
with tempfile.TemporaryDirectory() as directory:
policy = self.prepare(directory, f"hy2://{secret}@example.com?insecure=1")
doctor = Doctor(
policy,
runner=FakeRunner(),
resolver=lambda *args: [(None, None, None, None, ("8.8.4.4", 443))],
)
checks = doctor.run()
output = json.dumps([check.message for check in checks])
self.assertNotIn(secret, output)
selected = [check for check in checks if check.name == "tls-insecure"]
self.assertEqual(selected[0].level, "WARN")
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import subprocess
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.errors import CommandError
from vpn_egressctl.guard import apply_guard
from tests.helpers import make_policy
class GuardRunner:
def __init__(self, *, missing_interface: bool = False, batch_result: int = 0) -> None:
self.missing_interface = missing_interface
self.batch_result = batch_result
self.calls: list[tuple[list[str], str | None]] = []
def __call__(self, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
input_text = kwargs.get("input")
self.calls.append((list(args), input_text if isinstance(input_text, str) else None))
if "link" in args and self.missing_interface:
return subprocess.CompletedProcess(args, 1, "", "")
if args[-2:] == ["-f", "-"]:
return subprocess.CompletedProcess(args, self.batch_result, "", "")
return subprocess.CompletedProcess(args, 0, "", "")
class GuardTests(unittest.TestCase):
def test_atomic_ruleset_uses_policy_interfaces(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory))
runner = GuardRunner()
apply_guard(policy, runner)
batch = [item for args, item in runner.calls if args[-2:] == ["-f", "-"]][0]
self.assertIsNotNone(batch)
self.assertIn("delete table inet vpn_egress_guard", batch)
self.assertIn('iifname "eth1" oifname "eth0"', batch)
self.assertIn("counter reject", batch)
def test_missing_interface_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory:
runner = GuardRunner(missing_interface=True)
with self.assertRaises(CommandError):
apply_guard(make_policy(Path(directory)), runner)
self.assertFalse(any(args[-2:] == ["-f", "-"] for args, _ in runner.calls))
def test_batch_failure_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as directory:
with self.assertRaises(CommandError):
apply_guard(make_policy(Path(directory)), GuardRunner(batch_result=1))
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.errors import ValidationError
from vpn_egressctl.policy import load_policy
class PolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.raw = json.loads(Path("config/policy.json").read_text(encoding="utf-8"))
def load(self, raw: dict) -> object:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "policy.json"
path.write_text(json.dumps(raw), encoding="utf-8")
return load_policy(path)
def test_production_policy(self) -> None:
policy = self.load(self.raw)
self.assertEqual(policy.sing_box.required_version, "1.13.19")
self.assertEqual(policy.network.iproute2_table_index, 2022)
def test_unknown_root_key(self) -> None:
self.raw["future"] = True
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_unknown_nested_key(self) -> None:
self.raw["network"]["typo"] = 1
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_missing_key(self) -> None:
del self.raw["dns"]["strategy"]
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_version_is_exactly_pinned(self) -> None:
for value in ("1.13", ">=1.13,<1.14", "1.14.0"):
raw = json.loads(json.dumps(self.raw))
raw["sing_box"]["required_version"] = value
with self.subTest(value=value), self.assertRaises(ValidationError):
self.load(raw)
def test_public_exclusion_is_valid_but_diagnosable(self) -> None:
self.raw["network"]["route_exclude_address"].append("203.0.113.1/32")
policy = self.load(self.raw)
self.assertIn("203.0.113.1/32", policy.network.route_exclude_address)
def test_invalid_network_rejected(self) -> None:
self.raw["network"]["route_exclude_address"] = ["10.20.0.1/24"]
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_insecure_health_url_rejected(self) -> None:
self.raw["healthcheck"]["url"] = "http://example.com/"
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_health_url_credentials_rejected(self) -> None:
self.raw["healthcheck"]["url"] = "https://user:pass@example.com/check"
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_dns_path_must_be_absolute(self) -> None:
self.raw["dns"]["remote_path"] = "dns-query"
with self.assertRaises(ValidationError):
self.load(self.raw)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.renderer_1_13_19 import render_bytes
from vpn_egressctl.uri import parse_hysteria2_uri
from vpn_egressctl.version import probe_version
from tests.helpers import make_policy
class RealSingBoxIntegrationTests(unittest.TestCase):
@unittest.skipUnless(os.environ.get("SING_BOX_1_13_19"), "real sing-box 1.13.19 binary is not configured")
def test_real_binary_accepts_golden_config(self) -> None:
binary = os.environ["SING_BOX_1_13_19"]
version = probe_version(binary)
self.assertEqual(version.version, "1.13.19")
self.assertIn("with_quic", version.tags)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
policy = make_policy(root)
endpoint = parse_hysteria2_uri(
"hysteria2://auth@example.com:443?obfs=salamander&obfs-password=obfs"
)
config = root / "config.json"
config.write_bytes(render_bytes(policy, endpoint))
command = "check" if "linux/" in version.environment else "format"
result = subprocess.run([binary, command, "-c", str(config)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import unittest
from vpn_egressctl.redact import redact, redacted_diff, secret_fingerprint
class RedactionTests(unittest.TestCase):
def test_recursive_redaction(self) -> None:
value = {"password": "secret", "nested": {"obfs_password": "other", "server": "example.com"}}
safe = redact(value)
self.assertEqual(safe["password"], "<REDACTED>")
self.assertEqual(safe["nested"]["obfs_password"], "<REDACTED>")
self.assertEqual(safe["nested"]["server"], "example.com")
def test_diff_hides_secrets(self) -> None:
diff = "\n".join(redacted_diff({"password": "old"}, {"password": "new"}))
self.assertNotIn("old", diff)
self.assertNotIn("new", diff)
self.assertIn("REDACTED", diff)
def test_fingerprint_is_short_and_stable(self) -> None:
self.assertEqual(secret_fingerprint("x"), secret_fingerprint("x"))
self.assertEqual(len(secret_fingerprint("x")), 12)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.renderer_1_13_19 import render_bytes, render_config
from vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import make_policy
class RendererTests(unittest.TestCase):
def test_complete_production_shape(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory))
endpoint = parse_hysteria2_uri(
"hysteria2://AUTH@fi.api.withen.pro:443/?insecure=0&obfs=salamander&obfs-password=OBFS"
)
config = render_config(policy, endpoint)
self.assertEqual(list(config), ["log", "dns", "inbounds", "outbounds", "route"])
tun = config["inbounds"][0]
self.assertEqual(tun["route_exclude_address"], ["10.20.0.0/24", "10.30.0.0/24", "127.0.0.0/8"])
self.assertEqual(tun["iproute2_table_index"], 2022)
self.assertEqual(tun["auto_redirect_input_mark"], "0x2023")
self.assertEqual(tun["auto_redirect_output_mark"], "0x2024")
self.assertEqual(tun["auto_redirect_reset_mark"], "0x2025")
outbound = config["outbounds"][0]
self.assertEqual(outbound["server"], "fi.api.withen.pro")
self.assertEqual(outbound["password"], "AUTH")
self.assertEqual(outbound["obfs"]["password"], "OBFS")
self.assertEqual(outbound["bind_interface"], "eth0")
self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out")
self.assertEqual(config["route"]["final"], "hy2-out")
self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode())
def test_multi_port_mapping(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = render_config(
make_policy(Path(directory)),
parse_hysteria2_uri("hy2://x@example.com:443,5000-6000"),
)
outbound = config["outbounds"][0]
self.assertNotIn("server_port", outbound)
self.assertEqual(outbound["server_ports"], ["443", "5000:6000"])
def test_deterministic_utf8_json(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory))
endpoint = parse_hysteria2_uri("hy2://x@example.com#Тест")
first = render_bytes(policy, endpoint)
second = render_bytes(policy, endpoint)
self.assertEqual(first, second)
self.assertTrue(first.endswith(b"\n"))
json.loads(first)
if __name__ == "__main__":
unittest.main()
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.errors import ApplyError, UnsupportedVersionError, ValidationError
from vpn_egressctl.renderer_1_13_19 import render_bytes
from vpn_egressctl.transaction import Controller
from vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import FakeResponse, FakeRunner, VERSION_OUTPUT, make_policy
OLD_CONFIG = b'{"log":{"level":"error"}}\n'
URI_OLD = "hysteria2://OLD@example.com:443/?obfs=salamander&obfs-password=OLDOBFS"
URI_NEW = "hysteria2://NEW@example.com:443/?obfs=salamander&obfs-password=NEWOBFS"
class TransactionTests(unittest.TestCase):
def make(self, directory: str, *, runner: FakeRunner | None = None) -> tuple[Controller, FakeRunner, object]:
root = Path(directory)
policy = make_policy(root)
Path(policy.sing_box.config_path).parent.mkdir(parents=True)
Path(policy.runtime.uri_path).write_text(URI_NEW + "\n", encoding="utf-8")
selected = runner or FakeRunner()
controller = Controller(
policy,
runner=selected,
urlopen=lambda *args, **kwargs: FakeResponse(),
sleeper=lambda _: None,
)
return controller, selected, policy
def test_sync_applies_and_records_last_good(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
changed = controller.sync()
self.assertTrue(changed)
installed = Path(policy.sing_box.config_path).read_bytes()
self.assertIn(b'"password": "NEW"', installed)
self.assertEqual(controller.last_good_path.read_bytes(), OLD_CONFIG)
state_text = controller.state_path.read_text(encoding="utf-8")
self.assertNotIn("NEWOBFS", state_text)
self.assertNotIn('"NEW"', state_text)
self.assertEqual(json.loads(state_text)["status"], "ok")
self.assertTrue(any("restart" in call for call in runner.calls))
def test_no_change_does_not_restart(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint))
self.assertFalse(controller.sync())
self.assertFalse(any("restart" in call for call in runner.calls))
self.assertFalse(json.loads(controller.state_path.read_text())["changed"])
def test_generated_config_rejection_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1))
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(ValidationError):
controller.sync()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
def test_version_gate_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = VERSION_OUTPUT.replace("1.13.19", "1.13.12", 1)
controller, _, policy = self.make(directory, runner=FakeRunner(version=output))
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(UnsupportedVersionError):
controller.sync()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
def test_restart_failure_restores_config(self) -> None:
with tempfile.TemporaryDirectory() as directory:
runner = FakeRunner(restart_results=[1, 0])
controller, _, policy = self.make(directory, runner=runner)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(ApplyError) as caught:
controller.sync()
self.assertTrue(caught.exception.rollback_ok)
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
self.assertEqual(json.loads(controller.state_path.read_text())["status"], "apply_failed_rolled_back")
def test_import_failure_restores_uri_and_config(self) -> None:
with tempfile.TemporaryDirectory() as directory:
runner = FakeRunner(restart_results=[1, 0])
controller, _, policy = self.make(directory, runner=runner)
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(ApplyError):
controller.import_uri(URI_NEW)
self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD)
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
def test_import_validation_does_not_store_uri(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
with self.assertRaises(ValidationError):
controller.import_uri("hysteria2://secret@example.com?unknown=x")
self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD)
def test_manual_rollback_swaps_current_and_last_good(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
current = render_bytes(policy, parse_hysteria2_uri(URI_NEW))
previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD))
Path(policy.sing_box.config_path).write_bytes(current)
controller.state_dir.mkdir(parents=True)
controller.last_good_path.write_bytes(previous)
controller.rollback()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous)
self.assertEqual(controller.last_good_path.read_bytes(), current)
self.assertEqual(json.loads(controller.state_path.read_text())["status"], "rolled_back")
def test_diff_never_contains_secrets(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
output = "\n".join(controller.diff())
self.assertNotIn("NEWOBFS", output)
self.assertNotIn('"NEW"', output)
self.assertIn("REDACTED", output)
def test_status_detects_source_drift(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint))
controller.sync()
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
status = controller.status()
self.assertTrue(status["source_drift"])
if __name__ == "__main__":
unittest.main()
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import unittest
from vpn_egressctl.errors import ValidationError
from vpn_egressctl.uri import parse_hysteria2_uri
class UriParserTests(unittest.TestCase):
def test_minimal_uri(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@example.com/")
self.assertEqual(endpoint.server, "example.com")
self.assertEqual(endpoint.server_port, 443)
self.assertEqual(endpoint.password, "secret")
self.assertEqual(endpoint.sni, "example.com")
def test_short_scheme_and_percent_encoding(self) -> None:
endpoint = parse_hysteria2_uri("hy2://user%3Apass@EXAMPLE.com:8443?sni=t%C3%A9st.example#Moscow")
self.assertEqual(endpoint.password, "user:pass")
self.assertEqual(endpoint.server, "example.com")
self.assertEqual(endpoint.server_port, 8443)
self.assertEqual(endpoint.sni, "xn--tst-bma.example")
self.assertEqual(endpoint.display_name, "Moscow")
def test_salamander(self) -> None:
endpoint = parse_hysteria2_uri(
"hysteria2://secret@example.com:443/?insecure=1&obfs=salamander&obfs-password=o%40p"
)
self.assertTrue(endpoint.insecure)
self.assertEqual(endpoint.obfs_type, "salamander")
self.assertEqual(endpoint.obfs_password, "o@p")
def test_ipv6(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@[2001:db8::1]:444/")
self.assertEqual(endpoint.server, "2001:db8::1")
self.assertEqual(endpoint.server_port, 444)
self.assertEqual(endpoint.endpoint_label(), "[2001:db8::1]:444")
def test_multi_port(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@example.com:443,5000-6000/")
self.assertIsNone(endpoint.server_port)
self.assertEqual(endpoint.server_ports, ("443", "5000:6000"))
def test_range_only_uses_server_ports(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@example.com:5000-6000/")
self.assertIsNone(endpoint.server_port)
self.assertEqual(endpoint.server_ports, ("5000:6000",))
def test_userpass_is_preserved(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://alice%3Acorrect%20horse@example.com")
self.assertEqual(endpoint.password, "alice:correct horse")
def test_unicode_host_is_idna(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@пример.рф")
self.assertEqual(endpoint.server, "xn--e1afmkfd.xn--p1ai")
def test_repr_hides_secrets(self) -> None:
endpoint = parse_hysteria2_uri(
"hysteria2://TOPSECRET@example.com?obfs=salamander&obfs-password=OBFSSECRET"
)
text = repr(endpoint)
self.assertNotIn("TOPSECRET", text)
self.assertNotIn("OBFSSECRET", text)
def assert_invalid(self, uri: str, marker: str | None = None) -> None:
with self.assertRaises(ValidationError) as caught:
parse_hysteria2_uri(uri)
if marker:
self.assertIn(marker, str(caught.exception))
def test_rejections(self) -> None:
cases = [
("http://secret@example.com", "scheme"),
("hysteria2://example.com", "authentication"),
("hysteria2://@example.com", "authentication"),
("hysteria2://secret@", "server"),
("hysteria2://secret@example.com:0", "1..65535"),
("hysteria2://secret@example.com:65536", "1..65535"),
("hysteria2://secret@example.com:100-99", "range"),
("hysteria2://secret@example.com:100,100", "Overlapping"),
("hysteria2://secret@2001:db8::1", "brackets"),
("hysteria2://secret@example.com/path", "paths"),
("hysteria2://secret@example.com?unknown=x", "Unsupported"),
("hysteria2://secret@example.com?sni=a&sni=b", "Duplicate"),
("hysteria2://secret@example.com?insecure=true", "exactly"),
("hysteria2://secret@example.com?obfs=gecko", "1.13.19"),
("hysteria2://secret@example.com?obfs=salamander", "obfs-password"),
("hysteria2://secret@example.com?obfs-password=x", "requires"),
("hysteria2://secret@example.com?pinSHA256=x", "safely"),
("hysteria2://secret@example.com?ech=x", "safely"),
("hysteria2://sec%ZZret@example.com", "percent"),
("hysteria2://secret@example.com?sni=%FF", "UTF-8"),
]
for uri, marker in cases:
with self.subTest(uri=uri):
self.assert_invalid(uri, marker)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import subprocess
import unittest
from vpn_egressctl.errors import CommandError, UnsupportedVersionError
from vpn_egressctl.version import parse_version_output, probe_version, require_supported
from tests.helpers import FakeRunner, VERSION_OUTPUT
class VersionTests(unittest.TestCase):
def test_parse_and_require(self) -> None:
version = parse_version_output(VERSION_OUTPUT)
self.assertEqual(version.version, "1.13.19")
self.assertIn("with_quic", version.tags)
require_supported(version)
def test_other_versions_are_rejected(self) -> None:
for value in ("1.13.12", "1.13.20", "1.14.0", "1.13.19-rc.1"):
text = VERSION_OUTPUT.replace("1.13.19", value, 1)
with self.subTest(value=value), self.assertRaises(UnsupportedVersionError):
require_supported(parse_version_output(text))
def test_non_linux_rejected(self) -> None:
version = parse_version_output(VERSION_OUTPUT.replace("linux/amd64", "windows/amd64"))
with self.assertRaises(UnsupportedVersionError):
require_supported(version)
def test_missing_quic_rejected(self) -> None:
version = parse_version_output(VERSION_OUTPUT.replace("with_quic,", ""))
with self.assertRaises(UnsupportedVersionError):
require_supported(version)
def test_command_failure(self) -> None:
runner = FakeRunner()
runner.version = "garbage"
with self.assertRaises(UnsupportedVersionError):
probe_version("sing-box", runner)
if __name__ == "__main__":
unittest.main()