From 16214f3c6608cf43d3ccc35ee47be34c48a78968 Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 6 Aug 2026 15:32:26 +0500 Subject: [PATCH] AmneziaWG 3.0 readiness: centralize wg CLI contract, drop dead host-side fallback Peer discovery is now the single owner of how wg is invoked and parsed. wg.rs: - resolve the binary through ordered amnezia.wg_binaries (default [awg, wg]); awg is the real binary in the official image and wg is a symlink to it, so awg is tried first and wg stays for plain WireGuard containers. No probe call: resolution is a property of the command being run anyway. - drop the nsenter fallback. AmneziaWG is userspace and its UAPI socket lives under RUNSTATEDIR/amneziawg/ in the container mount namespace, while host wireguard-tools looks under /var/run/wireguard/. nsenter -n enters only the network namespace, so that path could never return peers. - classify dump lines structurally instead of by column count. The AWG interface line is longer than a peer line (14 columns on AWG 2.0, 28 on AWG 3.0) and its handshake/rx/tx positions hold junk-packet sizes that parse as integers, so only the allowed-ips column discriminates. Column 1 of an interface line is the device private key, which the previous heuristic rejected only incidentally. - fix allowed-ips parsing: `show all allowed-ips` separates a peer's addresses with spaces, not commas, so peers with more than one allowed IP were shaped on their first address only and the rest ran unlimited. - collapse the three duplicated /32-in-CIDR filters into one tokenizer shared by the allowed-ips, dump and pretty-print parsers. config.rs: - add amnezia.wg_binaries with serde default, validated as bare command names (non-empty, no whitespace, no path separators) since they execute inside the container. Existing deployed configs keep parsing unchanged. - remove host wg from the required-tool preflight. It gated daemon startup on wireguard-tools for a code path that produced nothing. status.rs: - discover peers once instead of twice per status call and degrade gracefully like the neighbouring sections instead of aborting the whole output. Tests: real AWG 3.0 / AWG 2.0 / plain WireGuard interface-line fixtures, AWG 3.0 range keepalive, space-separated allowed-ips, binary resolution order, and a test that parses and validates the shipped config.example.toml. --- README.md | 35 ++- config.example.toml | 18 +- docs/ACCEPTANCE_CHECKLIST.md | 13 +- docs/CONFIGURATION.md | 15 +- docs/IMPLEMENTATION_SPEC.md | 53 +++- docs/INSTALLATION.md | 10 +- docs/PRODUCTION_EXAMPLE.md | 16 +- traffic_core/src/config.rs | 81 +++++- traffic_core/src/firewall.rs | 4 + traffic_core/src/status.rs | 34 +-- traffic_core/src/sysctl_tuning.rs | 1 + traffic_core/src/traffic.rs | 18 +- traffic_core/src/wg.rs | 459 ++++++++++++++++++++++-------- 13 files changed, 566 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index d4e4e7d..40098bd 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,38 @@ AWG2 Harness не устанавливает Amnezia. Используйте о ```bash docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' -C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" echo "container=$C" docker port "$C" -docker exec "$C" wg show +docker exec "$C" awg show ``` -Ожидаемые имена контейнера по умолчанию: `amnezia-awg2` или `amnezia-awg`. Ожидаемый интерфейс внутри container netns: обычно `awg0`, fallback-кандидаты описаны в `config.example.toml`. +Ожидаемые имена контейнера по умолчанию: `amnezia-awg2` или `amnezia-awg`. `amnezia-awg3` перечислен на будущее — в AmneziaVPN 5.0 такого типа контейнера ещё нет. Ожидаемый интерфейс внутри container netns: обычно `awg0`, fallback-кандидаты описаны в `config.example.toml`. + +### Совместимость с AmneziaWG 3.0 + +Обвязка не реализует AWG на уровне пакетов: `tc`, IFB и `nftables` работают с inner-IP уже после декапсуляции, поэтому header protection, content padding и рандомизация таймеров AWG 3.0 её не касаются. + +Читается только peer state, всегда через `docker exec`. Реальный бинарь в официальном образе — `/usr/bin/awg`, `/usr/bin/wg` является symlink на него; порядок задаётся `amnezia.wg_binaries` (по умолчанию `["awg", "wg"]`). На хосте `wireguard-tools` не требуется и не проверяется в preflight: AmneziaWG — userspace-реализация, её UAPI-сокет лежит в mount namespace контейнера, и хостовый `wg` через `nsenter -n` до него не достаёт. + +Smoke-проверка после обновления контейнера: + +```bash +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" + +docker exec "$C" readlink -f /usr/bin/wg # ожидается /usr/bin/awg +docker exec "$C" awg --version +docker exec "$C" ip -o link show awg0 +docker exec "$C" awg show all allowed-ips +docker port "$C" + +# Структура dump без раскрытия значений: +docker exec "$C" awg show all dump | awk -F '\t' '{ print "row=" NR, "fields=" NF, "interface=" $1 }' +``` + +Ожидаемо: `/usr/bin/wg` указывает на `/usr/bin/awg`, интерфейс `awg0` существует, peer-адреса отображаются, серверный порт опубликован по UDP. Interface-строка даст 14 полей на AWG 2.0 и 28 на AWG 3.0, peer-строки — 9. + +Полный вывод `awg show all dump` передавать нельзя: во второй колонке interface-строки находится приватный ключ устройства. ### 3.1 Установить AmneziaDNS (обязательный production-шаг) @@ -288,7 +313,7 @@ awg2-harness --config /etc/awg2-harness/config.toml status Дополнительно можно проверить фактическую `tc` topology внутри network namespace Amnezia: ```bash -C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" PID="$(docker inspect -f '{{.State.Pid}}' "$C")" nsenter -t "$PID" -n -- tc class show dev awg0 @@ -309,7 +334,7 @@ awg2-harness --config /etc/awg2-harness/config.toml `validate-config` проверяет TOML и смысловые ограничения: пустые списки, формат rate (`50mbit`, `500mbit`), CIDR, reboot window, swap path, SSH/Amnezia ports, protocol list. -`validate-runtime` проверяет host tools, которые реально нужны выбранным модулям: `tc`, `ip`, `wg`, `docker`, `modprobe`, `nsenter`, а также `nft`, `systemctl`, swap tooling при включённых firewall/reboot/swap. +`validate-runtime` проверяет host tools, которые реально нужны выбранным модулям: `tc`, `ip`, `docker`, `modprobe`, `nsenter`, а также `nft`, `systemctl`, swap tooling при включённых firewall/reboot/swap. Хостовый `wg` в этот список не входит: peer state читается внутри контейнера через `docker exec` (`amnezia.wg_binaries`). `ensure-swap` приводит swap к desired state: создаёт/проверяет swapfile, включает его с заданным priority, применяет `vm.swappiness`, делает swap state exclusive и переписывает swap entries в `/etc/fstab` под управляемый swapfile. diff --git a/config.example.toml b/config.example.toml index a1ab7f6..14faa00 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,12 +1,22 @@ [amnezia] -container_names = ["amnezia-awg2", "amnezia-awg"] +# Порядок важен: используется первый запущенный контейнер из списка. +# amnezia-awg3 указан на будущее: в AmneziaVPN 5.0 такого типа контейнера ещё нет. +container_names = ["amnezia-awg2", "amnezia-awg3", "amnezia-awg"] interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"] interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"] + +# Утилита для чтения peer state внутри контейнера. Пробуются по порядку, +# используется первая успешная. В официальном образе AmneziaWG реальный бинарь — +# /usr/bin/awg, а /usr/bin/wg является symlink на него. +# Значения выполняются внутри контейнера: только имена команд, без путей и пробелов. +# На хосте wireguard-tools не требуется. +wg_binaries = ["awg", "wg"] + # Подсеть VPN peers. # Проверка: -# C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +# C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" # docker exec "$C" ip -4 addr -# docker exec "$C" wg show +# docker exec "$C" awg show peer_ipv4_cidr = "10.8.1.0/24" [traffic] @@ -53,7 +63,7 @@ enabled = true # Должен совпадать с реально слушающим портом sshd до apply-firewall. ssh_port = 2323 # Должен совпадать с опубликованным Docker/Amnezia UDP-портом. -# Проверка: docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg +# Проверка: docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg3 2>/dev/null || docker port amnezia-awg # Транспортный порт AmneziaWG/AWG, опубликованный Docker, обычно 443/udp. # Это не AmneziaDNS. Не указывайте здесь 53. amnezia_port = 443 diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 54afd60..cd2b82f 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -5,11 +5,11 @@ - [ ] Хост — Debian 13 amd64. - [ ] Сервер выделенный, без соседних сервисов. - [ ] AmneziaWG/AWG 2.0 или AmneziaWG/AWG 1.5 установлена официальным способом. -- [ ] Контейнер `amnezia-awg2` или `amnezia-awg` запущен. +- [ ] Контейнер `amnezia-awg2` или `amnezia-awg` запущен (`amnezia-awg3` — резерв на будущее, в AmneziaVPN 5.0 такого типа контейнера ещё нет). - [ ] `docker ps` видит контейнер. - [ ] Можно получить PID контейнера. - [ ] `/proc//ns/net` существует. -- [ ] На хосте доступны `ip`, `tc`, `wg`, `nsenter`, `modprobe`. +- [ ] На хосте доступны `ip`, `tc`, `nsenter`, `modprobe`. Хостовый `wg` не требуется. - [ ] `awg2-harness validate-config` проходит без ошибок. ## B. Peer discovery @@ -17,9 +17,12 @@ - [ ] `awg2-harness status` показывает найденный контейнер. - [ ] `awg2-harness status` показывает PID/netns контейнера. - [ ] `awg2-harness status` показывает найденный интерфейс. -- [ ] Количество peers совпадает с `wg show all allowed-ips`. +- [ ] Количество peers совпадает с `docker exec "$C" awg show all allowed-ips`. +- [ ] Peer с несколькими allowed IP даёт отдельный класс на каждый адрес (в этой команде адреса разделены пробелом). - [ ] Offline peers с allowed IP тоже попадают в список shaping. - [ ] IP вне `amnezia.peer_ipv4_cidr` не попадают в traffic shaping. +- [ ] В секции `WireGuard clients` вывода `status` нет строки, соответствующей самому интерфейсу (столбец `Public key` не содержит приватный ключ устройства). +- [ ] `docker exec "$C" readlink -f /usr/bin/wg` возвращает `/usr/bin/awg`. ## C. Traffic topology contract @@ -161,7 +164,7 @@ - [ ] AmneziaDNS установлен из приложения AmneziaVPN. - [ ] Контейнер `amnezia-dns` в состоянии healthy. - [ ] `docker inspect amnezia-dns` показывает `"53/tcp":null` и `"53/udp":null`. -- [ ] `amnezia-dns` и активный AWG-контейнер (`amnezia-awg2` или `amnezia-awg`) подключены к одной Amnezia DNS Docker-сети. +- [ ] `amnezia-dns` и активный AWG-контейнер (`amnezia-awg2`, `amnezia-awg3` или `amnezia-awg`) подключены к одной Amnezia DNS Docker-сети. - [ ] Публичный DNS-запрос к `:53` не резолвит домены; допустимы `connection refused`, `timeout` или `no servers could be reached`. - [ ] DNS-резолвинг у VPN-клиента работает при активном подключении. - [ ] AWG2 host firewall не открывает `53/tcp` и `53/udp`. @@ -181,6 +184,6 @@ - [ ] `status` показывает блок `Docker/Amnezia port path` с полями `docker_userland_proxy`, `published_udp_port` и note про Docker-managed `FORWARD`. - [ ] `status` показывает поля `host_forward_policy_drop` и `docker_forward_rules_present` в блоке `Docker/Amnezia port path`. - [ ] Если `docker_userland_proxy=false` и `FORWARD policy DROP`, при отсутствии явных Docker accept-признаков `status` выводит warning, но `awg2-harness` не изменяет `FORWARD` автоматически. -- [ ] Сверено соответствие: `config host_firewall.amnezia_port` ↔ опубликованный Docker UDP host port ↔ `docker exec amnezia-awg2 wg show` (`listening port`). +- [ ] Сверено соответствие: `config host_firewall.amnezia_port` ↔ опубликованный Docker UDP host port ↔ `docker exec amnezia-awg2 awg show` (`listening port`). - [ ] При нескольких UDP bindings без однозначного соответствия `host_firewall.amnezia_port` диагностика `published_udp_port` показывает `unknown` (ambiguous), а не произвольный первый порт. - [ ] Для диагностики FORWARD используется best-effort проверка: `iptables -S FORWARD` и fallback через `nft list ruleset`; `status` не падает при недоступности одной из команд. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4208fb8..e75d4b3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -24,20 +24,21 @@ nano /etc/awg2-harness/config.toml Discovery-команды: ```bash -docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg +docker port amnezia-awg2 2>/dev/null || docker port amnezia-awg3 2>/dev/null || docker port amnezia-awg -C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" docker exec "$C" ip -4 addr -docker exec "$C" wg show +docker exec "$C" awg show ``` ## Рекомендуемый `config.toml` ```toml [amnezia] -container_names = ["amnezia-awg2", "amnezia-awg"] +container_names = ["amnezia-awg2", "amnezia-awg3", "amnezia-awg"] interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"] interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"] +wg_binaries = ["awg", "wg"] peer_ipv4_cidr = "10.8.1.0/24" [traffic] @@ -91,12 +92,16 @@ swappiness = 1 ## Пояснения -`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`. +`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен, сопоставление точное. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`. `amnezia-awg3` перечислен на будущее: в AmneziaVPN 5.0 отдельного типа контейнера под AWG 3.0 нет, клиент по-прежнему знает только `Awg` и `Awg2`. `amnezia.interface_candidates` — точные имена интерфейса, которые пробуются первыми. `amnezia.interface_patterns` — fallback-паттерны для устойчивости к обновлениям Amnezia. Они нужны для снижения риска переименования интерфейса между `awg0`, `wg0`, `amnezia0`, `tun0`. +`amnezia.wg_binaries` — утилита чтения peer state внутри контейнера, пробуется по порядку до первого успеха. Default — `["awg", "wg"]`. В официальном образе AmneziaWG реальный бинарь `/usr/bin/awg`, а `/usr/bin/wg` — symlink на него, поэтому `awg` идёт первым; `wg` остаётся для контейнеров с чистым WireGuard. Значения выполняются внутри контейнера и валидируются как имена команд: непустые, без пробелов и без разделителей пути. Параметр опциональный — конфиги, созданные до его появления, продолжают работать с default. + +Хостовый `wireguard-tools` не нужен и не проверяется в preflight. Peer state читается только через `docker exec`: AmneziaWG — userspace-реализация, её UAPI-сокет находится в `RUNSTATEDIR/amneziawg/` внутри mount namespace контейнера, а `nsenter -n` входит только в network namespace, поэтому хостовый `wg` до него не достаёт. + `amnezia.peer_ipv4_cidr` — подсеть VPN peers. Production default — `10.8.1.0/24`; значение конфигурируемое. `traffic.peer_limit` — per-peer лимит в обе стороны. Текущий production default — `50mbit`. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 2d2c808..e06591b 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -147,9 +147,17 @@ Preferred strategy: Implementation options: -- current path: spawn host tools with namespace entry, for example `nsenter -t -n tc ...`, `nsenter -t -n ip ...`, `nsenter -t -n wg ...`; +- current path: spawn host tools with namespace entry, for example `nsenter -t -n tc ...`, `nsenter -t -n ip ...`; - later/native: use netlink crates and explicit namespace switching where practical. +Peer state is the one exception and must never be read with a host-side binary. +AmneziaWG is a userspace implementation whose UAPI socket lives under +`RUNSTATEDIR/amneziawg/.sock` inside the container mount namespace, while host +`wireguard-tools` looks under `/var/run/wireguard/`. `nsenter -n` enters only the network +namespace, so a host `wg` reaches neither the socket nor a kernel device and always +returns an empty peer list. Peer discovery therefore uses `docker exec` exclusively, and +`wireguard-tools` is not a host dependency. + `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 operational risk: if the Amnezia container drops `tc` or `iproute2`, host-side tools can still apply rules inside the container netns. @@ -174,15 +182,28 @@ Interface detection order: ## 7. Peer discovery +Transport is always `docker exec ` — see section 5 for why a host-side `wg` +cannot work. + +Binary resolution uses ordered `amnezia.wg_binaries`, default `["awg", "wg"]`. The first +binary that exits successfully wins. The official AmneziaWG image ships `/usr/bin/awg` and +symlinks `/usr/bin/wg` to it, so `awg` is the real dependency; `wg` remains for plain +WireGuard containers where `awg` does not exist. There is no probe call: the resolution is +a property of the command that is being run anyway. + Primary command: ```bash -wg show all allowed-ips +awg show all allowed-ips ``` Parsing rules: - parse lines structurally into peer public key and allowed IPs; +- a peer's allowed ips are **space-separated** in this command, comma-separated in + `show all dump` and `", "`-separated in plain `show`; all tokens from column 3 onward + must be consumed, otherwise peers with more than one allowed IP are shaped only on their + first address; - 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 the current lexicographic class-id contract. @@ -190,16 +211,31 @@ Parsing rules: Fallback: ```bash -wg show +awg show ``` The fallback parser should support standard `wg show` text output by extracting `allowed ips:` lines. +`show all dump` line classification: + +- a line is a peer line only if it has at least 9 tab-separated columns, column 5 + (allowed ips) is `(none)` or a comma-separated CIDR list, and columns 6-8 (handshake, + rx, tx) all parse as integers; +- column count is not a discriminator on its own: the AmneziaWG interface line carries the + AWG parameters and is *longer* than a peer line (14 columns for AWG 2.0, 28 for AWG 3.0), + and its handshake/rx/tx positions hold junk-packet sizes that parse as integers; +- this matters beyond correctness — column 2 of an interface line is the device private + key, so a misclassified interface line would surface it as a peer public key; +- AWG 3.0 prints protocol timers as ranges (`20-30`). The peer keepalive column is not + read, and a range value must not break parsing. + Execution policy for production: - do not use shell wrappers (`sh -c`) for `wg` invocation; - do not suppress stderr via redirection in command strings; -- execute `wg` directly and handle non-zero exit codes in Rust. +- execute the binary directly and handle non-zero exit codes in Rust; +- `amnezia.wg_binaries` values are executed inside the container and are validated as bare + command names: non-empty, no whitespace, no path separators. ## 8. Desired traffic topology @@ -463,9 +499,12 @@ The operation must be idempotent. Unit tests: -- config validation; -- `wg show all allowed-ips` parser; -- `wg show` fallback parser; +- config validation, including `amnezia.wg_binaries` shape; +- `awg|wg show all allowed-ips` parser, including multiple space-separated allowed ips; +- `awg|wg show` fallback parser; +- `awg|wg show all dump` peer/interface line classification against AWG 3.0 (28 columns), + AWG 2.0 (14 columns) and plain WireGuard (5 columns) interface lines; +- wg binary resolution order and the error raised when no binary is available; - CIDR filtering; - deterministic class assignment with lexicographic class-id stability contract; - nftables ruleset rendering and parsing for the AWG2-owned table; diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index ca2d0f1..b976e3d 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -127,14 +127,16 @@ Password or SSH private key: ```bash docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' -C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" echo "container=$C" docker port "$C" -docker exec "$C" wg show +docker exec "$C" awg show ``` Если контейнер называется иначе или интерфейс не `awg0`, отразите это в `/etc/awg2-harness/config.toml`. +`awg` — реальный бинарь в официальном образе AmneziaWG; `wg` внутри контейнера является symlink на него и тоже сработает. На хосте `wireguard-tools` устанавливать не нужно: обвязка читает peer state только через `docker exec`. + ### B3. Установить AmneziaDNS Установите AmneziaDNS из desktop-приложения AmneziaVPN: @@ -164,7 +166,7 @@ amnezia-dns amnezia-dns Up ... (healthy) 53/tcp, 53/udp Проверка публикации портов: ```bash -AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" echo "AWG=$AWG" docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns @@ -193,7 +195,7 @@ Optional deep-check DNS path (если нужна углублённая про ```bash apt install -y dnsutils -AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +AWG="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" DNS_IP="$(docker inspect -f '{{range $n,$v := .NetworkSettings.Networks}}{{if $v.IPAddress}}{{println $v.IPAddress}}{{end}}{{end}}' amnezia-dns | head -n1)" DNS_PID="$(docker inspect -f '{{.State.Pid}}' amnezia-dns)" AWG_PID="$(docker inspect -f '{{.State.Pid}}' "$AWG")" diff --git a/docs/PRODUCTION_EXAMPLE.md b/docs/PRODUCTION_EXAMPLE.md index fabb681..fcc786a 100644 --- a/docs/PRODUCTION_EXAMPLE.md +++ b/docs/PRODUCTION_EXAMPLE.md @@ -43,12 +43,24 @@ awg2-harness --config /etc/awg2-harness/config.toml status ```bash docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' -C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg)$' | head -n1)" +C="$(docker ps --format '{{.Names}}' | grep -E '^(amnezia-awg2|amnezia-awg3|amnezia-awg)$' | head -n1)" docker port "$C" -docker exec "$C" wg show +docker exec "$C" awg show docker inspect -f 'ports={{json .NetworkSettings.Ports}}' amnezia-dns ``` +Проверка AWG-контракта внутри контейнера (без раскрытия секретов): + +```bash +docker exec "$C" readlink -f /usr/bin/wg # ожидается /usr/bin/awg +docker exec "$C" awg --version +docker exec "$C" ip -o link show awg0 +docker exec "$C" awg show all allowed-ips +docker exec "$C" awg show all dump | awk -F '\t' '{ print "row=" NR, "fields=" NF, "interface=" $1 }' +``` + +Interface-строка `awg show all dump` даёт 14 полей на AWG 2.0 и 28 на AWG 3.0, peer-строки — 9. Полный вывод `awg show all dump` передавать нельзя: во второй колонке interface-строки находится приватный ключ устройства. + Проверка, что runtime config создан вручную (ожидаемое поведение): ```bash diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 93d649e..568d19d 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -26,6 +26,12 @@ fn default_input_policy() -> String { "drop".to_string() } +/// The official AmneziaWG image ships `/usr/bin/awg` and symlinks `/usr/bin/wg` to it, +/// so `awg` is the real dependency; `wg` stays as fallback for plain WireGuard containers. +fn default_wg_binaries() -> Vec { + vec!["awg".to_string(), "wg".to_string()] +} + fn default_sysctl_tuning_for_missing_block() -> SysctlTuningConfig { SysctlTuningConfig { enabled: false, @@ -85,6 +91,8 @@ pub struct AmneziaConfig { pub interface_candidates: Vec, pub interface_patterns: Vec, pub peer_ipv4_cidr: String, + #[serde(default = "default_wg_binaries")] + pub wg_binaries: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -168,6 +176,23 @@ impl Config { if self.amnezia.interface_patterns.is_empty() { anyhow::bail!("amnezia.interface_patterns must not be empty"); } + if self.amnezia.wg_binaries.is_empty() { + anyhow::bail!("amnezia.wg_binaries must not be empty"); + } + for binary in &self.amnezia.wg_binaries { + // Values are executed inside the container: keep them bare command names. + if binary.is_empty() { + anyhow::bail!("amnezia.wg_binaries must not contain an empty value"); + } + if binary.chars().any(char::is_whitespace) { + anyhow::bail!("amnezia.wg_binaries must not contain whitespace: {binary}"); + } + if binary.contains('/') || binary.contains('\\') { + anyhow::bail!( + "amnezia.wg_binaries must be bare command names without a path: {binary}" + ); + } + } if self.traffic.class_start_id < 10 { anyhow::bail!("traffic.class_start_id must be >= 10"); } @@ -314,7 +339,9 @@ impl Config { } fn check_host_tools(&self) -> anyhow::Result<()> { - let mut required = vec!["tc", "ip", "wg", "docker"]; + // `wg` is intentionally not required on the host: peer state is read with + // `docker exec awg|wg`, never with a host-side binary. + let mut required = vec!["tc", "ip", "docker"]; if !crate::netns::nsenter_available() { anyhow::bail!("required host tool not found in PATH: nsenter"); } @@ -460,7 +487,6 @@ fn tool_exists(tool: &str) -> bool { let probes: &[&[&str]] = match tool { "tc" => &[&["-V"]], "ip" => &[&["-V"], &["-Version"]], - "wg" => &[&["--version"]], "docker" => &[&["--version"]], "modprobe" => &[&["--version"]], "nsenter" => &[&["--help"]], @@ -523,6 +549,7 @@ mod tests { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: TrafficConfig { enabled: true, @@ -647,6 +674,54 @@ mod tests { assert!(cfg.validate_config().is_err()); } + /// The shipped example is the template every deployment starts from: it must parse and + /// validate against the current schema. + #[test] + fn config_example_parses_and_validates() { + let raw = include_str!("../../config.example.toml"); + let cfg = toml::from_str::(raw).expect("config.example.toml parse"); + cfg.validate_config().expect("config.example.toml validate"); + assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]); + assert!( + cfg.amnezia + .container_names + .contains(&"amnezia-awg2".to_string()) + ); + } + + #[test] + fn wg_binaries_default_prefers_awg() { + let cfg = base_config(); + assert_eq!(default_wg_binaries(), vec!["awg", "wg"]); + assert!(cfg.validate_config().is_ok()); + } + + #[test] + fn wg_binaries_rejects_empty_list() { + let mut cfg = base_config(); + cfg.amnezia.wg_binaries = vec![]; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn wg_binaries_rejects_empty_value() { + let mut cfg = base_config(); + cfg.amnezia.wg_binaries = vec!["awg".into(), "".into()]; + assert!(cfg.validate_config().is_err()); + } + + #[test] + fn wg_binaries_rejects_whitespace_and_paths() { + for bad in ["awg show", "/usr/bin/awg", "..\\awg"] { + let mut cfg = base_config(); + cfg.amnezia.wg_binaries = vec![bad.into()]; + assert!( + cfg.validate_config().is_err(), + "must reject wg binary value: {bad}" + ); + } + } + #[test] fn config_requires_host_firewall_section() { let raw = r#" @@ -736,6 +811,8 @@ swappiness = 1 assert!(!cfg.sysctl_tuning.enabled); assert!(cfg.sysctl_tuning.apply_on_daemon_start); assert_eq!(cfg.sysctl_tuning.tcp_bbr, "auto"); + // Deployed configs predate amnezia.wg_binaries and must keep parsing. + assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]); } #[test] diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs index f0d21a6..5c62ea1 100644 --- a/traffic_core/src/firewall.rs +++ b/traffic_core/src/firewall.rs @@ -682,6 +682,7 @@ table inet awg2_host_firewall { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: crate::config::TrafficConfig { enabled: true, @@ -744,6 +745,7 @@ table inet awg2_host_firewall { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: crate::config::TrafficConfig { enabled: true, @@ -790,6 +792,7 @@ table inet awg2_host_firewall { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: crate::config::TrafficConfig { enabled: true, @@ -848,6 +851,7 @@ table inet awg2_host_firewall { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: crate::config::TrafficConfig { enabled: true, diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 35208ec..2f7e9b0 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -48,27 +48,25 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { cfg.host_firewall.amnezia_port, ); - let peers = wg::discover_peers( - target.container_pid, - &target.container_name, - &cfg.amnezia.peer_ipv4_cidr, - )?; + // Peer discovery is one container round-trip, shared by both sections below. + let peers = wg::discover_peers(cfg, &target); println!("Peers:"); - println!(" total: {}", peers.len()); - for p in peers { - println!(" - {}", p.ipv4); + match &peers { + Ok(list) => { + println!(" total: {}", list.len()); + for p in list { + println!(" - {}", p.ipv4); + } + } + Err(e) => println!(" unavailable ({e})"), } println!("WireGuard clients:"); println!( " IP State Latest handshake RX TX Public key Endpoint" ); - match wg::discover_peer_runtime( - target.container_pid, - &target.container_name, - &cfg.amnezia.peer_ipv4_cidr, - ) { - Ok(runtime_peers) => { + match (&peers, wg::discover_peer_runtime(cfg, &target)) { + (Ok(peers_sorted), Ok(runtime_peers)) => { let mut by_ip: BTreeMap = BTreeMap::new(); for rp in &runtime_peers { for ip in &rp.allowed_ips { @@ -76,11 +74,6 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } } - let peers_sorted = wg::discover_peers( - target.container_pid, - &target.container_name, - &cfg.amnezia.peer_ipv4_cidr, - )?; for p in peers_sorted { let ip = p.ipv4.to_string(); if let Some(rp) = by_ip.get(&ip) { @@ -104,7 +97,8 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { } } } - Err(e) => println!(" unavailable ({e})"), + (Err(e), _) => println!(" unavailable ({e})"), + (_, Err(e)) => println!(" unavailable ({e})"), } println!("Traffic desired:"); diff --git a/traffic_core/src/sysctl_tuning.rs b/traffic_core/src/sysctl_tuning.rs index 11b3050..aba596e 100644 --- a/traffic_core/src/sysctl_tuning.rs +++ b/traffic_core/src/sysctl_tuning.rs @@ -516,6 +516,7 @@ mod tests { interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), + wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: TrafficConfig { enabled: true, diff --git a/traffic_core/src/traffic.rs b/traffic_core/src/traffic.rs index 2a7f4f7..a00ba3c 100644 --- a/traffic_core/src/traffic.rs +++ b/traffic_core/src/traffic.rs @@ -51,11 +51,7 @@ pub fn inspect_drift_summary(cfg: &Config) -> anyhow::Result> { let target = docker::discover_target(cfg)?; let transport = build_transport(target.container_pid, &target.container_name); - let peers = wg::discover_peers( - target.container_pid, - &target.container_name, - &cfg.amnezia.peer_ipv4_cidr, - )?; + let peers = wg::discover_peers(cfg, &target)?; if peers.is_empty() { return Ok(Some("no peers discovered".to_string())); @@ -87,11 +83,7 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> { FileLock::acquire_timeout("/run/awg2-harness/traffic.lock", Duration::from_secs(10))?; let target = docker::discover_target(cfg)?; let transport = build_transport(target.container_pid, &target.container_name); - let peers = wg::discover_peers( - target.container_pid, - &target.container_name, - &cfg.amnezia.peer_ipv4_cidr, - )?; + let peers = wg::discover_peers(cfg, &target)?; if peers.is_empty() { println!("no peers found, skip tc apply"); @@ -138,11 +130,7 @@ pub fn peer_status_table(cfg: &Config) -> anyhow::Result, } -pub fn discover_peers(pid: u32, container: &str, peer_cidr: &str) -> anyhow::Result> { - let docker = DockerExecTransport { - container: container.to_string(), - }; - let docker_result = discover_peers_with_transport(&docker, peer_cidr); - if let Ok(peers) = &docker_result - && (!peers.is_empty() || !nsenter_available()) - { - return docker_result; +/// Peer state is only readable inside the container's mount namespace: AmneziaWG is a +/// userspace implementation and its UAPI socket lives under `RUNSTATEDIR/amneziawg/` +/// in the container filesystem. A host-side `wg` entered into the container netns via +/// `nsenter -n` cannot reach that socket, so `docker exec` is the only valid transport. +fn container_transport(target: &RuntimeTarget) -> DockerExecTransport { + DockerExecTransport { + container: target.container_name.clone(), } +} - if nsenter_available() { - let nsenter = NsenterTransport { pid }; - match discover_peers_with_transport(&nsenter, peer_cidr) { - Ok(peers) if !peers.is_empty() => Ok(peers), - _ => docker_result, - } - } else { - docker_result - } +pub fn discover_peers(cfg: &Config, target: &RuntimeTarget) -> anyhow::Result> { + discover_peers_with_transport( + &container_transport(target), + &cfg.amnezia.wg_binaries, + &cfg.amnezia.peer_ipv4_cidr, + ) } pub fn discover_peers_with_transport( transport: &dyn NsTransport, + binaries: &[String], peer_cidr: &str, ) -> anyhow::Result> { - let mut peers = match transport.run(&["wg", "show", "all", "allowed-ips"]) { + let mut peers = match run_wg(transport, binaries, &["show", "all", "allowed-ips"]) { Ok(primary) => parse_allowed_ips(&primary, peer_cidr)?, Err(_) => Vec::new(), }; if peers.is_empty() { - let fallback = transport - .run(&["wg", "show"]) + let fallback = run_wg(transport, binaries, &["show"]) .map_err(|e| anyhow::anyhow!("wg show fallback failed: {e}"))?; peers = parse_fallback_wg_show(&fallback, peer_cidr)?; } @@ -63,41 +68,80 @@ pub fn discover_peers_with_transport( } pub fn discover_peer_runtime( - pid: u32, - container: &str, - peer_cidr: &str, + cfg: &Config, + target: &RuntimeTarget, ) -> anyhow::Result> { - let docker = DockerExecTransport { - container: container.to_string(), - }; - let docker_result = discover_peer_runtime_with_transport(&docker, peer_cidr); - if let Ok(peers) = &docker_result - && (!peers.is_empty() || !nsenter_available()) - { - return docker_result; - } - - if nsenter_available() { - let nsenter = NsenterTransport { pid }; - match discover_peer_runtime_with_transport(&nsenter, peer_cidr) { - Ok(peers) if !peers.is_empty() => Ok(peers), - _ => docker_result, - } - } else { - docker_result - } + discover_peer_runtime_with_transport( + &container_transport(target), + &cfg.amnezia.wg_binaries, + &cfg.amnezia.peer_ipv4_cidr, + ) } pub fn discover_peer_runtime_with_transport( transport: &dyn NsTransport, + binaries: &[String], peer_cidr: &str, ) -> anyhow::Result> { - let raw = transport - .run(&["wg", "show", "all", "dump"]) + let raw = run_wg(transport, binaries, &["show", "all", "dump"]) .map_err(|e| anyhow::anyhow!("wg show all dump failed: {e}"))?; parse_wg_show_dump_runtime_peers(&raw, peer_cidr) } +/// Runs a wg subcommand with the first binary that succeeds. +/// +/// The official AmneziaWG image ships `/usr/bin/awg` and symlinks `/usr/bin/wg` to it, +/// so `awg` is the real dependency and is tried first; `wg` remains for plain WireGuard +/// containers where `awg` does not exist. +fn run_wg( + transport: &dyn NsTransport, + binaries: &[String], + args: &[&str], +) -> anyhow::Result { + let mut last_err: Option = None; + for binary in binaries { + let mut argv: Vec<&str> = Vec::with_capacity(args.len() + 1); + argv.push(binary.as_str()); + argv.extend_from_slice(args); + match transport.run(&argv) { + Ok(out) => return Ok(out), + Err(e) => last_err = Some(anyhow::anyhow!("{binary}: {e}")), + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("amnezia.wg_binaries is empty"))) +} + +/// Extracts peer host addresses inside `peer_cidr` from raw allowed-ips text. +/// +/// The three wg outputs consumed here use three different separators for a single +/// peer's allowed ips: `wg show all allowed-ips` uses spaces, `wg show all dump` uses +/// commas and plain `wg show` uses `", "`. Callers pass whitespace-separated tokens and +/// each token is additionally split on commas, so all three are handled identically. +fn collect_peer_ipv4_in_cidr<'a>( + tokens: impl IntoIterator, + peer_cidr: &str, +) -> anyhow::Result> { + let mut out = Vec::new(); + for token in tokens { + for entry in token.split(',') { + let entry = entry.trim(); + let Some((addr, prefix)) = entry.split_once('/') else { + continue; + }; + if prefix != HOST_PREFIX { + continue; + } + let Ok(v4) = addr.parse::() else { + continue; + }; + if config::ip_in_cidr(v4, peer_cidr)? { + out.push(v4); + } + } + } + Ok(out) +} + fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { @@ -105,18 +149,8 @@ fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result> { if cols.len() < 3 { continue; } - for tok in cols[2].split(',') { - let tok = tok.trim(); - if let Some((ip, pfx)) = tok.split_once('/') { - if pfx != "32" { - continue; - } - if let Ok(v4) = ip.parse::() - && config::ip_in_cidr(v4, peer_cidr)? - { - out.push(Peer { ipv4: v4 }); - } - } + for ipv4 in collect_peer_ipv4_in_cidr(cols[2..].iter().copied(), peer_cidr)? { + out.push(Peer { ipv4 }); } } Ok(out) @@ -130,23 +164,57 @@ fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result() - && config::ip_in_cidr(v4, peer_cidr)? - { - out.push(Peer { ipv4: v4 }); - } - } + for ipv4 in collect_peer_ipv4_in_cidr(rest.split_whitespace(), peer_cidr)? { + out.push(Peer { ipv4 }); } } Ok(out) } +/// Tells a peer line of `wg show all dump` apart from the interface line. +/// +/// Column count alone is not a discriminator: the AmneziaWG interface line carries the +/// AWG parameters (14 columns for AWG 2.0, 28 for AWG 3.0) and is therefore longer than +/// a peer line, while its handshake/rx/tx positions hold junk-packet sizes that parse as +/// integers just as well. The allowed-ips column is the reliable signal — on an interface +/// line it holds `junk_packet_count`, which is never `(none)` and never a CIDR list. +/// +/// This matters beyond correctness: column 1 of an interface line is the device private +/// key, so an interface line must never be read as a peer. +fn dump_line_is_peer(cols: &[&str]) -> bool { + if cols.len() < WG_DUMP_PEER_COLS { + return false; + } + if !is_allowed_ips_field(cols[4]) { + return false; + } + cols[5..8].iter().all(|v| v.trim().parse::().is_ok()) +} + +fn is_allowed_ips_field(raw: &str) -> bool { + let t = raw.trim(); + if t == "(none)" { + return true; + } + if t.is_empty() { + return false; + } + t.split(',').all(|entry| { + let entry = entry.trim(); + let Some((addr, prefix)) = entry.split_once('/') else { + return false; + }; + let Ok(bits) = prefix.parse::() else { + return false; + }; + match addr.parse::() { + Ok(IpAddr::V4(_)) => bits <= 32, + Ok(IpAddr::V6(_)) => bits <= 128, + Err(_) => false, + } + }) +} + fn parse_wg_show_dump_runtime_peers( raw: &str, peer_cidr: &str, @@ -154,34 +222,33 @@ fn parse_wg_show_dump_runtime_peers( let mut out = Vec::new(); for line in raw.lines() { let cols: Vec<&str> = line.split('\t').collect(); - if cols.len() < 8 { + if !dump_line_is_peer(&cols) { continue; } - // peer lines include endpoint/allowed_ips/handshake/rx/tx; interface lines do not match this contract - let public_key = cols.get(1).map(|s| s.trim()).unwrap_or_default(); - let allowed = cols.get(4).map(|s| s.trim()).unwrap_or_default(); - if public_key.is_empty() || allowed.is_empty() || allowed == "(none)" { + let public_key = cols[1].trim(); + if public_key.is_empty() { continue; } - let filtered_allowed = filter_allowed_ips_in_cidr(allowed, peer_cidr)?; + let mut filtered_allowed: Vec = + collect_peer_ipv4_in_cidr(std::iter::once(cols[4]), peer_cidr)? + .into_iter() + .map(|v| v.to_string()) + .collect(); + filtered_allowed.sort(); + filtered_allowed.dedup(); if filtered_allowed.is_empty() { continue; } - let endpoint = cols - .get(3) - .map(|s| s.trim()) + let endpoint = Some(cols[3].trim()) .filter(|s| !s.is_empty() && *s != "(none)") .map(|s| s.to_string()); - let latest_handshake_epoch = cols - .get(5) - .and_then(|s| s.trim().parse::().ok()) - .filter(|v| *v > 0); - let transfer_rx_bytes = cols.get(6).and_then(|s| s.trim().parse::().ok()); - let transfer_tx_bytes = cols.get(7).and_then(|s| s.trim().parse::().ok()); + let latest_handshake_epoch = cols[5].trim().parse::().ok().filter(|v| *v > 0); + let transfer_rx_bytes = cols[6].trim().parse::().ok(); + let transfer_tx_bytes = cols[7].trim().parse::().ok(); out.push(PeerRuntime { public_key: public_key.to_string(), @@ -195,34 +262,22 @@ fn parse_wg_show_dump_runtime_peers( Ok(out) } -fn filter_allowed_ips_in_cidr(raw: &str, peer_cidr: &str) -> anyhow::Result> { - let mut out = Vec::new(); - for tok in raw.split(',') { - let tok = tok.trim(); - let Some((ip, pfx)) = tok.split_once('/') else { - continue; - }; - if pfx != "32" { - continue; - } - let Ok(v4) = ip.parse::() else { - continue; - }; - if config::ip_in_cidr(v4, peer_cidr)? { - out.push(v4.to_string()); - } - } - out.sort(); - out.dedup(); - Ok(out) -} - #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; use std::sync::Mutex; + /// Interface line of `wg show all dump` produced by AmneziaWG 3.0 tools: 28 columns, + /// AWG parameters appended after the listen port and fwmark last. + const AWG3_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1-5\t2-6\t3-7\t4-8\t(null)\t(null)\t(null)\t(null)\t(null)\thProtectionKeyAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t10-40\t120-180\t5-10\t180-200\t25-30\t5\toff"; + + /// Interface line produced by AmneziaWG 2.0 tools: 14 columns. + const AWG2_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t1\t2\t3\t4\toff"; + + /// Interface line produced by plain WireGuard tools: 5 columns. + const WG_INTERFACE_LINE: &str = "wg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff"; + struct TestTransport { replies: Mutex>>, } @@ -249,6 +304,10 @@ mod tests { } } + fn binaries() -> Vec { + vec!["awg".to_string(), "wg".to_string()] + } + #[test] fn parse_primary() { let raw = "wg0 key1 10.8.1.10/32\nwg0 key2 10.8.1.2/32"; @@ -280,14 +339,43 @@ mod tests { assert_eq!(peers[1].ipv4.to_string(), "10.8.1.2"); } + /// `wg show all allowed-ips` separates a peer's allowed ips with spaces, not commas. + #[test] + fn parse_primary_collects_every_space_separated_allowed_ip() { + let raw = "awg0 key1 10.8.1.10/32 10.8.1.11/32 10.8.1.12/32"; + let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); + let got: Vec = peers.iter().map(|p| p.ipv4.to_string()).collect(); + assert_eq!(got, vec!["10.8.1.10", "10.8.1.11", "10.8.1.12"]); + } + + #[test] + fn parse_primary_skips_peer_without_allowed_ips() { + let raw = "awg0 key1 (none)\nawg0 key2 10.8.1.7/32"; + let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].ipv4.to_string(), "10.8.1.7"); + } + + #[test] + fn parse_fallback_collects_every_allowed_ip_on_one_line() { + let raw = " allowed ips: 10.8.1.4/32, 10.8.1.5/32, 10.8.1.6/32"; + let peers = parse_fallback_wg_show(raw, "10.8.1.0/24").expect("parse"); + assert_eq!(peers.len(), 3); + } + #[test] fn primary_failure_falls_back_to_wg_show() { let transport = TestTransport::new(vec![ + ( + "awg show all allowed-ips", + Err(anyhow::anyhow!("awg broken")), + ), ("wg show all allowed-ips", Err(anyhow::anyhow!("wg broken"))), - ("wg show", Ok("allowed ips: 10.8.1.4/32")), + ("awg show", Ok("allowed ips: 10.8.1.4/32")), ]); - let peers = discover_peers_with_transport(&transport, "10.8.1.0/24").expect("peers"); + let peers = + discover_peers_with_transport(&transport, &binaries(), "10.8.1.0/24").expect("peers"); assert_eq!(peers.len(), 1); assert_eq!(peers[0].ipv4.to_string(), "10.8.1.4"); } @@ -295,11 +383,12 @@ mod tests { #[test] fn empty_primary_output_falls_back_to_wg_show() { let transport = TestTransport::new(vec![ - ("wg show all allowed-ips", Ok("")), - ("wg show", Ok("allowed ips: 10.8.1.4/32, 10.8.1.5/32")), + ("awg show all allowed-ips", Ok("")), + ("awg show", Ok("allowed ips: 10.8.1.4/32, 10.8.1.5/32")), ]); - let peers = discover_peers_with_transport(&transport, "10.8.1.0/24").expect("peers"); + let peers = + discover_peers_with_transport(&transport, &binaries(), "10.8.1.0/24").expect("peers"); assert_eq!(peers.len(), 2); assert_eq!(peers[0].ipv4.to_string(), "10.8.1.4"); assert_eq!(peers[1].ipv4.to_string(), "10.8.1.5"); @@ -307,8 +396,10 @@ mod tests { #[test] fn parses_wg_show_dump_runtime_peers() { - let raw = "wg0\tpriv\tpub\t51820\toff\nwg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nwg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0"; - let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse"); + let raw = format!( + "{AWG3_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nawg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0" + ); + let peers = parse_wg_show_dump_runtime_peers(&raw, "10.8.1.0/24").expect("parse"); assert_eq!(peers.len(), 2); assert_eq!(peers[0].allowed_ips, vec!["10.8.1.10".to_string()]); assert_eq!(peers[1].allowed_ips, vec!["10.8.1.27".to_string()]); @@ -317,19 +408,143 @@ mod tests { #[test] fn maps_allowed_ip_to_runtime_peer() { - let raw = "wg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32,10.8.1.11/32\t1715000000\t123\t456\t0"; + let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32,10.8.1.11/32\t1715000000\t123\t456\t0"; let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse"); assert_eq!(peers.len(), 1); assert_eq!(peers[0].allowed_ips.len(), 2); } + /// AmneziaWG 3.0 prints protocol timers as ranges; the peer keepalive column may be + /// `20-30` instead of a single integer. It is not read, but must not break parsing. + #[test] + fn accepts_awg3_range_persistent_keepalive() { + let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t20-30"; + let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse"); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].transfer_rx_bytes, Some(123)); + assert_eq!(peers[0].transfer_tx_bytes, Some(456)); + } + + #[test] + fn rejects_awg3_interface_line() { + let cols: Vec<&str> = AWG3_INTERFACE_LINE.split('\t').collect(); + assert_eq!(cols.len(), 28); + assert!(!dump_line_is_peer(&cols)); + assert!( + parse_wg_show_dump_runtime_peers(AWG3_INTERFACE_LINE, "0.0.0.0/0") + .expect("parse") + .is_empty() + ); + } + + #[test] + fn rejects_awg2_interface_line() { + let cols: Vec<&str> = AWG2_INTERFACE_LINE.split('\t').collect(); + assert_eq!(cols.len(), 14); + assert!(!dump_line_is_peer(&cols)); + } + + #[test] + fn rejects_plain_wireguard_interface_line() { + let cols: Vec<&str> = WG_INTERFACE_LINE.split('\t').collect(); + assert_eq!(cols.len(), 5); + assert!(!dump_line_is_peer(&cols)); + } + + /// Column 1 of an interface line is the device private key. A misclassified interface + /// line would surface it as a peer public key in `status` output. + #[test] + fn never_exposes_interface_private_key_as_peer() { + let raw = format!( + "{AWG3_INTERFACE_LINE}\n{AWG2_INTERFACE_LINE}\n{WG_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0" + ); + let peers = parse_wg_show_dump_runtime_peers(&raw, "0.0.0.0/0").expect("parse"); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].public_key, "pubkey1"); + for peer in &peers { + assert!(!peer.public_key.contains("PrivateKey")); + } + } + + #[test] + fn skips_peer_without_allowed_ips_in_dump() { + let raw = "awg0\tpubkey1\t(none)\t(none)\t(none)\t0\t0\t0\t0"; + let cols: Vec<&str> = raw.split('\t').collect(); + assert!(dump_line_is_peer(&cols)); + assert!( + parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24") + .expect("parse") + .is_empty() + ); + } + #[test] fn propagates_wg_dump_command_failure() { - let transport = TestTransport::new(vec![( - "wg show all dump", - Err(anyhow::anyhow!("wg broken")), - )]); - let err = discover_peer_runtime_with_transport(&transport, "10.8.1.0/24").expect_err("err"); + let transport = TestTransport::new(vec![ + ("awg show all dump", Err(anyhow::anyhow!("awg broken"))), + ("wg show all dump", Err(anyhow::anyhow!("wg broken"))), + ]); + let err = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24") + .expect_err("err"); assert!(err.to_string().contains("wg show all dump failed")); } + + #[test] + fn prefers_awg_binary_over_wg() { + let transport = TestTransport::new(vec![ + ( + "awg show all dump", + Ok("awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t1\t2\t0"), + ), + ("wg show all dump", Err(anyhow::anyhow!("must not be used"))), + ]); + let peers = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24") + .expect("peers"); + assert_eq!(peers.len(), 1); + } + + #[test] + fn falls_back_to_wg_binary_when_awg_is_absent() { + let transport = TestTransport::new(vec![ + ( + "awg show all dump", + Err(anyhow::anyhow!("exec: \"awg\": not found")), + ), + ( + "wg show all dump", + Ok("wg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t1\t2\t0"), + ), + ]); + let peers = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24") + .expect("peers"); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].public_key, "pubkey1"); + } + + #[test] + fn reports_error_when_no_wg_binary_is_available() { + let transport = TestTransport::new(vec![ + ( + "awg show all dump", + Err(anyhow::anyhow!("exec: \"awg\": not found")), + ), + ( + "wg show all dump", + Err(anyhow::anyhow!("exec: \"wg\": not found")), + ), + ]); + let err = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24") + .expect_err("err"); + let msg = err.to_string(); + assert!(msg.contains("wg show all dump failed")); + assert!(msg.contains("wg:")); + } + + #[test] + fn reports_error_when_binary_list_is_empty() { + let transport = TestTransport::new(vec![]); + let err = + discover_peer_runtime_with_transport(&transport, &[], "10.8.1.0/24").expect_err("err"); + assert!(err.to_string().contains("amnezia.wg_binaries is empty")); + } }