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();