From 6643328567c08f3012ac56eadb94bfd2924a1326 Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 6 Aug 2026 23:03:10 +0500 Subject: [PATCH] docs+test(wg): column count never identifies an AWG version [P2] Both the code comment and the docs said the interface row is "14 columns for AWG 2.0, 28 for AWG 3.0". That reads as a version signal and it is not one. The width of the row is decided by the version of amneziawg-tools doing the printing, not by which protocol parameters the tunnel has configured: AWG 3.0 tools emit the wide row for an AWG 2.0 configuration too, with the newer fields simply zero, empty or (none). Any rule of the form "N columns means version X" is wrong by construction. The classifier never relied on column count for that purpose - it discriminates on the allowed-ips column - so this is a correction to what the code claims about itself and to what the docs would lead a future change to assume. Five fixtures added from the audit's list, all previously uncovered: - AWG 3.0 interface row with header protection disabled: the key column is (none) and the range fields collapse to single values; - an AWG 2.0 configuration read through AWG 3.0 tools, asserted to have the same width as a native AWG 3.0 row and to still be rejected while its peer parses; - a dump containing several interfaces, each with its own peers; - an interface with no peers at all; - malformed rows - truncated, non-numeric handshake, non-CIDR allowed-ips - which must be skipped rather than partially interpreted. Docs also pin what was verified and against which versions, rather than stating it as timeless: AmneziaVPN 5.0.0.5 and the master branches of amneziawg-tools and amneziawg-go at the time of the change. amnezia-awg3 is now described as what it is - not a confirmed official name. AmneziaVPN 5.0.0.5 added AWG 3.0 support but still exposes only Awg and Awg2 container variants. It stays in the example list on purpose, and the reasoning is written down: a name that does not match costs nothing because the search continues down the list, while a name missing from the list produces a silent "container not found". This is a deliberate deviation from the audit's suggestion to build defaults only from names in use today. cargo fmt --check, clippy -D warnings, and cargo test --locked --workspace --release all clean: 163 + 4 passed. --- README.md | 6 ++- docs/CONFIGURATION.md | 4 +- docs/IMPLEMENTATION_SPEC.md | 6 ++- traffic_core/src/wg.rs | 92 +++++++++++++++++++++++++++++++++---- 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 40098bd..7b8455a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ docker exec "$C" awg show ### Совместимость с AmneziaWG 3.0 +Проверено по исходникам на: AmneziaVPN `5.0.0.5`, `amneziawg-tools` и `amneziawg-go` ветки `master` на момент правки. Утверждения ниже относятся к этим версиям; при более новых релизах их нужно перепроверить, а не считать бессрочными. + Обвязка не реализует 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` до него не достаёт. @@ -154,7 +156,9 @@ docker port "$C" 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. +Ожидаемо: `/usr/bin/wg` указывает на `/usr/bin/awg`, интерфейс `awg0` существует, peer-адреса отображаются, серверный порт опубликован по UDP. Peer-строки дают 9 полей. + +Ширина interface-строки зависит от версии `amneziawg-tools`, а не от версии параметров в конфигурации: утилиты AWG 3.0 печатают расширенную строку и для туннеля, настроенного как AWG 2.0. По количеству колонок определять версию протокола нельзя, и обвязка этого не делает. Полный вывод `awg show all dump` передавать нельзя: во второй колонке interface-строки находится приватный ключ устройства. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c3961c9..83bb940 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -92,7 +92,9 @@ swappiness = 1 ## Пояснения -`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен, сопоставление точное. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`. `amnezia-awg3` перечислен на будущее: в AmneziaVPN 5.0 отдельного типа контейнера под AWG 3.0 нет, клиент по-прежнему знает только `Awg` и `Awg2`. +`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен, сопоставление точное. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`. + +`amnezia-awg3` перечислен на будущее и **не является подтверждённым официальным именем**: в проверенной версии AmneziaVPN `5.0.0.5` поддержка AWG 3.0 добавлена, но варианты контейнера по-прежнему представлены как `Awg` и `Awg2`, отдельного типа `Awg3` нет. Имя оставлено в примере потому, что несовпавшее имя не стоит ничего — поиск просто идёт дальше по списку, — тогда как отсутствующее в списке имя даст молчаливое «контейнер не найден». Если Amnezia введёт другое имя, его нужно будет дописать сюда. `amnezia.interface_candidates` — точные имена интерфейса, которые пробуются первыми. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 4ea736b..5760f17 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -288,7 +288,8 @@ Fallback-парсер разбирает текстовый вывод, извл Классификация строк `show all dump`: - строка считается peer-строкой, только если в ней не менее 9 tab-колонок, колонка 5 (allowed ips) равна `(none)` или является списком CIDR через запятую, а колонки 6–8 (handshake, rx, tx) целиком парсятся как целые; -- количество колонок само по себе не является дискриминатором: interface-строка AmneziaWG содержит AWG-параметры и *длиннее* peer-строки (14 колонок на AWG 2.0, 28 на AWG 3.0), а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые; +- **количество колонок не является дискриминатором и не должно им становиться.** Ширину interface-строки определяет версия `amneziawg-tools`, которая её печатает, а не то, какие параметры протокола реально настроены в туннеле: AWG 3.0-утилиты печатают расширенную строку и для конфигурации AWG 2.0, просто с нулевыми или пустыми новыми полями. Любое правило вида «N колонок означает версию X» неверно по построению; +- interface-строка к тому же *длиннее* peer-строки, а на позициях handshake/rx/tx у неё стоят размеры junk-пакетов, которые тоже парсятся как целые, поэтому и эти колонки не разделяют два случая; - это важно не только для корректности: колонка 2 interface-строки — приватный ключ устройства, и ошибочно классифицированная interface-строка выдала бы его как public key peer; - AWG 3.0 печатает протокольные таймеры диапазонами (`20-30`). Колонка keepalive у peer не читается, и диапазон в ней не должен ломать разбор. @@ -657,7 +658,8 @@ Production-путь перезагрузки — внешний бинарь/с - разбор `config.example.toml` в актуальную схему; - парсер `awg|wg show all allowed-ips`, включая несколько allowed ips через пробел; - fallback-парсер `awg|wg show`; -- классификация peer/interface строк `awg|wg show all dump` против interface-строк AWG 3.0 (28 колонок), AWG 2.0 (14 колонок) и чистого WireGuard (5 колонок); +- классификация peer/interface строк `awg|wg show all dump` против interface-строк AWG 3.0 с включённой и с выключенной защитой заголовка, AWG 2.0, чистого WireGuard, а также конфигурации AWG 2.0, прочитанной утилитами AWG 3.0; +- разбор dump с несколькими интерфейсами, с интерфейсом без peers и с повреждёнными строками; - порядок выбора бинаря wg и ошибка при отсутствии всех кандидатов; - фильтрация по CIDR; - детерминированное присвоение class id с контрактом лексикографической стабильности; diff --git a/traffic_core/src/wg.rs b/traffic_core/src/wg.rs index 4c03b1f..87a3e54 100644 --- a/traffic_core/src/wg.rs +++ b/traffic_core/src/wg.rs @@ -173,10 +173,15 @@ fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result>>, } @@ -444,6 +459,67 @@ mod tests { assert!(!dump_line_is_peer(&cols)); } + #[test] + fn rejects_awg3_interface_line_without_header_protection() { + let cols: Vec<&str> = AWG3_INTERFACE_LINE_NO_HEADER_PROTECTION + .split('\t') + .collect(); + assert!(!dump_line_is_peer(&cols)); + } + + /// The case that makes "column count means protocol version" wrong: an AWG 2.0 tunnel + /// read through AWG 3.0 tools still prints the wide interface row. Peers must parse + /// and the interface row must be dropped, exactly as with a native AWG 3.0 tunnel. + #[test] + fn awg2_configuration_read_through_awg3_tools() { + let raw = format!("{AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE}\n{PEER_LINE}"); + let cols: Vec<&str> = AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE + .split('\t') + .collect(); + assert_eq!( + cols.len(), + AWG3_INTERFACE_LINE.split('\t').count(), + "an AWG 2.0 config through AWG 3.0 tools has the same width as AWG 3.0" + ); + assert!(!dump_line_is_peer(&cols)); + + 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].public_key, "pubkey1"); + } + + /// Several interfaces in one `show all dump`, each with its own peers. + #[test] + fn parses_multiple_interfaces_in_one_dump() { + let raw = format!( + "{AWG3_INTERFACE_LINE}\n{PEER_LINE}\n{AWG2_INTERFACE_LINE}\nwg0\tpubkey2\t(none)\t5.6.7.8:51820\t10.8.1.20/32\t1715000001\t7\t8\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.20".to_string()]); + } + + #[test] + fn interface_without_peers_yields_no_peers() { + let peers = + parse_wg_show_dump_runtime_peers(AWG3_INTERFACE_LINE, "10.8.1.0/24").expect("parse"); + assert!(peers.is_empty()); + } + + /// Truncated or garbled rows must be skipped, not partially interpreted. + #[test] + fn malformed_rows_are_skipped() { + let raw = format!( + "\nnot-a-dump-line\nawg0\tpubkey3\t(none)\n\ + awg0\tpubkey4\t(none)\t1.2.3.4:51820\t10.8.1.30/32\tnot-a-number\t1\t2\t0\n\ + awg0\tpubkey5\t(none)\t1.2.3.4:51820\tnot-a-cidr\t1715000000\t1\t2\t0\n{PEER_LINE}" + ); + 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].public_key, "pubkey1"); + } + #[test] fn rejects_plain_wireguard_interface_line() { let cols: Vec<&str> = WG_INTERFACE_LINE.split('\t').collect();