269 lines
20 KiB
Markdown
269 lines
20 KiB
Markdown
# Миграция AWG2 Harness с bash-системы на Rust multi-orchestrator
|
||
|
||
## 1. Исправленный scope
|
||
|
||
AWG2 Harness не должен быть просто Rust-эквивалентом пары bash-скриптов. Цель шире: сделать единый production multi-orchestrator, который сначала приводит сервер и Amnezia network namespace к нужному состоянию, а затем постоянно следит за drift и возвращает систему в desired state.
|
||
|
||
Источник поведения — папка `Amnezia limits (old bash)/`. Всё, что там реально настроено и используется в production, должно быть явно перенесено в Rust-модель: обнаружение контейнера, обнаружение интерфейса, получение peers, `tc` rules, `ifb0`, ingress redirect, per-peer HTB classes, `fq_codel`, auto-healing, статусная диагностика и логи. Новые правила, которых не было в old bash, например anti-torrent через nftables, должны быть описаны отдельно как расширения, а не смешаны с parity contract.
|
||
|
||
Сервер выделенный. На нём нет соседних сервисов, которые надо защищать от наших сетевых правил. Поэтому бинарь может и должен владеть host/network rules, необходимыми для Amnezia. Ограничение только одно: не ломать саму Amnezia как продукт и не менять её конфиги без явной задачи. Но системные правила, созданные old bash для лимитов, становятся зоной ответственности Rust-бинаря.
|
||
|
||
## 2. Production-контекст
|
||
|
||
Целевой хост:
|
||
|
||
- Debian 13 amd64.
|
||
- SSH настроен заранее владельцем сервера.
|
||
- AmneziaWG/AWG 2.0 устанавливается официальным способом до установки AWG2 Harness.
|
||
- После Amnezia доставляется один Rust-бинарь и единый конфиг.
|
||
- Пакет ставится один раз; автообновления самого пакета не проектируются.
|
||
- Бинарь запускается от root через systemd, потому что ему нужны Docker/systemd/net-admin/swap возможности.
|
||
|
||
Система должна переживать:
|
||
|
||
- reboot хоста;
|
||
- restart Docker;
|
||
- restart контейнера Amnezia;
|
||
- update контейнера Amnezia;
|
||
- изменение имени интерфейса `awg0` / `wg0` / `amnezia0` / `tun0`;
|
||
- добавление peer;
|
||
- удаление peer;
|
||
- частично применённое или повреждённое `tc`-состояние;
|
||
- частично применённое или повреждённое AWG2-owned nftables-состояние.
|
||
|
||
## 3. Что именно делает старая bash-система
|
||
|
||
Старая система состоит из нескольких файлов, и все они важны.
|
||
|
||
`limit_peers.sh` — главный apply/reconcile-скрипт. Он:
|
||
|
||
- берёт lock через `/tmp/amnezia-tc.lock`, чтобы не было параллельных применений;
|
||
- ищет запущенный контейнер `amnezia-awg2`, затем fallback `amnezia-awg`;
|
||
- проверяет интерфейс `WG_IF`, по умолчанию `wg0`, и если его нет, авто-детектит `wg*`, `awg*`, `amnezia*`, `tun*`;
|
||
- получает client IP из `wg show all allowed-ips`, fallback — regex из `wg show`;
|
||
- фильтрует текущую production-подсеть `10.8.1.x`;
|
||
- проверяет существующие `tc filter` по hex-адресам client IP;
|
||
- если состояние не совпадает, полностью удаляет старую topology и создаёт новую;
|
||
- создаёт root HTB на WG/AWG-интерфейсе: `tc qdisc add dev <iface> root handle 1: htb`;
|
||
- создаёт root class `1:1` с `rate 1000mbit ceil 1000mbit`;
|
||
- загружает/создаёт `ifb0`;
|
||
- добавляет ingress qdisc `ffff:` на WG/AWG-интерфейс;
|
||
- добавляет ingress redirect через `mirred egress redirect dev ifb0`;
|
||
- создаёт root HTB на `ifb0`: `handle 2:`;
|
||
- создаёт root class `2:1` с `rate 1000mbit ceil 1000mbit`;
|
||
- для каждого peer создаёт egress class `1:<class_id>` с `rate LIMIT ceil LIMIT`;
|
||
- для каждого peer создаёт `fq_codel` под egress class;
|
||
- для каждого peer добавляет egress filter `match ip dst <peer_ip> flowid 1:<class_id>`;
|
||
- для каждого peer создаёт ingress class `2:<class_id>` с тем же лимитом;
|
||
- для каждого peer создаёт `fq_codel` под ingress class;
|
||
- для каждого peer добавляет ingress filter на `ifb0`: `match ip src <peer_ip> flowid 2:<class_id>`;
|
||
- начинает `class_id` с 10;
|
||
- пишет события в `/var/log/amnezia-tc.log`.
|
||
|
||
`docker_watcher.sh` слушает `docker events --filter event=start`. При старте `amnezia-awg2` или `amnezia-awg` он ждёт появления `awg0` до 20 секунд и запускает `limit_peers.sh`. В Rust это нужно исправить: watcher не должен ждать только `awg0`, он обязан использовать общий detector интерфейса.
|
||
|
||
`check-vpn-limits` — CLI-диагностика. Он показывает systemd статус, контейнер, peers из `wg show`, классы с лимитом 70 Mbit/s, filter stats и последние строки `/var/log/amnezia-tc.log`. Rust `status` должен заменить этот отчёт, но проверять шире: оба направления, `ifb0`, ingress redirect и соответствие desired state.
|
||
|
||
`amnezia-tc.service` — oneshot apply после Docker/network-online.
|
||
|
||
`amnezia-tc-watcher.service` — постоянно запущенный watcher Docker events.
|
||
|
||
`traffic_control-was.txt`, `wg-peers.txt`, `iproute2_a_core.txt`, `permissions.txt`, `Нюансы.txt` — production-снимки. Они фиксируют реальные условия: Debian 13 cloud kernel, Docker 26.1.5, iproute2 6.15.0, интерфейс `awg0`, 23 peer IP в `10.8.1.0/24`, HTB classes `1:10..1:32`, `fq_codel` leaves и enabled systemd units.
|
||
|
||
## 4. Desired state Rust-бинаря
|
||
|
||
Rust-бинарь должен иметь explicit desired state model. Нельзя ограничиться последовательным запуском команд “как в bash”. Нужно уметь сравнить expected и actual, а затем принять решение: no-op, incremental fix или full rebuild.
|
||
|
||
Базовый desired state для traffic limits:
|
||
|
||
```text
|
||
container: first running from configured names
|
||
netns: namespace of selected container
|
||
interface: detected AWG/WG interface inside that netns
|
||
peers: IPv4 /32 allowed IPs inside configured peer CIDR
|
||
root egress qdisc: <iface> root handle 1: htb
|
||
root egress class: 1:1 rate root_rate ceil root_rate
|
||
per-peer egress class: 1:<id> rate peer_limit ceil peer_limit
|
||
per-peer egress qdisc: fq_codel under 1:<id>
|
||
per-peer egress filter: dst peer_ip -> 1:<id>
|
||
ingress qdisc: <iface> handle ffff:
|
||
ingress redirect: <iface> ingress -> ifb0
|
||
ifb device: ifb0 up in the same namespace where shaping is applied
|
||
root ingress qdisc: ifb0 root handle 2: htb
|
||
root ingress class: 2:1 rate root_rate ceil root_rate
|
||
per-peer ingress class: 2:<id> rate peer_limit ceil peer_limit
|
||
per-peer ingress qdisc: fq_codel under 2:<id>
|
||
per-peer ingress filter: src peer_ip -> 2:<id>
|
||
```
|
||
|
||
Любое отклонение от этого состояния считается drift. Если drift простой, можно исправить точечно. Для v1 допустим и предпочтителен full rebuild всей AWG traffic topology, потому что он проще, предсказуемее и повторяет текущую production-модель.
|
||
|
||
## 5. Модель управления host/network state
|
||
|
||
Исправление к предыдущей версии документации: бинарь не должен рассматриваться как осторожная внешняя обёртка, которая только вызывает `docker exec` и не владеет правилами. Правильная модель — host-level orchestrator with namespace control.
|
||
|
||
Практический порядок для v1:
|
||
|
||
1. На хосте определить контейнер через Docker API или `docker inspect`.
|
||
2. Получить PID контейнера и путь к его network namespace: `/proc/<pid>/ns/net`.
|
||
3. Все операции `wg`, `ip`, `tc`, `ifb` выполнять в правильном namespace.
|
||
4. Для реализации можно выбрать один из двух путей:
|
||
- через `nsenter -t <pid> -n ...` / `ip netns exec`-эквивалент и системные команды;
|
||
- через netlink/native Rust libraries после входа в namespace.
|
||
5. `docker exec` допустим только как fallback или transitional adapter, но не как архитектурный центр. Причина: это делает нас зависимыми от shell/tools внутри контейнера и хуже соответствует модели “наш бинарь управляет состоянием сервера”.
|
||
|
||
Для первой production-версии нормальный компромисс: Rust-бинарь владеет orchestration, locking, state comparison, retry, status и lifecycle, а низкоуровневые `tc/ip/wg` операции выполняет через команды в namespace. Нативный netlink можно вводить после стабилизации, если это даст реальный выигрыш по надёжности или нагрузке.
|
||
|
||
## 6. Обязательные функции Rust-версии
|
||
|
||
Минимум для parity со старой системой:
|
||
|
||
1. `daemon` — основной systemd-процесс.
|
||
2. `apply-limits` — ручное применение/reconcile traffic limits.
|
||
3. `status` — диагностика уровня `check-vpn-limits`, но с проверкой обоих направлений и drift.
|
||
4. `validate-config` — проверка конфига без изменения системы.
|
||
5. `apply-firewall` — ручной reconcile AWG2-owned nftables state, если anti-torrent включён.
|
||
6. `ensure-swap` — идемпотентный setup swap-файла 2 ГБ.
|
||
7. Встроенный reboot scheduler в `traffic_core` (единый production path, без отдельного runtime-модуля).
|
||
8. Locking, чтобы одновременно не шли два apply/reconcile.
|
||
9. Docker event watcher.
|
||
10. Периодический reconciliation loop, потому что Docker events могут быть пропущены.
|
||
11. Start-up reconciliation: при запуске daemon сразу проверить и применить лимиты и AWG2-owned firewall state.
|
||
12. Graceful behavior, если контейнер временно отсутствует: логировать и продолжать loop, а не падать насмерть.
|
||
|
||
## 7. Подкоманды
|
||
|
||
```bash
|
||
awg2-harness daemon
|
||
awg2-harness apply-limits
|
||
awg2-harness apply-firewall
|
||
awg2-harness status
|
||
awg2-harness ensure-swap
|
||
awg2-harness validate-config
|
||
```
|
||
|
||
`daemon` — основной production-режим. Он делает initial reconcile, слушает Docker events, периодически проверяет state, интегрирует reboot scheduler и пишет runtime-логи в journald.
|
||
|
||
`apply-limits` — немедленный reconcile traffic limits. Команда должна быть безопасной для повторного запуска.
|
||
|
||
`apply-firewall` — немедленный reconcile nftables anti-torrent rules. Команда должна менять только AWG2-owned table/chain.
|
||
|
||
`status` — отчёт о текущем состоянии: контейнер, PID/netns, интерфейс, peers, egress topology, ingress/ifb topology, drift summary, последние ошибки.
|
||
|
||
`ensure-swap` — setup-задача, не постоянный loop.
|
||
|
||
`validate-config` — синтаксическая и семантическая проверка.
|
||
|
||
## 8. Что нужно исправить относительно bash
|
||
|
||
Bash работает, но в Rust нужно убрать известные слабые места.
|
||
|
||
Первое: единый detector. Сейчас `limit_peers.sh` умеет авто-детектить интерфейс, а `docker_watcher.sh` ждёт только `awg0`. В Rust detector должен быть один и использоваться везде.
|
||
|
||
Второе: проверка полного state. Старый skip decision в основном смотрит фильтры на WG-интерфейсе. Rust должен проверять также `ifb0`, ingress redirect, classes на `ifb0`, qdisc leaves и соответствие class_id ↔ peer_ip.
|
||
|
||
Третье: парсинг peers. Текущий regex жёстко ищет `10.8.1.x`. В Rust подсеть должна быть конфигурируемой, а парсинг `wg show all allowed-ips` — структурным.
|
||
|
||
Четвёртое: наблюдаемость. Status должен отличать “container отсутствует”, “interface отсутствует”, “wg не читается”, “tc отсутствует”, “rules partial”, “rules match desired”.
|
||
|
||
Пятое: namespace ownership. Не надо полагаться на shell внутри контейнера как на единственный способ управления. Бинарь должен уметь работать от host authority и заходить в namespace контейнера.
|
||
|
||
## 9. Единый конфиг
|
||
|
||
Конфиг должен жить в `/etc/awg2-harness/config.toml` и содержать только параметры, которые реально меняются оператором.
|
||
|
||
```toml
|
||
[amnezia]
|
||
container_names = ["amnezia-awg2", "amnezia-awg"]
|
||
interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"]
|
||
interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"]
|
||
peer_ipv4_cidr = "10.8.1.0/24"
|
||
|
||
[traffic]
|
||
enabled = true
|
||
peer_limit = "70mbit"
|
||
root_rate = "1000mbit"
|
||
reconcile_interval_secs = 15
|
||
container_ready_timeout_secs = 120
|
||
class_start_id = 10
|
||
ifb_name = "ifb0"
|
||
|
||
[anti_torrent]
|
||
enabled = true
|
||
max_connections_per_peer = 100
|
||
peer_ipv4_cidr = "10.8.1.0/24"
|
||
apply_in_amnezia_netns = true
|
||
|
||
[reboot]
|
||
enabled = true
|
||
window_start_utc = "01:00"
|
||
window_end_utc = "02:00"
|
||
|
||
[swap]
|
||
enabled = true
|
||
path = "/swapfile"
|
||
size_mb = 2048
|
||
priority = -2
|
||
swappiness = 1
|
||
```
|
||
|
||
`ifb_name` можно оставить в конфиге, потому что это имя системного интерфейса, которым владеет orchestrator. Handles `1:`, `2:`, `ffff:` лучше не выносить в конфиг: это implementation contract AWG traffic module, а не эксплуатационная настройка.
|
||
|
||
## 10. Anti-torrent extension
|
||
|
||
Anti-torrent не входит в старую bash-систему, но хорошо ложится в целевую модель multi-orchestrator. Бинарь уже владеет нужным network namespace и должен уметь применять, проверять и восстанавливать nftables state так же, как он восстанавливает `tc` topology.
|
||
|
||
Правильная v1-модель — не protocol detection и не strike-система, а простой per-peer connection-count guard через nftables `ct count`. Правило должно быть scoped к VPN peer CIDR и keyed by source peer IP через `meter`, иначе можно случайно получить общий лимит на всю подсеть.
|
||
|
||
Правило должно жить в отдельной AWG2-owned таблице, например `table inet awg2_harness`, и применяться в Amnezia network namespace. Нельзя делать `flush ruleset`; удалять можно только owned table/chain.
|
||
|
||
См. `docs/ANTI_TORRENT.md`.
|
||
|
||
## 11. Systemd-модель
|
||
|
||
Вместо двух старых units нужен один daemon-unit:
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=AWG2 Harness multi-orchestrator
|
||
After=network-online.target docker.service
|
||
Wants=network-online.target
|
||
Requires=docker.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=root
|
||
ExecStart=/usr/local/bin/awg2-harness daemon --config /etc/awg2-harness/config.toml
|
||
Restart=always
|
||
RestartSec=5
|
||
StandardOutput=journal
|
||
StandardError=journal
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
Сервис должен запускаться от root. На выделенном сервере не нужно искусственно ограничивать его так, чтобы он не мог управлять сетью. Hardening можно добавлять после реальной проверки, но нельзя включать `PrivateNetwork`, `PrivateDevices` или слишком жёсткий `CapabilityBoundingSet`, если они ломают `tc`, `ifb`, Docker или reboot.
|
||
|
||
## 12. Reboot и swap
|
||
|
||
Логика reboot работает только через встроенный scheduler `traffic_core`. Окно перезагрузки задаётся в конфиге, включая корректные окна через полночь. Механика `systemctl --no-block reboot` сохраняется.
|
||
|
||
`ensure-swap` должен создать 2 ГБ swap-файл с правами `0600`, добавить одну запись в `/etc/fstab`, включить swap и выставить низкую склонность к использованию (`vm.swappiness = 1`, низкий priority). Если на сервере уже есть несовместимый swap, команда должна сообщить об этом явно, а не разрушать чужое состояние без подтверждения.
|
||
|
||
## 13. Acceptance критерий миграции
|
||
|
||
Миграция считается корректной только если Rust-бинарь на реальном Debian 13 хосте:
|
||
|
||
- создаёт ту же traffic-control topology, что старая bash-система;
|
||
- ограничивает каждого peer в обе стороны на `traffic.peer_limit`;
|
||
- после повторного запуска не создаёт дубли и не ломает state;
|
||
- после restart контейнера сам восстанавливает rules;
|
||
- после restart Docker сам восстанавливает rules;
|
||
- после reboot хоста сам восстанавливает rules;
|
||
- после добавления peer создаёт новый class/filter;
|
||
- после удаления peer убирает устаревший class/filter через rebuild;
|
||
- `status` показывает не только “есть 70Mbit classes”, а полную картину desired vs actual;
|
||
- swap и reboot работают как части одного пакета;
|
||
- anti-torrent, если включён, применяет per-peer flow limit и не затрагивает unrelated nftables rules.
|