fix: harden sing-box egress rollout
This commit is contained in:
@@ -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.
|
||||
|
||||
## Источники состояния
|
||||
|
||||
|
||||
Vendored
+4
@@ -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 <dev@flamy.studio> Thu, 27 Aug 2026 00:00:00 +0500
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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 не является
|
||||
|
||||
@@ -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` являются локальной политикой и намеренно не
|
||||
|
||||
+29
-2
@@ -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
|
||||
```
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
|
||||
+10
-6
@@ -10,7 +10,8 @@ generated config и все backups.
|
||||
- Ошибки parser не включают исходное значение.
|
||||
- diff заменяет secret values на `<REDACTED>`.
|
||||
- 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
|
||||
|
||||
|
||||
+18
-4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[Unit]
|
||||
Requires=vpn-egress-guard.service
|
||||
After=vpn-egress-guard.service
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
+2
-2
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user