feat: add declarative sing-box egress control plane

This commit is contained in:
2026-08-27 00:58:52 +05:00
commit b8d19b2c3e
54 changed files with 3337 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+16
View File
@@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
*.egg-info/
.coverage
.mypy_cache/
.pytest_cache/
build/
dist/
*.deb
*.buildinfo
*.changes
hysteria2.uri
config.json
state.json
*.backup
.tmp/
+27
View File
@@ -0,0 +1,27 @@
stages:
- test
- package
default:
image: debian:trixie
test:
stage: test
script:
- apt-get update
- apt-get install -y --no-install-recommends python3
- python3 --version
- python3 -m compileall -q src tests
- PYTHONPATH=src python3 -m unittest discover -s tests -v
package:
stage: package
script:
- apt-get update
- apt-get install -y --no-install-recommends build-essential debhelper dh-python pybuild-plugin-pyproject python3-all python3-setuptools python3-wheel dpkg-dev
- dpkg-buildpackage -us -uc -b
- mkdir -p dist
- cp ../vpn-egressctl_*.deb dist/
artifacts:
paths:
- dist/*.deb
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Flamy Studio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
.PHONY: test compile check package
PYTHON ?= python3
test:
$(PYTHON) -m unittest discover -s tests -v
compile:
$(PYTHON) -m compileall -q src tests
check: compile test
package:
dpkg-buildpackage -us -uc -b
+81
View File
@@ -0,0 +1,81 @@
# singbox_glue / vpn-egressctl
`vpn-egressctl` — локальный декларативный control plane для шлюза `vpn-egress-gw`.
Он получает реквизиты подключения из защищённого Hysteria2 URI, объединяет их с
локальной инфраструктурной политикой и полностью генерирует конфигурацию
sing-box.
Проект намеренно поддерживает только **sing-box 1.13.19**. Версии 1.13.12,
другие patch-релизы и вся ветка 1.14 отклоняются до изменения файлов.
## Что решает проект
- IP Hysteria2-сервера отсутствует в `route_exclude_address` и nftables policy.
- Смена DNS A-записи не требует перегенерации конфигурации.
- Смена credential выполняется одной безопасной командой через stdin.
- Перед установкой candidate проверяется реальным `sing-box check`.
- Запись атомарна; после неуспешного restart/healthcheck выполняется rollback.
- URI, production config, state и backups имеют режим `0600`.
- Неизвестные URI/policy-параметры отклоняются, а не игнорируются.
- systemd следит за desired state без постоянно работающего Python-процесса.
- Отдельный nftables guard блокирует прямой forwarding `eth1 -> eth0`.
## Источники состояния
```text
/etc/vpn-egress/policy.json
/etc/vpn-egress/hysteria2.uri
renderer 1.13.19
/etc/sing-box/config.json
```
`/etc/sing-box/config.json` является генерируемым артефактом. Редактировать его
вручную после миграции нельзя.
## Основные команды
```bash
# URI не попадает в argv и shell history.
sudo vpn-egressctl import --stdin
sudo vpn-egressctl check
sudo vpn-egressctl diff
sudo vpn-egressctl sync
sudo vpn-egressctl status
sudo vpn-egressctl doctor
sudo vpn-egressctl rollback
```
Позиционный `vpn-egressctl import 'hysteria2://...'` запрещён специально.
## Документация
- [Архитектура](docs/architecture.md)
- [Конфигурация](docs/configuration.md)
- [Установка и миграция](docs/migration.md)
- [Эксплуатация](docs/operations.md)
- [Безопасность](docs/security.md)
- [Диагностика](docs/troubleshooting.md)
- [Тестирование](docs/testing.md)
- [Почему не поддерживается 1.14](docs/sing-box-1.14.md)
## Локальная проверка
Проект не имеет runtime-зависимостей вне Python stdlib.
```powershell
E:\python-31312\python.exe -m compileall -q src tests
```
Изолированная Windows-сборка Python в указанном каталоге не добавляет cwd в
`sys.path`, поэтому полный тестовый запуск выполняется так:
```powershell
E:\python-31312\python.exe -c "import sys,unittest; sys.path[:0]=[r'F:\projects\singbox_glue\src',r'F:\projects\singbox_glue']; s=unittest.defaultTestLoader.discover(r'F:\projects\singbox_glue\tests'); r=unittest.TextTestRunner(verbosity=2).run(s); raise SystemExit(not r.wasSuccessful())"
```
На Linux достаточно `make check`.
+54
View File
@@ -0,0 +1,54 @@
{
"schema_version": 1,
"sing_box": {
"binary": "/usr/bin/sing-box",
"config_path": "/etc/sing-box/config.json",
"service": "sing-box.service",
"required_version": "1.13.19"
},
"runtime": {
"uri_path": "/etc/vpn-egress/hysteria2.uri",
"state_dir": "/var/lib/vpn-egress",
"lock_path": "/run/lock/vpn-egressctl.lock",
"backup_keep": 5
},
"network": {
"upstream_interface": "eth0",
"vpn_lan_interface": "eth1",
"tun_name": "tun-sb0",
"tun_address": "172.19.0.1/30",
"mtu": 1400,
"route_exclude_address": [
"10.20.0.0/24",
"10.30.0.0/24",
"127.0.0.0/8"
],
"iproute2_table_index": 2022,
"iproute2_rule_index": 9000,
"auto_redirect_input_mark": "0x2023",
"auto_redirect_output_mark": "0x2024",
"auto_redirect_reset_mark": "0x2025",
"auto_redirect_nfqueue": 100,
"auto_redirect_fallback_rule_index": 32768
},
"dns": {
"bootstrap_server": "1.1.1.1",
"bootstrap_port": 53,
"remote_server": "1.1.1.1",
"remote_port": 443,
"remote_path": "/dns-query",
"remote_tls_server_name": "cloudflare-dns.com",
"strategy": "ipv4_only"
},
"bandwidth": {
"up_mbps": 50,
"down_mbps": 200
},
"healthcheck": {
"url": "https://www.cloudflare.com/cdn-cgi/trace",
"timeout_seconds": 15,
"settle_seconds": 2,
"expected_status": 200,
"body_contains": "ip="
}
}
+5
View File
@@ -0,0 +1,5 @@
vpn-egressctl (0.1.0) unstable; urgency=medium
* Initial release for sing-box 1.13.19.
-- Flamy Studio <dev@flamy.studio> Thu, 27 Aug 2026 00:00:00 +0500
+14
View File
@@ -0,0 +1,14 @@
Source: vpn-egressctl
Section: net
Priority: optional
Maintainer: Flamy Studio <dev@flamy.studio>
Build-Depends: debhelper-compat (= 13), dh-python, pybuild-plugin-pyproject, python3-all, python3-setuptools, python3-wheel
Standards-Version: 4.7.2
Rules-Requires-Root: no
Package: vpn-egressctl
Architecture: all
Depends: ${misc:Depends}, ${python3:Depends}, python3 (>= 3.13), sing-box (= 1.13.19), systemd, nftables, iproute2
Description: declarative control plane for the sing-box VPN egress gateway
Generates, validates and transactionally applies the complete sing-box
configuration from a protected Hysteria2 URI and a versioned local policy.
Vendored
+8
View File
@@ -0,0 +1,8 @@
docs/architecture.md
docs/configuration.md
docs/migration.md
docs/operations.md
docs/security.md
docs/troubleshooting.md
docs/testing.md
docs/sing-box-1.14.md
+4
View File
@@ -0,0 +1,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
Vendored Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
if [ "$1" = configure ]; then
install -d -m 0700 -o root -g root /etc/vpn-egress
install -d -m 0700 -o root -g root /var/lib/vpn-egress /var/lib/vpn-egress/backups
if [ ! -e /etc/vpn-egress/policy.json ]; then
install -m 0600 -o root -g root /usr/share/vpn-egressctl/policy.json /etc/vpn-egress/policy.json
fi
systemctl daemon-reload >/dev/null 2>&1 || true
fi
# Watcher activation is deliberately left to the documented migration step.
exit 0
Vendored Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
systemctl daemon-reload >/dev/null 2>&1 || true
# Secrets, policy and last-good backups are deliberately preserved.
exit 0
Vendored Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
if [ "$1" = remove ]; then
systemctl stop vpn-egress-sync.path >/dev/null 2>&1 || true
systemctl disable vpn-egress-sync.path vpn-egress-guard.service >/dev/null 2>&1 || true
fi
exit 0
Vendored Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/make -f
%:
dh $@ --with python3 --buildsystem=pybuild
override_dh_installsystemd:
dh_installsystemd --no-start --no-enable
+1
View File
@@ -0,0 +1 @@
3.0 (native)
+93
View File
@@ -0,0 +1,93 @@
# Архитектура
## Границы ответственности
Hysteria2 URI содержит только переносимые реквизиты VPN:
- authentication;
- hostname и port/port ranges;
- SNI и `insecure`;
- `salamander` и obfs password.
`policy.json` содержит локальную инфраструктурную политику:
- `eth0` — upstream;
- `eth1` — VPN LAN;
- `tun-sb0`, `172.19.0.1/30`, MTU 1400;
- TUN routes, nftables marks и rule/table indexes;
- bootstrap DNS и DoH через `hy2-out`;
- bandwidth hints и healthcheck.
Renderer не патчит существующий JSON. Полная конфигурация каждый раз строится
из typed model. Это устраняет config drift и исторические endpoint `/32`.
## Поток применения
```text
URI + policy
├─ strict parsing / schema validation
├─ exact sing-box version and build-tag gate
├─ deterministic render
├─ secure candidate in /etc/sing-box
└─ sing-box check -c candidate
redacted comparison
unchanged ─────── changed
│ │
▼ ├─ last-good backup
no restart ├─ atomic replace
├─ systemctl restart
└─ bounded healthcheck
fail ┴ success
│ │
▼ ▼
rollback state.json
```
Все операции изменения сериализованы `flock`-совместимой блокировкой. Поэтому
ручной `import` и запоздалый event от systemd.path не могут применять два
candidate одновременно.
## Почему endpoint IP не нужен
Outbound сохраняет DNS hostname и содержит `bind_interface=eth0`. В 1.13.19
`route.auto_detect_interface` не применяется к outbound с явным
`bind_interface`. Bootstrap DNS также привязан к `eth0`. Endpoint IP поэтому не
является частью TUN policy.
После смены A-записи следующая Hysteria2-сессия разрешает имя заново через
`bootstrap-dns`. `doctor` дополнительно проверяет, что текущие A-записи не
попали в exclusions.
## Файловая модель
```text
/etc/vpn-egress/
├── policy.json root:root 0600
└── hysteria2.uri root:root 0600
/var/lib/vpn-egress/
├── state.json без секретов, 0600
├── last-good.json содержит secrets, 0600
└── backups/ ограниченная история, 0700/0600
/etc/sing-box/
└── config.json root:root 0600
```
Service sing-box уже имеет `CAP_DAC_READ_SEARCH`, поэтому сохраняется текущая
рабочая модель `root:root 0600`.
Guard читает имена интерфейсов из того же policy и устанавливает всю nftables
таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет
систему с частично заменённой таблицей.
## Версионная граница
В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является
пустой заготовкой: он появится только в отдельной миграции после аудита схемы,
маршрутизации и полных интеграционных тестов.
+70
View File
@@ -0,0 +1,70 @@
# Конфигурация
## Policy schema version 1
Production-образец находится в `config/policy.json`. Неизвестные и отсутствующие
ключи являются ошибкой.
### `sing_box`
- `binary`: абсолютный путь к бинарнику;
- `config_path`: production JSON;
- `service`: systemd service;
- `required_version`: допускается только `1.13.19`.
### `runtime`
- `uri_path`: desired-state secret;
- `state_dir`: state и backups;
- `lock_path`: межпроцессная блокировка;
- `backup_keep`: число timestamped backups, от 1 до 100.
### `network`
Здесь явно фиксируются интерфейсы, TUN CIDR, MTU, exclusions, таблица 2022,
начальный rule index 9000, marks `0x2023`/`0x2024`/`0x2025`, NFQUEUE 100 и
fallback rule 32768.
Публичные адреса не должны добавляться в `route_exclude_address`. Локальные
подсети `10.20.0.0/24`, `10.30.0.0/24` и loopback сохраняются.
### `dns`
Текущий контракт:
- UDP bootstrap `1.1.1.1:53`, bind `eth0`;
- DoH `1.1.1.1:443/dns-query`, SNI `cloudflare-dns.com`;
- DoH detour `hy2-out`;
- только `ipv4_only`.
### `healthcheck`
По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с
маркером `ip=`. Этот запрос идёт после запуска sing-box и подтверждает не только
состояние systemd, но и рабочий data plane.
`url: null` оставляет только проверку `systemctl is-active`. Это допустимо для
изолированного стенда, но слабее production-проверки.
## Поддерживаемая часть Hysteria2 URI
Поддерживаются:
- `hysteria2://` и `hy2://`;
- percent-encoded auth, включая `username:password`;
- DNS, IPv4, bracketed IPv6 и IDNA;
- port 443 по умолчанию;
- одиночный port и официальный multi-port/ranges;
- `sni`, `insecure=0|1`;
- `obfs=salamander&obfs-password=...`;
- fragment как необязательное display name.
Отклоняются `gecko`, `pinSHA256`, `ech`, client modes, неизвестные и
повторяющиеся параметры. Причина для `pinSHA256`: Hysteria URI и sing-box
1.13.19 используют разные виды certificate hash. `ech` будет добавлен только
после доказанного преобразования формата config list.
## Bandwidth
`up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не
принимаются из URI.
+122
View File
@@ -0,0 +1,122 @@
# Установка и миграция
Инструкция рассчитана на Debian 13 внутри `vpn-egress-gw`.
## 1. Резервная копия
До установки сохранить:
```bash
install -d -m 0700 /root/vpn-egress-migration
cp -a /etc/sing-box/config.json /root/vpn-egress-migration/config.json.before
cp -a /root/render-singbox-hy2.sh /root/vpn-egress-migration/
cp -a /usr/local/sbin/vpn-egress-guard.sh /root/vpn-egress-migration/
nft list ruleset > /root/vpn-egress-migration/nft.before.rules
ip -4 rule show > /root/vpn-egress-migration/ip-rule.before.txt
ip -4 route show table all > /root/vpn-egress-migration/ip-route.before.txt
```
## 2. Обновление sing-box
Пакет имеет строгую зависимость `sing-box (= 1.13.19)`.
```bash
apt-get update
apt-get install sing-box=1.13.19
sing-box version
```
Если репозиторий SagerNet ещё не публикует 1.13.19, миграцию не продолжать и не
обходить dependency/version gate.
## 3. Установка пакета
```bash
dpkg -i vpn-egressctl_0.1.0_all.deb
install -m 0600 -o root -g root \
/usr/share/vpn-egressctl/policy.json \
/etc/vpn-egress/policy.json
systemctl daemon-reload
```
Проверить локальные значения policy до первого применения.
## 4. Guard до VPN
Старый unit находится в `/etc/systemd/system` и перекрывает package unit из
`/usr/lib`. Сначала обратимо убрать старое определение:
```bash
systemctl stop vpn-egress-guard.service
mv /etc/systemd/system/vpn-egress-guard.service \
/root/vpn-egress-migration/vpn-egress-guard.service.disabled
systemctl daemon-reload
systemctl enable --now vpn-egress-guard.service
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`.
## 5. Dry run и первый import
Для первого `check` URI ещё должен существовать. Создать файл без попадания
secret в argv:
```bash
install -m 0600 -o root -g root /dev/null /etc/vpn-egress/hysteria2.uri
read -r -s URI
printf '%s\n' "$URI" > /etc/vpn-egress/hysteria2.uri
unset URI
vpn-egressctl check
vpn-egressctl diff
vpn-egressctl sync
```
Более простой вариант для интерактивного применения:
```bash
vpn-egressctl import --stdin
```
Команда сама запросит URI без echo, выполнит check/apply/healthcheck и при
неуспехе восстановит старые URI и config.
## 6. Включение watcher
```bash
systemctl enable --now vpn-egress-sync.path
vpn-egressctl doctor
```
## 7. Вывод старого renderer из эксплуатации
После успешного canary и rollback-теста:
```bash
mv /root/render-singbox-hy2.sh \
/root/vpn-egress-migration/render-singbox-hy2.sh.disabled
```
Удалять старый файл в день миграции не нужно: перемещение остаётся обратимым.
Старый `/usr/local/sbin/vpn-egress-guard.sh` можно архивировать после проверки,
что package-managed unit использует `vpn-egressctl guard-apply`.
## 8. Acceptance
Обязательные проверки:
```bash
vpn-egressctl status
vpn-egressctl doctor
sing-box check -c /etc/sing-box/config.json
ip -4 rule show
ip -4 route show table 2022
nft list table inet vpn_egress_guard
nft list table inet sing-box
```
В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни
текущего `85.208.119.160`.
+76
View File
@@ -0,0 +1,76 @@
# Эксплуатация
## Ротация credential
Интерактивно:
```bash
sudo vpn-egressctl import --stdin
```
Из защищённого файла:
```bash
sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri
```
Файл должен иметь режим `0600`. Не используйте positional argument, environment
variable, shell history или URL в тикете/логе.
## Изменение policy
```bash
install -m 0600 new-policy.json /etc/vpn-egress/policy.json
vpn-egressctl check
vpn-egressctl diff
vpn-egressctl sync
```
Path unit следит только за URI. Policy применяется явным `sync`, чтобы случайное
редактирование инфраструктурных параметров не вызвало неожиданный restart.
## Состояние
```bash
vpn-egressctl status
vpn-egressctl status --json
```
State содержит только hashes, endpoint без auth, версию, время и результат.
## Rollback
```bash
vpn-egressctl rollback
```
Команда проверяет last-good реальным sing-box, атомарно меняет конфиги местами,
перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает
конфигурацию, которая была активна до первого rollback.
После ручного rollback desired URI остаётся прежним, поэтому status показывает
drift. Перед включением нового `sync` нужно либо исправить URI, либо осознанно
вернуть desired state.
## Реакция systemd.path
`PathChanged` запускает oneshot `vpn-egress-sync.service`. Одновременный ручной
import сериализуется lock-файлом. Если новый URI некорректен, production config
не меняется, а unit завершается с ошибкой, видимой в journal.
```bash
journalctl -u vpn-egress-sync.service -n 100 --no-pager
systemctl reset-failed vpn-egress-sync.service
```
## Плановое обновление
Обновление policy или пакета внутри 1.13.19:
1. `vpn-egressctl check`;
2. `vpn-egressctl diff`;
3. backup артефакта пакета;
4. обновление;
5. `vpn-egressctl sync`;
6. `vpn-egressctl doctor`;
7. контролируемый rollback-тест на staging.
+54
View File
@@ -0,0 +1,54 @@
# Безопасность
## Секреты
Секретами считаются Hysteria authentication, obfs password, URI целиком,
generated config и все backups.
- CLI не принимает URI в argv.
- Dataclass скрывает credentials из `repr()`.
- Ошибки parser не включают исходное значение.
- diff заменяет secret values на `<REDACTED>`.
- state хранит только SHA-256 source/config и безопасный endpoint label.
- URI/config/backups имеют `0600`, каталоги — `0700`.
- subprocess вызывается массивом аргументов без shell.
После попадания действующего URI в чат, issue, shell history или journal оба
credential следует перевыпустить.
## Fail closed
До изменения production проверяются:
- policy schema;
- URI syntax и поддерживаемые параметры;
- точная версия и `with_quic`;
- deterministic candidate;
- `sing-box check`.
Неизвестные параметры никогда не игнорируются. Production bypass для другой
версии отсутствует.
## Anti-leak
Отдельная таблица `inet vpn_egress_guard` отклоняет forwarding с `eth1` напрямую
на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической
таблицы `inet sing-box`.
Guard запускается до sing-box. Остановка или удаление guard unit является
security-sensitive операцией и не выполняется CLI автоматически.
Имена интерфейсов поступают из уже провалидированного policy, subprocess не
использует shell, а замена таблицы выполняется одной nft batch-транзакцией.
## Healthcheck
HTTPS healthcheck подтверждает data plane, но раскрывает проверочному endpoint
факт обращения с VPN egress IP. URL можно заменить внутренним контролируемым
endpoint. Отключение URL ослабляет проверку до состояния systemd.
## Ограничения URI 1.13.19
`pinSHA256` не преобразуется в `certificate_public_key_sha256`, потому что это
разные fingerprint semantics. `ech` не преобразуется без проверенного PEM/config
list adapter. Silent downgrade TLS запрещён.
+15
View File
@@ -0,0 +1,15 @@
# Почему sing-box 1.14 не поддерживается
Ветка 1.14 меняет конфигурационный контракт TUN, TLS/QUIC и Hysteria2. Проект не
пытается угадать совместимость и не содержит `--allow-unsupported`.
Отдельная миграция на 1.14 потребует:
- аудита release notes и tagged schema;
- нового renderer с отдельными golden fixtures;
- проверки удалённых/deprecated полей;
- повторного TUN/nftables/strict-route canary;
- тестов Hysteria2 reconnect, DNS и rollback;
- отдельного package release и runbook.
До завершения этой работы любой 1.14.x отклоняется до записи файлов.
+42
View File
@@ -0,0 +1,42 @@
# Тестирование
## Уровни
1. Unit: URI, policy, version, renderer, redaction.
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.
5. Canary: DNS A change без endpoint CIDR и credential rotation.
6. Reboot: guard ordering, persisted config и path watcher.
## Unit tests
```bash
make check
```
Тест с реальным бинарником включается явно:
```bash
SING_BOX_1_13_19=/usr/bin/sing-box make test
```
Он предварительно проверяет exact version и `with_quic`. Windows-бинарник не
может создать Linux auto-redirect и поэтому выполняет `format` полной схемы;
обязательный `check` остаётся в Linux CI/Incus.
## Privileged acceptance
В disposable Incus-контейнере с двумя NIC:
1. установить sing-box 1.13.19 и пакет;
2. применить test URI;
3. проверить таблицу 2022 и marks;
4. подтвердить, что `eth1 -> eth0` отклоняется без sing-box;
5. подтвердить TCP/UDP/DNS через TUN при рабочем HY2;
6. сломать credential и проверить автоматический rollback;
7. изменить A-запись endpoint, не меняя URI/config;
8. перезагрузить контейнер и повторить doctor.
Production A-запись не используется для эксперимента: нужен staging hostname.
+61
View File
@@ -0,0 +1,61 @@
# Диагностика
Начинать с:
```bash
vpn-egressctl doctor
vpn-egressctl status --json
```
## Unsupported sing-box version
Установлена не `1.13.19`. Конфигурация не изменялась. Проверить:
```bash
sing-box version
apt-cache policy sing-box
```
Не использовать ручной обход version gate.
## sing-box rejected generated configuration
Candidate удалён, production не менялся. Проверить package version, policy и
логи oneshot. Секреты в отчёт не прикладывать.
## Apply failed, previous configuration restored
Автоматический rollback успешен. Проверить:
```bash
systemctl status sing-box --no-pager
journalctl -u sing-box -u vpn-egress-sync.service -n 200 --no-pager
vpn-egressctl doctor
```
## Critical rollback failed
Не выполнять новый sync. Использовать `/var/lib/vpn-egress/last-good.json` или
timestamped backup из консоли контейнера, затем `sing-box check` и restart.
## Endpoint exclusion
Если doctor сообщает, что A-запись endpoint находится в TUN exclusions, удалить
публичный CIDR из policy и выполнить `check`, `diff`, `sync`. Не заменять его на
новый IP.
## Healthcheck request failed
Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно
ставить `url: null` на production нельзя без отдельного решения: это скрывает
неработающий data plane.
## Watcher failed
Некорректный файл URI не меняет рабочий config. Исправить его безопасным
`vpn-egressctl import --stdin`, затем:
```bash
systemctl reset-failed vpn-egress-sync.service
systemctl status vpn-egress-sync.path --no-pager
```
@@ -0,0 +1,27 @@
[Unit]
Description=Block direct leaks from the VPN LAN to the upstream interface
Before=sing-box.service vpn-egress-sync.service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/vpn-egressctl guard-apply
RemainAfterExit=yes
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
RestrictAddressFamilies=AF_UNIX AF_NETLINK
ReadOnlyPaths=/etc/vpn-egress/policy.json
CapabilityBoundingSet=CAP_NET_ADMIN
AmbientCapabilities=CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Watch the Hysteria2 desired-state source
After=network-online.target
[Path]
PathChanged=/etc/vpn-egress/hysteria2.uri
Unit=vpn-egress-sync.service
[Install]
WantedBy=multi-user.target
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Reconcile sing-box VPN egress configuration
After=network-online.target vpn-egress-guard.service
Wants=network-online.target
Requires=vpn-egress-guard.service
ConditionPathExists=/etc/vpn-egress/hysteria2.uri
[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/bin/vpn-egressctl sync
UMask=0077
StateDirectory=vpn-egress
StateDirectoryMode=0700
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
ReadWritePaths=/etc/vpn-egress /etc/sing-box /var/lib/vpn-egress /run/lock
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_DAC_READ_SEARCH
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "vpn-egressctl"
version = "0.1.0"
description = "Declarative control plane for the sing-box VPN egress gateway"
readme = "README.md"
requires-python = ">=3.13"
license = "MIT"
authors = [{name = "Flamy Studio"}]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Operating System :: POSIX :: Linux",
]
dependencies = []
[project.scripts]
vpn-egressctl = "vpn_egressctl.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
+3
View File
@@ -0,0 +1,3 @@
"""Declarative control plane for the sing-box VPN egress gateway."""
__version__ = "0.1.0"
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
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
DEFAULT_POLICY = "/etc/vpn-egress/policy.json"
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="vpn-egressctl",
description="Declarative control plane for sing-box 1.13.19",
)
parser.add_argument("--policy", default=DEFAULT_POLICY, help="path to policy.json")
commands = parser.add_subparsers(dest="command", required=True)
import_parser = commands.add_parser("import", help="validate, store and apply a Hysteria2 URI")
source = import_parser.add_mutually_exclusive_group()
source.add_argument("--stdin", action="store_true", help="read URI from stdin or a hidden TTY prompt")
source.add_argument("--file", metavar="PATH", help="read URI from a protected file")
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")
doctor = commands.add_parser("doctor", help="run complete runtime diagnostics")
doctor.add_argument("--json", action="store_true")
commands.add_parser("rollback", help="atomically swap to the last-good configuration")
commands.add_parser("guard-apply", help=argparse.SUPPRESS)
return parser
def _read_import_source(args: argparse.Namespace) -> str:
if args.file:
path = Path(args.file)
if os.name == "posix" and path.exists() and (path.stat().st_mode & 0o077):
raise ValidationError("URI source file must not be accessible by group or others")
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError("Cannot read URI source file") from exc
if not args.stdin:
raise ValidationError("Use --stdin or --file; URI positional arguments are intentionally disabled")
if sys.stdin.isatty():
return getpass.getpass("Hysteria2 URI: ")
return sys.stdin.read()
def _print_status(state: dict[str, object]) -> None:
for key in sorted(state):
value = state[key]
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False)
print(f"{key}: {value}")
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
policy = load_policy(args.policy)
controller = Controller(policy)
if args.command == "import":
changed = controller.import_uri(_read_import_source(args))
print("Configuration applied." if changed else "Desired state is already installed.")
elif args.command == "check":
endpoint = controller.check()
print(f"OK: {endpoint.endpoint_label()}, sing-box 1.13.19")
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.")
elif args.command == "status":
state = controller.status()
if args.json:
print(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True))
else:
_print_status(state)
elif args.command == "doctor":
doctor = Doctor(policy)
checks = doctor.run()
if args.json:
print(doctor.as_json())
else:
for check in checks:
print(f"[{check.level}] {check.name}: {check.message}")
return doctor.exit_code()
elif args.command == "rollback":
controller.rollback()
print("Rollback completed and validated.")
elif args.command == "guard-apply":
apply_guard(policy)
print("VPN egress guard installed.")
return 0
except ApplyError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 4 if exc.rollback_ok else 5
except UnsupportedVersionError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 3
except (ValidationError, VpnEgressError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
except OSError:
print("ERROR: filesystem operation failed", file=sys.stderr)
return 2
except KeyboardInterrupt:
print("ERROR: interrupted", file=sys.stderr)
return 130
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import ipaddress
import json
import os
import socket
import stat
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
from .errors import CommandError, ValidationError, VpnEgressError
from .policy import Policy
from .renderer_1_13_19 import render_bytes
from .uri import parse_hysteria2_uri
from .version import probe_version, require_supported
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
@dataclass(frozen=True, slots=True)
class Check:
level: str
name: str
message: str
class Doctor:
def __init__(
self,
policy: Policy,
*,
runner: RunFunction = subprocess.run,
resolver: Callable[..., Any] = socket.getaddrinfo,
) -> None:
self.policy = policy
self.runner = runner
self.resolver = resolver
self.checks: list[Check] = []
def _add(self, level: str, name: str, message: str) -> None:
self.checks.append(Check(level, name, message))
def _run(self, args: list[str], timeout: float = 15) -> subprocess.CompletedProcess[str] | None:
try:
return self.runner(args, text=True, capture_output=True, timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired):
return None
def _command_contains(self, name: str, args: list[str], required: list[str]) -> None:
result = self._run(args)
if result is None or result.returncode != 0:
self._add("ERROR", name, "command failed")
return
output = result.stdout + result.stderr
missing = [item for item in required if item not in output]
if missing:
self._add("ERROR", name, "expected runtime state is missing")
else:
self._add("OK", name, "runtime state matches policy")
def _permissions(self, path: str, expected: int, required: bool = True) -> None:
target = Path(path)
if not target.exists():
self._add("ERROR" if required else "WARN", f"permissions:{path}", "file does not exist")
return
actual = stat.S_IMODE(target.stat().st_mode)
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}")
def run(self) -> list[Check]:
if os.name == "posix" and os.geteuid() != 0:
self._add("WARN", "privileges", "doctor is not running as root; some checks may fail")
else:
self._add("OK", "privileges", "sufficient privileges")
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")
except VpnEgressError as exc:
self._add("ERROR", "sing-box-version", str(exc))
endpoint = None
source = None
try:
source = Path(self.policy.runtime.uri_path).read_text(encoding="utf-8").strip()
endpoint = parse_hysteria2_uri(source)
self._add("OK", "hysteria2-uri", f"valid endpoint {endpoint.endpoint_label()}")
if endpoint.insecure:
self._add("WARN", "tls-insecure", "TLS certificate verification is disabled by the URI")
else:
self._add("OK", "tls-insecure", "TLS certificate verification is enabled")
except (OSError, ValidationError) as exc:
self._add("ERROR", "hysteria2-uri", str(exc))
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"
self._permissions(str(state_path), 0o600, required=False)
if endpoint is not None:
try:
expected = render_bytes(self.policy, endpoint)
installed = Path(self.policy.sing_box.config_path).read_bytes()
if expected == installed:
self._add("OK", "config-drift", "installed configuration matches desired state")
else:
self._add("ERROR", "config-drift", "installed configuration differs from desired state")
except OSError:
self._add("ERROR", "config-drift", "cannot read installed configuration")
exclusions = [ipaddress.ip_network(item) for item in self.policy.network.route_exclude_address]
for network in exclusions:
if network.version == 4 and network.prefixlen == 32 and network.is_global:
self._add("WARN", "public-route-exclusion", f"public host route is configured: {network}")
try:
answers = self.resolver(endpoint.server, endpoint.server_port or 443, socket.AF_INET)
addresses = sorted({item[4][0] for item in answers})
conflict = [address for address in addresses if any(ipaddress.ip_address(address) in network for network in exclusions)]
if conflict:
self._add("ERROR", "endpoint-exclusion", "resolved endpoint is present in TUN exclusions")
else:
self._add("OK", "endpoint-exclusion", f"{len(addresses)} resolved IPv4 address(es), none excluded")
except OSError:
self._add("ERROR", "endpoint-resolution", "cannot resolve endpoint through system resolver")
check = self._run([
self.policy.sing_box.binary,
"check",
"-c",
self.policy.sing_box.config_path,
])
if check is not None and check.returncode == 0:
self._add("OK", "sing-box-check", "installed configuration is accepted")
else:
self._add("ERROR", "sing-box-check", "installed configuration is rejected")
if os.name == "posix":
for interface in (
self.policy.network.upstream_interface,
self.policy.network.vpn_lan_interface,
self.policy.network.tun_name,
):
result = self._run(["/usr/sbin/ip", "link", "show", interface])
self._add(
"OK" if result is not None and result.returncode == 0 else "ERROR",
f"interface:{interface}",
"interface exists" if result is not None and result.returncode == 0 else "interface is missing",
)
self._add(
"OK" if Path("/dev/net/tun").exists() else "ERROR",
"tun-device",
"/dev/net/tun exists" if Path("/dev/net/tun").exists() else "/dev/net/tun is missing",
)
self._command_contains(
"ip-forward",
["/usr/sbin/sysctl", "net.ipv4.ip_forward"],
["= 1"],
)
self._command_contains(
"ip-rules",
["/usr/sbin/ip", "-4", "rule", "show"],
[
str(self.policy.network.iproute2_rule_index),
self.policy.network.auto_redirect_input_mark,
self.policy.network.auto_redirect_output_mark,
str(self.policy.network.auto_redirect_fallback_rule_index),
],
)
self._command_contains(
"route-table",
["/usr/sbin/ip", "-4", "route", "show", "table", str(self.policy.network.iproute2_table_index)],
[self.policy.network.tun_name],
)
self._command_contains(
"anti-leak-nft",
["/usr/sbin/nft", "list", "table", "inet", "vpn_egress_guard"],
[
f'iifname "{self.policy.network.vpn_lan_interface}"',
f'oifname "{self.policy.network.upstream_interface}"',
"reject",
],
)
for unit in (self.policy.sing_box.service, "vpn-egress-sync.path", "vpn-egress-guard.service"):
result = self._run(["/usr/bin/systemctl", "is-active", "--quiet", unit])
self._add(
"OK" if result is not None and result.returncode == 0 else "ERROR",
f"systemd:{unit}",
"active" if result is not None and result.returncode == 0 else "not active",
)
if endpoint is not None:
journal = self._run(["/usr/bin/journalctl", "-u", self.policy.sing_box.service, "-n", "500", "--no-pager"])
if journal is None:
self._add("WARN", "journal-secrets", "journal could not be inspected")
else:
text = journal.stdout + journal.stderr
secrets = [endpoint.password]
if endpoint.obfs_password:
secrets.append(endpoint.obfs_password)
if any(secret and secret in text for secret in secrets):
self._add("ERROR", "journal-secrets", "a current credential was found in journal")
else:
self._add("OK", "journal-secrets", "current credentials were not found in journal")
legacy = Path("/root/render-singbox-hy2.sh")
if legacy.exists():
self._add("WARN", "legacy-renderer", "legacy renderer still exists and must not be used")
else:
self._add("OK", "legacy-renderer", "legacy renderer is absent")
return self.checks
def exit_code(self) -> int:
return 1 if any(check.level == "ERROR" for check in self.checks) else 0
def as_json(self) -> str:
return json.dumps([asdict(item) for item in self.checks], ensure_ascii=False, indent=2)
+20
View File
@@ -0,0 +1,20 @@
class VpnEgressError(Exception):
"""Expected, user-facing failure which must not include secrets."""
class ValidationError(VpnEgressError):
pass
class UnsupportedVersionError(VpnEgressError):
pass
class CommandError(VpnEgressError):
pass
class ApplyError(VpnEgressError):
def __init__(self, message: str, *, rollback_ok: bool | None = None) -> None:
super().__init__(message)
self.rollback_ok = rollback_ok
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def ensure_private_dir(path: str | Path, mode: int = 0o700) -> Path:
target = Path(path)
target.mkdir(mode=mode, parents=True, exist_ok=True)
if os.name == "posix":
os.chmod(target, mode)
return target
def fsync_directory(path: str | Path) -> None:
if os.name != "posix":
return
descriptor = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def atomic_write(path: str | Path, data: bytes, mode: int = 0o600) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
owner: tuple[int, int] | None = None
if target.exists() and os.name == "posix":
stat_result = target.stat()
owner = stat_result.st_uid, stat_result.st_gid
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
temporary = Path(temporary_name)
try:
os.chmod(temporary, mode)
if owner is not None:
os.chown(temporary, *owner)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
fsync_directory(target.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temporary.unlink(missing_ok=True)
def atomic_write_text(path: str | Path, text: str, mode: int = 0o600) -> None:
atomic_write(path, text.encode("utf-8"), mode)
def atomic_write_json(path: str | Path, value: Any, mode: int = 0o600) -> None:
data = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
atomic_write(path, data, mode)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import subprocess
from typing import Callable
from .errors import CommandError
from .policy import Policy
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
def _run(runner: RunFunction, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
try:
return runner(
args,
text=True,
capture_output=True,
timeout=15,
check=False,
**kwargs,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandError(f"Cannot execute {args[0].rsplit('/', 1)[-1]}") from exc
def apply_guard(policy: Policy, runner: RunFunction = subprocess.run) -> None:
network = policy.network
for interface in (network.upstream_interface, network.vpn_lan_interface):
result = _run(runner, ["/usr/sbin/ip", "link", "show", interface])
if result.returncode != 0:
raise CommandError(f"Required interface is missing: {interface}")
existing = _run(runner, ["/usr/sbin/nft", "list", "table", "inet", "vpn_egress_guard"])
prefix = "delete table inet vpn_egress_guard\n" if existing.returncode == 0 else ""
ruleset = prefix + f'''table inet vpn_egress_guard {{
chain forward {{
type filter hook forward priority filter; policy accept;
iifname "{network.vpn_lan_interface}" oifname "{network.upstream_interface}" counter reject
}}
}}
'''
result = _run(runner, ["/usr/sbin/nft", "-f", "-"], input=ruleset)
if result.returncode != 0:
raise CommandError("Cannot atomically install the VPN egress guard")
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Hy2Endpoint:
server: str
password: str = field(repr=False)
server_port: int | None = None
server_ports: tuple[str, ...] = ()
sni: str = ""
insecure: bool = False
obfs_type: str | None = None
obfs_password: str | None = field(default=None, repr=False)
display_name: str | None = None
def endpoint_label(self) -> str:
host = f"[{self.server}]" if ":" in self.server else self.server
if self.server_port is not None:
return f"{host}:{self.server_port}"
return f"{host}:{','.join(self.server_ports)}"
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import ipaddress
import json
import re
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlsplit
from .errors import ValidationError
@dataclass(frozen=True, slots=True)
class SingBoxPolicy:
binary: str
config_path: str
service: str
required_version: str
@dataclass(frozen=True, slots=True)
class RuntimePolicy:
uri_path: str
state_dir: str
lock_path: str
backup_keep: int
@dataclass(frozen=True, slots=True)
class NetworkPolicy:
upstream_interface: str
vpn_lan_interface: str
tun_name: str
tun_address: str
mtu: int
route_exclude_address: tuple[str, ...]
iproute2_table_index: int
iproute2_rule_index: int
auto_redirect_input_mark: str
auto_redirect_output_mark: str
auto_redirect_reset_mark: str
auto_redirect_nfqueue: int
auto_redirect_fallback_rule_index: int
@dataclass(frozen=True, slots=True)
class DnsPolicy:
bootstrap_server: str
bootstrap_port: int
remote_server: str
remote_port: int
remote_path: str
remote_tls_server_name: str
strategy: str
@dataclass(frozen=True, slots=True)
class BandwidthPolicy:
up_mbps: int
down_mbps: int
@dataclass(frozen=True, slots=True)
class HealthcheckPolicy:
url: str | None
timeout_seconds: float
settle_seconds: float
expected_status: int
body_contains: str | None
@dataclass(frozen=True, slots=True)
class Policy:
schema_version: int
sing_box: SingBoxPolicy
runtime: RuntimePolicy
network: NetworkPolicy
dns: DnsPolicy
bandwidth: BandwidthPolicy
healthcheck: HealthcheckPolicy
_EXPECTED: dict[str, set[str]] = {
"root": {"schema_version", "sing_box", "runtime", "network", "dns", "bandwidth", "healthcheck"},
"sing_box": {"binary", "config_path", "service", "required_version"},
"runtime": {"uri_path", "state_dir", "lock_path", "backup_keep"},
"network": {
"upstream_interface", "vpn_lan_interface", "tun_name", "tun_address", "mtu",
"route_exclude_address", "iproute2_table_index", "iproute2_rule_index",
"auto_redirect_input_mark", "auto_redirect_output_mark", "auto_redirect_reset_mark",
"auto_redirect_nfqueue", "auto_redirect_fallback_rule_index",
},
"dns": {"bootstrap_server", "bootstrap_port", "remote_server", "remote_port", "remote_path", "remote_tls_server_name", "strategy"},
"bandwidth": {"up_mbps", "down_mbps"},
"healthcheck": {"url", "timeout_seconds", "settle_seconds", "expected_status", "body_contains"},
}
def _mapping(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValidationError(f"Policy section {label} must be an object")
unknown = set(value) - _EXPECTED[label]
missing = _EXPECTED[label] - set(value)
if unknown:
raise ValidationError(f"Unknown policy keys in {label}: {', '.join(sorted(unknown))}")
if missing:
raise ValidationError(f"Missing policy keys in {label}: {', '.join(sorted(missing))}")
return value
def _string(mapping: dict[str, Any], key: str, *, nonempty: bool = True) -> str:
value = mapping[key]
if not isinstance(value, str) or (nonempty and not value):
raise ValidationError(f"Policy value {key} must be a non-empty string")
return value
def _integer(mapping: dict[str, Any], key: str, minimum: int, maximum: int) -> int:
value = mapping[key]
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
raise ValidationError(f"Policy value {key} must be in {minimum}..{maximum}")
return value
def _absolute(value: str, label: str) -> str:
path = PurePosixPath(value)
if not path.is_absolute() or ".." in path.parts:
raise ValidationError(f"Policy path {label} must be an absolute normalised POSIX path")
return str(path)
def _interface(value: str, label: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,15}", value):
raise ValidationError(f"Invalid interface name in {label}")
return value
def _mark(value: str, label: str) -> str:
if not re.fullmatch(r"0x[0-9a-fA-F]{1,8}", value):
raise ValidationError(f"Invalid hexadecimal mark in {label}")
return "0x" + value[2:].lower()
def load_policy(path: str | Path) -> Policy:
try:
with Path(path).open("r", encoding="utf-8") as stream:
raw = json.load(stream)
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(f"Cannot read policy file: {path}") from exc
root = _mapping(raw, "root")
if root["schema_version"] != 1:
raise ValidationError("Only policy schema_version=1 is supported")
sb = _mapping(root["sing_box"], "sing_box")
runtime = _mapping(root["runtime"], "runtime")
network = _mapping(root["network"], "network")
dns = _mapping(root["dns"], "dns")
bandwidth = _mapping(root["bandwidth"], "bandwidth")
health = _mapping(root["healthcheck"], "healthcheck")
required_version = _string(sb, "required_version")
if required_version != "1.13.19":
raise ValidationError("This release requires sing-box version exactly 1.13.19")
service = _string(sb, "service")
if not re.fullmatch(r"[A-Za-z0-9@_.:-]+\.service", service):
raise ValidationError("Invalid sing-box systemd service name")
exclusions_raw = network["route_exclude_address"]
if not isinstance(exclusions_raw, list) or not exclusions_raw:
raise ValidationError("route_exclude_address must be a non-empty array")
exclusions: list[str] = []
for value in exclusions_raw:
if not isinstance(value, str):
raise ValidationError("route_exclude_address entries must be strings")
try:
parsed = ipaddress.ip_network(value, strict=True)
except ValueError as exc:
raise ValidationError(f"Invalid route exclusion: {value}") from exc
exclusions.append(str(parsed))
tun_address = _string(network, "tun_address")
try:
ipaddress.ip_interface(tun_address)
except ValueError as exc:
raise ValidationError("Invalid tun_address") from exc
bootstrap = _string(dns, "bootstrap_server")
remote = _string(dns, "remote_server")
try:
ipaddress.ip_address(bootstrap)
ipaddress.ip_address(remote)
except ValueError as exc:
raise ValidationError("DNS bootstrap and remote servers must be IP addresses") from exc
if dns["strategy"] != "ipv4_only":
raise ValidationError("Only DNS strategy ipv4_only is supported")
url = health["url"]
body_contains = health["body_contains"]
if url is not None and (not isinstance(url, str) or not url.startswith("https://")):
raise ValidationError("healthcheck.url must be null or an https:// URL")
if url is not None:
parsed_url = urlsplit(url)
if not parsed_url.hostname or parsed_url.username is not None or parsed_url.password is not None or parsed_url.fragment:
raise ValidationError("healthcheck.url must not contain credentials or a fragment")
if not _string(dns, "remote_path").startswith("/"):
raise ValidationError("dns.remote_path must start with /")
if body_contains is not None and not isinstance(body_contains, str):
raise ValidationError("healthcheck.body_contains must be null or a string")
for float_key in ("timeout_seconds", "settle_seconds"):
value = health[float_key]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 120:
raise ValidationError(f"healthcheck.{float_key} must be in 0..120")
return Policy(
schema_version=1,
sing_box=SingBoxPolicy(
binary=_absolute(_string(sb, "binary"), "sing_box.binary"),
config_path=_absolute(_string(sb, "config_path"), "sing_box.config_path"),
service=service,
required_version=required_version,
),
runtime=RuntimePolicy(
uri_path=_absolute(_string(runtime, "uri_path"), "runtime.uri_path"),
state_dir=_absolute(_string(runtime, "state_dir"), "runtime.state_dir"),
lock_path=_absolute(_string(runtime, "lock_path"), "runtime.lock_path"),
backup_keep=_integer(runtime, "backup_keep", 1, 100),
),
network=NetworkPolicy(
upstream_interface=_interface(_string(network, "upstream_interface"), "upstream_interface"),
vpn_lan_interface=_interface(_string(network, "vpn_lan_interface"), "vpn_lan_interface"),
tun_name=_interface(_string(network, "tun_name"), "tun_name"),
tun_address=tun_address,
mtu=_integer(network, "mtu", 576, 9000),
route_exclude_address=tuple(exclusions),
iproute2_table_index=_integer(network, "iproute2_table_index", 1, 2**31 - 1),
iproute2_rule_index=_integer(network, "iproute2_rule_index", 1, 32765),
auto_redirect_input_mark=_mark(_string(network, "auto_redirect_input_mark"), "input mark"),
auto_redirect_output_mark=_mark(_string(network, "auto_redirect_output_mark"), "output mark"),
auto_redirect_reset_mark=_mark(_string(network, "auto_redirect_reset_mark"), "reset mark"),
auto_redirect_nfqueue=_integer(network, "auto_redirect_nfqueue", 0, 65535),
auto_redirect_fallback_rule_index=_integer(network, "auto_redirect_fallback_rule_index", 32766, 2**31 - 1),
),
dns=DnsPolicy(
bootstrap_server=bootstrap,
bootstrap_port=_integer(dns, "bootstrap_port", 1, 65535),
remote_server=remote,
remote_port=_integer(dns, "remote_port", 1, 65535),
remote_path=_string(dns, "remote_path"),
remote_tls_server_name=_string(dns, "remote_tls_server_name"),
strategy=_string(dns, "strategy"),
),
bandwidth=BandwidthPolicy(
up_mbps=_integer(bandwidth, "up_mbps", 0, 1_000_000),
down_mbps=_integer(bandwidth, "down_mbps", 0, 1_000_000),
),
healthcheck=HealthcheckPolicy(
url=url,
timeout_seconds=float(health["timeout_seconds"]),
settle_seconds=float(health["settle_seconds"]),
expected_status=_integer(health, "expected_status", 100, 599),
body_contains=body_contains,
),
)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from typing import Any
_SECRET_RE = re.compile(
r"password|passwd|passphrase|token|secret|credential|private[_-]?key|"
r"pre[_-]?shared[_-]?key|psk|authorization|api[_-]?key|auth$",
re.IGNORECASE,
)
def is_secret_key(key: str) -> bool:
return bool(_SECRET_RE.search(key))
def redact(value: Any, key: str = "") -> Any:
if key and is_secret_key(key):
return "<REDACTED>"
if isinstance(value, Mapping):
return {str(item_key): redact(item_value, str(item_key)) for item_key, item_value in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [redact(item) for item in value]
return value
def secret_fingerprint(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
def _flatten(value: Any, prefix: str = "") -> dict[str, Any]:
if isinstance(value, dict):
flattened: dict[str, Any] = {}
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
flattened.update(_flatten(item, path))
return flattened
if isinstance(value, list):
flattened = {}
for index, item in enumerate(value):
flattened.update(_flatten(item, f"{prefix}[{index}]"))
return flattened
return {prefix: value}
def redacted_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> list[str]:
old_flat = _flatten(old or {})
new_flat = _flatten(new)
changes: list[str] = []
for path in sorted(set(old_flat) | set(new_flat)):
before = old_flat.get(path, "<MISSING>")
after = new_flat.get(path, "<MISSING>")
if before == after:
continue
leaf = re.split(r"[.[]", path)[-1].rstrip("]")
if is_secret_key(leaf):
changes.append(f"~ {path}: <REDACTED> -> <REDACTED>")
elif before == "<MISSING>":
changes.append(f"+ {path}: {json.dumps(after, ensure_ascii=False)}")
elif after == "<MISSING>":
changes.append(f"- {path}: {json.dumps(before, ensure_ascii=False)}")
else:
changes.append(
f"~ {path}: {json.dumps(before, ensure_ascii=False)} -> {json.dumps(after, ensure_ascii=False)}"
)
return changes
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
from typing import Any
from .model import Hy2Endpoint
from .policy import Policy
TARGET_VERSION = "1.13.19"
def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
network = policy.network
dns = policy.dns
outbound: dict[str, Any] = {
"type": "hysteria2",
"tag": "hy2-out",
"server": endpoint.server,
"up_mbps": policy.bandwidth.up_mbps,
"down_mbps": policy.bandwidth.down_mbps,
"password": endpoint.password,
"tls": {
"enabled": True,
"server_name": endpoint.sni,
"insecure": endpoint.insecure,
},
"bind_interface": network.upstream_interface,
"domain_resolver": {"server": "bootstrap-dns", "strategy": dns.strategy},
}
if endpoint.server_port is not None:
outbound["server_port"] = endpoint.server_port
else:
outbound["server_ports"] = list(endpoint.server_ports)
if endpoint.obfs_type == "salamander":
outbound["obfs"] = {
"type": "salamander",
"password": endpoint.obfs_password,
}
return {
"log": {"level": "info", "timestamp": True},
"dns": {
"servers": [
{
"type": "udp",
"tag": "bootstrap-dns",
"server": dns.bootstrap_server,
"server_port": dns.bootstrap_port,
"bind_interface": network.upstream_interface,
},
{
"type": "https",
"tag": "remote-dns",
"server": dns.remote_server,
"server_port": dns.remote_port,
"path": dns.remote_path,
"tls": {
"enabled": True,
"server_name": dns.remote_tls_server_name,
},
"detour": "hy2-out",
},
],
"final": "remote-dns",
"strategy": dns.strategy,
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": network.tun_name,
"address": [network.tun_address],
"mtu": network.mtu,
"auto_route": True,
"iproute2_table_index": network.iproute2_table_index,
"iproute2_rule_index": network.iproute2_rule_index,
"auto_redirect": True,
"auto_redirect_input_mark": network.auto_redirect_input_mark,
"auto_redirect_output_mark": network.auto_redirect_output_mark,
"auto_redirect_reset_mark": network.auto_redirect_reset_mark,
"auto_redirect_nfqueue": network.auto_redirect_nfqueue,
"auto_redirect_iproute2_fallback_rule_index": network.auto_redirect_fallback_rule_index,
"strict_route": True,
"stack": "mixed",
"route_exclude_address": list(network.route_exclude_address),
}
],
"outbounds": [
outbound,
{
"type": "direct",
"tag": "direct",
"bind_interface": network.upstream_interface,
},
],
"route": {
"auto_detect_interface": True,
"default_domain_resolver": {
"server": "bootstrap-dns",
"strategy": dns.strategy,
},
"final": "hy2-out",
"rules": [
{
"network": ["tcp", "udp"],
"port": 53,
"action": "hijack-dns",
}
],
},
}
def render_bytes(policy: Policy, endpoint: Hy2Endpoint) -> bytes:
return (json.dumps(render_config(policy, endpoint), ensure_ascii=False, indent=2) + "\n").encode("utf-8")
+490
View File
@@ -0,0 +1,490 @@
from __future__ import annotations
import hashlib
import json
import os
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from contextlib import AbstractContextManager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
from .errors import ApplyError, CommandError, ValidationError, VpnEgressError
from .fsutil import atomic_write, atomic_write_json, atomic_write_text, ensure_private_dir, fsync_directory
from .model import Hy2Endpoint
from .policy import Policy
from .redact import redacted_diff
from .renderer_1_13_19 import render_bytes, render_config
from .uri import parse_hysteria2_uri
from .version import SingBoxVersion, probe_version, require_supported
RunFunction = Callable[..., subprocess.CompletedProcess[str]]
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
class FileLock(AbstractContextManager["FileLock"]):
def __init__(self, path: str) -> None:
self.path = Path(path)
self._stream: Any = None
def __enter__(self) -> "FileLock":
self.path.parent.mkdir(parents=True, exist_ok=True)
self._stream = self.path.open("a+b")
if os.name == "posix":
import fcntl
fcntl.flock(self._stream.fileno(), fcntl.LOCK_EX)
else:
import msvcrt
self._stream.seek(0)
if not self._stream.read(1):
self._stream.seek(0)
self._stream.write(b"0")
self._stream.flush()
self._stream.seek(0)
msvcrt.locking(self._stream.fileno(), msvcrt.LK_LOCK, 1)
return self
def __exit__(self, *args: object) -> None:
if self._stream is None:
return
if os.name == "posix":
import fcntl
fcntl.flock(self._stream.fileno(), fcntl.LOCK_UN)
else:
import msvcrt
self._stream.seek(0)
msvcrt.locking(self._stream.fileno(), msvcrt.LK_UNLCK, 1)
self._stream.close()
self._stream = None
class Controller:
def __init__(
self,
policy: Policy,
*,
runner: RunFunction = subprocess.run,
urlopen: Callable[..., Any] = urllib.request.urlopen,
sleeper: Callable[[float], None] = time.sleep,
) -> None:
self.policy = policy
self.runner = runner
self.urlopen = urlopen
self.sleeper = sleeper
@property
def state_dir(self) -> Path:
return Path(self.policy.runtime.state_dir)
@property
def state_path(self) -> Path:
return self.state_dir / "state.json"
@property
def backup_dir(self) -> Path:
return self.state_dir / "backups"
@property
def last_good_path(self) -> Path:
return self.state_dir / "last-good.json"
def _run(self, args: list[str], timeout: float = 30) -> subprocess.CompletedProcess[str]:
try:
return self.runner(
args,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandError(f"Command failed to execute: {Path(args[0]).name}") from exc
def _probe(self) -> SingBoxVersion:
version = probe_version(self.policy.sing_box.binary, self.runner)
require_supported(version, self.policy.sing_box.required_version)
return version
def _read_uri(self) -> tuple[str, Hy2Endpoint]:
path = Path(self.policy.runtime.uri_path)
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise ValidationError(f"Cannot read Hysteria2 URI source: {path}") from exc
return raw, parse_hysteria2_uri(raw)
def _installed_bytes(self) -> bytes | None:
try:
return Path(self.policy.sing_box.config_path).read_bytes()
except FileNotFoundError:
return None
except OSError as exc:
raise CommandError("Cannot read installed sing-box configuration") from exc
def _installed_json(self) -> dict[str, Any] | None:
data = self._installed_bytes()
if data is None:
return None
try:
value = json.loads(data)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValidationError("Installed sing-box configuration is not valid JSON") from exc
if not isinstance(value, dict):
raise ValidationError("Installed sing-box configuration must be an object")
return value
def _candidate(self, endpoint: Hy2Endpoint) -> tuple[bytes, Path]:
data = render_bytes(self.policy, endpoint)
target = Path(self.policy.sing_box.config_path)
target.parent.mkdir(parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(prefix=f".{target.name}.candidate.", dir=target.parent)
candidate = Path(name)
try:
os.chmod(candidate, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
result = self._run([self.policy.sing_box.binary, "check", "-c", str(candidate)])
if result.returncode != 0:
raise ValidationError("sing-box rejected the generated configuration")
return data, candidate
except Exception:
candidate.unlink(missing_ok=True)
raise
finally:
if descriptor >= 0:
os.close(descriptor)
def _service_active(self) -> bool:
result = self._run(
["/usr/bin/systemctl", "is-active", "--quiet", self.policy.sing_box.service],
timeout=10,
)
return result.returncode == 0
def _restart(self) -> None:
result = self._run(
["/usr/bin/systemctl", "restart", self.policy.sing_box.service],
timeout=45,
)
if result.returncode != 0:
raise CommandError("sing-box service restart failed")
def _healthcheck(self) -> None:
health = self.policy.healthcheck
if health.settle_seconds:
self.sleeper(health.settle_seconds)
deadline = time.monotonic() + health.timeout_seconds
last_error = "healthcheck failed"
while True:
if not self._service_active():
last_error = "sing-box service is not active"
elif health.url is None:
return
else:
try:
remaining = max(0.1, deadline - time.monotonic())
with self.urlopen(health.url, timeout=remaining) as response:
body = response.read(1_048_576).decode("utf-8", errors="replace")
status = getattr(response, "status", response.getcode())
if status != health.expected_status:
last_error = "healthcheck returned an unexpected HTTP status"
elif health.body_contains is not None and health.body_contains not in body:
last_error = "healthcheck response did not contain the expected marker"
else:
return
except (OSError, urllib.error.URLError, TimeoutError, socket.timeout):
last_error = "healthcheck request failed"
if time.monotonic() >= deadline:
raise CommandError(last_error)
self.sleeper(min(0.5, max(0.0, deadline - time.monotonic())))
def _write_state(
self,
*,
status: str,
version: SingBoxVersion,
config_data: bytes,
source_data: str | None,
endpoint: Hy2Endpoint | None,
changed: bool,
message: str | None = None,
) -> None:
ensure_private_dir(self.state_dir)
state: dict[str, Any] = {
"schema_version": 1,
"status": status,
"updated_at": _now(),
"sing_box_version": version.version,
"config_sha256": _sha256(config_data),
"changed": changed,
}
if source_data is not None:
state["source_sha256"] = _sha256(source_data.encode("utf-8"))
if endpoint is not None:
state["endpoint"] = endpoint.endpoint_label()
if message is not None:
state["message"] = message
atomic_write_json(self.state_path, state)
def _save_backup(self, current: bytes) -> None:
ensure_private_dir(self.backup_dir)
ensure_private_dir(self.state_dir)
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
name = self.backup_dir / f"config.{stamp}.{_sha256(current)[:12]}.json"
atomic_write(name, current)
atomic_write(self.last_good_path, current)
backups = sorted(self.backup_dir.glob("config.*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
for old in backups[self.policy.runtime.backup_keep :]:
old.unlink(missing_ok=True)
fsync_directory(self.backup_dir)
def _restore(self, previous: bytes | None) -> bool:
target = Path(self.policy.sing_box.config_path)
try:
if previous is None:
target.unlink(missing_ok=True)
return False
atomic_write(target, previous)
check = self._run([self.policy.sing_box.binary, "check", "-c", str(target)])
if check.returncode != 0:
return False
self._restart()
self._healthcheck()
return True
except (CommandError, OSError):
return False
def _install_candidate(
self,
*,
source: str,
endpoint: Hy2Endpoint,
candidate_data: bytes,
candidate_path: Path,
version: SingBoxVersion,
) -> bool:
target = Path(self.policy.sing_box.config_path)
previous = self._installed_bytes()
if previous == candidate_data:
candidate_path.unlink(missing_ok=True)
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=False,
)
return False
if previous is not None:
try:
self._save_backup(previous)
except OSError as exc:
candidate_path.unlink(missing_ok=True)
raise CommandError("Cannot create a protected last-good backup") from exc
try:
if os.name == "posix":
os.chmod(candidate_path, 0o600)
if target.exists():
stat_result = target.stat()
os.chown(candidate_path, stat_result.st_uid, stat_result.st_gid)
os.replace(candidate_path, target)
fsync_directory(target.parent)
self._restart()
self._healthcheck()
except (CommandError, OSError) as exc:
candidate_path.unlink(missing_ok=True)
rollback_ok = self._restore(previous)
self._write_state(
status="apply_failed_rolled_back" if rollback_ok else "critical_rollback_failed",
version=version,
config_data=previous or b"",
source_data=source,
endpoint=endpoint,
changed=False,
message="Generated configuration was not activated",
)
raise ApplyError(
"Generated configuration failed health validation; previous configuration restored"
if rollback_ok
else "Generated configuration failed and automatic rollback also failed",
rollback_ok=rollback_ok,
) from exc
self._write_state(
status="ok",
version=version,
config_data=candidate_data,
source_data=source,
endpoint=endpoint,
changed=True,
)
return True
def check(self) -> Hy2Endpoint:
with FileLock(self.policy.runtime.lock_path):
self._probe()
_, endpoint = self._read_uri()
_, candidate = self._candidate(endpoint)
candidate.unlink(missing_ok=True)
return endpoint
def render(self) -> bytes:
with FileLock(self.policy.runtime.lock_path):
self._probe()
_, endpoint = self._read_uri()
data, candidate = self._candidate(endpoint)
candidate.unlink(missing_ok=True)
return data
def diff(self) -> list[str]:
with FileLock(self.policy.runtime.lock_path):
self._probe()
_, endpoint = self._read_uri()
_, candidate = self._candidate(endpoint)
candidate.unlink(missing_ok=True)
return redacted_diff(self._installed_json(), render_config(self.policy, endpoint))
def sync(self) -> bool:
with FileLock(self.policy.runtime.lock_path):
version = self._probe()
source, endpoint = self._read_uri()
data, candidate = self._candidate(endpoint)
return self._install_candidate(
source=source,
endpoint=endpoint,
candidate_data=data,
candidate_path=candidate,
version=version,
)
def import_uri(self, source: str) -> bool:
source = source.strip()
endpoint = parse_hysteria2_uri(source)
uri_path = Path(self.policy.runtime.uri_path)
with FileLock(self.policy.runtime.lock_path):
version = self._probe()
data, candidate = self._candidate(endpoint)
old_uri = uri_path.read_bytes() if uri_path.exists() else None
atomic_write_text(uri_path, source + "\n")
try:
return self._install_candidate(
source=source,
endpoint=endpoint,
candidate_data=data,
candidate_path=candidate,
version=version,
)
except Exception:
if old_uri is None:
uri_path.unlink(missing_ok=True)
else:
atomic_write(uri_path, old_uri)
raise
def rollback(self) -> None:
with FileLock(self.policy.runtime.lock_path):
version = self._probe()
if not self.last_good_path.exists():
raise ValidationError("No last-good configuration is available")
previous = self.last_good_path.read_bytes()
current = self._installed_bytes()
if current is None:
raise ValidationError("Installed sing-box configuration does not exist")
descriptor, name = tempfile.mkstemp(
prefix=".rollback.", dir=Path(self.policy.sing_box.config_path).parent
)
candidate = Path(name)
try:
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(previous)
stream.flush()
os.fsync(stream.fileno())
os.chmod(candidate, 0o600)
check = self._run([self.policy.sing_box.binary, "check", "-c", str(candidate)])
if check.returncode != 0:
raise ValidationError("last-good configuration is rejected by sing-box")
os.replace(candidate, self.policy.sing_box.config_path)
fsync_directory(Path(self.policy.sing_box.config_path).parent)
self._restart()
self._healthcheck()
atomic_write(self.last_good_path, current)
self._write_state(
status="rolled_back",
version=version,
config_data=previous,
source_data=None,
endpoint=None,
changed=True,
)
except Exception as exc:
candidate.unlink(missing_ok=True)
rollback_ok = self._restore(current)
raise ApplyError(
"Manual rollback failed; original configuration restored"
if rollback_ok
else "Manual rollback and recovery both failed",
rollback_ok=rollback_ok,
) from exc
finally:
if descriptor >= 0:
os.close(descriptor)
def status(self) -> dict[str, Any]:
state: dict[str, Any] = {"state": "not_applied"}
try:
if self.state_path.exists():
loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
state = loaded
except (OSError, json.JSONDecodeError):
state = {"state": "invalid_state_file"}
installed = self._installed_bytes()
state["installed_config_sha256"] = _sha256(installed) if installed is not None else None
state["config_drift"] = bool(
installed is not None
and state.get("config_sha256")
and state.get("config_sha256") != _sha256(installed)
)
try:
source = Path(self.policy.runtime.uri_path).read_text(encoding="utf-8").strip()
source_hash = _sha256(source.encode("utf-8"))
state["installed_source_sha256"] = source_hash
state["source_drift"] = bool(
state.get("source_sha256") and state.get("source_sha256") != source_hash
)
except OSError:
state["installed_source_sha256"] = None
state["source_drift"] = True
try:
version = probe_version(self.policy.sing_box.binary, self.runner)
state["detected_sing_box_version"] = version.version
state["version_supported"] = version.version == self.policy.sing_box.required_version
except (CommandError, ValidationError, VpnEgressError):
state["detected_sing_box_version"] = None
state["version_supported"] = False
try:
state["service_active"] = self._service_active()
except CommandError:
state["service_active"] = False
return state
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import ipaddress
import re
from urllib.parse import parse_qsl, unquote_to_bytes
from .errors import ValidationError
from .model import Hy2Endpoint
_SCHEME_RE = re.compile(r"^(hysteria2|hy2)://", re.IGNORECASE)
_BAD_ESCAPE_RE = re.compile(r"%(?![0-9A-Fa-f]{2})")
_SUPPORTED_QUERY = {"sni", "insecure", "obfs", "obfs-password"}
_KNOWN_UNSUPPORTED_QUERY = {"pinSHA256", "ech"}
def _decode(value: str, label: str) -> str:
if _BAD_ESCAPE_RE.search(value):
raise ValidationError(f"Invalid percent encoding in {label}")
try:
return unquote_to_bytes(value).decode("utf-8")
except UnicodeDecodeError as exc:
raise ValidationError(f"Invalid UTF-8 in {label}") from exc
def _normalise_host(value: str, label: str) -> str:
value = _decode(value, label).strip()
if not value:
raise ValidationError(f"Missing {label}")
try:
return str(ipaddress.ip_address(value))
except ValueError:
pass
if value.endswith("."):
value = value[:-1]
try:
ascii_host = value.encode("idna").decode("ascii").lower()
except UnicodeError as exc:
raise ValidationError(f"Invalid {label}") from exc
labels = ascii_host.split(".")
if (
len(ascii_host) > 253
or any(not item or len(item) > 63 for item in labels)
or any(item.startswith("-") or item.endswith("-") for item in labels)
or any(not re.fullmatch(r"[a-z0-9-]+", item) for item in labels)
):
raise ValidationError(f"Invalid {label}")
return ascii_host
def _parse_port_item(item: str) -> tuple[int, int, str]:
if not item:
raise ValidationError("Empty port in Hysteria2 endpoint")
if "-" in item:
if item.count("-") != 1:
raise ValidationError("Invalid Hysteria2 port range")
left, right = item.split("-", 1)
if not left.isdecimal() or not right.isdecimal():
raise ValidationError("Invalid Hysteria2 port range")
start, end = int(left), int(right)
if not 1 <= start <= end <= 65535:
raise ValidationError("Hysteria2 port range is outside 1..65535")
return start, end, f"{start}:{end}"
if not item.isdecimal():
raise ValidationError("Invalid Hysteria2 port")
port = int(item)
if not 1 <= port <= 65535:
raise ValidationError("Hysteria2 port is outside 1..65535")
return port, port, str(port)
def _parse_ports(spec: str | None) -> tuple[int | None, tuple[str, ...]]:
if spec is None or spec == "":
return 443, ()
parsed = [_parse_port_item(item) for item in spec.split(",")]
ordered = sorted((start, end) for start, end, _ in parsed)
for previous, current in zip(ordered, ordered[1:]):
if current[0] <= previous[1]:
raise ValidationError("Overlapping Hysteria2 ports are not allowed")
if len(parsed) == 1 and parsed[0][0] == parsed[0][1]:
return parsed[0][0], ()
return None, tuple(item[2] for item in parsed)
def _split_authority(authority: str) -> tuple[str, str | None]:
if authority.startswith("["):
closing = authority.find("]")
if closing < 0:
raise ValidationError("Unclosed IPv6 address in Hysteria2 URI")
host = authority[1:closing]
tail = authority[closing + 1 :]
if not tail:
return host, None
if not tail.startswith(":"):
raise ValidationError("Invalid text after IPv6 address")
return host, tail[1:]
if authority.count(":") > 1:
raise ValidationError("IPv6 addresses in Hysteria2 URI must use brackets")
if ":" in authority:
return tuple(authority.rsplit(":", 1)) # type: ignore[return-value]
return authority, None
def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint:
uri = raw_uri.strip()
scheme = _SCHEME_RE.match(uri)
if not scheme:
raise ValidationError("Unsupported URI scheme; expected hysteria2:// or hy2://")
remainder = uri[scheme.end() :]
if any(char.isspace() for char in remainder):
raise ValidationError("Whitespace is not allowed in Hysteria2 URI")
without_fragment, separator, raw_fragment = remainder.partition("#")
if separator and "#" in raw_fragment:
raise ValidationError("Invalid URI fragment")
authority_path, query_separator, raw_query = without_fragment.partition("?")
if not query_separator:
raw_query = ""
authority, slash, path_tail = authority_path.partition("/")
if slash and path_tail:
raise ValidationError("Client modes and non-empty paths are not supported")
raw_auth, at, endpoint_authority = authority.rpartition("@")
if not at:
raise ValidationError("Missing Hysteria2 authentication data")
password = _decode(raw_auth, "authentication data")
if not password:
raise ValidationError("Missing Hysteria2 authentication data")
raw_host, port_spec = _split_authority(endpoint_authority)
server = _normalise_host(raw_host, "server host")
server_port, server_ports = _parse_ports(port_spec)
if _BAD_ESCAPE_RE.search(raw_query):
raise ValidationError("Invalid percent encoding in query")
for component in re.split(r"[&=]", raw_query):
_decode(component, "query")
try:
pairs = parse_qsl(raw_query, keep_blank_values=True, strict_parsing=True)
except ValueError as exc:
raise ValidationError("Invalid Hysteria2 URI query") from exc
query: dict[str, str] = {}
for key, value in pairs:
if key in query:
raise ValidationError(f"Duplicate Hysteria2 URI parameter: {key}")
if key in _KNOWN_UNSUPPORTED_QUERY:
raise ValidationError(
f"Hysteria2 URI parameter {key} cannot be mapped safely to sing-box 1.13.19"
)
if key not in _SUPPORTED_QUERY:
raise ValidationError(f"Unsupported Hysteria2 URI parameter: {key}")
query[key] = value
sni = _normalise_host(query.get("sni", server), "TLS server name")
insecure_raw = query.get("insecure", "0")
if insecure_raw not in {"0", "1"}:
raise ValidationError("insecure must be exactly 0 or 1")
insecure = insecure_raw == "1"
obfs_type = query.get("obfs") or None
obfs_password = query.get("obfs-password") or None
if obfs_type not in {None, "salamander"}:
raise ValidationError("Requested obfs type is not supported by sing-box 1.13.19")
if obfs_type == "salamander" and not obfs_password:
raise ValidationError("obfs=salamander requires obfs-password")
if obfs_type is None and obfs_password is not None:
raise ValidationError("obfs-password requires obfs=salamander")
display_name = _decode(raw_fragment, "fragment") if separator else None
return Hy2Endpoint(
server=server,
password=password,
server_port=server_port,
server_ports=server_ports,
sni=sni,
insecure=insecure,
obfs_type=obfs_type,
obfs_password=obfs_password,
display_name=display_name or None,
)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
from typing import Callable
from .errors import CommandError, UnsupportedVersionError
Runner = Callable[..., subprocess.CompletedProcess[str]]
@dataclass(frozen=True, slots=True)
class SingBoxVersion:
version: str
environment: str
tags: frozenset[str]
revision: str | None
def parse_version_output(output: str) -> SingBoxVersion:
first = re.search(r"(?m)^sing-box version ([^\s]+)\s*$", output)
if not first:
raise UnsupportedVersionError("Cannot parse sing-box version output")
environment = re.search(r"(?m)^Environment:\s*(.+)$", output)
tags = re.search(r"(?m)^Tags:\s*(.*)$", output)
revision = re.search(r"(?m)^Revision:\s*(\S+)$", output)
return SingBoxVersion(
version=first.group(1),
environment=environment.group(1).strip() if environment else "",
tags=frozenset(filter(None, re.split(r"[\s,]+", tags.group(1)))) if tags else frozenset(),
revision=revision.group(1) if revision else None,
)
def probe_version(binary: str, runner: Runner = subprocess.run) -> SingBoxVersion:
try:
result = runner(
[binary, "version"],
text=True,
capture_output=True,
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandError("Cannot execute sing-box binary") from exc
if result.returncode != 0:
raise CommandError("sing-box version command failed")
return parse_version_output(result.stdout + result.stderr)
def require_supported(version: SingBoxVersion, required: str = "1.13.19") -> None:
if version.version != required:
raise UnsupportedVersionError(
f"Unsupported sing-box version {version.version}; required exactly {required}"
)
if "linux/" not in version.environment:
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")
+1
View File
@@ -0,0 +1 @@
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import subprocess
from dataclasses import replace
from pathlib import Path
from vpn_egressctl.policy import (
BandwidthPolicy,
DnsPolicy,
HealthcheckPolicy,
NetworkPolicy,
Policy,
RuntimePolicy,
SingBoxPolicy,
)
VERSION_OUTPUT = """sing-box version 1.13.19
Environment: go1.25.9 linux/amd64
Tags: with_quic,with_gvisor,with_utls
Revision: testrevision
CGO: enabled
"""
def make_policy(root: Path, *, health_url: str | None = "https://health.invalid/") -> Policy:
etc = root / "etc"
state = root / "state"
return Policy(
schema_version=1,
sing_box=SingBoxPolicy(
binary=str(root / "sing-box"),
config_path=str(etc / "config.json"),
service="sing-box.service",
required_version="1.13.19",
),
runtime=RuntimePolicy(
uri_path=str(etc / "hysteria2.uri"),
state_dir=str(state),
lock_path=str(root / "run" / "controller.lock"),
backup_keep=3,
),
network=NetworkPolicy(
upstream_interface="eth0",
vpn_lan_interface="eth1",
tun_name="tun-sb0",
tun_address="172.19.0.1/30",
mtu=1400,
route_exclude_address=("10.20.0.0/24", "10.30.0.0/24", "127.0.0.0/8"),
iproute2_table_index=2022,
iproute2_rule_index=9000,
auto_redirect_input_mark="0x2023",
auto_redirect_output_mark="0x2024",
auto_redirect_reset_mark="0x2025",
auto_redirect_nfqueue=100,
auto_redirect_fallback_rule_index=32768,
),
dns=DnsPolicy(
bootstrap_server="1.1.1.1",
bootstrap_port=53,
remote_server="1.1.1.1",
remote_port=443,
remote_path="/dns-query",
remote_tls_server_name="cloudflare-dns.com",
strategy="ipv4_only",
),
bandwidth=BandwidthPolicy(up_mbps=50, down_mbps=200),
healthcheck=HealthcheckPolicy(
url=health_url,
timeout_seconds=0.1,
settle_seconds=0,
expected_status=200,
body_contains="ip=",
),
)
class FakeResponse:
status = 200
def __init__(self, body: bytes = b"ip=203.0.113.10\n") -> None:
self.body = body
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
def read(self, limit: int = -1) -> bytes:
return self.body[:limit]
def getcode(self) -> int:
return self.status
class FakeRunner:
def __init__(self, *, version: str = VERSION_OUTPUT, restart_results: list[int] | None = None, check_result: int = 0) -> None:
self.version = version
self.restart_results = list(restart_results or [0])
self.check_result = check_result
self.calls: list[list[str]] = []
def __call__(self, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
self.calls.append(list(args))
if len(args) > 1 and args[1] == "version":
return subprocess.CompletedProcess(args, 0, self.version, "")
if len(args) > 1 and args[1] == "check":
return subprocess.CompletedProcess(args, self.check_result, "", "")
if "restart" in args:
result = self.restart_results.pop(0) if self.restart_results else 0
return subprocess.CompletedProcess(args, result, "", "")
if "is-active" in args:
return subprocess.CompletedProcess(args, 0, "active\n", "")
return subprocess.CompletedProcess(args, 0, "", "")
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import contextlib
import io
import unittest
from vpn_egressctl.cli import _parser
class CliTests(unittest.TestCase):
def test_uri_positional_argument_is_rejected(self) -> None:
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["import", "hysteria2://secret@example.com"])
def test_render_requires_output(self) -> None:
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["render"])
def test_all_commands_parse(self) -> None:
for command in ("check", "diff", "sync", "status", "doctor", "rollback"):
with self.subTest(command=command):
args = _parser().parse_args([command])
self.assertEqual(args.command, command)
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from vpn_egressctl.doctor import Doctor
from vpn_egressctl.renderer_1_13_19 import render_bytes
from vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import FakeRunner, make_policy
class DoctorTests(unittest.TestCase):
def prepare(self, directory: str, uri: str):
root = Path(directory)
policy = make_policy(root)
endpoint = parse_hysteria2_uri(uri)
Path(policy.runtime.uri_path).parent.mkdir(parents=True)
Path(policy.runtime.uri_path).write_text(uri + "\n", encoding="utf-8")
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint))
return policy
def test_endpoint_exclusion_is_an_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = self.prepare(directory, "hy2://auth@example.com")
policy = replace(
policy,
network=replace(
policy.network,
route_exclude_address=policy.network.route_exclude_address + ("8.8.8.8/32",),
),
)
doctor = Doctor(
policy,
runner=FakeRunner(),
resolver=lambda *args: [(None, None, None, None, ("8.8.8.8", 443))],
)
checks = doctor.run()
selected = [check for check in checks if check.name == "endpoint-exclusion"]
self.assertEqual(selected[0].level, "ERROR")
def test_insecure_tls_is_reported_without_secret(self) -> None:
secret = "NEVER-LOG-ME"
with tempfile.TemporaryDirectory() as directory:
policy = self.prepare(directory, f"hy2://{secret}@example.com?insecure=1")
doctor = Doctor(
policy,
runner=FakeRunner(),
resolver=lambda *args: [(None, None, None, None, ("8.8.4.4", 443))],
)
checks = doctor.run()
output = json.dumps([check.message for check in checks])
self.assertNotIn(secret, output)
selected = [check for check in checks if check.name == "tls-insecure"]
self.assertEqual(selected[0].level, "WARN")
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import subprocess
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.errors import CommandError
from vpn_egressctl.guard import apply_guard
from tests.helpers import make_policy
class GuardRunner:
def __init__(self, *, missing_interface: bool = False, batch_result: int = 0) -> None:
self.missing_interface = missing_interface
self.batch_result = batch_result
self.calls: list[tuple[list[str], str | None]] = []
def __call__(self, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
input_text = kwargs.get("input")
self.calls.append((list(args), input_text if isinstance(input_text, str) else None))
if "link" in args and self.missing_interface:
return subprocess.CompletedProcess(args, 1, "", "")
if args[-2:] == ["-f", "-"]:
return subprocess.CompletedProcess(args, self.batch_result, "", "")
return subprocess.CompletedProcess(args, 0, "", "")
class GuardTests(unittest.TestCase):
def test_atomic_ruleset_uses_policy_interfaces(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory))
runner = GuardRunner()
apply_guard(policy, runner)
batch = [item for args, item in runner.calls if args[-2:] == ["-f", "-"]][0]
self.assertIsNotNone(batch)
self.assertIn("delete table inet vpn_egress_guard", batch)
self.assertIn('iifname "eth1" oifname "eth0"', batch)
self.assertIn("counter reject", batch)
def test_missing_interface_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory:
runner = GuardRunner(missing_interface=True)
with self.assertRaises(CommandError):
apply_guard(make_policy(Path(directory)), runner)
self.assertFalse(any(args[-2:] == ["-f", "-"] for args, _ in runner.calls))
def test_batch_failure_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as directory:
with self.assertRaises(CommandError):
apply_guard(make_policy(Path(directory)), GuardRunner(batch_result=1))
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.errors import ValidationError
from vpn_egressctl.policy import load_policy
class PolicyTests(unittest.TestCase):
def setUp(self) -> None:
self.raw = json.loads(Path("config/policy.json").read_text(encoding="utf-8"))
def load(self, raw: dict) -> object:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "policy.json"
path.write_text(json.dumps(raw), encoding="utf-8")
return load_policy(path)
def test_production_policy(self) -> None:
policy = self.load(self.raw)
self.assertEqual(policy.sing_box.required_version, "1.13.19")
self.assertEqual(policy.network.iproute2_table_index, 2022)
def test_unknown_root_key(self) -> None:
self.raw["future"] = True
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_unknown_nested_key(self) -> None:
self.raw["network"]["typo"] = 1
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_missing_key(self) -> None:
del self.raw["dns"]["strategy"]
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_version_is_exactly_pinned(self) -> None:
for value in ("1.13", ">=1.13,<1.14", "1.14.0"):
raw = json.loads(json.dumps(self.raw))
raw["sing_box"]["required_version"] = value
with self.subTest(value=value), self.assertRaises(ValidationError):
self.load(raw)
def test_public_exclusion_is_valid_but_diagnosable(self) -> None:
self.raw["network"]["route_exclude_address"].append("203.0.113.1/32")
policy = self.load(self.raw)
self.assertIn("203.0.113.1/32", policy.network.route_exclude_address)
def test_invalid_network_rejected(self) -> None:
self.raw["network"]["route_exclude_address"] = ["10.20.0.1/24"]
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_insecure_health_url_rejected(self) -> None:
self.raw["healthcheck"]["url"] = "http://example.com/"
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_health_url_credentials_rejected(self) -> None:
self.raw["healthcheck"]["url"] = "https://user:pass@example.com/check"
with self.assertRaises(ValidationError):
self.load(self.raw)
def test_dns_path_must_be_absolute(self) -> None:
self.raw["dns"]["remote_path"] = "dns-query"
with self.assertRaises(ValidationError):
self.load(self.raw)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.renderer_1_13_19 import render_bytes
from vpn_egressctl.uri import parse_hysteria2_uri
from vpn_egressctl.version import probe_version
from tests.helpers import make_policy
class RealSingBoxIntegrationTests(unittest.TestCase):
@unittest.skipUnless(os.environ.get("SING_BOX_1_13_19"), "real sing-box 1.13.19 binary is not configured")
def test_real_binary_accepts_golden_config(self) -> None:
binary = os.environ["SING_BOX_1_13_19"]
version = probe_version(binary)
self.assertEqual(version.version, "1.13.19")
self.assertIn("with_quic", version.tags)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
policy = make_policy(root)
endpoint = parse_hysteria2_uri(
"hysteria2://auth@example.com:443?obfs=salamander&obfs-password=obfs"
)
config = root / "config.json"
config.write_bytes(render_bytes(policy, endpoint))
command = "check" if "linux/" in version.environment else "format"
result = subprocess.run([binary, command, "-c", str(config)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import unittest
from vpn_egressctl.redact import redact, redacted_diff, secret_fingerprint
class RedactionTests(unittest.TestCase):
def test_recursive_redaction(self) -> None:
value = {"password": "secret", "nested": {"obfs_password": "other", "server": "example.com"}}
safe = redact(value)
self.assertEqual(safe["password"], "<REDACTED>")
self.assertEqual(safe["nested"]["obfs_password"], "<REDACTED>")
self.assertEqual(safe["nested"]["server"], "example.com")
def test_diff_hides_secrets(self) -> None:
diff = "\n".join(redacted_diff({"password": "old"}, {"password": "new"}))
self.assertNotIn("old", diff)
self.assertNotIn("new", diff)
self.assertIn("REDACTED", diff)
def test_fingerprint_is_short_and_stable(self) -> None:
self.assertEqual(secret_fingerprint("x"), secret_fingerprint("x"))
self.assertEqual(len(secret_fingerprint("x")), 12)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.renderer_1_13_19 import render_bytes, render_config
from vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import make_policy
class RendererTests(unittest.TestCase):
def test_complete_production_shape(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory))
endpoint = parse_hysteria2_uri(
"hysteria2://AUTH@fi.api.withen.pro:443/?insecure=0&obfs=salamander&obfs-password=OBFS"
)
config = render_config(policy, endpoint)
self.assertEqual(list(config), ["log", "dns", "inbounds", "outbounds", "route"])
tun = config["inbounds"][0]
self.assertEqual(tun["route_exclude_address"], ["10.20.0.0/24", "10.30.0.0/24", "127.0.0.0/8"])
self.assertEqual(tun["iproute2_table_index"], 2022)
self.assertEqual(tun["auto_redirect_input_mark"], "0x2023")
self.assertEqual(tun["auto_redirect_output_mark"], "0x2024")
self.assertEqual(tun["auto_redirect_reset_mark"], "0x2025")
outbound = config["outbounds"][0]
self.assertEqual(outbound["server"], "fi.api.withen.pro")
self.assertEqual(outbound["password"], "AUTH")
self.assertEqual(outbound["obfs"]["password"], "OBFS")
self.assertEqual(outbound["bind_interface"], "eth0")
self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out")
self.assertEqual(config["route"]["final"], "hy2-out")
self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode())
def test_multi_port_mapping(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = render_config(
make_policy(Path(directory)),
parse_hysteria2_uri("hy2://x@example.com:443,5000-6000"),
)
outbound = config["outbounds"][0]
self.assertNotIn("server_port", outbound)
self.assertEqual(outbound["server_ports"], ["443", "5000:6000"])
def test_deterministic_utf8_json(self) -> None:
with tempfile.TemporaryDirectory() as directory:
policy = make_policy(Path(directory))
endpoint = parse_hysteria2_uri("hy2://x@example.com#Тест")
first = render_bytes(policy, endpoint)
second = render_bytes(policy, endpoint)
self.assertEqual(first, second)
self.assertTrue(first.endswith(b"\n"))
json.loads(first)
if __name__ == "__main__":
unittest.main()
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from vpn_egressctl.errors import ApplyError, UnsupportedVersionError, ValidationError
from vpn_egressctl.renderer_1_13_19 import render_bytes
from vpn_egressctl.transaction import Controller
from vpn_egressctl.uri import parse_hysteria2_uri
from tests.helpers import FakeResponse, FakeRunner, VERSION_OUTPUT, make_policy
OLD_CONFIG = b'{"log":{"level":"error"}}\n'
URI_OLD = "hysteria2://OLD@example.com:443/?obfs=salamander&obfs-password=OLDOBFS"
URI_NEW = "hysteria2://NEW@example.com:443/?obfs=salamander&obfs-password=NEWOBFS"
class TransactionTests(unittest.TestCase):
def make(self, directory: str, *, runner: FakeRunner | None = None) -> tuple[Controller, FakeRunner, object]:
root = Path(directory)
policy = make_policy(root)
Path(policy.sing_box.config_path).parent.mkdir(parents=True)
Path(policy.runtime.uri_path).write_text(URI_NEW + "\n", encoding="utf-8")
selected = runner or FakeRunner()
controller = Controller(
policy,
runner=selected,
urlopen=lambda *args, **kwargs: FakeResponse(),
sleeper=lambda _: None,
)
return controller, selected, policy
def test_sync_applies_and_records_last_good(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
changed = controller.sync()
self.assertTrue(changed)
installed = Path(policy.sing_box.config_path).read_bytes()
self.assertIn(b'"password": "NEW"', installed)
self.assertEqual(controller.last_good_path.read_bytes(), OLD_CONFIG)
state_text = controller.state_path.read_text(encoding="utf-8")
self.assertNotIn("NEWOBFS", state_text)
self.assertNotIn('"NEW"', state_text)
self.assertEqual(json.loads(state_text)["status"], "ok")
self.assertTrue(any("restart" in call for call in runner.calls))
def test_no_change_does_not_restart(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, runner, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint))
self.assertFalse(controller.sync())
self.assertFalse(any("restart" in call for call in runner.calls))
self.assertFalse(json.loads(controller.state_path.read_text())["changed"])
def test_generated_config_rejection_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1))
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(ValidationError):
controller.sync()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
def test_version_gate_is_non_mutating(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = VERSION_OUTPUT.replace("1.13.19", "1.13.12", 1)
controller, _, policy = self.make(directory, runner=FakeRunner(version=output))
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(UnsupportedVersionError):
controller.sync()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
def test_restart_failure_restores_config(self) -> None:
with tempfile.TemporaryDirectory() as directory:
runner = FakeRunner(restart_results=[1, 0])
controller, _, policy = self.make(directory, runner=runner)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(ApplyError) as caught:
controller.sync()
self.assertTrue(caught.exception.rollback_ok)
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
self.assertEqual(json.loads(controller.state_path.read_text())["status"], "apply_failed_rolled_back")
def test_import_failure_restores_uri_and_config(self) -> None:
with tempfile.TemporaryDirectory() as directory:
runner = FakeRunner(restart_results=[1, 0])
controller, _, policy = self.make(directory, runner=runner)
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
with self.assertRaises(ApplyError):
controller.import_uri(URI_NEW)
self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD)
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG)
def test_import_validation_does_not_store_uri(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
with self.assertRaises(ValidationError):
controller.import_uri("hysteria2://secret@example.com?unknown=x")
self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD)
def test_manual_rollback_swaps_current_and_last_good(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
current = render_bytes(policy, parse_hysteria2_uri(URI_NEW))
previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD))
Path(policy.sing_box.config_path).write_bytes(current)
controller.state_dir.mkdir(parents=True)
controller.last_good_path.write_bytes(previous)
controller.rollback()
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous)
self.assertEqual(controller.last_good_path.read_bytes(), current)
self.assertEqual(json.loads(controller.state_path.read_text())["status"], "rolled_back")
def test_diff_never_contains_secrets(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG)
output = "\n".join(controller.diff())
self.assertNotIn("NEWOBFS", output)
self.assertNotIn('"NEW"', output)
self.assertIn("REDACTED", output)
def test_status_detects_source_drift(self) -> None:
with tempfile.TemporaryDirectory() as directory:
controller, _, policy = self.make(directory)
endpoint = parse_hysteria2_uri(URI_NEW)
Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint))
controller.sync()
Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8")
status = controller.status()
self.assertTrue(status["source_drift"])
if __name__ == "__main__":
unittest.main()
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import unittest
from vpn_egressctl.errors import ValidationError
from vpn_egressctl.uri import parse_hysteria2_uri
class UriParserTests(unittest.TestCase):
def test_minimal_uri(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@example.com/")
self.assertEqual(endpoint.server, "example.com")
self.assertEqual(endpoint.server_port, 443)
self.assertEqual(endpoint.password, "secret")
self.assertEqual(endpoint.sni, "example.com")
def test_short_scheme_and_percent_encoding(self) -> None:
endpoint = parse_hysteria2_uri("hy2://user%3Apass@EXAMPLE.com:8443?sni=t%C3%A9st.example#Moscow")
self.assertEqual(endpoint.password, "user:pass")
self.assertEqual(endpoint.server, "example.com")
self.assertEqual(endpoint.server_port, 8443)
self.assertEqual(endpoint.sni, "xn--tst-bma.example")
self.assertEqual(endpoint.display_name, "Moscow")
def test_salamander(self) -> None:
endpoint = parse_hysteria2_uri(
"hysteria2://secret@example.com:443/?insecure=1&obfs=salamander&obfs-password=o%40p"
)
self.assertTrue(endpoint.insecure)
self.assertEqual(endpoint.obfs_type, "salamander")
self.assertEqual(endpoint.obfs_password, "o@p")
def test_ipv6(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@[2001:db8::1]:444/")
self.assertEqual(endpoint.server, "2001:db8::1")
self.assertEqual(endpoint.server_port, 444)
self.assertEqual(endpoint.endpoint_label(), "[2001:db8::1]:444")
def test_multi_port(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@example.com:443,5000-6000/")
self.assertIsNone(endpoint.server_port)
self.assertEqual(endpoint.server_ports, ("443", "5000:6000"))
def test_range_only_uses_server_ports(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@example.com:5000-6000/")
self.assertIsNone(endpoint.server_port)
self.assertEqual(endpoint.server_ports, ("5000:6000",))
def test_userpass_is_preserved(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://alice%3Acorrect%20horse@example.com")
self.assertEqual(endpoint.password, "alice:correct horse")
def test_unicode_host_is_idna(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@пример.рф")
self.assertEqual(endpoint.server, "xn--e1afmkfd.xn--p1ai")
def test_repr_hides_secrets(self) -> None:
endpoint = parse_hysteria2_uri(
"hysteria2://TOPSECRET@example.com?obfs=salamander&obfs-password=OBFSSECRET"
)
text = repr(endpoint)
self.assertNotIn("TOPSECRET", text)
self.assertNotIn("OBFSSECRET", text)
def assert_invalid(self, uri: str, marker: str | None = None) -> None:
with self.assertRaises(ValidationError) as caught:
parse_hysteria2_uri(uri)
if marker:
self.assertIn(marker, str(caught.exception))
def test_rejections(self) -> None:
cases = [
("http://secret@example.com", "scheme"),
("hysteria2://example.com", "authentication"),
("hysteria2://@example.com", "authentication"),
("hysteria2://secret@", "server"),
("hysteria2://secret@example.com:0", "1..65535"),
("hysteria2://secret@example.com:65536", "1..65535"),
("hysteria2://secret@example.com:100-99", "range"),
("hysteria2://secret@example.com:100,100", "Overlapping"),
("hysteria2://secret@2001:db8::1", "brackets"),
("hysteria2://secret@example.com/path", "paths"),
("hysteria2://secret@example.com?unknown=x", "Unsupported"),
("hysteria2://secret@example.com?sni=a&sni=b", "Duplicate"),
("hysteria2://secret@example.com?insecure=true", "exactly"),
("hysteria2://secret@example.com?obfs=gecko", "1.13.19"),
("hysteria2://secret@example.com?obfs=salamander", "obfs-password"),
("hysteria2://secret@example.com?obfs-password=x", "requires"),
("hysteria2://secret@example.com?pinSHA256=x", "safely"),
("hysteria2://secret@example.com?ech=x", "safely"),
("hysteria2://sec%ZZret@example.com", "percent"),
("hysteria2://secret@example.com?sni=%FF", "UTF-8"),
]
for uri, marker in cases:
with self.subTest(uri=uri):
self.assert_invalid(uri, marker)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import subprocess
import unittest
from vpn_egressctl.errors import CommandError, UnsupportedVersionError
from vpn_egressctl.version import parse_version_output, probe_version, require_supported
from tests.helpers import FakeRunner, VERSION_OUTPUT
class VersionTests(unittest.TestCase):
def test_parse_and_require(self) -> None:
version = parse_version_output(VERSION_OUTPUT)
self.assertEqual(version.version, "1.13.19")
self.assertIn("with_quic", version.tags)
require_supported(version)
def test_other_versions_are_rejected(self) -> None:
for value in ("1.13.12", "1.13.20", "1.14.0", "1.13.19-rc.1"):
text = VERSION_OUTPUT.replace("1.13.19", value, 1)
with self.subTest(value=value), self.assertRaises(UnsupportedVersionError):
require_supported(parse_version_output(text))
def test_non_linux_rejected(self) -> None:
version = parse_version_output(VERSION_OUTPUT.replace("linux/amd64", "windows/amd64"))
with self.assertRaises(UnsupportedVersionError):
require_supported(version)
def test_missing_quic_rejected(self) -> None:
version = parse_version_output(VERSION_OUTPUT.replace("with_quic,", ""))
with self.assertRaises(UnsupportedVersionError):
require_supported(version)
def test_command_failure(self) -> None:
runner = FakeRunner()
runner.version = "garbage"
with self.assertRaises(UnsupportedVersionError):
probe_version("sing-box", runner)
if __name__ == "__main__":
unittest.main()