feat: bootstrap awg2 harness rust workspace and docs
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# Acceptance checklist перед production-выкладкой
|
||||
|
||||
## A. Базовая установка
|
||||
|
||||
- [ ] Хост — Debian 13 amd64.
|
||||
- [ ] Сервер выделенный, без соседних сервисов.
|
||||
- [ ] AmneziaWG/AWG 2.0 установлена официальным способом.
|
||||
- [ ] Контейнер `amnezia-awg2` или `amnezia-awg` запущен.
|
||||
- [ ] `docker ps` видит контейнер.
|
||||
- [ ] Можно получить PID контейнера.
|
||||
- [ ] `/proc/<pid>/ns/net` существует.
|
||||
- [ ] На хосте доступны `ip`, `tc`, `wg`, `nsenter`, `modprobe`.
|
||||
- [ ] `awg2-harness validate-config` проходит без ошибок.
|
||||
|
||||
## B. Peer discovery
|
||||
|
||||
- [ ] `awg2-harness status` показывает найденный контейнер.
|
||||
- [ ] `awg2-harness status` показывает PID/netns контейнера.
|
||||
- [ ] `awg2-harness status` показывает найденный интерфейс.
|
||||
- [ ] Количество peers совпадает с `wg show all allowed-ips`.
|
||||
- [ ] Offline peers с allowed IP тоже попадают в список shaping.
|
||||
- [ ] IP вне `amnezia.peer_ipv4_cidr` не попадают в traffic shaping.
|
||||
|
||||
## C. Traffic topology parity with old bash
|
||||
|
||||
- [ ] `awg2-harness apply-limits` успешно завершается.
|
||||
- [ ] На AWG/WG-интерфейсе есть root qdisc `1: htb`.
|
||||
- [ ] На AWG/WG-интерфейсе есть root class `1:1` с `traffic.root_rate`.
|
||||
- [ ] Для каждого peer есть class `1:<id>` с `traffic.peer_limit`.
|
||||
- [ ] Для каждого peer есть `fq_codel` под `1:<id>`.
|
||||
- [ ] Для каждого peer есть egress filter `dst peer_ip -> 1:<id>`.
|
||||
- [ ] Есть ingress qdisc `ffff:` на AWG/WG-интерфейсе.
|
||||
- [ ] Есть ingress redirect через `mirred ... redirect dev ifb0`.
|
||||
- [ ] `ifb0` существует и находится в UP state.
|
||||
- [ ] На `ifb0` есть root qdisc `2: htb`.
|
||||
- [ ] На `ifb0` есть root class `2:1` с `traffic.root_rate`.
|
||||
- [ ] Для каждого peer есть class `2:<id>` с `traffic.peer_limit`.
|
||||
- [ ] Для каждого peer есть `fq_codel` под `2:<id>`.
|
||||
- [ ] Для каждого peer есть ingress filter `src peer_ip -> 2:<id>`.
|
||||
- [ ] Class ids начинаются с `traffic.class_start_id`, default `10`.
|
||||
- [ ] Peer IP → class id mapping сохраняет bash parity: IP сортируются лексикографически как строки, не numeric IPv4.
|
||||
|
||||
## D. Idempotency and drift recovery
|
||||
|
||||
- [ ] Повторный `apply-limits` не создаёт дубли.
|
||||
- [ ] Повторный `apply-limits` не меняет healthy state без причины.
|
||||
- [ ] Если удалить root qdisc на AWG/WG-интерфейсе, daemon восстанавливает topology.
|
||||
- [ ] Если удалить ingress qdisc, daemon восстанавливает topology.
|
||||
- [ ] Если удалить `ifb0`, daemon восстанавливает topology.
|
||||
- [ ] Если удалить один peer filter, daemon восстанавливает topology.
|
||||
- [ ] После удаления peer устаревшие class/filter исчезают через rebuild.
|
||||
- [ ] После добавления peer появляются новые class/filter.
|
||||
|
||||
## E. Auto-healing lifecycle
|
||||
|
||||
- [ ] `docker restart amnezia-awg2` → лимиты восстанавливаются автоматически.
|
||||
- [ ] `systemctl restart docker` → после возвращения контейнера лимиты восстанавливаются автоматически.
|
||||
- [ ] `systemctl restart awg2-harness` → сервис сам находит контейнер и применяет/проверяет лимиты.
|
||||
- [ ] Reboot хоста → сервис стартует и восстанавливает лимиты.
|
||||
- [ ] Изменение имени интерфейса на допустимое fallback-имя не ломает detector.
|
||||
- [ ] Если контейнер временно отсутствует, daemon логирует ожидание и продолжает работу.
|
||||
|
||||
## F. Status and observability
|
||||
|
||||
- [ ] `status` показывает desired peer limit из конфига, а не hardcoded 70.
|
||||
- [ ] `status` показывает egress state.
|
||||
- [ ] `status` показывает ingress/ifb state.
|
||||
- [ ] `status` показывает missing/stale peer rules.
|
||||
- [ ] `status` явно показывает partial/drift state.
|
||||
- [ ] Логи доступны через `journalctl -u awg2-harness`.
|
||||
- [ ] Ошибка отсутствия `tc`/`ip`/`wg` логируется явно.
|
||||
- [ ] Последний reconcile result виден в status или journal.
|
||||
|
||||
## G. Reboot scheduler
|
||||
|
||||
- [ ] Если `reboot.enabled = false`, сервис не планирует reboot.
|
||||
- [ ] Если `reboot.enabled = true`, в journal видна запланированная перезагрузка.
|
||||
- [ ] Время попадает в окно `window_start_utc`–`window_end_utc`.
|
||||
- [ ] Вызов reboot выполняется через `systemctl --no-block reboot`.
|
||||
|
||||
## H. Swap
|
||||
|
||||
- [ ] `ensure-swap` создаёт `/swapfile` размером 2048 MB.
|
||||
- [ ] Права `/swapfile` — `0600`.
|
||||
- [ ] `swapon --show` показывает активный swap.
|
||||
- [ ] В `/etc/fstab` нет дублей.
|
||||
- [ ] `vm.swappiness = 1` применён.
|
||||
- [ ] Повторный `ensure-swap` не пересоздаёт корректный swap.
|
||||
- [ ] Несовместимый существующий swap не удаляется молча.
|
||||
|
||||
## I. Anti-torrent nftables guard
|
||||
|
||||
- [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`.
|
||||
- [ ] Есть chain `forward_antitorrent` с hook `forward` и policy `accept`.
|
||||
- [ ] Правило ограничивает `ip saddr <peer_ipv4_cidr>`, default `10.8.1.0/24`.
|
||||
- [ ] Лимит считается per source VPN IP через meter/set, а не глобально по всей подсети.
|
||||
- [ ] Порог совпадает с `anti_torrent.max_connections_per_peer`, default `200`.
|
||||
- [ ] Drop counter виден в `status`.
|
||||
- [ ] При превышении порога новые flows дропаются, established flows ниже порога не ломаются.
|
||||
- [ ] После restart контейнера nftables rule пересоздаётся в новом netns.
|
||||
- [ ] При `anti_torrent.enabled = false` удаляется/отключается только AWG2-owned nftables state; Docker/Amnezia rules не flush-ятся.
|
||||
- [ ] `status` показывает `nf_conntrack_count` / `nf_conntrack_max`, если sysctl доступны.
|
||||
|
||||
## J. Rollback
|
||||
|
||||
- [ ] Старые bash-файлы сохранены до завершения миграции.
|
||||
- [ ] Известно, как остановить `awg2-harness.service`.
|
||||
- [ ] Известно, как вернуть `amnezia-tc.service` и `amnezia-tc-watcher.service`.
|
||||
- [ ] Rollback проверен на тестовом сервере.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Anti-torrent connection-count guard
|
||||
|
||||
Этот модуль не является частью `Amnezia limits (old bash)`. Это новое расширение Rust multi-orchestrator. Оно нормально вписывается в проект, потому что сервер выделенный, а бинарь уже владеет network state вокруг Amnezia: `tc`, `ifb0`, lifecycle recovery и диагностика.
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Не детектить BitTorrent как протокол. Цель проще: ограничить количество одновременных conntrack flows на один VPN peer IP. Торрент-клиенты обычно пытаются держать сотни TCP/UDP peer-сессий, DHT/uTP/trackers. Лимит на 200–250 одновременных flows резко ухудшает P2P-поведение, но почти не затрагивает обычные сценарии.
|
||||
|
||||
Это мягкий технический барьер, а не abuse-система со страйками.
|
||||
|
||||
## 2. Важное исправление к простой команде
|
||||
|
||||
Не использовать такую форму как production rule:
|
||||
|
||||
```nft
|
||||
nft add rule inet filter forward ip saddr 10.8.1.0/24 ct count over 200 drop
|
||||
```
|
||||
|
||||
Проблемы:
|
||||
|
||||
1. Это может стать общим лимитом для всей подсети, а не лимитом на каждого клиента.
|
||||
2. Нет ownership: правило кладётся в generic `inet filter forward`, который может принадлежать не нашему пакету.
|
||||
3. Неочевидно, в каком network namespace оно применяется.
|
||||
4. Нет named counter и нормальной диагностики.
|
||||
|
||||
Нужен per-source key через meter/set и отдельная таблица, которой владеет только AWG2 Harness.
|
||||
|
||||
## 3. Recommended v1 nftables shape
|
||||
|
||||
Правило должно применяться в том же network namespace, где живёт Amnezia AWG/WG interface и где уже применяются `tc`/`ifb0` rules. На практике это namespace контейнера Amnezia, полученный через `/proc/<container_pid>/ns/net`.
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пример для текущей production-подсети:
|
||||
|
||||
```nft
|
||||
table inet awg2_harness {
|
||||
counter antitorrent_dropped_v4 {}
|
||||
|
||||
chain forward_antitorrent {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
|
||||
ip saddr 10.8.1.0/24 ct state new \
|
||||
meter awg2_peer_connlimit_v4 { ip saddr ct count over 200 } \
|
||||
counter name antitorrent_dropped_v4 \
|
||||
drop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ct state new` важен: ограничиваются новые flow attempts сверх порога, а уже установленные flows не начинают внезапно дропаться только потому, что счётчик стал высоким.
|
||||
|
||||
## 4. Threshold
|
||||
|
||||
Рекомендуемый default:
|
||||
|
||||
```toml
|
||||
[anti_torrent]
|
||||
enabled = true
|
||||
max_connections_per_peer = 200
|
||||
```
|
||||
|
||||
Практические варианты:
|
||||
|
||||
- `150` — агрессивнее, больше риск задеть редкие легитимные сценарии.
|
||||
- `200` — хороший default для VPN peer с лимитом 70 Mbit/s.
|
||||
- `250` — более осторожный вариант, если хочется минимизировать риск жалоб от тяжёлых браузерных/WebRTC/WebSocket-сценариев.
|
||||
|
||||
## 5. Почему это должно быть в orchestrator
|
||||
|
||||
Модуль должен жить в Rust-бинаре, а не отдельным ручным правилом, потому что orchestrator должен:
|
||||
|
||||
- применять правило после container restart/update, когда network namespace изменился;
|
||||
- проверять actual state и чинить drift;
|
||||
- показывать status/counters;
|
||||
- не ломать unrelated nftables/Docker/Amnezia rules;
|
||||
- удалять или отключать только owned state при `enabled = false`.
|
||||
|
||||
## 6. Conntrack и производительность
|
||||
|
||||
`ct count` использует conntrack. На текущем масштабе это нормально: даже 23 peers × 200 flows = около 4600 активных flows до ограничения. Это не выглядит опасно для production-хоста само по себе.
|
||||
|
||||
Что надо мониторить:
|
||||
|
||||
```bash
|
||||
sysctl net.netfilter.nf_conntrack_count
|
||||
sysctl net.netfilter.nf_conntrack_max
|
||||
nft list table inet awg2_harness
|
||||
```
|
||||
|
||||
Если `nf_conntrack_count` приближается к `nf_conntrack_max`, проблема уже системная: надо разбираться с timeout, abuse или capacity. Не надо автоматически увеличивать `nf_conntrack_max` из orchestrator без отдельного решения.
|
||||
|
||||
## 7. Acceptance criteria
|
||||
|
||||
- В правильном Amnezia netns существует `table inet awg2_harness`.
|
||||
- В таблице есть `chain forward_antitorrent` с hook `forward`.
|
||||
- Правило ограничивает `ip saddr <peer_ipv4_cidr>`.
|
||||
- Лимит применяется per source VPN IP, а не глобально на всю подсеть.
|
||||
- Drop counter растёт при превышении threshold.
|
||||
- Нормальные сценарии с числом flows ниже threshold не затрагиваются.
|
||||
- При restart контейнера правило пересоздаётся в новом netns.
|
||||
- При `anti_torrent.enabled = false` удаляется или отключается только AWG2-owned nftables state.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Конфигурация AWG2 Harness
|
||||
|
||||
Конфиг живёт в `/etc/awg2-harness/config.toml`. Он общий для всего пакета и должен содержать только эксплуатационно значимые параметры.
|
||||
|
||||
## Рекомендуемый `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 = 60
|
||||
container_ready_timeout_secs = 30
|
||||
class_start_id = 10
|
||||
ifb_name = "ifb0"
|
||||
full_rebuild_on_drift = true
|
||||
|
||||
[anti_torrent]
|
||||
enabled = true
|
||||
max_connections_per_peer = 200
|
||||
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
|
||||
```
|
||||
|
||||
## Пояснения
|
||||
|
||||
`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`.
|
||||
|
||||
`amnezia.interface_candidates` — точные имена интерфейса, которые пробуются первыми.
|
||||
|
||||
`amnezia.interface_patterns` — fallback-паттерны для устойчивости к обновлениям Amnezia. Старые материалы прямо отмечают риск переименования интерфейса между `awg0`, `wg0`, `amnezia0`.
|
||||
|
||||
`amnezia.peer_ipv4_cidr` — подсеть VPN peers. Сейчас старая bash-система жёстко ищет `10.8.1.x`; в Rust это должно быть конфигурируемо.
|
||||
|
||||
`traffic.peer_limit` — per-peer лимит в обе стороны. Текущий production default — `70mbit`.
|
||||
|
||||
`traffic.root_rate` — верхний HTB-класс, в старой системе `1000mbit`.
|
||||
|
||||
`traffic.reconcile_interval_secs` — период фоновой проверки. Он нужен не вместо Docker events, а вместе с ними.
|
||||
|
||||
`traffic.container_ready_timeout_secs` — сколько ждать появления интерфейса после старта контейнера.
|
||||
|
||||
`traffic.class_start_id` — первый class id. В старой системе это `10`, что даёт классы `1:10`, `2:10` и дальше.
|
||||
|
||||
`traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. В старой системе это `ifb0`. Этот параметр можно оставить в конфиге, потому что бинарь реально создаёт/удаляет этот интерфейс.
|
||||
|
||||
`traffic.full_rebuild_on_drift` — безопасная v1-модель: при drift удалить owned topology и собрать заново.
|
||||
|
||||
`anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это новое расширение, не часть old bash.
|
||||
|
||||
`anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Default `200`; `250` — более консервативный вариант.
|
||||
|
||||
`anti_torrent.peer_ipv4_cidr` — подсеть, на которую распространяется правило. Обычно должна совпадать с `amnezia.peer_ipv4_cidr`.
|
||||
|
||||
`anti_torrent.apply_in_amnezia_netns` — правило применяется в network namespace контейнера Amnezia. Это default, потому что именно там видны исходные VPN peer IP до возможного NAT.
|
||||
|
||||
`reboot.enabled` — включает плановую перезагрузку.
|
||||
|
||||
`reboot.window_start_utc` / `reboot.window_end_utc` — UTC-окно плановой перезагрузки. В старом Rust-модуле это было зашито как 01:00–02:00 UTC.
|
||||
|
||||
`swap.enabled` — включает setup-задачу swap.
|
||||
|
||||
`swap.path`, `swap.size_mb`, `swap.priority`, `swap.swappiness` — параметры аварийного swap. Default: 2 ГБ, низкий priority, `swappiness = 1`.
|
||||
|
||||
## Что не выносить в конфиг
|
||||
|
||||
Не нужно выносить:
|
||||
|
||||
- handles `1:`, `2:`, `ffff:`;
|
||||
- имена внутренних Rust-модулей;
|
||||
- формат команд `tc`;
|
||||
- retry micro-tuning;
|
||||
- формат логов;
|
||||
- путь lock-файла, если нет эксплуатационной причины;
|
||||
- hardening systemd;
|
||||
- nftables hook/priority/table names, если нет осознанной причины.
|
||||
|
||||
Handles и структура `HTB + fq_codel + ingress + ifb` — это contract traffic module, а не настройка оператора.
|
||||
@@ -0,0 +1,444 @@
|
||||
# AWG2 Harness implementation spec
|
||||
|
||||
## 1. Architectural role
|
||||
|
||||
`awg2-harness` is a single Rust binary acting as a host-side multi-orchestrator for a dedicated Debian 13 amd64 AmneziaWG/AWG 2.0 server.
|
||||
|
||||
It owns the infrastructure state needed by the VPN service:
|
||||
|
||||
- Amnezia container discovery;
|
||||
- container network namespace discovery;
|
||||
- AWG/WG interface discovery;
|
||||
- peer discovery;
|
||||
- traffic-control desired state;
|
||||
- IFB ingress shaping;
|
||||
- nftables connection-count anti-torrent guard;
|
||||
- Docker lifecycle recovery;
|
||||
- periodic reconciliation;
|
||||
- status/diagnostics;
|
||||
- planned reboot;
|
||||
- emergency swap setup.
|
||||
|
||||
It does not replace Amnezia. It controls the surrounding system state that the old bash package configured manually.
|
||||
|
||||
## 2. Process model
|
||||
|
||||
One systemd service runs:
|
||||
|
||||
```bash
|
||||
/usr/local/bin/awg2-harness daemon --config /etc/awg2-harness/config.toml
|
||||
```
|
||||
|
||||
The daemon starts as root and keeps running. On startup it performs initial reconcile. During runtime it reacts to Docker events and also runs a periodic reconcile loop.
|
||||
|
||||
Subcommands:
|
||||
|
||||
```bash
|
||||
awg2-harness daemon
|
||||
awg2-harness apply-limits
|
||||
awg2-harness apply-firewall
|
||||
awg2-harness status
|
||||
awg2-harness ensure-swap
|
||||
awg2-harness validate-config
|
||||
```
|
||||
|
||||
## 3. Module layout
|
||||
|
||||
Recommended Rust module split:
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.rs
|
||||
├── cli.rs
|
||||
├── config.rs
|
||||
├── daemon.rs
|
||||
├── lock.rs
|
||||
├── docker.rs
|
||||
├── netns.rs
|
||||
├── iface.rs
|
||||
├── wg.rs
|
||||
├── traffic/
|
||||
│ ├── mod.rs
|
||||
│ ├── model.rs
|
||||
│ ├── inspect.rs
|
||||
│ ├── apply.rs
|
||||
│ ├── parse_tc.rs
|
||||
│ └── commands.rs
|
||||
├── firewall/
|
||||
│ ├── mod.rs
|
||||
│ ├── model.rs
|
||||
│ ├── inspect.rs
|
||||
│ ├── apply.rs
|
||||
│ └── nft.rs
|
||||
├── status.rs
|
||||
├── reboot.rs
|
||||
├── swap.rs
|
||||
└── error.rs
|
||||
```
|
||||
|
||||
## 4. Core data model
|
||||
|
||||
The traffic module should operate on explicit model structs, not raw strings passed around the codebase.
|
||||
|
||||
Conceptual model:
|
||||
|
||||
```rust
|
||||
struct RuntimeTarget {
|
||||
container_name: String,
|
||||
container_id: String,
|
||||
container_pid: u32,
|
||||
netns_path: PathBuf,
|
||||
interface_name: String,
|
||||
}
|
||||
|
||||
struct Peer {
|
||||
public_key: Option<String>,
|
||||
ipv4: Ipv4Addr,
|
||||
cidr: u8, // normally 32
|
||||
latest_handshake: Option<HandshakeAge>,
|
||||
transfer_rx: Option<ByteSize>,
|
||||
transfer_tx: Option<ByteSize>,
|
||||
}
|
||||
|
||||
struct TrafficDesiredState {
|
||||
iface: String,
|
||||
ifb_name: String,
|
||||
root_rate: Rate,
|
||||
peer_limit: Rate,
|
||||
class_start_id: u16,
|
||||
peers: Vec<PeerTrafficClass>,
|
||||
}
|
||||
|
||||
struct PeerTrafficClass {
|
||||
peer_ip: Ipv4Addr,
|
||||
class_id: u16,
|
||||
}
|
||||
```
|
||||
|
||||
Class assignment must be deterministic. For strict parity with bash v1, peer IP strings must be sorted lexicographically, exactly like `sort -u` in the old script, then assigned `class_start_id + index`. Do not silently switch this to numeric IPv4 sorting in v1: the production snapshot maps `10.8.1.10` before `10.8.1.2` because bash sorted text, not numeric addresses.
|
||||
|
||||
## 5. Namespace strategy
|
||||
|
||||
Preferred strategy:
|
||||
|
||||
1. Detect running container through Docker API or `docker ps` / `docker inspect`.
|
||||
2. Read container PID.
|
||||
3. Use `/proc/<pid>/ns/net` as the target namespace.
|
||||
4. Execute network operations in that namespace.
|
||||
|
||||
Implementation options:
|
||||
|
||||
- transitional v1: spawn host tools with namespace entry, for example `nsenter -t <pid> -n tc ...`, `nsenter -t <pid> -n ip ...`, `nsenter -t <pid> -n wg ...`;
|
||||
- later/native: use netlink crates and explicit namespace switching where practical.
|
||||
|
||||
`docker exec` should not be the architectural default. It can remain as compatibility fallback if `nsenter`/host tools are unavailable, but the design assumption is that the Rust binary has host authority and controls the relevant namespace.
|
||||
|
||||
This also reduces the risk noted in the old docs: if the Amnezia container drops `tc` or `iproute2`, host-side tools can still apply rules inside the container netns.
|
||||
|
||||
## 6. Discovery flow
|
||||
|
||||
`discover_target()`:
|
||||
|
||||
1. Load ordered `amnezia.container_names`.
|
||||
2. Find the first running matching container.
|
||||
3. Get container ID, status and PID.
|
||||
4. Build `netns_path`.
|
||||
5. Detect interface in that namespace.
|
||||
6. Return `RuntimeTarget`.
|
||||
|
||||
Interface detection order:
|
||||
|
||||
1. Exact names from `amnezia.interface_candidates`.
|
||||
2. Pattern scan from `amnezia.interface_patterns`.
|
||||
3. Fallback: first non-loopback interface with IPv4 address, only if it is clearly inside the Amnezia namespace.
|
||||
4. If ambiguous, return an explicit error and do not apply rules.
|
||||
|
||||
## 7. Peer discovery
|
||||
|
||||
Primary command:
|
||||
|
||||
```bash
|
||||
wg show all allowed-ips
|
||||
```
|
||||
|
||||
Parsing rules:
|
||||
|
||||
- parse lines structurally into peer public key and allowed IPs;
|
||||
- keep IPv4 `/32` addresses within `amnezia.peer_ipv4_cidr`;
|
||||
- include offline peers; online state is not a requirement for shaping;
|
||||
- sort peer IP strings lexicographically before class assignment to preserve old bash `sort -u` parity.
|
||||
|
||||
Fallback:
|
||||
|
||||
```bash
|
||||
wg show
|
||||
```
|
||||
|
||||
The fallback parser should support the old output format from `wg-peers.txt`, extracting `allowed ips:` lines.
|
||||
|
||||
## 8. Desired traffic topology
|
||||
|
||||
The expected topology exactly mirrors old bash.
|
||||
|
||||
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 u32 match ip dst <peer_ip> flowid 1:<id>
|
||||
```
|
||||
|
||||
Ingress through IFB:
|
||||
|
||||
```text
|
||||
modprobe ifb
|
||||
ip link add name <ifb_name> type ifb
|
||||
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 u32 match ip src <peer_ip> flowid 2:<id>
|
||||
```
|
||||
|
||||
## 9. Reconcile algorithm
|
||||
|
||||
Pseudo-flow:
|
||||
|
||||
```text
|
||||
reconcile_traffic():
|
||||
acquire global lock
|
||||
target = discover_target()
|
||||
peers = discover_peers(target)
|
||||
if peers empty:
|
||||
log and return NoPeers
|
||||
desired = build_desired_state(target, peers, config)
|
||||
actual = inspect_actual_state(target, desired)
|
||||
drift = diff(desired, actual)
|
||||
firewall_desired = build_firewall_desired_state(target, config)
|
||||
firewall_actual = inspect_firewall_state(target, firewall_desired)
|
||||
firewall_drift = diff(firewall_desired, firewall_actual)
|
||||
|
||||
if drift.none and firewall_drift.none:
|
||||
log "rules already up-to-date"
|
||||
return NoChange
|
||||
|
||||
if drift.exists:
|
||||
if config.traffic.full_rebuild_on_drift:
|
||||
teardown_owned_topology(target, desired)
|
||||
apply_full_topology(target, desired)
|
||||
else:
|
||||
apply_incremental_fix(drift)
|
||||
verify_after_apply(target, desired)
|
||||
|
||||
if firewall_drift.exists:
|
||||
apply_owned_firewall_table(target, firewall_desired)
|
||||
verify_firewall_after_apply(target, firewall_desired)
|
||||
```
|
||||
|
||||
For v1, `full_rebuild_on_drift = true` is the safer default.
|
||||
|
||||
## 10. Owned state and teardown rules
|
||||
|
||||
The binary owns:
|
||||
|
||||
- root qdisc `1:` on selected AWG/WG interface;
|
||||
- ingress qdisc `ffff:` on selected AWG/WG interface when traffic limits are enabled;
|
||||
- IFB device named `traffic.ifb_name`, default `ifb0`, in the target namespace;
|
||||
- root qdisc `2:` on IFB;
|
||||
- classes and filters under these handles.
|
||||
|
||||
Teardown may delete and recreate these owned objects. Because the server is dedicated, this is acceptable and preferable to leaving stale or partial rules.
|
||||
|
||||
The binary should not blindly flush unrelated host firewall rules, Docker NAT rules, or unrelated interfaces. Firewall ownership must be explicit and scoped to the AWG2 Harness table, for example `table inet awg2_harness`, inside the target network namespace.
|
||||
|
||||
|
||||
## 11. Anti-torrent firewall guard
|
||||
|
||||
This is a new module, not part of old bash parity. It fits the multi-orchestrator model because the server is dedicated and the binary is allowed to own host/network rules required for the VPN product. The rule must still be scoped narrowly and observable.
|
||||
|
||||
Goal: limit concurrent conntrack flows per VPN peer IP. This is not torrent protocol detection. It is a generic connection-count guard that makes high-peer-count P2P workloads unattractive while leaving normal browsing, streaming, gaming and messaging traffic unaffected.
|
||||
|
||||
The rule must not be implemented as a global subnet limit:
|
||||
|
||||
```nft
|
||||
ip saddr 10.8.1.0/24 ct count over 200 drop
|
||||
```
|
||||
|
||||
That form can become one shared counter for the whole matched traffic set, depending on how it is written and where it is placed. For per-peer behavior, key the stateful object by source VPN IP. Recommended v1 shape:
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Target namespace: the same Amnezia network namespace where `tc` shaping is applied. Do not assume the initial host namespace sees original peer source addresses. If Amnezia performs NAT inside the container namespace, the host Docker bridge may only see the container address, not `10.8.1.x`.
|
||||
|
||||
Default threshold: `200` concurrent flows per peer IP. `250` is the conservative compatibility setting if there are complaints from unusually connection-heavy WebSocket/WebRTC/trading-browser workloads. `150` is more aggressive and should only be used after real traffic observation.
|
||||
|
||||
Rule placement: use `forward` hook with priority `filter` or any priority after conntrack lookup. Restrict evaluation to `ct state new` so established flows continue normally and only new flow attempts beyond the threshold are dropped.
|
||||
|
||||
Owned state:
|
||||
|
||||
- table `inet awg2_harness`;
|
||||
- chain `forward_antitorrent`;
|
||||
- counter `antitorrent_dropped_v4`;
|
||||
- meter/set state created by the rule.
|
||||
|
||||
Status must show whether the table/chain/rule exists, the configured threshold, the peer CIDR, and the drop counter. It should also expose host conntrack pressure from `net.netfilter.nf_conntrack_count` and `net.netfilter.nf_conntrack_max` when available.
|
||||
|
||||
If `anti_torrent.enabled = false`, the daemon should remove or leave inactive only the AWG2-owned anti-torrent table according to config policy. It must never flush the global nftables ruleset.
|
||||
|
||||
## 12. Actual-state inspection
|
||||
|
||||
`inspect_actual_state()` must collect enough information to diagnose drift:
|
||||
|
||||
- qdisc on `<iface>`;
|
||||
- classes on `<iface>`;
|
||||
- qdiscs attached to per-peer classes on `<iface>`;
|
||||
- filters under parent `1:` on `<iface>`;
|
||||
- ingress qdisc on `<iface>`;
|
||||
- ingress redirect filter on `<iface>`;
|
||||
- existence and state of `<ifb_name>`;
|
||||
- qdisc on `<ifb_name>`;
|
||||
- classes on `<ifb_name>`;
|
||||
- filters under parent `2:` on `<ifb_name>`.
|
||||
|
||||
`status` should display a compact summary and optionally a verbose mode for raw `tc` sections.
|
||||
|
||||
## 13. Docker events and periodic reconciliation
|
||||
|
||||
The daemon should use two triggers:
|
||||
|
||||
1. Docker events: container start/restart signals cause delayed reconcile after interface readiness.
|
||||
2. Timer: periodic reconcile every `traffic.reconcile_interval_secs`.
|
||||
|
||||
The timer is mandatory. Events can be missed during daemon restart, Docker restart or transient errors.
|
||||
|
||||
Readiness check must not hardcode `awg0`. It must call the shared interface detector until timeout.
|
||||
|
||||
## 14. Locking
|
||||
|
||||
Only one traffic reconcile may run at a time across all subcommands and daemon tasks.
|
||||
|
||||
Recommended lock:
|
||||
|
||||
```text
|
||||
/run/awg2-harness/traffic.lock
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `daemon` skips overlapping reconcile and logs it;
|
||||
- manual `apply-limits` either waits briefly or exits with a clear message;
|
||||
- lock release is automatic when process exits.
|
||||
|
||||
## 15. Status output contract
|
||||
|
||||
`awg2-harness status` should show:
|
||||
|
||||
```text
|
||||
System:
|
||||
service: active/inactive/unknown
|
||||
docker: available/unavailable
|
||||
container: name, id, status, pid
|
||||
netns: /proc/<pid>/ns/net
|
||||
interface: awg0/wg0/...
|
||||
|
||||
Peers:
|
||||
total: N
|
||||
each peer: ip, optional public key prefix, handshake, rx/tx
|
||||
|
||||
Traffic desired:
|
||||
peer_limit, root_rate, ifb_name, class_start_id
|
||||
|
||||
Traffic actual:
|
||||
egress qdisc/classes/filters: ok/drift
|
||||
ingress redirect: ok/drift
|
||||
ifb topology: ok/drift
|
||||
stale peers: list
|
||||
missing peers: list
|
||||
|
||||
Anti-torrent actual:
|
||||
enabled: true/false
|
||||
nft table/chain/rule: ok/drift
|
||||
max_connections_per_peer
|
||||
dropped_new_flows counter
|
||||
conntrack_count / conntrack_max if available
|
||||
|
||||
Last actions:
|
||||
last reconcile result
|
||||
last error if any
|
||||
```
|
||||
|
||||
The old status banner “system ready” is not enough. Rust status must be falsifiable: it should clearly say when state is partial.
|
||||
|
||||
## 16. Reboot module integration
|
||||
|
||||
Move existing `reboot_module` logic into `reboot.rs`:
|
||||
|
||||
- window from config, not constants;
|
||||
- random point inside `[window_start_utc, window_end_utc)`;
|
||||
- `systemctl --no-block reboot` remains;
|
||||
- disabled when `reboot.enabled = false`;
|
||||
- daemon logs planned reboot time.
|
||||
|
||||
## 17. Swap module
|
||||
|
||||
`ensure-swap` desired state:
|
||||
|
||||
- path from config, default `/swapfile`;
|
||||
- size 2048 MB;
|
||||
- mode `0600`;
|
||||
- `mkswap` completed;
|
||||
- active in `swapon --show`;
|
||||
- one `/etc/fstab` entry with configured priority;
|
||||
- `vm.swappiness = 1` persisted and applied.
|
||||
|
||||
The module must be idempotent. It must not delete unknown swap state automatically.
|
||||
|
||||
## 18. Testing
|
||||
|
||||
Unit tests:
|
||||
|
||||
- config validation;
|
||||
- `wg show all allowed-ips` parser;
|
||||
- `wg show` fallback parser;
|
||||
- CIDR filtering;
|
||||
- deterministic class assignment with lexicographic bash parity;
|
||||
- nftables ruleset rendering and parsing for the AWG2-owned table;
|
||||
- `tc` parser for qdisc/class/filter snapshots;
|
||||
- drift diff;
|
||||
- reboot window calculation;
|
||||
- fstab/sysctl idempotency.
|
||||
|
||||
Integration tests on real host:
|
||||
|
||||
- initial apply;
|
||||
- repeated apply;
|
||||
- partial state recovery after deleting only ingress qdisc;
|
||||
- partial state recovery after deleting `ifb0`;
|
||||
- container restart;
|
||||
- Docker restart;
|
||||
- host reboot;
|
||||
- peer addition;
|
||||
- peer deletion;
|
||||
- status output in healthy and broken states;
|
||||
- anti-torrent rule counter increments when a peer exceeds the configured concurrent-flow threshold;
|
||||
- anti-torrent disable path does not flush unrelated nftables/Docker/Amnezia rules.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Установка AWG2 Harness на production-хост
|
||||
|
||||
## 1. Предусловия
|
||||
|
||||
Хост:
|
||||
|
||||
- Debian 13 amd64;
|
||||
- root-доступ;
|
||||
- SSH настроен заранее;
|
||||
- AmneziaWG/AWG 2.0 установлена официальным способом;
|
||||
- контейнер Amnezia запускается через Docker;
|
||||
- сервер выделенный, без соседних сервисов, чьи сетевые правила нужно сохранять.
|
||||
|
||||
На хосте должны быть доступны:
|
||||
|
||||
```bash
|
||||
docker
|
||||
systemctl
|
||||
ip
|
||||
tc
|
||||
wg
|
||||
nsenter
|
||||
modprobe
|
||||
nft
|
||||
```
|
||||
|
||||
`tc/ip/wg` внутри контейнера полезны для fallback, но целевая модель — host tools + вход в network namespace контейнера.
|
||||
|
||||
## 2. Сборка бинаря
|
||||
|
||||
Сборку лучше выполнять на отдельной build-машине с тем же целевым ABI.
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Проверки перед переносом:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --release
|
||||
file target/release/awg2-harness
|
||||
ldd target/release/awg2-harness
|
||||
```
|
||||
|
||||
## 3. Установка файлов
|
||||
|
||||
```bash
|
||||
install -m 0755 target/release/awg2-harness /usr/local/bin/awg2-harness
|
||||
install -d -m 0755 /etc/awg2-harness
|
||||
install -m 0644 config.toml /etc/awg2-harness/config.toml
|
||||
```
|
||||
|
||||
Проверка конфига:
|
||||
|
||||
```bash
|
||||
awg2-harness validate-config --config /etc/awg2-harness/config.toml
|
||||
```
|
||||
|
||||
## 4. Первичное применение traffic topology
|
||||
|
||||
```bash
|
||||
awg2-harness apply-limits --config /etc/awg2-harness/config.toml
|
||||
awg2-harness status --config /etc/awg2-harness/config.toml
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
- найден контейнер `amnezia-awg2` или `amnezia-awg`;
|
||||
- найден network namespace контейнера;
|
||||
- найден AWG/WG-интерфейс;
|
||||
- найдены peers из `wg show all allowed-ips`;
|
||||
- создана egress topology на AWG/WG-интерфейсе;
|
||||
- создан `ifb0`;
|
||||
- создан ingress redirect в `ifb0`;
|
||||
- на каждого peer есть egress и ingress class/filter;
|
||||
- если anti-torrent включён, в Amnezia netns есть `table inet awg2_harness` и drop counter отображается в status.
|
||||
|
||||
## 5. Настройка swap
|
||||
|
||||
```bash
|
||||
awg2-harness ensure-swap --config /etc/awg2-harness/config.toml
|
||||
swapon --show
|
||||
cat /proc/sys/vm/swappiness
|
||||
```
|
||||
|
||||
Ожидается `/swapfile` размером 2 ГБ, права `0600`, запись в `/etc/fstab`, низкая склонность к использованию.
|
||||
|
||||
## 6. Установка systemd unit
|
||||
|
||||
`/etc/systemd/system/awg2-harness.service`:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Активация:
|
||||
|
||||
```bash
|
||||
systemd-analyze verify /etc/systemd/system/awg2-harness.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now awg2-harness.service
|
||||
```
|
||||
|
||||
## 7. Проверка после запуска daemon
|
||||
|
||||
```bash
|
||||
systemctl status awg2-harness.service
|
||||
journalctl -u awg2-harness.service -n 100 --no-pager
|
||||
awg2-harness status --config /etc/awg2-harness/config.toml
|
||||
```
|
||||
|
||||
## 8. Проверка auto-healing
|
||||
|
||||
```bash
|
||||
docker restart amnezia-awg2
|
||||
sleep 30
|
||||
awg2-harness status --config /etc/awg2-harness/config.toml
|
||||
```
|
||||
|
||||
Затем проверить Docker restart и host reboot. После каждого события status должен показывать healthy state без ручного запуска old bash.
|
||||
|
||||
## 9. Rollback
|
||||
|
||||
Если нужно временно вернуться к old bash:
|
||||
|
||||
```bash
|
||||
systemctl stop awg2-harness.service
|
||||
systemctl disable awg2-harness.service
|
||||
# вернуть /opt/amnezia/limit_peers.sh и /opt/amnezia/docker_watcher.sh
|
||||
# вернуть amnezia-tc.service и amnezia-tc-watcher.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now amnezia-tc.service amnezia-tc-watcher.service
|
||||
```
|
||||
|
||||
Rollback должен быть проверен до первой production-выкладки.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Миграция 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 из `reboot_module`.
|
||||
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 = 60
|
||||
container_ready_timeout_secs = 30
|
||||
class_start_id = 10
|
||||
ifb_name = "ifb0"
|
||||
full_rebuild_on_drift = true
|
||||
|
||||
[anti_torrent]
|
||||
enabled = true
|
||||
max_connections_per_peer = 200
|
||||
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_module` интегрируется внутрь общего daemon. Окно перезагрузки должно переехать из констант в конфиг. Механика `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.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Поведение `Amnezia limits (old bash)`
|
||||
|
||||
Этот документ фиксирует фактический behavior старой bash-системы. Он нужен как migration contract: Rust-версия должна воспроизвести не только видимый лимит 70 Mbit/s, но и всю сетевую topology, lifecycle-логику и диагностику.
|
||||
|
||||
## 1. Файлы старой системы
|
||||
|
||||
```text
|
||||
Amnezia limits (old bash)/
|
||||
├── limit_peers.sh.txt # основной apply/reconcile traffic-control rules
|
||||
├── docker_watcher.sh.txt # watcher Docker container start events
|
||||
├── check-vpn-limits.txt # status/diagnostic CLI
|
||||
├── amnezia-tc.service.txt # systemd oneshot apply
|
||||
├── amnezia-tc-watcher.service.txt # systemd watcher
|
||||
├── traffic_control-was.txt # production snapshot of tc qdisc/classes/filters
|
||||
├── wg-peers.txt # production snapshot of wg peers
|
||||
├── iproute2_a_core.txt # kernel/iproute2/docker/container facts
|
||||
├── permissions.txt # installed paths and enabled units
|
||||
└── Нюансы.txt # operational notes and known risks
|
||||
```
|
||||
|
||||
## 2. Runtime environment from snapshots
|
||||
|
||||
Зафиксированное окружение:
|
||||
|
||||
- Debian 13 cloud amd64 kernel line: `6.12.57+deb13-cloud-amd64`.
|
||||
- `tc` from `iproute2-6.15.0`.
|
||||
- Docker `26.1.5+dfsg1`.
|
||||
- Amnezia container name in snapshot: `amnezia-awg2`.
|
||||
- Interface in snapshot: `awg0`.
|
||||
- Systemd units `amnezia-tc.service` and `amnezia-tc-watcher.service` enabled.
|
||||
- Scripts installed in `/opt/amnezia/` and executable by root.
|
||||
|
||||
## 3. Container discovery
|
||||
|
||||
`limit_peers.sh` searches running containers in this order:
|
||||
|
||||
```text
|
||||
amnezia-awg2
|
||||
amnezia-awg
|
||||
```
|
||||
|
||||
`check-vpn-limits` uses the opposite order in its array:
|
||||
|
||||
```text
|
||||
amnezia-awg
|
||||
amnezia-awg2
|
||||
```
|
||||
|
||||
Rust should normalize this into one configured ordered list. Recommended default: `amnezia-awg2`, then `amnezia-awg`, because `amnezia-awg2` is the current AWG 2.0 production name in the snapshot.
|
||||
|
||||
## 4. Interface discovery
|
||||
|
||||
`limit_peers.sh` starts from `WG_IF`, default `wg0`. If this interface is absent inside the container, it auto-detects:
|
||||
|
||||
```text
|
||||
wg[0-9]+ | wg | awg[0-9]* | amnezia[0-9]* | tun[0-9]+
|
||||
```
|
||||
|
||||
`check-vpn-limits` detects:
|
||||
|
||||
```text
|
||||
wg[0-9]+ | wg | amnezia[0-9]* | tun[0-9]+ | veth[0-9]+
|
||||
```
|
||||
|
||||
Operational note says the important risk is future Amnezia interface rename between `awg0`, `wg0`, `amnezia0`. Rust must use a single detector shared by apply, daemon, watcher and status.
|
||||
|
||||
## 5. Peer discovery
|
||||
|
||||
Primary source:
|
||||
|
||||
```bash
|
||||
wg show all allowed-ips
|
||||
```
|
||||
|
||||
The script extracts the third field and keeps IPv4 addresses matching:
|
||||
|
||||
```text
|
||||
10.8.1.[0-9]
|
||||
```
|
||||
|
||||
Fallback source:
|
||||
|
||||
```bash
|
||||
wg show
|
||||
```
|
||||
|
||||
Then regex extracts the same `10.8.1.x` addresses.
|
||||
|
||||
The production peer snapshot contains 23 peers in `10.8.1.0/24`, with a mix of online peers and peers without endpoint/handshake. Rust must shape all peers with allowed IPs, not only currently online peers.
|
||||
|
||||
## 6. Traffic-control topology
|
||||
|
||||
The old script owns both egress and ingress shaping.
|
||||
|
||||
Egress on the AWG/WG interface:
|
||||
|
||||
```text
|
||||
<iface> root qdisc: handle 1: htb
|
||||
<iface> root class: 1:1 rate 1000mbit ceil 1000mbit
|
||||
per peer class: 1:<class_id> rate LIMIT ceil LIMIT
|
||||
per peer qdisc: fq_codel under 1:<class_id>
|
||||
per peer filter: match ip dst <peer_ip> flowid 1:<class_id>
|
||||
```
|
||||
|
||||
Ingress is implemented through IFB:
|
||||
|
||||
```text
|
||||
modprobe ifb
|
||||
ip link add name ifb0 type ifb
|
||||
ip link set dev ifb0 up
|
||||
<iface> ingress qdisc: handle ffff:
|
||||
<iface> ingress filter: action mirred egress redirect dev ifb0
|
||||
ifb0 root qdisc: handle 2: htb
|
||||
ifb0 root class: 2:1 rate 1000mbit ceil 1000mbit
|
||||
per peer class: 2:<class_id> rate LIMIT ceil LIMIT
|
||||
per peer qdisc: fq_codel under 2:<class_id>
|
||||
per peer filter: match ip src <peer_ip> flowid 2:<class_id>
|
||||
```
|
||||
|
||||
Default limit:
|
||||
|
||||
```text
|
||||
LIMIT=70mbit
|
||||
```
|
||||
|
||||
Default root rate:
|
||||
|
||||
```text
|
||||
1000mbit
|
||||
```
|
||||
|
||||
Default first class id:
|
||||
|
||||
```text
|
||||
10
|
||||
```
|
||||
|
||||
The snapshot `traffic_control-was.txt` shows classes `1:10` through `1:32`, which corresponds to 23 peers. Class assignment follows bash `sort -u`, so it is lexicographic string order, not numeric IPv4 order. For example, `10.8.1.10` sorts before `10.8.1.2`. Rust v1 must preserve this if strict parity with production class IDs is required.
|
||||
|
||||
## 7. Rebuild behavior
|
||||
|
||||
Before applying new rules, the script deletes old topology:
|
||||
|
||||
```bash
|
||||
tc qdisc del dev <iface> root || true
|
||||
tc qdisc del dev <iface> ingress || true
|
||||
ip link del ifb0 || true
|
||||
```
|
||||
|
||||
Then it recreates everything. This is important: the production model is full rebuild on detected mismatch. Rust v1 should keep this behavior unless a well-tested incremental diff is implemented later.
|
||||
|
||||
## 8. Drift detection in bash
|
||||
|
||||
The script reads existing filters:
|
||||
|
||||
```bash
|
||||
tc filter show dev <iface> parent 1:
|
||||
```
|
||||
|
||||
It extracts hex IP matches and compares count/content with expected peer IPs. If count differs or any peer IP is missing, it rebuilds rules.
|
||||
|
||||
Weakness: this only strongly verifies egress filters on `<iface>`. It does not fully verify `ifb0`, ingress redirect, qdisc leaves or per-peer ingress filters. Rust must improve this and treat any partial state as drift.
|
||||
|
||||
## 9. Docker watcher behavior
|
||||
|
||||
`docker_watcher.sh` listens to:
|
||||
|
||||
```bash
|
||||
docker events --filter event=start --format '{{.Actor.Attributes.name}}'
|
||||
```
|
||||
|
||||
If the started container is `amnezia-awg2` or `amnezia-awg`, it waits up to 20 seconds for:
|
||||
|
||||
```bash
|
||||
ip link show awg0
|
||||
```
|
||||
|
||||
Then it runs:
|
||||
|
||||
```bash
|
||||
WG_IF=awg0 /opt/amnezia/limit_peers.sh
|
||||
```
|
||||
|
||||
Weakness: hardcoded `awg0`. Rust watcher must reuse the same interface detector as `apply-limits`.
|
||||
|
||||
## 10. Status behavior
|
||||
|
||||
`check-vpn-limits` reports:
|
||||
|
||||
- status of `amnezia-tc.service`;
|
||||
- status of `amnezia-tc.timer`, although timer file is not present in the archive;
|
||||
- running container and Docker status string;
|
||||
- connected clients from `wg show`;
|
||||
- TC classes with `rate 70Mbit` on the AWG/WG interface;
|
||||
- filter statistics from `tc -s filter show dev <iface> parent 1:`;
|
||||
- last 3 events from `/var/log/amnezia-tc.log`.
|
||||
|
||||
Rust `status` must be at least as useful, but should not hardcode 70 Mbit/s; it should use config and display desired vs actual.
|
||||
|
||||
## 11. Operational guarantees stated in old notes
|
||||
|
||||
The old notes claim the system currently survives:
|
||||
|
||||
- host reboot;
|
||||
- Docker restart;
|
||||
- container restart;
|
||||
- Amnezia update;
|
||||
- user addition;
|
||||
- user removal.
|
||||
|
||||
They also identify two breakage scenarios:
|
||||
|
||||
- Amnezia fully changes `wg show` output format;
|
||||
- container loses `tc` or `iproute2` tools.
|
||||
|
||||
In the Rust architecture, the second risk should be reduced by host namespace control: `tc/ip` should preferably come from the host while operating in the container netns, rather than requiring them inside the container.
|
||||
|
||||
## 12. Migration contract
|
||||
|
||||
Rust migration is not accepted until all of the following are true:
|
||||
|
||||
- same peers are discovered as in old bash;
|
||||
- same default limit is applied: 70 Mbit/s per peer;
|
||||
- both egress and ingress shaping exist;
|
||||
- `ifb0` exists and is up when ingress shaping is enabled;
|
||||
- class ids are deterministic, start from 10 and use lexicographic bash `sort -u` peer ordering;
|
||||
- full rebuild removes stale peer rules after peer deletion;
|
||||
- repeated apply is safe;
|
||||
- daemon recovers after Docker/container lifecycle events;
|
||||
- status explains actual drift rather than printing only a success banner.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Документация AWG2 Harness
|
||||
|
||||
Рекомендуемый порядок чтения:
|
||||
|
||||
1. [`OLD_BASH_BEHAVIOR.md`](OLD_BASH_BEHAVIOR.md) — что реально делает старая production bash-система.
|
||||
2. [`MIGRATION.md`](MIGRATION.md) — corrected migration scope: Rust multi-orchestrator, а не механический bash port.
|
||||
3. [`IMPLEMENTATION_SPEC.md`](IMPLEMENTATION_SPEC.md) — целевая архитектура и state reconciliation.
|
||||
4. [`CONFIGURATION.md`](CONFIGURATION.md) — единый конфиг.
|
||||
5. [`ANTI_TORRENT.md`](ANTI_TORRENT.md) — опциональный nftables guard против high-peer-count P2P.
|
||||
6. [`INSTALLATION.md`](INSTALLATION.md) — установка на Debian 13 amd64.
|
||||
7. [`ACCEPTANCE_CHECKLIST.md`](ACCEPTANCE_CHECKLIST.md) — критерии production-ready.
|
||||
Reference in New Issue
Block a user