feat: add declarative sing-box egress control plane
This commit is contained in:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user