fix: harden sing-box egress rollout

This commit is contained in:
2026-08-27 03:39:06 +05:00
parent adb3e849df
commit 8ac3b2b1ba
24 changed files with 228 additions and 48 deletions
+3 -1
View File
@@ -15,10 +15,12 @@ sing-box.
- Смена credential выполняется одной безопасной командой через stdin. - Смена credential выполняется одной безопасной командой через stdin.
- Перед установкой candidate проверяется реальным `sing-box check`. - Перед установкой candidate проверяется реальным `sing-box check`.
- Запись атомарна; после неуспешного restart/healthcheck выполняется rollback. - Запись атомарна; после неуспешного restart/healthcheck выполняется rollback.
- URI, production config, state и backups имеют режим `0600`. - Policy, URI, production config, state и backups имеют `root:root 0600`,
защищённые каталоги — `root:root 0700`.
- Неизвестные URI/policy-параметры отклоняются, а не игнорируются. - Неизвестные URI/policy-параметры отклоняются, а не игнорируются.
- systemd следит за desired state без постоянно работающего Python-процесса. - systemd следит за desired state без постоянно работающего Python-процесса.
- Отдельный nftables guard блокирует прямой forwarding `eth1 -> eth0`. - Отдельный nftables guard блокирует прямой forwarding `eth1 -> eth0`.
- `sing-box.service` требует успешного запуска guard через package-managed drop-in.
## Источники состояния ## Источники состояния
+4
View File
@@ -1,5 +1,9 @@
vpn-egressctl (0.1.0) unstable; urgency=medium vpn-egressctl (0.1.0) unstable; urgency=medium
* Initial release for sing-box 1.13.19. * 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 <dev@flamy.studio> Thu, 27 Aug 2026 00:00:00 +0500 -- Flamy Studio <dev@flamy.studio> Thu, 27 Aug 2026 00:00:00 +0500
+1
View File
@@ -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-guard.service usr/lib/systemd/system
packaging/systemd/vpn-egress-sync.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/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
+8 -3
View File
@@ -40,7 +40,7 @@ URI + policy
▼ ├─ last-good backup ▼ ├─ last-good backup
no restart ├─ atomic replace no restart ├─ atomic replace
├─ systemctl restart ├─ systemctl restart
└─ bounded healthcheck └─ bounded local connectivity healthcheck
fail ┴ success fail ┴ success
│ │ │ │
@@ -66,11 +66,11 @@ Outbound сохраняет DNS hostname и содержит `bind_interface=eth
## Файловая модель ## Файловая модель
```text ```text
/etc/vpn-egress/ /etc/vpn-egress/ root:root 0700
├── policy.json root:root 0600 ├── policy.json root:root 0600
└── hysteria2.uri root:root 0600 └── hysteria2.uri root:root 0600
/var/lib/vpn-egress/ /var/lib/vpn-egress/ root:root 0700
├── state.json без секретов, 0600 ├── state.json без секретов, 0600
├── last-good.json содержит secrets, 0600 ├── last-good.json содержит secrets, 0600
└── backups/ ограниченная история, 0700/0600 └── backups/ ограниченная история, 0700/0600
@@ -86,6 +86,11 @@ Guard читает имена интерфейсов из того же policy
таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет таблицу одной 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 не является В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является
+6 -2
View File
@@ -40,8 +40,9 @@ fallback rule 32768.
### `healthcheck` ### `healthcheck`
По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с
маркером `ip=`. Этот запрос идёт после запуска sing-box и подтверждает не только маркером `ip=`. Этот локальный post-activation connectivity healthcheck идёт
состояние systemd, но и рабочий data plane. после запуска sing-box и проверяет состояние systemd и исходящую связность
самого шлюза. Он не заменяет acceptance-тест forwarding с workload за `eth1`.
`url: null` оставляет только проверку `systemctl is-active`. Это допустимо для `url: null` оставляет только проверку `systemctl is-active`. Это допустимо для
изолированного стенда, но слабее production-проверки. изолированного стенда, но слабее production-проверки.
@@ -64,6 +65,9 @@ fallback rule 32768.
1.13.19 используют разные виды certificate hash. `ech` будет добавлен только 1.13.19 используют разные виды certificate hash. `ech` будет добавлен только
после доказанного преобразования формата config list. после доказанного преобразования формата config list.
Специальные символы в auth должны быть percent-encoded: сырой `@` отклоняется,
а `%40` декодируется в `@`.
## Bandwidth ## Bandwidth
`up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не `up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не
+29 -2
View File
@@ -56,8 +56,11 @@ systemctl status vpn-egress-guard.service --no-pager
nft list table inet vpn_egress_guard nft list table inet vpn_egress_guard
``` ```
Новый unit имеет `Before=sing-box.service`; это закрывает boot window, который Новый unit имеет `Before=sing-box.service`, а package-managed drop-in для
существовал у старого `After=sing-box.service`. `sing-box.service` добавляет `Requires=` и `After=` от guard. Это закрывает boot
window и блокирует запуск sing-box, если совместно запущенный guard завершился с
ошибкой. Зависимость не является watchdog для ручного удаления nftables-таблицы;
текущее runtime-состояние проверяет `vpn-egressctl doctor`.
## 5. Dry run и первый import ## 5. Dry run и первый import
@@ -116,7 +119,31 @@ ip -4 rule show
ip -4 route show table 2022 ip -4 route show table 2022
nft list table inet vpn_egress_guard nft list table inet vpn_egress_guard
nft list table inet sing-box nft list table inet sing-box
systemctl show sing-box.service -p Requires -p After
``` ```
В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни
текущего `85.208.119.160`. текущего `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
```
+4 -3
View File
@@ -48,9 +48,10 @@ vpn-egressctl rollback
перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает
конфигурацию, которая была активна до первого rollback. конфигурацию, которая была активна до первого rollback.
После ручного rollback desired URI остаётся прежним, поэтому status показывает После ручного rollback desired URI остаётся прежним. `status` показывает
drift. Перед включением нового `sync` нужно либо исправить URI, либо осознанно `status=rolled_back`, а `doctor` сообщает `config-drift`, потому что production
вернуть desired state. config отличается от заново отрендеренного desired state. Перед новым `sync`
нужно либо исправить URI, либо осознанно вернуть desired state.
## Реакция systemd.path ## Реакция systemd.path
+10 -6
View File
@@ -10,7 +10,8 @@ generated config и все backups.
- Ошибки parser не включают исходное значение. - Ошибки parser не включают исходное значение.
- diff заменяет secret values на `<REDACTED>`. - diff заменяет secret values на `<REDACTED>`.
- state хранит только SHA-256 source/config и безопасный endpoint label. - 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. - subprocess вызывается массивом аргументов без shell.
После попадания действующего URI в чат, issue, shell history или journal оба После попадания действующего URI в чат, issue, shell history или journal оба
@@ -22,7 +23,7 @@ credential следует перевыпустить.
- policy schema; - policy schema;
- URI syntax и поддерживаемые параметры; - URI syntax и поддерживаемые параметры;
- точная версия и `with_quic`; - точная версия, `with_quic` и `with_gvisor` для `stack=mixed`;
- deterministic candidate; - deterministic candidate;
- `sing-box check`. - `sing-box check`.
@@ -35,7 +36,9 @@ credential следует перевыпустить.
на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической
таблицы `inet 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 автоматически. security-sensitive операцией и не выполняется CLI автоматически.
Имена интерфейсов поступают из уже провалидированного policy, subprocess не Имена интерфейсов поступают из уже провалидированного policy, subprocess не
@@ -43,9 +46,10 @@ security-sensitive операцией и не выполняется CLI авт
## Healthcheck ## Healthcheck
HTTPS healthcheck подтверждает data plane, но раскрывает проверочному endpoint HTTPS healthcheck подтверждает локальную post-activation связность шлюза, но не
факт обращения с VPN egress IP. URL можно заменить внутренним контролируемым весь forwarded path `eth1 -> TUN -> HY2`. Он раскрывает проверочному endpoint факт
endpoint. Отключение URL ослабляет проверку до состояния systemd. обращения с VPN egress IP. URL можно заменить внутренним контролируемым endpoint.
Отключение URL ослабляет проверку до состояния systemd.
## Ограничения URI 1.13.19 ## Ограничения URI 1.13.19
+18 -4
View File
@@ -6,7 +6,7 @@
2. Failure injection: candidate rejection, restart failure, URI/config rollback, 2. Failure injection: candidate rejection, restart failure, URI/config rollback,
idempotency и manual rollback swap. idempotency и manual rollback swap.
3. Real binary: `sing-box 1.13.19 check` на Linux и schema/format validation на Windows. 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. 5. Canary: DNS A change без endpoint CIDR и credential rotation.
6. Reboot: guard ordering, persisted config и path watcher. 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 SING_BOX_1_13_19=/usr/bin/sing-box make test
``` ```
Он предварительно проверяет exact version и `with_quic`. Windows-бинарник не Он предварительно проверяет exact version, `with_quic` и `with_gvisor`.
может создать Linux auto-redirect и поэтому выполняет `format` полной схемы; Windows-бинарник не может создать Linux auto-redirect и поэтому выполняет
обязательный `check` остаётся в Linux CI/Incus. `format` полной схемы; обязательный `check` остаётся в Linux CI/Incus.
## Privileged acceptance ## Privileged acceptance
@@ -39,4 +39,18 @@ SING_BOX_1_13_19=/usr/bin/sing-box make test
7. изменить A-запись endpoint, не меняя URI/config; 7. изменить A-запись endpoint, не меняя URI/config;
8. перезагрузить контейнер и повторить doctor. 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. Production A-запись не используется для эксперимента: нужен staging hostname.
+2 -1
View File
@@ -48,7 +48,8 @@ timestamped backup из консоли контейнера, затем `sing-bo
Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно
ставить `url: null` на production нельзя без отдельного решения: это скрывает ставить `url: null` на production нельзя без отдельного решения: это скрывает
неработающий data plane. неработающую локальную post-activation связность. Даже успешный healthcheck не
заменяет отдельную проверку forwarded path с workload за `eth1`.
## Watcher failed ## Watcher failed
@@ -0,0 +1,3 @@
[Unit]
Requires=vpn-egress-guard.service
After=vpn-egress-guard.service
+1 -7
View File
@@ -9,7 +9,6 @@ from pathlib import Path
from .doctor import Doctor from .doctor import Doctor
from .errors import ApplyError, UnsupportedVersionError, ValidationError, VpnEgressError from .errors import ApplyError, UnsupportedVersionError, ValidationError, VpnEgressError
from .fsutil import atomic_write
from .guard import apply_guard from .guard import apply_guard
from .policy import load_policy from .policy import load_policy
from .transaction import Controller 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("check", help="validate source, renderer and generated config")
commands.add_parser("diff", help="show a redacted desired-state diff") 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") commands.add_parser("sync", help="reconcile the current URI and policy")
status = commands.add_parser("status", help="show safe state summary") status = commands.add_parser("status", help="show safe state summary")
status.add_argument("--json", action="store_true") status.add_argument("--json", action="store_true")
@@ -82,9 +79,6 @@ def main(argv: list[str] | None = None) -> int:
elif args.command == "diff": elif args.command == "diff":
changes = controller.diff() changes = controller.diff()
print("\n".join(changes) if changes else "No changes.") 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": elif args.command == "sync":
changed = controller.sync() changed = controller.sync()
print("Configuration applied." if changed else "Desired state is already installed.") print("Configuration applied." if changed else "Desired state is already installed.")
@@ -95,7 +89,7 @@ def main(argv: list[str] | None = None) -> int:
else: else:
_print_status(state) _print_status(state)
elif args.command == "doctor": elif args.command == "doctor":
doctor = Doctor(policy) doctor = Doctor(policy, policy_path=args.policy)
checks = doctor.run() checks = doctor.run()
if args.json: if args.json:
print(doctor.as_json()) print(doctor.as_json())
+56 -9
View File
@@ -31,10 +31,12 @@ class Doctor:
self, self,
policy: Policy, policy: Policy,
*, *,
policy_path: str | None = None,
runner: RunFunction = subprocess.run, runner: RunFunction = subprocess.run,
resolver: Callable[..., Any] = socket.getaddrinfo, resolver: Callable[..., Any] = socket.getaddrinfo,
) -> None: ) -> None:
self.policy = policy self.policy = policy
self.policy_path = policy_path
self.runner = runner self.runner = runner
self.resolver = resolver self.resolver = resolver
self.checks: list[Check] = [] self.checks: list[Check] = []
@@ -60,16 +62,48 @@ class Doctor:
else: else:
self._add("OK", name, "runtime state matches policy") 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) target = Path(path)
if not target.exists(): try:
self._add("ERROR" if required else "WARN", f"permissions:{path}", "file does not exist") 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 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: if actual != expected:
self._add("ERROR", f"permissions:{path}", f"mode is {actual:04o}, expected {expected:04o}") problems.append(f"mode is {actual:04o}, expected {expected:04o}")
else: if os.name == "posix" and (metadata.st_uid != 0 or metadata.st_gid != 0):
self._add("OK", f"permissions:{path}", f"mode is {actual:04o}") 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]: def run(self) -> list[Check]:
if os.name == "posix" and os.geteuid() != 0: if os.name == "posix" and os.geteuid() != 0:
@@ -80,7 +114,7 @@ class Doctor:
try: try:
version = probe_version(self.policy.sing_box.binary, self.runner) version = probe_version(self.policy.sing_box.binary, self.runner)
require_supported(version, self.policy.sing_box.required_version) 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: except VpnEgressError as exc:
self._add("ERROR", "sing-box-version", str(exc)) self._add("ERROR", "sing-box-version", str(exc))
@@ -97,10 +131,23 @@ class Doctor:
except (OSError, ValidationError) as exc: except (OSError, ValidationError) as exc:
self._add("ERROR", "hysteria2-uri", str(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.runtime.uri_path, 0o600)
self._permissions(self.policy.sing_box.config_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(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: if endpoint is not None:
try: try:
+1 -8
View File
@@ -85,14 +85,7 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"route_exclude_address": list(network.route_exclude_address), "route_exclude_address": list(network.route_exclude_address),
} }
], ],
"outbounds": [ "outbounds": [outbound],
outbound,
{
"type": "direct",
"tag": "direct",
"bind_interface": network.upstream_interface,
},
],
"route": { "route": {
"auto_detect_interface": True, "auto_detect_interface": True,
"default_domain_resolver": { "default_domain_resolver": {
+2
View File
@@ -122,6 +122,8 @@ def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint:
raw_auth, at, endpoint_authority = authority.rpartition("@") raw_auth, at, endpoint_authority = authority.rpartition("@")
if not at: if not at:
raise ValidationError("Missing Hysteria2 authentication data") 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") password = _decode(raw_auth, "authentication data")
if not password: if not password:
raise ValidationError("Missing Hysteria2 authentication data") raise ValidationError("Missing Hysteria2 authentication data")
+4
View File
@@ -58,3 +58,7 @@ def require_supported(version: SingBoxVersion, required: str = "1.13.19") -> Non
raise UnsupportedVersionError("sing-box must be a Linux build") raise UnsupportedVersionError("sing-box must be a Linux build")
if "with_quic" not in version.tags: if "with_quic" not in version.tags:
raise UnsupportedVersionError("sing-box build does not include with_quic") 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"
)
+2 -2
View File
@@ -12,9 +12,9 @@ class CliTests(unittest.TestCase):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["import", "hysteria2://secret@example.com"]) _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): 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: def test_all_commands_parse(self) -> None:
for command in ("check", "diff", "sync", "status", "doctor", "rollback"): for command in ("check", "diff", "sync", "status", "doctor", "rollback"):
+32
View File
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
import stat
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from vpn_egressctl.doctor import Doctor from vpn_egressctl.doctor import Doctor
from vpn_egressctl.renderer_1_13_19 import render_bytes 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"] selected = [check for check in checks if check.name == "tls-insecure"]
self.assertEqual(selected[0].level, "WARN") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+27
View File
@@ -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()
+1
View File
@@ -20,6 +20,7 @@ class RealSingBoxIntegrationTests(unittest.TestCase):
version = probe_version(binary) version = probe_version(binary)
self.assertEqual(version.version, "1.13.19") self.assertEqual(version.version, "1.13.19")
self.assertIn("with_quic", version.tags) self.assertIn("with_quic", version.tags)
self.assertIn("with_gvisor", version.tags)
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
policy = make_policy(root) policy = make_policy(root)
+1
View File
@@ -31,6 +31,7 @@ class RendererTests(unittest.TestCase):
self.assertEqual(outbound["password"], "AUTH") self.assertEqual(outbound["password"], "AUTH")
self.assertEqual(outbound["obfs"]["password"], "OBFS") self.assertEqual(outbound["obfs"]["password"], "OBFS")
self.assertEqual(outbound["bind_interface"], "eth0") 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["dns"]["servers"][1]["detour"], "hy2-out")
self.assertEqual(config["route"]["final"], "hy2-out") self.assertEqual(config["route"]["final"], "hy2-out")
self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode()) self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode())
+3
View File
@@ -116,6 +116,9 @@ class TransactionTests(unittest.TestCase):
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous) self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous)
self.assertEqual(controller.last_good_path.read_bytes(), current) self.assertEqual(controller.last_good_path.read_bytes(), current)
self.assertEqual(json.loads(controller.state_path.read_text())["status"], "rolled_back") 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: def test_diff_never_contains_secrets(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
+5
View File
@@ -50,6 +50,10 @@ class UriParserTests(unittest.TestCase):
endpoint = parse_hysteria2_uri("hysteria2://alice%3Acorrect%20horse@example.com") endpoint = parse_hysteria2_uri("hysteria2://alice%3Acorrect%20horse@example.com")
self.assertEqual(endpoint.password, "alice:correct horse") 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: def test_unicode_host_is_idna(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@пример.рф") endpoint = parse_hysteria2_uri("hysteria2://secret@пример.рф")
self.assertEqual(endpoint.server, "xn--e1afmkfd.xn--p1ai") 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?pinSHA256=x", "safely"),
("hysteria2://secret@example.com?ech=x", "safely"), ("hysteria2://secret@example.com?ech=x", "safely"),
("hysteria2://sec%ZZret@example.com", "percent"), ("hysteria2://sec%ZZret@example.com", "percent"),
("hysteria2://alice@example@server.example", "percent-encoded"),
("hysteria2://secret@example.com?sni=%FF", "UTF-8"), ("hysteria2://secret@example.com?sni=%FF", "UTF-8"),
] ]
for uri, marker in cases: for uri, marker in cases:
+5
View File
@@ -32,6 +32,11 @@ class VersionTests(unittest.TestCase):
with self.assertRaises(UnsupportedVersionError): with self.assertRaises(UnsupportedVersionError):
require_supported(version) 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: def test_command_failure(self) -> None:
runner = FakeRunner() runner = FakeRunner()
runner.version = "garbage" runner.version = "garbage"