From 8ac3b2b1ba9fe618e1e59c058ac7a2eeeae565cc Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 27 Aug 2026 03:39:06 +0500 Subject: [PATCH] fix: harden sing-box egress rollout --- README.md | 4 +- debian/changelog | 4 ++ debian/install | 1 + docs/architecture.md | 11 +++- docs/configuration.md | 8 ++- docs/migration.md | 31 ++++++++- docs/operations.md | 7 +- docs/security.md | 16 +++-- docs/testing.md | 22 +++++-- docs/troubleshooting.md | 3 +- .../10-vpn-egress-guard.conf | 3 + src/vpn_egressctl/cli.py | 8 +-- src/vpn_egressctl/doctor.py | 65 ++++++++++++++++--- src/vpn_egressctl/renderer_1_13_19.py | 9 +-- src/vpn_egressctl/uri.py | 2 + src/vpn_egressctl/version.py | 4 ++ tests/test_cli.py | 4 +- tests/test_doctor.py | 32 +++++++++ tests/test_packaging.py | 27 ++++++++ tests/test_real_sing_box.py | 1 + tests/test_renderer.py | 1 + tests/test_transaction.py | 3 + tests/test_uri.py | 5 ++ tests/test_version.py | 5 ++ 24 files changed, 228 insertions(+), 48 deletions(-) create mode 100644 packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf create mode 100644 tests/test_packaging.py diff --git a/README.md b/README.md index 39b86d0..0b96365 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ sing-box. - Смена credential выполняется одной безопасной командой через stdin. - Перед установкой candidate проверяется реальным `sing-box check`. - Запись атомарна; после неуспешного restart/healthcheck выполняется rollback. -- URI, production config, state и backups имеют режим `0600`. +- Policy, URI, production config, state и backups имеют `root:root 0600`, + защищённые каталоги — `root:root 0700`. - Неизвестные URI/policy-параметры отклоняются, а не игнорируются. - systemd следит за desired state без постоянно работающего Python-процесса. - Отдельный nftables guard блокирует прямой forwarding `eth1 -> eth0`. +- `sing-box.service` требует успешного запуска guard через package-managed drop-in. ## Источники состояния diff --git a/debian/changelog b/debian/changelog index e26927b..d930679 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,9 @@ vpn-egressctl (0.1.0) unstable; urgency=medium * Initial release for sing-box 1.13.19. + * Require the nftables guard before starting sing-box. + * Enforce QUIC and gVisor build tags and remove the unsafe render command. + * Tighten URI parsing and protected filesystem diagnostics. + * Document local healthcheck and forwarded workload acceptance separately. -- Flamy Studio Thu, 27 Aug 2026 00:00:00 +0500 diff --git a/debian/install b/debian/install index 505b3b2..35c0a1d 100644 --- a/debian/install +++ b/debian/install @@ -2,3 +2,4 @@ config/policy.json usr/share/vpn-egressctl packaging/systemd/vpn-egress-guard.service usr/lib/systemd/system packaging/systemd/vpn-egress-sync.service usr/lib/systemd/system packaging/systemd/vpn-egress-sync.path usr/lib/systemd/system +packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf usr/lib/systemd/system/sing-box.service.d diff --git a/docs/architecture.md b/docs/architecture.md index e8f750f..afe120b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ URI + policy ▼ ├─ last-good backup no restart ├─ atomic replace ├─ systemctl restart - └─ bounded healthcheck + └─ bounded local connectivity healthcheck │ fail ┴ success │ │ @@ -66,11 +66,11 @@ Outbound сохраняет DNS hostname и содержит `bind_interface=eth ## Файловая модель ```text -/etc/vpn-egress/ +/etc/vpn-egress/ root:root 0700 ├── policy.json root:root 0600 └── hysteria2.uri root:root 0600 -/var/lib/vpn-egress/ +/var/lib/vpn-egress/ root:root 0700 ├── state.json без секретов, 0600 ├── last-good.json содержит secrets, 0600 └── backups/ ограниченная история, 0700/0600 @@ -86,6 +86,11 @@ Guard читает имена интерфейсов из того же policy таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет систему с частично заменённой таблицей. +Package-managed drop-in добавляет для `sing-box.service` зависимости +`Requires=` и `After=` от guard. Поэтому при совместном запуске ошибка guard +блокирует старт sing-box. Уже активный oneshot не является watchdog: ручное +удаление nftables-таблицы обнаруживает `doctor`, но не systemd dependency. + ## Версионная граница В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является diff --git a/docs/configuration.md b/docs/configuration.md index beada71..d27a991 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,8 +40,9 @@ fallback rule 32768. ### `healthcheck` По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с -маркером `ip=`. Этот запрос идёт после запуска sing-box и подтверждает не только -состояние systemd, но и рабочий data plane. +маркером `ip=`. Этот локальный post-activation connectivity healthcheck идёт +после запуска sing-box и проверяет состояние systemd и исходящую связность +самого шлюза. Он не заменяет acceptance-тест forwarding с workload за `eth1`. `url: null` оставляет только проверку `systemctl is-active`. Это допустимо для изолированного стенда, но слабее production-проверки. @@ -64,6 +65,9 @@ fallback rule 32768. 1.13.19 используют разные виды certificate hash. `ech` будет добавлен только после доказанного преобразования формата config list. +Специальные символы в auth должны быть percent-encoded: сырой `@` отклоняется, +а `%40` декодируется в `@`. + ## Bandwidth `up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не diff --git a/docs/migration.md b/docs/migration.md index 12ba2c8..e1d9f3e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -56,8 +56,11 @@ systemctl status vpn-egress-guard.service --no-pager nft list table inet vpn_egress_guard ``` -Новый unit имеет `Before=sing-box.service`; это закрывает boot window, который -существовал у старого `After=sing-box.service`. +Новый unit имеет `Before=sing-box.service`, а package-managed drop-in для +`sing-box.service` добавляет `Requires=` и `After=` от guard. Это закрывает boot +window и блокирует запуск sing-box, если совместно запущенный guard завершился с +ошибкой. Зависимость не является watchdog для ручного удаления nftables-таблицы; +текущее runtime-состояние проверяет `vpn-egressctl doctor`. ## 5. Dry run и первый import @@ -116,7 +119,31 @@ ip -4 rule show ip -4 route show table 2022 nft list table inet vpn_egress_guard nft list table inet sing-box +systemctl show sing-box.service -p Requires -p After ``` В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни текущего `85.208.119.160`. + +С настоящего workload в `10.30.0.0/24` обязательно проверить: + +1. DNS через шлюз; +2. TCP и UDP через VPN; +3. HTTPS-запрос к контролируемому endpoint или Cloudflare trace; +4. соответствие наблюдаемого public IP ожидаемому VPN egress; +5. повтор тех же проверок после restart sing-box. + +Anti-leak проверяется только из консоли canary/staging, чтобы не потерять +удалённый доступ к production: + +```bash +# На шлюзе: +systemctl stop sing-box.service + +# На workload за eth1: запрос наружу должен завершиться ошибкой, а не пойти напрямую. +curl --fail --connect-timeout 5 https://www.cloudflare.com/cdn-cgi/trace + +# На шлюзе: +systemctl start sing-box.service +vpn-egressctl doctor +``` diff --git a/docs/operations.md b/docs/operations.md index 48c8379..4c9362d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -48,9 +48,10 @@ vpn-egressctl rollback перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает конфигурацию, которая была активна до первого rollback. -После ручного rollback desired URI остаётся прежним, поэтому status показывает -drift. Перед включением нового `sync` нужно либо исправить URI, либо осознанно -вернуть desired state. +После ручного rollback desired URI остаётся прежним. `status` показывает +`status=rolled_back`, а `doctor` сообщает `config-drift`, потому что production +config отличается от заново отрендеренного desired state. Перед новым `sync` +нужно либо исправить URI, либо осознанно вернуть desired state. ## Реакция systemd.path diff --git a/docs/security.md b/docs/security.md index 0ca28d8..fe85a95 100644 --- a/docs/security.md +++ b/docs/security.md @@ -10,7 +10,8 @@ generated config и все backups. - Ошибки parser не включают исходное значение. - diff заменяет secret values на ``. - state хранит только SHA-256 source/config и безопасный endpoint label. -- URI/config/backups имеют `0600`, каталоги — `0700`. +- Policy/URI/config/state/backups имеют `root:root 0600`, каталоги — + `root:root 0700`; `doctor` проверяет этот ограниченный набор объектов. - subprocess вызывается массивом аргументов без shell. После попадания действующего URI в чат, issue, shell history или journal оба @@ -22,7 +23,7 @@ credential следует перевыпустить. - policy schema; - URI syntax и поддерживаемые параметры; -- точная версия и `with_quic`; +- точная версия, `with_quic` и `with_gvisor` для `stack=mixed`; - deterministic candidate; - `sing-box check`. @@ -35,7 +36,9 @@ credential следует перевыпустить. на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической таблицы `inet sing-box`. -Guard запускается до sing-box. Остановка или удаление guard unit является +Guard запускается до sing-box. Drop-in `sing-box.service` одновременно задаёт +requirement и ordering dependency: ошибка запуска guard блокирует sing-box. +Остановка, удаление guard unit или ручное изменение его nftables-таблицы является security-sensitive операцией и не выполняется CLI автоматически. Имена интерфейсов поступают из уже провалидированного policy, subprocess не @@ -43,9 +46,10 @@ security-sensitive операцией и не выполняется CLI авт ## Healthcheck -HTTPS healthcheck подтверждает data plane, но раскрывает проверочному endpoint -факт обращения с VPN egress IP. URL можно заменить внутренним контролируемым -endpoint. Отключение URL ослабляет проверку до состояния systemd. +HTTPS healthcheck подтверждает локальную post-activation связность шлюза, но не +весь forwarded path `eth1 -> TUN -> HY2`. Он раскрывает проверочному endpoint факт +обращения с VPN egress IP. URL можно заменить внутренним контролируемым endpoint. +Отключение URL ослабляет проверку до состояния systemd. ## Ограничения URI 1.13.19 diff --git a/docs/testing.md b/docs/testing.md index 7ebfac2..365085f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,7 @@ 2. Failure injection: candidate rejection, restart failure, URI/config rollback, idempotency и manual rollback swap. 3. Real binary: `sing-box 1.13.19 check` на Linux и schema/format validation на Windows. -4. Privileged Linux: TUN, nftables, systemd и HTTP data-plane healthcheck. +4. Privileged Linux: TUN, nftables, systemd и локальный HTTP connectivity healthcheck. 5. Canary: DNS A change без endpoint CIDR и credential rotation. 6. Reboot: guard ordering, persisted config и path watcher. @@ -22,9 +22,9 @@ make check SING_BOX_1_13_19=/usr/bin/sing-box make test ``` -Он предварительно проверяет exact version и `with_quic`. Windows-бинарник не -может создать Linux auto-redirect и поэтому выполняет `format` полной схемы; -обязательный `check` остаётся в Linux CI/Incus. +Он предварительно проверяет exact version, `with_quic` и `with_gvisor`. +Windows-бинарник не может создать Linux auto-redirect и поэтому выполняет +`format` полной схемы; обязательный `check` остаётся в Linux CI/Incus. ## Privileged acceptance @@ -39,4 +39,18 @@ SING_BOX_1_13_19=/usr/bin/sing-box make test 7. изменить A-запись endpoint, не меняя URI/config; 8. перезагрузить контейнер и повторить doctor. +Отдельный dependency failure test выполняется только в disposable Incus: + +1. остановить sing-box и guard; +2. временно задать в test policy отсутствующий upstream interface; +3. убедиться, что `systemctl start sing-box.service` завершается ошибкой из-за + неуспешного guard; +4. восстановить policy и подтвердить успешный совместный запуск; +5. проверить drop-in командой `systemd-analyze verify` и содержимое собранного + Debian-пакета через `dpkg-deb -c`. + +Локальный HTTPS healthcheck не заменяет запросы DNS/TCP/UDP с реального workload +за `eth1`. При остановленном sing-box такой workload не должен получить прямой +доступ через `eth0`. + Production A-запись не используется для эксперимента: нужен staging hostname. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 831ae3e..1448180 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -48,7 +48,8 @@ timestamped backup из консоли контейнера, затем `sing-bo Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно ставить `url: null` на production нельзя без отдельного решения: это скрывает -неработающий data plane. +неработающую локальную post-activation связность. Даже успешный healthcheck не +заменяет отдельную проверку forwarded path с workload за `eth1`. ## Watcher failed diff --git a/packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf b/packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf new file mode 100644 index 0000000..2d92036 --- /dev/null +++ b/packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=vpn-egress-guard.service +After=vpn-egress-guard.service diff --git a/src/vpn_egressctl/cli.py b/src/vpn_egressctl/cli.py index 1ae3569..dcdbd3c 100644 --- a/src/vpn_egressctl/cli.py +++ b/src/vpn_egressctl/cli.py @@ -9,7 +9,6 @@ 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 @@ -32,8 +31,6 @@ def _parser() -> argparse.ArgumentParser: 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") @@ -82,9 +79,6 @@ def main(argv: list[str] | None = None) -> int: 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.") @@ -95,7 +89,7 @@ def main(argv: list[str] | None = None) -> int: else: _print_status(state) elif args.command == "doctor": - doctor = Doctor(policy) + doctor = Doctor(policy, policy_path=args.policy) checks = doctor.run() if args.json: print(doctor.as_json()) diff --git a/src/vpn_egressctl/doctor.py b/src/vpn_egressctl/doctor.py index bb1e176..2b38f1f 100644 --- a/src/vpn_egressctl/doctor.py +++ b/src/vpn_egressctl/doctor.py @@ -31,10 +31,12 @@ class Doctor: self, policy: Policy, *, + policy_path: str | None = None, runner: RunFunction = subprocess.run, resolver: Callable[..., Any] = socket.getaddrinfo, ) -> None: self.policy = policy + self.policy_path = policy_path self.runner = runner self.resolver = resolver self.checks: list[Check] = [] @@ -60,16 +62,48 @@ class Doctor: else: self._add("OK", name, "runtime state matches policy") - def _permissions(self, path: str, expected: int, required: bool = True) -> None: + def _permissions( + self, + path: str, + expected: int, + required: bool = True, + *, + directory: bool = False, + ) -> None: target = Path(path) - if not target.exists(): - self._add("ERROR" if required else "WARN", f"permissions:{path}", "file does not exist") + try: + metadata = target.stat() + except FileNotFoundError: + object_name = "directory" if directory else "file" + self._add( + "ERROR" if required else "WARN", + f"permissions:{path}", + f"{object_name} does not exist", + ) return - actual = stat.S_IMODE(target.stat().st_mode) + except OSError: + self._add("ERROR", f"permissions:{path}", "cannot inspect filesystem metadata") + return + + expected_type = "directory" if directory else "regular file" + actual_type_ok = stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode) + if not actual_type_ok: + self._add("ERROR", f"permissions:{path}", f"object is not a {expected_type}") + return + + actual = stat.S_IMODE(metadata.st_mode) + problems: list[str] = [] 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}") + problems.append(f"mode is {actual:04o}, expected {expected:04o}") + if os.name == "posix" and (metadata.st_uid != 0 or metadata.st_gid != 0): + problems.append( + f"owner is {metadata.st_uid}:{metadata.st_gid}, expected 0:0" + ) + if problems: + self._add("ERROR", f"permissions:{path}", "; ".join(problems)) + return + owner = " and owner is 0:0" if os.name == "posix" else "" + self._add("OK", f"permissions:{path}", f"mode is {actual:04o}{owner}") def run(self) -> list[Check]: if os.name == "posix" and os.geteuid() != 0: @@ -80,7 +114,7 @@ class Doctor: 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") + self._add("OK", "sing-box-version", f"exact version {version.version} with QUIC and gVisor") except VpnEgressError as exc: self._add("ERROR", "sing-box-version", str(exc)) @@ -97,10 +131,23 @@ class Doctor: except (OSError, ValidationError) as exc: self._add("ERROR", "hysteria2-uri", str(exc)) + if self.policy_path is not None: + self._permissions(self.policy_path, 0o600) 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" + uri_dir = Path(self.policy.runtime.uri_path).parent + state_dir = Path(self.policy.runtime.state_dir) + backup_dir = state_dir / "backups" + state_path = state_dir / "state.json" + last_good_path = state_dir / "last-good.json" + self._permissions(str(uri_dir), 0o700, directory=True) + self._permissions(str(state_dir), 0o700, required=False, directory=True) + self._permissions(str(backup_dir), 0o700, required=False, directory=True) self._permissions(str(state_path), 0o600, required=False) + self._permissions(str(last_good_path), 0o600, required=False) + if backup_dir.is_dir(): + for backup in sorted(backup_dir.glob("config.*.json")): + self._permissions(str(backup), 0o600) if endpoint is not None: try: diff --git a/src/vpn_egressctl/renderer_1_13_19.py b/src/vpn_egressctl/renderer_1_13_19.py index cad8952..714f294 100644 --- a/src/vpn_egressctl/renderer_1_13_19.py +++ b/src/vpn_egressctl/renderer_1_13_19.py @@ -85,14 +85,7 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]: "route_exclude_address": list(network.route_exclude_address), } ], - "outbounds": [ - outbound, - { - "type": "direct", - "tag": "direct", - "bind_interface": network.upstream_interface, - }, - ], + "outbounds": [outbound], "route": { "auto_detect_interface": True, "default_domain_resolver": { diff --git a/src/vpn_egressctl/uri.py b/src/vpn_egressctl/uri.py index 080f597..cabcf72 100644 --- a/src/vpn_egressctl/uri.py +++ b/src/vpn_egressctl/uri.py @@ -122,6 +122,8 @@ def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint: raw_auth, at, endpoint_authority = authority.rpartition("@") if not at: raise ValidationError("Missing Hysteria2 authentication data") + if "@" in raw_auth: + raise ValidationError("Special characters in Hysteria2 authentication data must be percent-encoded") password = _decode(raw_auth, "authentication data") if not password: raise ValidationError("Missing Hysteria2 authentication data") diff --git a/src/vpn_egressctl/version.py b/src/vpn_egressctl/version.py index d2d0454..d74cfcf 100644 --- a/src/vpn_egressctl/version.py +++ b/src/vpn_egressctl/version.py @@ -58,3 +58,7 @@ def require_supported(version: SingBoxVersion, required: str = "1.13.19") -> Non 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") + if "with_gvisor" not in version.tags: + raise UnsupportedVersionError( + "sing-box build does not include with_gvisor required by TUN stack=mixed" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index e6fcdc3..ad37d76 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,9 +12,9 @@ class CliTests(unittest.TestCase): with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): _parser().parse_args(["import", "hysteria2://secret@example.com"]) - def test_render_requires_output(self) -> None: + def test_render_command_is_not_public(self) -> None: with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - _parser().parse_args(["render"]) + _parser().parse_args(["render", "--output", "/etc/sing-box/config.json"]) def test_all_commands_parse(self) -> None: for command in ("check", "diff", "sync", "status", "doctor", "rollback"): diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 2800357..ba939ad 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import stat import subprocess import tempfile import unittest from dataclasses import replace from pathlib import Path +from types import SimpleNamespace +from unittest import mock from vpn_egressctl.doctor import Doctor from vpn_egressctl.renderer_1_13_19 import render_bytes @@ -58,6 +61,35 @@ class DoctorTests(unittest.TestCase): selected = [check for check in checks if check.name == "tls-insecure"] self.assertEqual(selected[0].level, "WARN") + def test_permissions_reject_non_root_owner_on_posix(self) -> None: + with tempfile.TemporaryDirectory() as directory: + doctor = Doctor(make_policy(Path(directory))) + metadata = SimpleNamespace( + st_mode=stat.S_IFREG | 0o600, + st_uid=1000, + st_gid=1000, + ) + with ( + mock.patch("vpn_egressctl.doctor.Path.stat", return_value=metadata), + mock.patch("vpn_egressctl.doctor.os.name", "posix"), + ): + doctor._permissions("/protected/file", 0o600) + self.assertEqual(doctor.checks[-1].level, "ERROR") + self.assertIn("expected 0:0", doctor.checks[-1].message) + + def test_permissions_require_expected_object_type(self) -> None: + with tempfile.TemporaryDirectory() as directory: + doctor = Doctor(make_policy(Path(directory))) + metadata = SimpleNamespace( + st_mode=stat.S_IFDIR | 0o700, + st_uid=0, + st_gid=0, + ) + with mock.patch("vpn_egressctl.doctor.Path.stat", return_value=metadata): + doctor._permissions("/protected/file", 0o600) + self.assertEqual(doctor.checks[-1].level, "ERROR") + self.assertIn("regular file", doctor.checks[-1].message) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..54ef84b --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class PackagingTests(unittest.TestCase): + def test_sing_box_drop_in_requires_guard(self) -> None: + drop_in = ROOT / "packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf" + text = drop_in.read_text(encoding="utf-8") + self.assertIn("Requires=vpn-egress-guard.service", text) + self.assertIn("After=vpn-egress-guard.service", text) + + def test_debian_package_installs_sing_box_drop_in(self) -> None: + install = (ROOT / "debian/install").read_text(encoding="utf-8") + self.assertIn( + "packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf " + "usr/lib/systemd/system/sing-box.service.d", + install, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_real_sing_box.py b/tests/test_real_sing_box.py index 8cc1028..0998191 100644 --- a/tests/test_real_sing_box.py +++ b/tests/test_real_sing_box.py @@ -20,6 +20,7 @@ class RealSingBoxIntegrationTests(unittest.TestCase): version = probe_version(binary) self.assertEqual(version.version, "1.13.19") self.assertIn("with_quic", version.tags) + self.assertIn("with_gvisor", version.tags) with tempfile.TemporaryDirectory() as directory: root = Path(directory) policy = make_policy(root) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 43d3f5f..d64fe90 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -31,6 +31,7 @@ class RendererTests(unittest.TestCase): self.assertEqual(outbound["password"], "AUTH") self.assertEqual(outbound["obfs"]["password"], "OBFS") self.assertEqual(outbound["bind_interface"], "eth0") + self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"]) 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()) diff --git a/tests/test_transaction.py b/tests/test_transaction.py index 729938a..a720b46 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -116,6 +116,9 @@ class TransactionTests(unittest.TestCase): 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") + status = controller.status() + self.assertEqual(status["status"], "rolled_back") + self.assertFalse(status["source_drift"]) def test_diff_never_contains_secrets(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_uri.py b/tests/test_uri.py index f0a94b8..6c3414b 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -50,6 +50,10 @@ class UriParserTests(unittest.TestCase): endpoint = parse_hysteria2_uri("hysteria2://alice%3Acorrect%20horse@example.com") self.assertEqual(endpoint.password, "alice:correct horse") + def test_percent_encoded_at_is_preserved(self) -> None: + endpoint = parse_hysteria2_uri("hysteria2://alice%40example@example.com") + self.assertEqual(endpoint.password, "alice@example") + def test_unicode_host_is_idna(self) -> None: endpoint = parse_hysteria2_uri("hysteria2://secret@пример.рф") self.assertEqual(endpoint.server, "xn--e1afmkfd.xn--p1ai") @@ -89,6 +93,7 @@ class UriParserTests(unittest.TestCase): ("hysteria2://secret@example.com?pinSHA256=x", "safely"), ("hysteria2://secret@example.com?ech=x", "safely"), ("hysteria2://sec%ZZret@example.com", "percent"), + ("hysteria2://alice@example@server.example", "percent-encoded"), ("hysteria2://secret@example.com?sni=%FF", "UTF-8"), ] for uri, marker in cases: diff --git a/tests/test_version.py b/tests/test_version.py index aa0120b..54b54cb 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -32,6 +32,11 @@ class VersionTests(unittest.TestCase): with self.assertRaises(UnsupportedVersionError): require_supported(version) + def test_missing_gvisor_rejected(self) -> None: + version = parse_version_output(VERSION_OUTPUT.replace("with_gvisor,", "")) + with self.assertRaises(UnsupportedVersionError): + require_supported(version) + def test_command_failure(self) -> None: runner = FakeRunner() runner.version = "garbage"