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