fix: harden sing-box egress rollout
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
## Источники состояния
|
## Источники состояния
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
@@ -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
|
||||||
|
|||||||
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-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
|
||||||
|
|||||||
@@ -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 не является
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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"):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user