feat: add declarative sing-box egress control plane
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
+16
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -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
|
||||
Vendored
+14
@@ -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
@@ -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
|
||||
Vendored
+4
@@ -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
|
||||
+14
@@ -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
|
||||
+7
@@ -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
|
||||
+9
@@ -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
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@ --with python3 --buildsystem=pybuild
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --no-start --no-enable
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
@@ -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 не является
|
||||
пустой заготовкой: он появится только в отдельной миграции после аудита схемы,
|
||||
маршрутизации и полных интеграционных тестов.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 запрещён.
|
||||
@@ -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 отклоняется до записи файлов.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Declarative control plane for the sing-box VPN egress gateway."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)}"
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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, "", "")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user