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.
This commit is contained in:
2026-08-06 23:03:10 +05:00
parent c6fa1a0af9
commit 6643328567
4 changed files with 96 additions and 12 deletions
+5 -1
View File
@@ -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-строки находится приватный ключ устройства.
+3 -1
View File
@@ -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` — точные имена интерфейса, которые пробуются первыми.
+4 -2
View File
@@ -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 с контрактом лексикографической стабильности;
+84 -8
View File
@@ -173,10 +173,15 @@ fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer
/// 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
/// Column count is not a discriminator and must never be used as one. The width of the
/// interface line is decided by the version of `amneziawg-tools` doing the printing, not
/// by which protocol parameters the tunnel actually has configured: AWG 3.0 tools emit the
/// extended row for an AWG 2.0 configuration too, with the newer fields simply zero or
/// empty. Any rule of the form "N columns means version X" is wrong by construction.
///
/// The interface line is also *longer* than a peer line, and its handshake/rx/tx positions
/// hold junk-packet sizes that parse as integers just as well, so those columns cannot
/// separate the two either. 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
@@ -268,16 +273,26 @@ mod tests {
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.
/// Interface line printed by AmneziaWG 3.0 tools with header protection enabled.
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.
/// Same tools, header protection disabled: the key column is `(none)` and the AWG 3.0
/// range fields collapse to single values.
const AWG3_INTERFACE_LINE_NO_HEADER_PROTECTION: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t120\t5\t180\t25\t5\toff";
/// An AWG 2.0 *configuration* read through AWG 3.0 tools: still the wide row, with the
/// newer fields zeroed. This is precisely why column count cannot identify a version.
const AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t0\t0\t0\t0\t0\toff";
/// Interface line printed by AmneziaWG 2.0 tools.
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.
/// Interface line printed by plain WireGuard tools.
const WG_INTERFACE_LINE: &str = "wg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff";
const PEER_LINE: &str =
"awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0";
struct TestTransport {
replies: Mutex<BTreeMap<String, anyhow::Result<String>>>,
}
@@ -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();