766 lines
74 KiB
Markdown
766 lines
74 KiB
Markdown
# Спецификация реализации AWG2 Harness
|
||
|
||
Документ описывает архитектуру и контракт reconciliation. Он отражает фактическое состояние кода, а не только замысел: расхождения между этим документом и `traffic_core/src` считаются дефектом документа.
|
||
|
||
## 1. Архитектурная роль
|
||
|
||
Production-пакет AWG2 Harness содержит два Rust-бинаря:
|
||
|
||
- `awg2-harness` — host-side оркестратор traffic/firewall/status/swap;
|
||
- `awg2-reboot-scheduler` — отдельный сервис планировщика перезагрузки.
|
||
|
||
`awg2-harness` остаётся основным host-side мульти-оркестратором для выделенного сервера Debian 13 amd64. Основной upstream target релиза 0.3.1 — self-hosted AmneziaWG 3.1; AWG 3.0/2.x сохраняются как regression compatibility. Версии и source contract зафиксированы в [`UPSTREAM_COMPATIBILITY.md`](UPSTREAM_COMPATIBILITY.md).
|
||
|
||
Он владеет инфраструктурным состоянием, которое нужно VPN-сервису:
|
||
|
||
- обнаружение контейнера Amnezia;
|
||
- обнаружение network namespace контейнера;
|
||
- обнаружение интерфейса AWG/WG;
|
||
- обнаружение peers;
|
||
- целевое состояние traffic control;
|
||
- ingress shaping через IFB;
|
||
- nftables connection-count guard (anti-torrent);
|
||
- восстановление после Docker lifecycle events;
|
||
- периодический reconcile;
|
||
- status и диагностика;
|
||
- плановая перезагрузка;
|
||
- аварийная настройка swap.
|
||
|
||
Он не заменяет Amnezia. Он управляет окружающим host-side состоянием, которое нужно сервису Amnezia.
|
||
|
||
## 2. Модель процессов
|
||
|
||
Работают два systemd-сервиса:
|
||
|
||
```bash
|
||
/usr/sbin/awg2-harness --config /etc/awg2-harness/config.toml daemon
|
||
/usr/sbin/awg2-reboot-scheduler --config /etc/awg2-harness/config.toml
|
||
```
|
||
|
||
Демон `awg2-harness` стартует от root и работает постоянно. На старте выполняет начальный reconcile. В runtime реагирует на Docker events и параллельно крутит периодический цикл reconcile.
|
||
|
||
Право планировать перезагрузку принадлежит сервису `awg2-reboot-scheduler`. `awg2-harness` не планирует перезагрузки внутри себя.
|
||
|
||
### Конфигурация читается один раз
|
||
|
||
Оба процесса загружают `/etc/awg2-harness/config.toml` при старте и дальше работают с этой копией. Правка файла **не применяется автоматически**. Реализовывать hot reload сейчас намеренно не планируется: это потребовало бы повторной валидации, атомарной подмены текущего конфига, перепланирования reboot, смены контейнера и интерфейса на лету и отката при неверной конфигурации.
|
||
|
||
Порядок применения изменений:
|
||
|
||
```bash
|
||
awg2-harness --config /etc/awg2-harness/config.toml validate-config
|
||
awg2-harness --config /etc/awg2-harness/config.toml validate-runtime
|
||
systemctl restart awg2-harness.service
|
||
```
|
||
|
||
При изменении секции `[reboot]` дополнительно:
|
||
|
||
```bash
|
||
systemctl restart awg2-reboot-scheduler.service
|
||
```
|
||
|
||
`systemctl daemon-reload` для правки TOML не нужен — он относится к unit-файлам, а не к конфигу приложения. `ExecReload` в units намеренно отсутствует: его добавление без реальной обработки SIGHUP создало бы ложное впечатление, что перезагрузка конфига поддерживается.
|
||
|
||
### Политика systemd
|
||
|
||
Обе службы используют `Type=exec`, а не `Type=simple`: старт считается успешным только после фактического запуска бинаря, поэтому отсутствующий или неисполняемый файл роняет unit, а не рапортует об успешном старте.
|
||
|
||
`Restart=on-failure` для обеих. Для планировщика это обязательно: он штатно завершается с кодом 0 в двух случаях — при `reboot.enabled = false` и после того, как systemd принял запрос на перезагрузку. С `Restart=always` он перезапускался бы в цикле на хосте с отключёнными перезагрузками.
|
||
|
||
Задержка перезапуска ступенчатая: `RestartSec=10s`, `RestartSteps=4`, `RestartMaxDelaySec=160s`, то есть 10 → 20 → 40 → 80 → 160 секунд. Плотный цикл перезапусков против ещё поднимающейся зависимости (Docker, контейнер, сеть) только сжигает лимит стартов до того, как зависимость успеет восстановиться. Окно лимита расширено до `StartLimitIntervalSec=15min` при `StartLimitBurst=10`, иначе полная последовательность backoff не укладывалась бы в него.
|
||
|
||
`ExecStartPre` с `ensure-swap` удалён. Демон выполняет swap первым шагом собственной startup-последовательности, где действуют единый error contract, единая блокировка и единый поток логов. Дублирование давало две блокировки, два чтения `/proc/swaps` и раздвоенный путь диагностики отказа. Отдельная CLI-команда `ensure-swap` при этом остаётся доступной оператору.
|
||
|
||
Подкоманды:
|
||
|
||
```bash
|
||
awg2-harness daemon
|
||
awg2-harness apply-limits
|
||
awg2-harness apply-network-tuning
|
||
awg2-harness inspect-network-tuning
|
||
awg2-harness apply-firewall
|
||
awg2-harness status
|
||
awg2-harness ensure-swap
|
||
awg2-harness validate-config
|
||
awg2-harness validate-runtime
|
||
```
|
||
|
||
Глобальный флаг `--config`; если не задан, используется `/etc/awg2-harness/config.toml`.
|
||
|
||
Уровень валидации у подкоманд разный, и это часть контракта:
|
||
|
||
| Подкоманда | Валидация перед выполнением |
|
||
| --- | --- |
|
||
| `validate-config` | `validate_config` |
|
||
| `validate-runtime` | `validate_runtime` (поддерживаемый хост + host tools) |
|
||
| `status`, `inspect-network-tuning` | `validate_config` |
|
||
| `apply-network-tuning` | `validate_sysctl_tuning_runtime` |
|
||
| `apply-limits`, `apply-firewall`, `ensure-swap`, `daemon` | `validate_all` |
|
||
|
||
`apply-network-tuning` и `inspect-network-tuning` относятся к отдельному host-level модулю `sysctl_tuning`. Этот модуль:
|
||
|
||
- управляет только sysctl-параметрами хоста;
|
||
- пишет `/etc/sysctl.d/90-awg2-harness-vpn.conf`;
|
||
- дополнительно пишет `/etc/modules-load.d/90-awg2-harness-bbr.conf`, но только когда BBR выбран desired policy (`auto`/`require` и BBR доступен); иначе этот файл удаляется;
|
||
- применяет runtime-изменения точечно (`sysctl -w`) и только для drifted keys;
|
||
- не использует `/etc/sysctl.conf` и `sysctl --system`;
|
||
- не управляет `udp_wmem_min`.
|
||
|
||
### Управляемые файлы и владение
|
||
|
||
Все файлы, которые пакет пишет под `/etc`, проходят через общий модуль `managed_file.rs`. Правила одни для всех и не дублируются по модулям:
|
||
|
||
- ошибка чтения никогда не трактуется как отсутствие файла;
|
||
- файл, у которого первая строка не содержит маркер владельца, не перезаписывается и не удаляется — возвращается ошибка;
|
||
- запись атомарная: временный файл-сосед, `sync_all`, `rename`, синхронизация каталога, права `0644`;
|
||
- повторная запись того же содержимого не трогает файл.
|
||
|
||
| Файл | Владелец | Маркер |
|
||
| --- | --- | --- |
|
||
| `/etc/sysctl.d/90-awg2-harness-vpn.conf` | `sysctl_tuning` | `# Managed by awg2-harness sysctl_tuning` |
|
||
| `/etc/modules-load.d/90-awg2-harness-bbr.conf` | `sysctl_tuning` | `# Managed by awg2-harness sysctl_tuning` |
|
||
| `/etc/sysctl.d/90-awg2-harness-swap.conf` | `swap` | `# Managed by awg2-harness swap` |
|
||
|
||
Модуль `swap` раньше писал `vm.swappiness` в `/etc/sysctl.d/99-awg2-harness.conf` с другим маркером, без проверки владения перед перезаписью и без удаления при отключении. Файл переехал на `90-`, как и остальные. Старый путь удаляется автоматически, но **только** если он всё ещё несёт прежний маркер `# Managed by awg2-harness`; файл администратора по этому пути не трогается, и это не считается ошибкой миграции.
|
||
|
||
Если управляемый файл существует, но не читается, `status` показывает отдельную причину `managed sysctl file is unreadable`, а не «файл отсутствует». Оператору важна разница между файлом, который пакет ещё не писал, и файлом, который он не может прочитать.
|
||
|
||
При `swap.enabled = false` управляемый файл удаляется. **Остаются на месте**: сам swapfile, его активность в ядре и запись в `/etc/fstab` — то есть после перезагрузки swap снова включится. Удаляется только persistence `vm.swappiness`. Runtime-значение `vm.swappiness` при этом намеренно не меняется: назначать произвольное значение означало бы выдумать политику, о которой оператор не просил. После перезагрузки действует kernel default. Активный swap при отключении модуля тоже не выключается — деструктивное действие не должно запускаться самим фактом отключения функции.
|
||
|
||
`net.ipv4.tcp_congestion_control` и `net.core.default_qdisc` попадают в целевое состояние только когда BBR желателен по policy. Фактическая активность диагностируется отдельно по runtime `net.ipv4.tcp_congestion_control`.
|
||
|
||
## 3. Структура модулей
|
||
|
||
```text
|
||
traffic_core/src/
|
||
├── main.rs точка входа и диспетчер подкоманд
|
||
├── cli.rs определение CLI (clap)
|
||
├── config.rs модель конфига, валидация, проверка host tools
|
||
├── daemon.rs цикл демона, Docker events, журнал событий
|
||
├── docker.rs обнаружение контейнера, диагностика port path
|
||
├── netns.rs транспорты выполнения команд
|
||
├── iface.rs обнаружение интерфейса
|
||
├── wg.rs обнаружение peers и разбор вывода awg/wg
|
||
├── traffic/
|
||
│ ├── mod.rs общие модели и точки входа
|
||
│ ├── parse_tc.rs чистые парсеры вывода tc/ip
|
||
│ ├── inspect.rs inspect_and_diff и контракт class id
|
||
│ └── apply.rs teardown и полная пересборка топологии
|
||
├── firewall/
|
||
│ ├── mod.rs apply_firewall, выбор namespace, общие константы
|
||
│ ├── nft.rs нормализация текста nftables и обёртки команд
|
||
│ ├── host.rs owned iptables/ip6tables INPUT chains (host namespace)
|
||
│ └── antitorrent.rs таблица inet awg2_harness (namespace по конфигу)
|
||
├── sysctl_tuning.rs управляемый sysctl baseline
|
||
├── swap/
|
||
│ ├── mod.rs facade и orchestration ensure_swap
|
||
│ ├── model.rs модели desired/runtime/status и replacement state
|
||
│ ├── path.rs syntax/path-chain safety и state directory
|
||
│ ├── ownership.rs ownership record и legacy sidecar
|
||
│ ├── runtime.rs swapon/swapoff/mkswap и parser runtime state
|
||
│ ├── replacement.rs создание, resize и rollback транзакции
|
||
│ ├── persistence.rs /etc/fstab, swappiness и legacy migration
|
||
│ └── status.rs inspect_swap и drift aggregation
|
||
├── status.rs сборка и печать статуса
|
||
├── lock.rs файловые блокировки
|
||
└── error.rs типы ошибок
|
||
|
||
reboot_module/src/
|
||
└── main.rs планировщик перезагрузки
|
||
```
|
||
|
||
`traffic` разбит по ответственности: парсеры отдельно от инспекции, инспекция отдельно от apply. Чистые парсеры собраны в `parse_tc.rs` вместе со своими тестами — это тестируемая поверхность модуля, всё остальное требует живого контейнера.
|
||
|
||
`firewall` разбит по подсистемам, а не по фазам: host firewall и anti-torrent независимы. Host firewall использует совместимый с Docker frontend `iptables`/`ip6tables`; anti-torrent владеет отдельной nftables-таблицей в configured namespace.
|
||
|
||
Приватные типы объявлены в соответствующем `mod.rs`. В Rust приватные элементы видны в модуле объявления и всех дочерних модулях, поэтому подмодули работают с ними без расширения видимости до `pub(crate)`.
|
||
|
||
## 4. Модель данных
|
||
|
||
Модули работают на явных структурах, а не на строках, которые передаются по всему коду.
|
||
|
||
Фактические типы:
|
||
|
||
```rust
|
||
// docker.rs
|
||
struct RuntimeTarget {
|
||
container_name: String,
|
||
container_id: String,
|
||
container_pid: u32,
|
||
netns_path: String, // /proc/<pid>/ns/net
|
||
interface_name: String,
|
||
}
|
||
|
||
// wg.rs — состав peers для shaping
|
||
struct Peer {
|
||
ipv4: Ipv4Addr,
|
||
}
|
||
|
||
// wg.rs — runtime-состояние peers для status
|
||
struct PeerRuntime {
|
||
public_key: String,
|
||
allowed_ips: Vec<String>,
|
||
endpoint: Option<String>,
|
||
latest_handshake_epoch: Option<u64>,
|
||
transfer_rx_bytes: Option<u64>,
|
||
transfer_tx_bytes: Option<u64>,
|
||
}
|
||
|
||
// traffic.rs — построчный статус peer в выводе status
|
||
struct PeerTrafficStatus {
|
||
ip: String,
|
||
class_id: u16,
|
||
egress_filter_present: bool,
|
||
ingress_filter_present: bool,
|
||
egress_rate: Option<String>,
|
||
egress_ceil: Option<String>,
|
||
ingress_rate: Option<String>,
|
||
ingress_ceil: Option<String>,
|
||
drift_reason: Option<String>,
|
||
}
|
||
```
|
||
|
||
Разделение `Peer` и `PeerRuntime` намеренное: shaping зависит только от адреса, а handshake/RX/TX нужны исключительно для диагностики. Ошибка чтения runtime-состояния не должна ломать применение лимитов.
|
||
|
||
Отдельной структуры desired state нет. Целевое состояние выводится детерминированно из конфига и отсортированного списка peers прямо в `inspect_and_diff` и `apply_full_topology`.
|
||
|
||
Присвоение class id обязано быть детерминированным. Строки peer IP сортируются лексикографически, затем присваивается `class_start_id + index`. Это нельзя молча заменить на числовую сортировку IPv4: production-контракт class-id ставит `10.8.1.10` раньше `10.8.1.2`, потому что сортировка текстовая, а не числовая.
|
||
|
||
Рендеримое textual-значение minor id ограничено `9999`. `tc` интерпретирует class id как hexadecimal; переход от строки `9999` к `10000` вышел бы за 16-bit minor. Проверяются и стартовое значение, и последний id для фактического числа peers.
|
||
|
||
## 5. Стратегия namespace
|
||
|
||
Основной путь:
|
||
|
||
1. Найти запущенный контейнер через `docker ps` / `docker inspect`.
|
||
2. Прочитать PID контейнера.
|
||
3. Использовать `/proc/<pid>/ns/net` как целевой namespace.
|
||
4. Выполнять сетевые операции в этом namespace.
|
||
|
||
Реализация:
|
||
|
||
- текущий путь — запуск host tools со входом в namespace: `nsenter -t <pid> -n -- tc ...`, `nsenter -t <pid> -n -- ip ...`, `nsenter -t <pid> -n -- nft ...`;
|
||
- в перспективе — netlink-крейты и явное переключение namespace там, где это практично.
|
||
|
||
`docker exec` не является архитектурным умолчанием для этих операций. Он остаётся fallback-транспортом, если `nsenter` недоступен. На практике `nsenter` доступен всегда: `validate-runtime` завершается с ошибкой при его отсутствии, а `validate_all` вызывается перед `daemon`, `apply-limits`, `apply-firewall` и `ensure-swap`.
|
||
|
||
Это снижает эксплуатационный риск: если контейнер Amnezia перестанет содержать `tc` или `iproute2`, host-side утилиты всё равно применят правила внутри его netns.
|
||
|
||
Чтение состояния peers — единственное исключение: harness намеренно использует официальный container-side `awg` через `docker exec`. `awg-quick` сначала пробует kernel interface типа `amneziawg`, а при его отсутствии запускает `amneziawg-go`. Container-side CLI является общей точкой доступа для обеих реализаций и изолирует harness от наличия и версии host-side `awg`/`wg`. Поэтому `wireguard-tools` не является зависимостью хоста и не проверяется в preflight.
|
||
|
||
## 6. Порядок discovery
|
||
|
||
`discover_target()`:
|
||
|
||
1. Прочитать упорядоченный `amnezia.container_names`.
|
||
2. Для каждого имени по порядку выполнить один `docker inspect`, который сразу возвращает running state, container ID и PID.
|
||
3. Выбрать первый запущенный контейнер с точным совпадением имени; отсутствующий или stopped candidate не меняет порядок fallback.
|
||
4. Построить `netns_path`.
|
||
5. Определить интерфейс в этом namespace.
|
||
6. Вернуть `RuntimeTarget`.
|
||
|
||
Порядок определения интерфейса:
|
||
|
||
1. Точные имена из `amnezia.interface_candidates` — проверяются через `ip link show <name>`.
|
||
2. Сканирование `ip -o link show` и сопоставление с `amnezia.interface_patterns`. Поддерживается только суффиксный `*` (`awg*`); имена вида `veth0@if8` нормализуются до `veth0`.
|
||
3. Если ничего не совпало — явная ошибка `unable to detect amnezia interface`, правила не применяются.
|
||
|
||
Эвристики «взять первый non-loopback интерфейс с IPv4» нет и быть не должно: молчаливый выбор произвольного интерфейса опаснее явной ошибки.
|
||
|
||
## 7. Обнаружение peers
|
||
|
||
Транспорт — всегда `docker exec <container>`. Обоснование в разделе 5.
|
||
|
||
Выбор бинаря идёт по упорядоченному `amnezia.wg_binaries`, default `["awg", "wg"]`. Побеждает первый, который завершился успешно. Официальный образ AmneziaWG содержит `/usr/bin/awg` и symlink `/usr/bin/wg` на него, поэтому `awg` — настоящая зависимость; `wg` остаётся для контейнеров с чистым WireGuard, где `awg` отсутствует. Отдельного probe-вызова нет: выбор бинаря является побочным результатом той команды, которая и так должна выполниться.
|
||
|
||
Значения `amnezia.wg_binaries` исполняются внутри контейнера и валидируются как голые имена команд: непустые, без пробелов, без разделителей пути.
|
||
|
||
Основная команда:
|
||
|
||
```bash
|
||
awg show all allowed-ips
|
||
```
|
||
|
||
Правила разбора и общий `PeerTopology` contract:
|
||
|
||
- строки разбираются структурно на public key peer и его allowed IPs;
|
||
- в этой команде allowed ips разделены **пробелом**, в `show all dump` — запятой, в обычном `show` — `", "`; parser обязан проверить весь список, а не молча взять первый адрес;
|
||
- поддерживается ровно один IPv4 `/32` внутри `amnezia.peer_ipv4_cidr` на peer; `(none)` допустим и не создаёт peer class;
|
||
- IPv6-only, IPv4+IPv6, несколько allowed IPs, маска не `/32`, невалидный CIDR или IPv4 вне configured peer CIDR возвращают typed `UnsupportedTopology`;
|
||
- topology проверяется один раз на startup/periodic/event cycle и передаётся traffic и anti-torrent. При ошибке ни одна из этих подсистем не начинает mutation и не сообщает успешный reconcile;
|
||
- offline peers включаются: online-состояние не является требованием для shaping;
|
||
- перед присвоением class id строки peer IP сортируются лексикографически, чтобы сохранить действующий контракт class-id.
|
||
|
||
Резервная команда:
|
||
|
||
```bash
|
||
awg show
|
||
```
|
||
|
||
Fallback-парсер разбирает текстовый вывод, извлекая строки `allowed ips:`. Он используется, если основная команда завершилась ошибкой или вернула пустой результат, и применяет тот же строгий topology contract.
|
||
|
||
Классификация строк `show all dump`:
|
||
|
||
- строка считается peer-строкой, только если в ней не менее 9 tab-колонок, колонка 5 (allowed ips) равна `(none)` или является списком CIDR через запятую, а колонки 6–8 (handshake, rx, tx) целиком парсятся как целые;
|
||
- **количество колонок не является дискриминатором и не должно им становиться.** Исторически WireGuard/AWG 2.x/AWG 3.0/AWG 3.1 tools печатают interface-row шириной 5/14/28/30 полей, а текущий peer-row содержит 9. Ширину определяет producer `amneziawg-tools`, а не настроенная версия параметров: новые tools могут печатать широкую строку для старой конфигурации с нулевыми или пустыми новыми полями. Любое правило вида «N колонок означает версию X» неверно по построению;
|
||
- interface-строка к тому же *длиннее* peer-строки, а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые, поэтому и эти колонки не разделяют два случая;
|
||
- это важно не только для корректности: колонка 2 interface-строки — приватный ключ устройства, и ошибочно классифицированная interface-строка выдала бы его как public key peer;
|
||
- AWG 3.x печатает протокольные таймеры диапазонами (`20-30`). AWG 3.1 tools `v3.1.20260812` дополнительно печатают `RandomTrailers` и `DisableCookies` перед `fwmark`. Колонка keepalive у peer не читается, и диапазон в ней не должен ломать разбор.
|
||
|
||
Политика выполнения для production:
|
||
|
||
- не использовать shell-обёртки (`sh -c`) для вызова `awg`/`wg`;
|
||
- не подавлять stderr редиректом внутри строки команды;
|
||
- вызывать бинарь напрямую и обрабатывать ненулевые коды возврата в Rust.
|
||
|
||
## 8. Целевая топология traffic
|
||
|
||
Ожидаемая топология — контракт traffic-топологии AWG2.
|
||
|
||
Egress:
|
||
|
||
```text
|
||
tc qdisc add dev <iface> root handle 1: htb
|
||
tc class add dev <iface> parent 1: classid 1:1 htb rate <root_rate> ceil <root_rate>
|
||
for each peer:
|
||
tc class add dev <iface> parent 1:1 classid 1:<id> htb rate <peer_limit> ceil <peer_limit>
|
||
tc qdisc add dev <iface> parent 1:<id> fq_codel
|
||
tc filter add dev <iface> parent 1: protocol ip pref 10 flower dst_ip <peer_ip>/32 classid 1:<id>
|
||
```
|
||
|
||
Ingress через IFB:
|
||
|
||
```text
|
||
modprobe ifb
|
||
ip link add name <ifb_name> type ifb
|
||
ip link set dev <ifb_name> alias awg2-harness:traffic
|
||
ip link set dev <ifb_name> up
|
||
tc qdisc add dev <iface> handle ffff: ingress
|
||
tc filter add dev <iface> parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev <ifb_name>
|
||
tc qdisc add dev <ifb_name> root handle 2: htb
|
||
tc class add dev <ifb_name> parent 2: classid 2:1 htb rate <root_rate> ceil <root_rate>
|
||
for each peer:
|
||
tc class add dev <ifb_name> parent 2:1 classid 2:<id> htb rate <peer_limit> ceil <peer_limit>
|
||
tc qdisc add dev <ifb_name> parent 2:<id> fq_codel
|
||
tc filter add dev <ifb_name> parent 2: protocol ip pref 10 flower src_ip <peer_ip>/32 classid 2:<id>
|
||
```
|
||
|
||
Все peer-фильтры на каждом parent используют один `pref 10`: для `flower` это даёт один hash table на priority вместо множества priority structures. Маска всегда `/32`, поэтому один priority не смешивает несовместимые masks. Catch-all redirect в IFB остаётся `u32`, поскольку это одно правило, а не per-peer lookup.
|
||
|
||
## 9. Алгоритм reconcile
|
||
|
||
Traffic и firewall — два независимых reconcile с отдельными блокировками. Они не объединены в одну транзакцию: сбой одного не должен блокировать другой.
|
||
|
||
```text
|
||
reconcile_traffic():
|
||
если traffic.enabled = false: выйти без изменений
|
||
взять /run/awg2-harness/traffic.lock (timeout 10 c)
|
||
target = discover_target()
|
||
peers = discover_peers(config, target)
|
||
если peers пуст:
|
||
проверить наличие AWG2-owned topology
|
||
если она осталась от прежних peers: teardown_owned_topology(target)
|
||
иначе выйти с debug-диагностикой
|
||
drift = inspect_and_diff(target, peers, config)
|
||
если drift отсутствует:
|
||
выйти с debug-диагностикой
|
||
teardown_owned_topology(target)
|
||
apply_full_topology(target, peers, config) # внутри — verify-after-apply
|
||
```
|
||
|
||
```text
|
||
apply_firewall():
|
||
взять /run/awg2-harness/firewall.lock (timeout 10 c)
|
||
apply_host_firewall(config) # host namespace, iptables/ip6tables A/B chains
|
||
apply_antitorrent_firewall(config) # namespace по anti_torrent.apply_in_amnezia_netns
|
||
```
|
||
|
||
Каждый из firewall-путей сначала инспектирует фактическое состояние и выходит без изменений, если drift отсутствует. Anti-torrent таблица заменяется одной атомарной nft-транзакцией и при включённом guard требует успешно проверенный `PeerTopology`; host firewall от server-side peer topology не зависит. Host firewall сначала строит и проверяет inactive chain отдельно для IPv4/IPv6. Новый jump всегда вставляется через `-I INPUT 1`; старый остаётся вторым до успешного переключения обеих IP-family и затем удаляется по полной rule specification. Numeric `-R/-D` для managed jumps не используется. Сбой IPv6 удаляет новый IPv4 jump по точной specification, поэтому старый IPv4 jump всё время остаётся рабочим. После apply выполняется обязательная повторная инспекция.
|
||
|
||
Для production контролируемая полная пересборка при drift — единственный поддерживаемый путь.
|
||
|
||
Требования к устойчивости парсеров:
|
||
|
||
- `tc filter show` разбирается блоками (конечный автомат), а не наивным сканированием токенов по несвязанным строкам;
|
||
- единый parser понимает production `flower` и legacy `u32`; точный healthy-контракт требует `flower`, `pref 10`, правильное direction-field и `/32`, а legacy `u32` peer rules считаются migration drift;
|
||
- нераспознанный или malformed detailed filter block не игнорируется, а делает инспекцию drifted;
|
||
- отображение `peer_ip -> class_id` проверяется для обеих сторон: egress (`1:`) и ingress (`2:`);
|
||
- проверка обязана падать, если классы и фильтры существуют, но отображение отличается от детерминированного присвоения.
|
||
|
||
Политика apply/verify:
|
||
|
||
- `modprobe ifb`, создание IFB, установка ownership alias и перевод в UP выполняются строго и до `tc` apply;
|
||
- после teardown вся `tc` topology подаётся одним fail-fast сценарием в stdin команды `tc -batch -`; `-force` не используется, поэтому первая ошибка останавливает batch и сохраняет номер строки в stderr;
|
||
- `tc -batch` не является транзакцией: любая ошибка после частичного применения запускает cleanup доказанно owned handles/IFB; если cleanup тоже падает, в итоговой ошибке сохраняются обе причины;
|
||
- ошибки «объект не найден» (`no such`, `not found`, `cannot find`, `does not exist`, `handle of zero`) допускаются только на шагах удаления — в teardown и при отключении владеемых таблиц;
|
||
- любая другая ошибка apply фатальна;
|
||
- `verify-after-apply` обязателен и для traffic, и для anti-torrent.
|
||
|
||
Контракт наблюдаемости anti-torrent:
|
||
|
||
- в владеемой таблице обязан присутствовать именованный counter `antitorrent_dropped_v4`;
|
||
- вывод status обязан показывать и дропнутые пакеты, и дропнутые байты.
|
||
|
||
## 10. Owned state и правила teardown
|
||
|
||
Бинарь владеет:
|
||
|
||
- root qdisc `1:` на выбранном интерфейсе AWG/WG;
|
||
- ingress qdisc `ffff:` на выбранном интерфейсе AWG/WG, когда лимиты включены;
|
||
- IFB-устройством с именем `traffic.ifb_name` (default `ifb0`), type `ifb` и alias `awg2-harness:traffic` в целевом namespace;
|
||
- root qdisc `2:` на IFB;
|
||
- классами и фильтрами под этими handles.
|
||
|
||
До первой операции удаления teardown инспектирует все цели. На AWG/WG-интерфейсе owned считаются только root `htb 1:` и ingress `ffff:`; `clsact` и любой другой root — ownership conflict. IFB удаляется только при совпадении type+alias (кроме IFB, доказанно созданного текущей неуспешной попыткой). Чужие объекты не заменяются даже на выделенном сервере.
|
||
|
||
Бинарь не должен вслепую сбрасывать посторонние host firewall rules, Docker NAT rules или чужие интерфейсы. Владение firewall явно ограничено таблицей `inet awg2_harness`, comment jump `awg2-harness:host-firewall` и цепочками `AWG2H_A`/`AWG2H_B`. Встроенные `INPUT`/`FORWARD`, их policy и чужие chains не очищаются.
|
||
|
||
Host firewall не открывает published Amnezia UDP port в `INPUT`: Docker bridge publish проходит через `FORWARD`/NAT. Собственная chain разрешает loopback, established/related, configured SSH и optional ICMP/ICMPv6, затем завершает обработку configured `DROP`/`ACCEPT` verdict. Поле `amnezia.expected_host_udp_port` относится только к диагностике port path.
|
||
|
||
Жизненный цикл managed jump использует только exact rule spec с comment `awg2-harness:host-firewall` и target `AWG2H_A`/`AWG2H_B`. Чужое правило, вставленное другим firewall manager между inspect и switch, не может быть заменено или удалено по изменившемуся номеру строки.
|
||
|
||
## 11. Anti-torrent firewall guard
|
||
|
||
Это часть production-контракта AWG2. Модуль вписывается в мульти-оркестраторную модель, потому что сервер выделенный и бинарю разрешено владеть host/network правилами, нужными VPN-продукту. Правило всё равно обязано быть узко ограниченным и наблюдаемым.
|
||
|
||
Цель — ограничить количество одновременных conntrack flows на один VPN peer IP. Это не детектирование торрент-протокола, а обобщённый connection-count guard, который делает P2P-нагрузку с большим числом peers непривлекательной, не задевая обычные browsing, streaming, gaming и messaging.
|
||
|
||
Правило не должно быть глобальным лимитом на подсеть:
|
||
|
||
```nft
|
||
ip saddr 10.8.1.0/24 ct count over 200 drop
|
||
```
|
||
|
||
Такая форма может превратиться в один общий счётчик на весь совпавший трафик — в зависимости от того, как она написана и куда помещена. Для per-peer поведения stateful-объект нужно ключевать по source VPN IP. Рекомендуемая форма:
|
||
|
||
```nft
|
||
table inet awg2_harness {
|
||
counter antitorrent_dropped_v4 {}
|
||
|
||
chain forward_antitorrent {
|
||
type filter hook forward priority filter; policy accept;
|
||
|
||
ip saddr <peer_ipv4_cidr> ct state new \
|
||
meter awg2_peer_connlimit_v4 { ip saddr ct count over <max_connections_per_peer> } \
|
||
counter name antitorrent_dropped_v4 \
|
||
drop
|
||
}
|
||
}
|
||
```
|
||
|
||
Целевой namespace — тот же Amnezia network namespace, где применяется `tc` shaping. Нельзя предполагать, что начальный host namespace видит исходные source-адреса peers. Если Amnezia выполняет NAT внутри namespace контейнера, host Docker bridge может видеть только адрес контейнера, а не `10.8.1.x`.
|
||
|
||
Порог в примере конфигурации — `500` одновременных flows на peer IP. Это production baseline по эксплуатационным измерениям проекта: guard остаётся включённым, но не должен вмешиваться в нормальный повседневный профиль. Значение ниже `500` является более агрессивной политикой и требует отдельной проверки на реальных клиентах. Порог передаётся из config и не зашит в алгоритм.
|
||
|
||
Размещение правила: hook `forward` с приоритетом `filter` или любым приоритетом после conntrack lookup. Оценка ограничивается `ct state new`, чтобы установленные flows продолжали работать и дропались только новые попытки сверх порога.
|
||
|
||
Владеемое состояние:
|
||
|
||
- таблица `inet awg2_harness`;
|
||
- chain `forward_antitorrent`;
|
||
- counter `antitorrent_dropped_v4`;
|
||
- meter/set state, создаваемый правилом.
|
||
|
||
Status обязан показывать наличие таблицы/chain/правила, настроенный порог и drop counter. При anti-torrent в Amnezia netns `net.netfilter.nf_conntrack_count` читается в этом namespace, а глобальный `net.netfilter.nf_conntrack_max` — на host. Если container transport недоступен, host count не подставляется как ложный fallback.
|
||
|
||
При `anti_torrent.enabled = false` демон удаляет только владеемую anti-torrent таблицу. Глобальный ruleset nftables не сбрасывается никогда. Если контейнер в этот момент именно не найден, удаление в его namespace пропускается — это не ошибка. Ошибка Docker API/daemon/permissions не маскируется под «контейнер не найден» и возвращается оператору.
|
||
|
||
## 12. Инспекция фактического состояния
|
||
|
||
`inspect_and_diff()` собирает достаточно информации для диагностики drift:
|
||
|
||
- qdisc на `<iface>`;
|
||
- классы на `<iface>`;
|
||
- qdiscs, привязанные к per-peer классам на `<iface>`;
|
||
- фильтры под parent `1:` на `<iface>`;
|
||
- ingress qdisc на `<iface>`;
|
||
- ingress redirect filter на `<iface>`;
|
||
- существование и состояние `<ifb_name>`;
|
||
- qdisc на `<ifb_name>`;
|
||
- классы на `<ifb_name>`;
|
||
- фильтры под parent `2:` на `<ifb_name>`.
|
||
|
||
Один вывод `tc qdisc show dev <iface>` используется одновременно для root, ingress и per-peer `fq_codel`; один вывод `tc class show dev <device>` — для root и per-peer classes. Инспекция не делает отдельный child process на каждый parent.
|
||
|
||
Дополнительно детектируются дубли: один и тот же peer IP в нескольких фильтрах и один и тот же class id у нескольких IP. Оба случая считаются drift.
|
||
|
||
`status` показывает компактную сводку и таблицу по peers.
|
||
|
||
## 13. Docker events и периодический reconcile
|
||
|
||
Демон использует два триггера:
|
||
|
||
1. Docker events: `docker events --filter event=start --format '{{.Actor.Attributes.name}}'`. Имя сверяется с `amnezia.container_names`, после чего выполняется ожидание готовности интерфейса и reconcile traffic + firewall. Если поток событий завершился, он перезапускается через 10 секунд.
|
||
2. Таймер: периодический reconcile каждые `traffic.reconcile_interval_secs`, но не чаще одного раза в 5 секунд. Он применяется к `traffic` и `firewall`; `swap` идёт по собственному расписанию — раз в 5 минут. Swapfile, запись в `/etc/fstab` и `vm.swappiness` меняются только тогда, когда их меняет оператор, а проверка на traffic-интервале означала несколько вызовов `swapon --show` и `sysctl` каждые 15 секунд ради состояния, которое само по себе не дрейфует.
|
||
|
||
Таймер обязателен. События могут быть пропущены при рестарте демона, рестарте Docker или транзиентных ошибках.
|
||
|
||
Внутри одного startup/periodic/event-цикла traffic и anti-torrent получают один и тот же результат discovery `RuntimeTarget`; повторные `docker inspect`, PID/interface discovery и transport construction в этом же цикле не выполняются. Наличие host `nsenter` кэшируется на время жизни процесса.
|
||
|
||
Проверка готовности не должна хардкодить `awg0`. Она вызывает общий детектор интерфейса с интервалом до 2 секунд до истечения `traffic.container_ready_timeout_secs`. Retry разрешён только для typed `AppError::NotFound`; Docker/API/permission/parse errors возвращаются немедленно. Истечение timeout является ошибкой, а не silent skip. Если start event пришёл от lower-priority container, но discovery уже выбрал running container выше в `amnezia.container_names`, событие игнорируется без reconcile.
|
||
|
||
Порядок на старте демона и разница в критичности ошибок:
|
||
|
||
1. `sysctl_tuning`, если `apply_on_daemon_start = true` — ошибка **фатальна**, демон завершается;
|
||
2. `swap` — ошибка **фатальна**, демон завершается;
|
||
3. `traffic` — ошибка логируется, демон продолжает работу;
|
||
4. `firewall` — ошибка логируется, демон продолжает работу.
|
||
|
||
Такое разделение намеренное: некорректный baseline хоста или swap означают неверные предпосылки, тогда как отсутствие контейнера Amnezia — нормальное транзиентное состояние.
|
||
|
||
Результаты пишутся в два файла:
|
||
|
||
- `/run/awg2-harness/last_action.state` — последний успех и последняя ошибка;
|
||
- `/run/awg2-harness/events.log` — журнал событий, ограниченный 1000 строками и 256 KiB.
|
||
|
||
Ошибки отслеживаются по подсистемам (`traffic`, `firewall`, `swap`, `sysctl_tuning`, `docker_events`), а не одним общим полем. Общее поле в `last_action.state` показывает ту ошибку, которая ещё не устранена: успешный reconcile одной подсистемы не гасит незакрытую ошибку другой и не объявляет восстановление того, что не падало. Поток `docker events` при успешном переподключении тоже отмечается как восстановленный — иначе один рестарт Docker оставлял бы ошибку в `status` навсегда при исправно работающем новом потоке.
|
||
|
||
В `events.log` и journald на уровне `info`/`error` пишутся только **переходы состояния**: первое появление ошибки, изменение её текста и восстановление. Рутинный healthy-цикл и повтор той же ошибки остаются на `debug`. Это исключае повторяющийся journal-шум на каждом 15-секундном reconcile, но сохраняет детали для отладки при включённом debug-уровне.
|
||
|
||
## 14. Блокировки
|
||
|
||
Одновременно может выполняться только один reconcile каждого вида — по всем подкомандам и задачам демона.
|
||
|
||
Блокировки:
|
||
|
||
| Файл | Таймаут ожидания | Кого защищает |
|
||
| --- | --- | --- |
|
||
| `/run/awg2-harness/traffic.lock` | 10 с | `reconcile_traffic` |
|
||
| `/run/awg2-harness/firewall.lock` | 10 с | `apply_firewall` (host + anti-torrent) |
|
||
| `/run/awg2-harness/sysctl_tuning.lock` | 30 с | `ensure_sysctl_tuning` |
|
||
|
||
Реализация — advisory-блокировка ядра (`flock` на Linux) через `std::fs::File`, а не PID-файл.
|
||
|
||
Поведение:
|
||
|
||
- ожидание захвата — с интервалом 100 мс до таймаута, затем ошибка `lock busy timeout`;
|
||
- PID пишется в файл **только как диагностика**; ни одно решение на его основе не принимается;
|
||
- освобождение выполняет ядро при завершении процесса — любым способом, включая `kill -9`. Понятия «устаревшая блокировка» не существует;
|
||
- файл блокировки никогда не удаляется: сам факт его наличия ничего не значит, значение имеет только удерживаемая на нём блокировка.
|
||
|
||
Прежняя реализация на PID-файлах имела типичные для такого подхода дефекты: конфликт определялся по подстроке в тексте ошибки; «устаревшая» блокировка удалялась и создавалась заново двумя отдельными операциями, поэтому два процесса могли одновременно счесть её устаревшей и оба захватить; переиспользованный PID делал живую блокировку похожей на мёртвую; удаление файла в `Drop` гонялось с процессом, который уже открыл его и ждёт.
|
||
|
||
## 15. Контракт вывода status
|
||
|
||
`awg2-harness status` печатает блоки в следующем порядке. Имена полей стабильны и пригодны для grep.
|
||
|
||
```text
|
||
System:
|
||
service: active|inactive|unknown
|
||
container: <name>
|
||
container_id: <id>
|
||
pid: <pid>
|
||
netns: /proc/<pid>/ns/net
|
||
interface: <iface>
|
||
Network tuning:
|
||
enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path,
|
||
значения управляемых ключей, bbr_available, bbr_desired,
|
||
bbr_runtime_active, drift[, reason][, warning]
|
||
Peers:
|
||
total: N
|
||
- <peer ip>
|
||
WireGuard clients:
|
||
IP, State, Latest handshake, RX, TX, Public key, Endpoint
|
||
Traffic desired:
|
||
peer_limit, root_rate, ifb_name, class_start_id
|
||
Traffic actual:
|
||
drift: no|yes[, reason]
|
||
topology: egress_htb_present, ingress_present, ifb_htb_present
|
||
peer_table: ip, class_id, egress_filter, ingress_filter, egress, ingress, drift
|
||
Peer traffic counters:
|
||
IP, Egress packets, Ingress packets
|
||
Host firewall:
|
||
enabled, ssh_port, input_policy, drift[, reason]
|
||
Docker/Amnezia port path:
|
||
expected_host_udp_port, docker_userland_proxy, published_udp_port,
|
||
host_bindings (все HostIp:HostPort, включая routed binding без HostPort),
|
||
awg_listen_udp_port, port_contract, host_forward_policy_drop,
|
||
docker_forward_rules_present, note[, warning]
|
||
Anti-torrent:
|
||
enabled, max_connections_per_peer, drift[, reason], dropped_packets, dropped_bytes
|
||
Swap:
|
||
enabled, desired_path, desired_size_mb, desired_priority, active_entries,
|
||
desired_active, exclusive, fstab_exclusive, fstab_swap_entries,
|
||
swappiness_current, swappiness_persisted, drift[, drift_reason]
|
||
Conntrack:
|
||
count_namespace, nf_conntrack_count, nf_conntrack_max_global, nf_conntrack_utilization
|
||
Last actions:
|
||
содержимое /run/awg2-harness/last_action.state
|
||
Recent events:
|
||
последние 10 строк /run/awg2-harness/events.log
|
||
Reboot scheduler:
|
||
enabled, window_start_utc, window_end_utc, service, planned
|
||
```
|
||
|
||
Блок `System` обязан оставаться неразрывным: строки `container`/`container_id`/`pid`/`netns`/`interface` имеют тот же отступ, что и поля любой другой секции, поэтому открытие постороннего блока между ними и заголовком `System` делает вывод неоднозначным при чтении.
|
||
|
||
Если контейнер обнаружить не удалось, `status` не завершается ошибкой, а переходит в degraded-режим. Строки `container`/`pid`/`netns`/`interface` печатаются со значениями `n/a`, секции `Peers` и `WireGuard clients` печатают `unavailable`, `Traffic actual` показывает `drift: unknown`, блок `Docker/Amnezia port path` заполняется значениями `unknown`, а блок `Peer traffic counters` не печатается вовсе. Секции host firewall, anti-torrent, swap, conntrack, событий и reboot остаются на месте.
|
||
|
||
Секции, общие для здорового и degraded путей, рендерятся одними и теми же функциями. Это обязательное требование: раньше они были продублированы, и расхождение форматов проявилось бы ровно в тот момент, когда контейнер лежит, то есть в худший для диагностики момент.
|
||
|
||
Отдельные секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap.
|
||
|
||
Универсальный баннер вида «system ready» недопустим. Status обязан быть фальсифицируемым: он должен явно сообщать, когда состояние частичное.
|
||
|
||
## 16. Интеграция reboot-модуля
|
||
|
||
Production-путь перезагрузки — внешний бинарь/сервис `awg2-reboot-scheduler`:
|
||
|
||
- окно берётся из конфига, а не из констант;
|
||
- случайная точка внутри окна, включая окна через полночь;
|
||
- вызов остаётся `systemctl --no-block reboot`;
|
||
- при `reboot.enabled = false` планирование отключено, а оставшийся от прежней конфигурации `/run/awg2-harness/reboot.plan` удаляется — иначе `status` продолжал бы показывать перезагрузку, которая никогда не наступит;
|
||
- запланированная отметка времени сохраняется в `/run/awg2-harness/reboot.plan` атомарно (временный файл, `sync_all`, `rename`), и ошибка записи прекращает работу. Это единственный источник информации о том, когда хост перезагрузится; прежняя best-effort запись позволяла планировщику молча уйти в ожидание момента, о котором в системе не было никаких данных;
|
||
- после принятия запроса на перезагрузку план удаляется, чтобы прошедшая отметка времени не выглядела как ожидающая.
|
||
|
||
## 17. Модуль swap
|
||
|
||
Целевое состояние `ensure-swap`:
|
||
|
||
- путь из конфига, default `/swapfile`;
|
||
- размер `swap.size_mb`, в примере конфигурации — 2048 MB;
|
||
- права `0600`;
|
||
- выполнен `mkswap`;
|
||
- запись активна в `swapon --show`;
|
||
- одна запись в `/etc/fstab` с настроенным приоритетом;
|
||
- `vm.swappiness` из конфига применён в runtime и сохранён persistently.
|
||
|
||
При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap выключаются через `swapoff`, а их записи убираются из `/etc/fstab`. Операция обязана быть идемпотентной.
|
||
|
||
Создание файла: `mkswap --file <path> --size <N>M` — одна команда, которая и создаёт файл, и форматирует его. Прежняя цепочка `fallocate` → fallback `dd` → `chmod` → `mkswap` давала четыре процесса, три ветки отказа и промежуток, в котором файл уже существовал, но ещё не был отформатирован. `util-linux` в Debian 13 дополнительно учитывает требования конкретных ФС (в частности Btrfs), которых голый `fallocate` не знает. Из обязательных host tools при `swap.enabled = true` соответственно ушли `fallocate`, `dd` и `chmod`.
|
||
|
||
### Владение swapfile
|
||
|
||
Пакет никогда не трогает файл, который не создавал. Состояние владения хранится в `/var/lib/awg2-harness/swap-ownership.conf` — каталог доступен только root (`0700`):
|
||
|
||
```text
|
||
# Managed by awg2-harness swap-ownership
|
||
version = 1
|
||
path = /swapfile
|
||
device = 2049
|
||
inode = 123456
|
||
```
|
||
|
||
Ключевое здесь — `device` и `inode`, а не путь. Путь не является идентичностью: файл можно удалить, а на его месте создать другой. Прежняя схема с sidecar-маркером рядом со swapfile этого не различала, и последовательность «наш swapfile удалили вручную → маркер остался → оператор создал по тому же пути другой файл» приводила к тому, что чужой файл признавался управляемым и при несовпадении размера удалялся. Sidecar удалён; при обнаружении он только вычищается и никогда не используется как доказательство владения.
|
||
|
||
| Состояние | Условие | Действие |
|
||
| --- | --- | --- |
|
||
| `Missing` | файла нет | создать |
|
||
| `ManagedValid` | запись есть, `device`+`inode`+путь совпали, размер совпал | переиспользовать |
|
||
| `ManagedWrongSize` | запись совпала, размер отличается | пересоздать |
|
||
| `Foreign` | файл есть, записи нет или запись про другой путь | **ошибка**, файл не трогается |
|
||
| `StaleOwnership` | запись есть, но `device`/`inode` не совпали — файл подменили | **ошибка**, файл не трогается |
|
||
|
||
Запись создаётся **после** появления файла, по его фактическим metadata. До этого момента файл управляемым не считается.
|
||
|
||
Чужие активные swap только выключаются через `swapoff`. Файлы не удаляются — ни обычные, ни блочные устройства. Удаление чужого swapfile не является решением этого пакета: эксклюзивное runtime-состояние достигается связкой `swapoff` плюс вычистка `/etc/fstab`, а файл оператор уберёт сам, когда сочтёт нужным.
|
||
|
||
### Порядок операций
|
||
|
||
Порядок принципиален. Управляемый swap создаётся, активируется и проверяется **до** того, как отключается любой чужой:
|
||
|
||
```text
|
||
1. взять /run/awg2-harness/swap.lock (timeout 30 c)
|
||
2. preflight: разобрать конфиг, проверить безопасность пути, снять текущее состояние
|
||
3. подготовить управляемый swapfile (с учётом владения)
|
||
4. swapon управляемого swap
|
||
5. убедиться, что он реально появился в списке активных
|
||
6. удалить неактивные остатки прерванной замены (.new, .previous)
|
||
7. только теперь swapoff чужих swap
|
||
8. перезаписать /etc/fstab транзакцией
|
||
9. применить swappiness
|
||
10. проверить итоговое состояние
|
||
```
|
||
|
||
Шаги 2–5 не выполняют ни одного деструктивного действия над чужим состоянием. Прежний порядок отключал чужие swap первым шагом, поэтому ошибка в `fallocate`, `dd`, `mkswap` или `swapon` оставляла хост вообще без swap.
|
||
|
||
Шаг 6 нужен потому, что вычистка остатков перед заменой (шаг 3) не покрывает процесс, убитый уже **после** активации нового файла: следующий запуск видит по основному пути валидный, активный и корректно принадлежащий нам swapfile, идёт быстрым путём и на остатки не смотрит. Размер swapfile обычно сопоставим с объёмом RAM, поэтому «когда-нибудь» здесь не годится. Удаляются только неактивные остатки; активный означает, что ядро всё ещё свопит в него, — это случай прерванного отката, и он требует оператора, а не догадки.
|
||
|
||
### Замена активного swapfile
|
||
|
||
Живой swapfile нельзя изменить в размере на месте, поэтому изменение `swap.size_mb` выполняется заменой. Новый файл создаётся и активируется, **пока старый ещё работает**; старый затем не удаляется, а откладывается под именем `.awg2-harness.previous`, и до подтверждения нового файла оба лежат на диске.
|
||
|
||
Инвариант отката один: откат никогда не оставляет хост без swap. Он сформулирован не как «вернуть исходное состояние любой ценой» — исходное состояние может быть недостижимо, — а как «на хосте должен работать хотя бы один подготовленный swap». Отсюда порядок восстановления:
|
||
|
||
1. активную замену сначала выключить; если `swapoff` не удался, она остаётся на месте и **продолжает обслуживать** — двигать файл, в который ядро свопит, значит только рассинхронизировать `/proc/swaps` с файловой системой;
|
||
2. неактивную замену не удалять, а отодвинуть под staging-имя: пока исходный файл не подтверждён рабочим, замена — единственная вторая swap-область на хосте;
|
||
3. вернуть исходный файл на основной путь и активировать его с **прежним** priority, а не с применяемым: применяемый мог оказаться ровно тем, что ядро отвергло;
|
||
4. если `rename` обратно не удался, исходный файл активируется прямо по пути `.awg2-harness.previous` — ядро активирует swap-область по пути, и имя файла для этого значения не имеет;
|
||
5. если исходный файл поднять не удалось вовсе, активируется отложенная замена.
|
||
|
||
Соответственно у отката есть три деградированных исхода, и в каждом текст ошибки описывает **фактическое** состояние, а не намерение: замена осталась активной по основному пути; исходный swap работает из `.previous` и оба файла целы; ни один кандидат не активировался — хост без управляемого swap. Первые два сопровождаются точными командами, которыми оператор доводит состояние до нормального, когда хост это позволит.
|
||
|
||
### Безопасность `swap.path`
|
||
|
||
Проверка разделена на две части. Лексическая не обращается к файловой системе и потому пригодна для `validate-config` на хосте, где файла ещё нет:
|
||
|
||
- путь абсолютный и не оканчивается на `/`;
|
||
- нет компонентов `.` и `..` — поэтому строка уже нормализована и список запрещённых путей нельзя обойти записью вида `/etc/../etc/fstab`;
|
||
- нет пробелов, управляющих символов и обратных слэшей;
|
||
- путь равен `/swapfile` **либо** лежит непосредственно в `/var/lib/awg2-harness/` (без вложенных каталогов).
|
||
|
||
Это allow-list, а не deny-list, и это принципиально. Список запрещённых путей закрывает только те случаи, которые кто-то придумал; значение имеет другое — может ли родительский каталог писать кто-то кроме root. Между preflight-проверкой и запуском `mkswap --file` непривилегированный пользователь с правом записи в родительский каталог успевает подменить финальный компонент на symlink и перенаправить операцию, выполняемую от root. Это классический TOCTOU, и никакое количество `stat` перед использованием его не закрывает — окно устраняется только тем, что запись в родительский каталог доступна лишь root.
|
||
|
||
Оба разрешённых корня root-owned. `/swapfile` оставлен, чтобы уже развёрнутые конфигурации не требовали правки.
|
||
|
||
Файловая часть выполняется непосредственно перед использованием пути: каждый родительский компонент обязан существовать и не быть symlink. Проверки только финального компонента недостаточно — symlink в середине цепочки перенаправляет операцию на файл, который никогда не настраивался.
|
||
|
||
### Перезапись `/etc/fstab`
|
||
|
||
Это самая разрушительная операция пакета, поэтому она транзакционная и изолирована в `swap/persistence.rs`. Контракт:
|
||
|
||
- ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля;
|
||
- пустой или состоящий из пробелов кандидат отклоняется;
|
||
- резервная копия обязательна. Если её не удалось записать, целевой файл не трогается вовсе — ситуация, в которой копия не пишется, и есть та, в которой она нужна;
|
||
- копий две: `/etc/fstab.awg2-harness.bak` перезаписывается перед каждой правкой и содержит состояние до неё, а `/etc/fstab.awg2-harness.original` создаётся один раз через `create_new` и хранит содержимое до первого вмешательства пакета;
|
||
- запись идёт во временный файл рядом с целевым, с `create_new`, `sync_all` и переносом прав с оригинала; устаревший временный файл от убитого процесса удаляется явно, а не переиспользуется;
|
||
- кандидат проверяется через `findmnt --verify --tab-file` до подмены. Проверка обязательна: `findmnt` входит в `util-linux`, от которого пакет и так зависит, и включён в обязательные host tools при `swap.enabled = true`. Невозможность его запустить и отказ файла одинаково прекращают операцию — молча пропускать единственную структурную проверку файла, от которого зависит загрузка хоста, недопустимо;
|
||
- подмена — атомарный `rename`, после него каталог синхронизируется;
|
||
- после `rename` содержимое перечитывается и сверяется с записанным. Расхождение приводит к восстановлению из резервной копии.
|
||
|
||
Путь в записи fstab экранируется: поля разделяются пробелами, поэтому пробел или табуляция в пути сдвинули бы все последующие колонки.
|
||
|
||
Чтение swap-записей для `status` использует ту же строгую функцию чтения. Маскировка ошибки здесь показала бы «в fstab нет swap-записей», то есть здоровое состояние без drift, на хосте, где `/etc/fstab` вообще не читается.
|
||
|
||
## 18. Тестирование
|
||
|
||
Модульные тесты:
|
||
|
||
- валидация конфига, включая форму `amnezia.wg_binaries`;
|
||
- разбор `config.example.toml` в актуальную схему с точным default-порядком контейнеров, `awg`/`wg` и anti-torrent baseline `500`;
|
||
- парсер `awg|wg show all allowed-ips`: один IPv4 `/32` проходит, IPv4+IPv6, IPv6-only, несколько маршрутов, маска не `/32` и адрес вне configured CIDR дают explicit `UnsupportedTopology`;
|
||
- fallback-парсер `awg|wg show` и dump-парсер применяют тот же topology contract; `(none)` остаётся допустимым;
|
||
- классификация peer/interface строк `awg|wg show all dump` против двух 30-полевых AWG 3.1 fixtures, AWG 3.0 с включённой и выключенной защитой заголовка, AWG 2.x, чистого WireGuard, а также конфигурации AWG 2.0, прочитанной tools AWG 3.0;
|
||
- разбор dump с несколькими интерфейсами, с интерфейсом без peers и с повреждёнными строками;
|
||
- порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов;
|
||
- фильтрация по CIDR;
|
||
- детерминированное присвоение class id с контрактом лексикографической стабильности;
|
||
- textual boundary class id `9999`/`10000`;
|
||
- рендеринг и разбор ruleset nftables для anti-torrent и ownership parser host iptables chains;
|
||
- парсер `tc` для снимков qdisc/class/filter;
|
||
- packet statistics из Debian-подобного `tc -s class`;
|
||
- отказ удалять foreign IFB/root qdisc;
|
||
- typed Docker readiness retry/timeout/lower-priority event;
|
||
- Docker UDP bindings: dual-stack HostIp, routed binding с пустым HostPort и неоднозначные/divergent host ports;
|
||
- BBR decision: доступный BBR при runtime `cubic` означает `desired=yes`, `runtime_active=no`; mode `off` не маскирует уже активный runtime BBR;
|
||
- host firewall rollback удаляет новый jump по exact specification и не затрагивает чужое правило в начале `INPUT`;
|
||
- conntrack count из Amnezia namespace и global max с host;
|
||
- diff drift;
|
||
- расчёт окна перезагрузки;
|
||
- идемпотентность fstab и sysctl.
|
||
|
||
Интеграционные тесты на реальном хосте:
|
||
|
||
- первичное применение;
|
||
- повторное применение;
|
||
- восстановление частичного состояния после удаления только ingress qdisc;
|
||
- восстановление частичного состояния после удаления `ifb0`;
|
||
- рестарт контейнера;
|
||
- рестарт Docker;
|
||
- перезагрузка хоста;
|
||
- добавление peer;
|
||
- удаление peer;
|
||
- вывод status в здоровом и сломанном состояниях;
|
||
- рост counter anti-torrent при превышении peer настроенного порога одновременных flows;
|
||
- путь отключения anti-torrent не сбрасывает посторонние правила nftables/Docker/Amnezia.
|