feat: add declarative sing-box egress control plane

This commit is contained in:
2026-08-27 00:58:52 +05:00
commit b8d19b2c3e
54 changed files with 3337 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Архитектура
## Границы ответственности
Hysteria2 URI содержит только переносимые реквизиты VPN:
- authentication;
- hostname и port/port ranges;
- SNI и `insecure`;
- `salamander` и obfs password.
`policy.json` содержит локальную инфраструктурную политику:
- `eth0` — upstream;
- `eth1` — VPN LAN;
- `tun-sb0`, `172.19.0.1/30`, MTU 1400;
- TUN routes, nftables marks и rule/table indexes;
- bootstrap DNS и DoH через `hy2-out`;
- bandwidth hints и healthcheck.
Renderer не патчит существующий JSON. Полная конфигурация каждый раз строится
из typed model. Это устраняет config drift и исторические endpoint `/32`.
## Поток применения
```text
URI + policy
├─ strict parsing / schema validation
├─ exact sing-box version and build-tag gate
├─ deterministic render
├─ secure candidate in /etc/sing-box
└─ sing-box check -c candidate
redacted comparison
unchanged ─────── changed
│ │
▼ ├─ last-good backup
no restart ├─ atomic replace
├─ systemctl restart
└─ bounded healthcheck
fail ┴ success
│ │
▼ ▼
rollback state.json
```
Все операции изменения сериализованы `flock`-совместимой блокировкой. Поэтому
ручной `import` и запоздалый event от systemd.path не могут применять два
candidate одновременно.
## Почему endpoint IP не нужен
Outbound сохраняет DNS hostname и содержит `bind_interface=eth0`. В 1.13.19
`route.auto_detect_interface` не применяется к outbound с явным
`bind_interface`. Bootstrap DNS также привязан к `eth0`. Endpoint IP поэтому не
является частью TUN policy.
После смены A-записи следующая Hysteria2-сессия разрешает имя заново через
`bootstrap-dns`. `doctor` дополнительно проверяет, что текущие A-записи не
попали в exclusions.
## Файловая модель
```text
/etc/vpn-egress/
├── policy.json root:root 0600
└── hysteria2.uri root:root 0600
/var/lib/vpn-egress/
├── state.json без секретов, 0600
├── last-good.json содержит secrets, 0600
└── backups/ ограниченная история, 0700/0600
/etc/sing-box/
└── config.json root:root 0600
```
Service sing-box уже имеет `CAP_DAC_READ_SEARCH`, поэтому сохраняется текущая
рабочая модель `root:root 0600`.
Guard читает имена интерфейсов из того же policy и устанавливает всю nftables
таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет
систему с частично заменённой таблицей.
## Версионная граница
В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является
пустой заготовкой: он появится только в отдельной миграции после аудита схемы,
маршрутизации и полных интеграционных тестов.
+70
View File
@@ -0,0 +1,70 @@
# Конфигурация
## Policy schema version 1
Production-образец находится в `config/policy.json`. Неизвестные и отсутствующие
ключи являются ошибкой.
### `sing_box`
- `binary`: абсолютный путь к бинарнику;
- `config_path`: production JSON;
- `service`: systemd service;
- `required_version`: допускается только `1.13.19`.
### `runtime`
- `uri_path`: desired-state secret;
- `state_dir`: state и backups;
- `lock_path`: межпроцессная блокировка;
- `backup_keep`: число timestamped backups, от 1 до 100.
### `network`
Здесь явно фиксируются интерфейсы, TUN CIDR, MTU, exclusions, таблица 2022,
начальный rule index 9000, marks `0x2023`/`0x2024`/`0x2025`, NFQUEUE 100 и
fallback rule 32768.
Публичные адреса не должны добавляться в `route_exclude_address`. Локальные
подсети `10.20.0.0/24`, `10.30.0.0/24` и loopback сохраняются.
### `dns`
Текущий контракт:
- UDP bootstrap `1.1.1.1:53`, bind `eth0`;
- DoH `1.1.1.1:443/dns-query`, SNI `cloudflare-dns.com`;
- DoH detour `hy2-out`;
- только `ipv4_only`.
### `healthcheck`
По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с
маркером `ip=`. Этот запрос идёт после запуска sing-box и подтверждает не только
состояние systemd, но и рабочий data plane.
`url: null` оставляет только проверку `systemctl is-active`. Это допустимо для
изолированного стенда, но слабее production-проверки.
## Поддерживаемая часть Hysteria2 URI
Поддерживаются:
- `hysteria2://` и `hy2://`;
- percent-encoded auth, включая `username:password`;
- DNS, IPv4, bracketed IPv6 и IDNA;
- port 443 по умолчанию;
- одиночный port и официальный multi-port/ranges;
- `sni`, `insecure=0|1`;
- `obfs=salamander&obfs-password=...`;
- fragment как необязательное display name.
Отклоняются `gecko`, `pinSHA256`, `ech`, client modes, неизвестные и
повторяющиеся параметры. Причина для `pinSHA256`: Hysteria URI и sing-box
1.13.19 используют разные виды certificate hash. `ech` будет добавлен только
после доказанного преобразования формата config list.
## Bandwidth
`up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не
принимаются из URI.
+122
View File
@@ -0,0 +1,122 @@
# Установка и миграция
Инструкция рассчитана на Debian 13 внутри `vpn-egress-gw`.
## 1. Резервная копия
До установки сохранить:
```bash
install -d -m 0700 /root/vpn-egress-migration
cp -a /etc/sing-box/config.json /root/vpn-egress-migration/config.json.before
cp -a /root/render-singbox-hy2.sh /root/vpn-egress-migration/
cp -a /usr/local/sbin/vpn-egress-guard.sh /root/vpn-egress-migration/
nft list ruleset > /root/vpn-egress-migration/nft.before.rules
ip -4 rule show > /root/vpn-egress-migration/ip-rule.before.txt
ip -4 route show table all > /root/vpn-egress-migration/ip-route.before.txt
```
## 2. Обновление sing-box
Пакет имеет строгую зависимость `sing-box (= 1.13.19)`.
```bash
apt-get update
apt-get install sing-box=1.13.19
sing-box version
```
Если репозиторий SagerNet ещё не публикует 1.13.19, миграцию не продолжать и не
обходить dependency/version gate.
## 3. Установка пакета
```bash
dpkg -i vpn-egressctl_0.1.0_all.deb
install -m 0600 -o root -g root \
/usr/share/vpn-egressctl/policy.json \
/etc/vpn-egress/policy.json
systemctl daemon-reload
```
Проверить локальные значения policy до первого применения.
## 4. Guard до VPN
Старый unit находится в `/etc/systemd/system` и перекрывает package unit из
`/usr/lib`. Сначала обратимо убрать старое определение:
```bash
systemctl stop vpn-egress-guard.service
mv /etc/systemd/system/vpn-egress-guard.service \
/root/vpn-egress-migration/vpn-egress-guard.service.disabled
systemctl daemon-reload
systemctl enable --now vpn-egress-guard.service
systemctl status vpn-egress-guard.service --no-pager
nft list table inet vpn_egress_guard
```
Новый unit имеет `Before=sing-box.service`; это закрывает boot window, который
существовал у старого `After=sing-box.service`.
## 5. Dry run и первый import
Для первого `check` URI ещё должен существовать. Создать файл без попадания
secret в argv:
```bash
install -m 0600 -o root -g root /dev/null /etc/vpn-egress/hysteria2.uri
read -r -s URI
printf '%s\n' "$URI" > /etc/vpn-egress/hysteria2.uri
unset URI
vpn-egressctl check
vpn-egressctl diff
vpn-egressctl sync
```
Более простой вариант для интерактивного применения:
```bash
vpn-egressctl import --stdin
```
Команда сама запросит URI без echo, выполнит check/apply/healthcheck и при
неуспехе восстановит старые URI и config.
## 6. Включение watcher
```bash
systemctl enable --now vpn-egress-sync.path
vpn-egressctl doctor
```
## 7. Вывод старого renderer из эксплуатации
После успешного canary и rollback-теста:
```bash
mv /root/render-singbox-hy2.sh \
/root/vpn-egress-migration/render-singbox-hy2.sh.disabled
```
Удалять старый файл в день миграции не нужно: перемещение остаётся обратимым.
Старый `/usr/local/sbin/vpn-egress-guard.sh` можно архивировать после проверки,
что package-managed unit использует `vpn-egressctl guard-apply`.
## 8. Acceptance
Обязательные проверки:
```bash
vpn-egressctl status
vpn-egressctl doctor
sing-box check -c /etc/sing-box/config.json
ip -4 rule show
ip -4 route show table 2022
nft list table inet vpn_egress_guard
nft list table inet sing-box
```
В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни
текущего `85.208.119.160`.
+76
View File
@@ -0,0 +1,76 @@
# Эксплуатация
## Ротация credential
Интерактивно:
```bash
sudo vpn-egressctl import --stdin
```
Из защищённого файла:
```bash
sudo vpn-egressctl import --file /run/credentials/new-hysteria2.uri
```
Файл должен иметь режим `0600`. Не используйте positional argument, environment
variable, shell history или URL в тикете/логе.
## Изменение policy
```bash
install -m 0600 new-policy.json /etc/vpn-egress/policy.json
vpn-egressctl check
vpn-egressctl diff
vpn-egressctl sync
```
Path unit следит только за URI. Policy применяется явным `sync`, чтобы случайное
редактирование инфраструктурных параметров не вызвало неожиданный restart.
## Состояние
```bash
vpn-egressctl status
vpn-egressctl status --json
```
State содержит только hashes, endpoint без auth, версию, время и результат.
## Rollback
```bash
vpn-egressctl rollback
```
Команда проверяет last-good реальным sing-box, атомарно меняет конфиги местами,
перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает
конфигурацию, которая была активна до первого rollback.
После ручного rollback desired URI остаётся прежним, поэтому status показывает
drift. Перед включением нового `sync` нужно либо исправить URI, либо осознанно
вернуть desired state.
## Реакция systemd.path
`PathChanged` запускает oneshot `vpn-egress-sync.service`. Одновременный ручной
import сериализуется lock-файлом. Если новый URI некорректен, production config
не меняется, а unit завершается с ошибкой, видимой в journal.
```bash
journalctl -u vpn-egress-sync.service -n 100 --no-pager
systemctl reset-failed vpn-egress-sync.service
```
## Плановое обновление
Обновление policy или пакета внутри 1.13.19:
1. `vpn-egressctl check`;
2. `vpn-egressctl diff`;
3. backup артефакта пакета;
4. обновление;
5. `vpn-egressctl sync`;
6. `vpn-egressctl doctor`;
7. контролируемый rollback-тест на staging.
+54
View File
@@ -0,0 +1,54 @@
# Безопасность
## Секреты
Секретами считаются Hysteria authentication, obfs password, URI целиком,
generated config и все backups.
- CLI не принимает URI в argv.
- Dataclass скрывает credentials из `repr()`.
- Ошибки parser не включают исходное значение.
- diff заменяет secret values на `<REDACTED>`.
- state хранит только SHA-256 source/config и безопасный endpoint label.
- URI/config/backups имеют `0600`, каталоги — `0700`.
- subprocess вызывается массивом аргументов без shell.
После попадания действующего URI в чат, issue, shell history или journal оба
credential следует перевыпустить.
## Fail closed
До изменения production проверяются:
- policy schema;
- URI syntax и поддерживаемые параметры;
- точная версия и `with_quic`;
- deterministic candidate;
- `sing-box check`.
Неизвестные параметры никогда не игнорируются. Production bypass для другой
версии отсутствует.
## Anti-leak
Отдельная таблица `inet vpn_egress_guard` отклоняет forwarding с `eth1` напрямую
на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической
таблицы `inet sing-box`.
Guard запускается до sing-box. Остановка или удаление guard unit является
security-sensitive операцией и не выполняется CLI автоматически.
Имена интерфейсов поступают из уже провалидированного policy, subprocess не
использует shell, а замена таблицы выполняется одной nft batch-транзакцией.
## Healthcheck
HTTPS healthcheck подтверждает data plane, но раскрывает проверочному endpoint
факт обращения с VPN egress IP. URL можно заменить внутренним контролируемым
endpoint. Отключение URL ослабляет проверку до состояния systemd.
## Ограничения URI 1.13.19
`pinSHA256` не преобразуется в `certificate_public_key_sha256`, потому что это
разные fingerprint semantics. `ech` не преобразуется без проверенного PEM/config
list adapter. Silent downgrade TLS запрещён.
+15
View File
@@ -0,0 +1,15 @@
# Почему sing-box 1.14 не поддерживается
Ветка 1.14 меняет конфигурационный контракт TUN, TLS/QUIC и Hysteria2. Проект не
пытается угадать совместимость и не содержит `--allow-unsupported`.
Отдельная миграция на 1.14 потребует:
- аудита release notes и tagged schema;
- нового renderer с отдельными golden fixtures;
- проверки удалённых/deprecated полей;
- повторного TUN/nftables/strict-route canary;
- тестов Hysteria2 reconnect, DNS и rollback;
- отдельного package release и runbook.
До завершения этой работы любой 1.14.x отклоняется до записи файлов.
+42
View File
@@ -0,0 +1,42 @@
# Тестирование
## Уровни
1. Unit: URI, policy, version, renderer, redaction.
2. Failure injection: candidate rejection, restart failure, URI/config rollback,
idempotency и manual rollback swap.
3. Real binary: `sing-box 1.13.19 check` на Linux и schema/format validation на Windows.
4. Privileged Linux: TUN, nftables, systemd и HTTP data-plane healthcheck.
5. Canary: DNS A change без endpoint CIDR и credential rotation.
6. Reboot: guard ordering, persisted config и path watcher.
## Unit tests
```bash
make check
```
Тест с реальным бинарником включается явно:
```bash
SING_BOX_1_13_19=/usr/bin/sing-box make test
```
Он предварительно проверяет exact version и `with_quic`. Windows-бинарник не
может создать Linux auto-redirect и поэтому выполняет `format` полной схемы;
обязательный `check` остаётся в Linux CI/Incus.
## Privileged acceptance
В disposable Incus-контейнере с двумя NIC:
1. установить sing-box 1.13.19 и пакет;
2. применить test URI;
3. проверить таблицу 2022 и marks;
4. подтвердить, что `eth1 -> eth0` отклоняется без sing-box;
5. подтвердить TCP/UDP/DNS через TUN при рабочем HY2;
6. сломать credential и проверить автоматический rollback;
7. изменить A-запись endpoint, не меняя URI/config;
8. перезагрузить контейнер и повторить doctor.
Production A-запись не используется для эксперимента: нужен staging hostname.
+61
View File
@@ -0,0 +1,61 @@
# Диагностика
Начинать с:
```bash
vpn-egressctl doctor
vpn-egressctl status --json
```
## Unsupported sing-box version
Установлена не `1.13.19`. Конфигурация не изменялась. Проверить:
```bash
sing-box version
apt-cache policy sing-box
```
Не использовать ручной обход version gate.
## sing-box rejected generated configuration
Candidate удалён, production не менялся. Проверить package version, policy и
логи oneshot. Секреты в отчёт не прикладывать.
## Apply failed, previous configuration restored
Автоматический rollback успешен. Проверить:
```bash
systemctl status sing-box --no-pager
journalctl -u sing-box -u vpn-egress-sync.service -n 200 --no-pager
vpn-egressctl doctor
```
## Critical rollback failed
Не выполнять новый sync. Использовать `/var/lib/vpn-egress/last-good.json` или
timestamped backup из консоли контейнера, затем `sing-box check` и restart.
## Endpoint exclusion
Если doctor сообщает, что A-запись endpoint находится в TUN exclusions, удалить
публичный CIDR из policy и выполнить `check`, `diff`, `sync`. Не заменять его на
новый IP.
## Healthcheck request failed
Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно
ставить `url: null` на production нельзя без отдельного решения: это скрывает
неработающий data plane.
## Watcher failed
Некорректный файл URI не меняет рабочий config. Исправить его безопасным
`vpn-egressctl import --stdin`, затем:
```bash
systemctl reset-failed vpn-egress-sync.service
systemctl status vpn-egress-sync.path --no-pager
```