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