Сервер, несколько пиров, всё работало. Добавляем ещё одного — и wg show показывает вот это (публичные ключи заменены на метки, остальное как есть):
peer: <ПИР-B> endpoint: 192.168.90.3:53333 allowed ips: 10.100.0.0/24 latest handshake: 16 seconds ago transfer: 884 B received, 732 B sent peer: <ПИР-A> endpoint: 192.168.90.2:52160 allowed ips: (none) latest handshake: 16 seconds ago transfer: 884 B received, 348 B sent
У пира A было 10.100.0.0/24. В конфиге на диске оно и сейчас там. В ядре — нет.
Обратите внимание, что именно сломалось. Пир не отвалился: endpoint на месте, handshake шестнадцатисекундной давности, принято от него столько же, сколько от живого соседа. Асимметрия в другой колонке — отправлено ему 348 байт против 732. Его пакеты доезжают и проходят криптографию, а обратно не едет ничего: данные умирают после расшифровки, в счётчике кадровых ошибок на интерфейсе, у которого нет кадров.
Команда, которая это устроила, отработала успешно: код возврата 0, dmesg пуст, wg-quick промолчал.
А если бы у второго пира был 10.100.0.5/32 вместо 10.100.0.0/24, ничего бы не произошло: обе строки остались бы на месте. Разницу делает не пересечение префиксов, а кое-что другое, и в документации этого нет.
Кого это касается
Модуль в мейнлайне с ядра 5.6 — с марта 2020, — и за это время AllowedIPs превратился из строчки в конфиге в машинно-генерируемое поле. Его выдаёт веб-форма панели, шаблон в Ansible, контроллер mesh-сети, CNI в кластере. Человек, который увидит последствия, чаще всего не тот, кто эту строчку написал.
Road warrior, самый частый случай. На форуме OpenWrt в январе 2024 спрашивают дословно так: «whenever I add a mobile phone as a peer, the phones added previously are no longer able to connect». Причина — 0.0.0.0/0 в allowed_ips у каждого пира на сервере: значению этому место в конфиге клиента, а не сервера. Каждый новый телефон забирает 0.0.0.0/0 себе, и предыдущий замолкает.
Сетевые ОС. В трекере VyOS это заводили как баг ещё в июле 2020 — «allowed-ips is overwritten» — и закрыли как Invalid: не VyOS виноват, так делает ядро. В OPNsense тот же отчёт от июля 2025: три пира на одном fd00:1234:5678::/56, конфиг на диске верный, а wg show печатает allowed ips: (none) у двух из трёх. Автор закрыл issue сам, решив, что напутал в настройках, — механизм он так и не узнал. И, перебирая варианты, наткнулся на вторую половину этой истории: с масками /62, /63 и /64 применяются все три. Почему так, будет ниже.
Кластеры. Cilium и Calico шифруют трафик между узлами Kubernetes именно WireGuard, и AllowedIPs там — генерируемый список адресов подов, по записи на узел. На wg show в кластере не смотрит никто.
Симптом везде один: конфиг правильный, туннель поднят, трафика нет. И везде его читают как ошибку в настройке, потому что больше читать нечего.
Что зафиксировано
репозиторий |
ref |
SHA |
|---|---|---|
|
тег |
|
|
тег |
|
|
тег |
|
|
тег |
|
|
ветка |
|
|
ветка |
|
|
ветка |
|
У BSD ref — движущаяся ветка, а не тег: номера строк в if_wg.c разъедутся при первом же коммите в файл, поэтому читать их надо из SHA.
Полное дерево ядра тянуть не нужно:
git clone --depth 1 --filter=blob:none --sparse --branch v6.12 \ https://github.com/torvalds/linux.git cd linux && git sparse-checkout set drivers/net/wireguard include/linux
Весь модуль — 14 файлов .c и 5157 строк, не считая selftest/, который собирается только при CONFIG_WIREGUARD_DEBUG. Интересующий нас allowedips.c — 389 строк.
Где на самом деле лежит AllowedIPs

AllowedIPs выглядит свойством пира: он написан внутри секции [Peer], рядом с PublicKey и Endpoint. Man-страница подтверждает ощущение:
AllowedIPs — a comma-separated list of IP (v4 or v6) addresses with CIDR masks from which incoming traffic for this peer is allowed and to which outgoing traffic for this peer is directed.
wireguard-tools/src/man/wg.8:158. Про то, что пространство префиксов общее, — ни слова.
В коде это устроено ровно наоборот. Префиксная trie одна на всё устройство:
struct pubkey_hashtable *peer_hashtable; struct index_hashtable *index_hashtable; struct allowedips peer_allowedips;
drivers/net/wireguard/device.h:48-50 — поле struct wg_device
А у пира лежит не список префиксов, а список обратных ссылок на узлы этой самой trie:
struct list_head peer_list; struct list_head allowedips_list;
drivers/net/wireguard/peer.h:63-64 — поле struct wg_peer
Разница не косметическая. Владелец адреса — узел trie, у него ровно один указатель node->peer. Пир не «имеет» префикс; префикс указывает на пира. Список у пира нужен лишь чтобы wg show мог распечатать, какие узлы сейчас смотрят на него.
Что делает вторая запись

Вставка в trie:
if (node_placement(*trie, key, cidr, bits, &node, lock)) { rcu_assign_pointer(node->peer, peer); list_move_tail(&node->peer_list, &peer->allowedips_list); return 0; }
drivers/net/wireguard/allowedips.c:199-203
Тело if — три строки, каждая делает ровно то, что написано.
rcu_assign_pointer перенаправляет узел на нового пира. list_move_tail — это list_del плюс list_add_tail: узел вынимается из списка старого пира. С этого момента wg show для первого пира про префикс не знает, потому что netlink обходит именно peer->allowedips_list:
list_for_each_entry_from(allowedips_node, &peer->allowedips_list,
drivers/net/wireguard/netlink.c:175
И return 0. Не -EEXIST, не предупреждение — успешное завершение, неотличимое от обычной вставки. Наверх подниматься нечему: в wg set единственная реакция на неудачу — perror("Unable to modify interface") (src/set.c:32), а при успехе утилита не печатает ничего.
Проверок нет и в userspace: ни wg, ни wg-quick не смотрят на пересечения.
git grep -in 'overlap\|duplicat\|conflict\|collid' v1.0.20260223 \ -- 'src/*.c' 'src/*.h' 'src/wg-quick/'
v1.0.20260223:src/curve25519-fiat32.h:258: * Can overlap h with f or g. v1.0.20260223:src/curve25519-fiat32.h:301: * Can overlap h with f or g.
Оба попадания — комментарии про перекрытие буферов в Curve25519. В коде, который разбирает конфиг и говорит с ядром, совпадений нет.
Почему «пропало у одних пиров, но не у всех»
На Server Fault висит вопрос с почти такой формулировкой, и ответ там сводится к «уберите подсети» — работает, но почему у одних пропадает, а у других нет, не объясняет. Объяснение той части, которая про исчезнувшую общую подсеть, — в условии выхода из цикла поиска места:
while (node && node->cidr <= cidr && prefix_matches(node, key, bits)) { parent = node; if (parent->cidr == cidr) { exact = true; break; }
drivers/net/wireguard/allowedips.c:157-161
exact становится true только когда совпала длина префикса — не когда префиксы пересекаются, а когда они одинаковой длины и на одном пути в trie. Иначе создаётся новый узел, и оба живут дальше. Выбирает между ними уже поиск:
while (node && prefix_matches(node, key, bits)) { if (rcu_access_pointer(node->peer)) found = node; if (node->cidr == bits) break;
drivers/net/wireguard/allowedips.c:116-120
Спуск идёт вниз, found каждый раз перезаписывается — побеждает длиннейший совпавший префикс. Обычный longest-prefix match, как в таблице маршрутов.
Отсюда два разных симптома из одной причины:
конфигурация |
что в |
кому уйдёт трафик |
|---|---|---|
A: |
у A строка исчезает |
B |
A: |
обе на месте |
B на |
Это же объясняет и находку из отчёта OPNsense: три пира на одинаковом /56 дают (none) у двух, а /62, /63 и /64 спокойно живут втроём. Длины разные — узлы разные.
Про пересечения предупреждают и вендоры — но описывают их неверно. defguard в чеклисте ротации ключей называет это «unpredictable routing», а в разборе AllowedIPs — «undefined behavior», трафик «could go to either peer». Ничего неопределённого здесь нет: длиннее — выигрывает, при равной длине — последний записавший. Предупреждение верное, механизм — нет, и из-за этого непонятно, что делать с конкретным конфигом.
Что происходит с пакетами

Обокраденный пир не отваливается. Он продолжает слать данные, они честно проходят криптографию — и отбрасываются уже после расшифровки:
routed_peer = wg_allowedips_lookup_src(&peer->device->peer_allowedips, skb); wg_peer_put(routed_peer); /* We don't need the extra reference. */ if (unlikely(routed_peer != peer)) goto dishonest_packet_peer;
drivers/net/wireguard/receive.c:404-409
Криптоключевая маршрутизация: адрес отправителя внутри туннеля обязан принадлежать пиру, чьим ключом пакет подписан. Иначе:
dishonest_packet_peer: net_dbg_skb_ratelimited("%s: Packet has unallowed src IP (%pISc) from peer %llu (%pISpfsc)\n", dev->name, skb, peer->internal_id, &peer->endpoint.addr); DEV_STATS_INC(dev, rx_errors); DEV_STATS_INC(dev, rx_frame_errors);
drivers/net/wireguard/receive.c:415-420
rx_frame_errors. Кадровая ошибка — на интерфейсе, у которого нет ни кадров, ни физического уровня. Вместе с ней растёт общий rx_errors, так что в обычном ip -s link видны просто «ошибки приёма», без намёка на причину. Разложение по видам показывает только ip -s -s link show wg0, колонка frame.
Три пинга от обокраденного пира на ядре 6.18.33.2-microsoft-standard-WSL2:
RX: bytes packets errors dropped missed mcast 1320 13 3 0 0 0 RX errors: length crc frame fifo overrun 0 0 3 0 0
Три пакета — три frame. Остальные по нулям: физического уровня, где они бывают, здесь нет.
А вот что модуль пишет в dmesg, если включить dynamic_debug:
[ 2506.903624] wireguard: wg0: Packet has unallowed src IP (10.100.0.2) from peer 13 (192.168.91.2:44011)
peer 13 — это internal_id, глобальный счётчик пиров на машине (peer.c:42). В wg show его нет, сопоставить с ключом нечем: из лога опознаётся только endpoint.
С исходящей стороны отказ выглядит иначе — и это единственное место во всей истории, где WireGuard говорит прямым текстом. Если под адрес назначения не нашлось пира:
peer = wg_allowedips_lookup_dst(&wg->peer_allowedips, skb); if (unlikely(!peer)) { ret = -ENOKEY;
drivers/net/wireguard/device.c:153-155
ENOKEY — это errno 126, «Required key not available». wg_xmit возвращает его наверх из метки err:, по дороге инкрементируя счётчик:
err: DEV_STATS_INC(dev, tx_errors); kfree_skb(skb); return ret;
drivers/net/wireguard/device.c:230-233
Приложение получает эту ошибку синхронно, из sendmsg. На живом ядре (6.18.33.2-microsoft-standard-WSL2), два пинга на адрес без пира:
ping: sendmsg: Required key not available --- 10.200.0.7 ping statistics --- 2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 1027ms
И счётчик интерфейса ровно после этого:
TX: bytes packets errors dropped carrier collsns 1080 9 2 0 0 0
Два пакета — две ошибки передачи, при живом туннеле, через который прошло девять пакетов. Отсюда и картина «до одних хостов достучаться можно, до других нет» — самый неприятный вид отказа для диагностики. Так что если вы когда-нибудь видели Required key not available и решили, что это что-то про ключи шифрования, — нет. Это AllowedIPs, под которые не подошёл адрес назначения.
Про «молча» — уточнение
Утверждать, что диагностики нет, было бы неправдой. CONFIG_WIREGUARD_DEBUG выключен по умолчанию (drivers/net/Kconfig:107-116), но он управляет селфтестами, а не этими строками. Строку про чужой src-адрес печатает net_dbg_skb_ratelimited, и гейт у неё свой — #if defined(CONFIG_DYNAMIC_DEBUG) || defined(DEBUG) в drivers/net/wireguard/socket.h:33. CONFIG_DYNAMIC_DEBUG в дистрибутивных ядрах включён, так что сообщение зажигается на живой системе без пересборки:
modprobe wireguard && echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
Способ описан в wg(8), в секции DEBUGGING INFORMATION. То есть правильная формулировка не «WireGuard молчит», а: сообщение есть, оно выключено, и о нём нужно знать заранее. Молчит именно перезапись префикса — про неё не сообщает никто и ничем.
Проверить самому, без ядра и без root
allowedips.c почти не зависит от ядра: списки, RCU, slab, fls — и всё. Значит его можно собрать в userspace как есть, не меняя ни строчки. Заглушек пять: mutex.h, ip.h, ipv6.h, peer.h — сто с небольшим строк на все четыре — и пустой selftest/allowedips.c, потому что последняя, 389-я строка файла подтягивает селфтест, живущий под #ifdef DEBUG. main.c дёргает wg_allowedips_insert_v4() и печатает списки пиров тем же обходом, что и netlink.
Заглушки и main.c — в репозитории со стендом, каталог stand/harness; из ядра берутся ровно два файла и не правятся:
git -C ~/linux show v6.12:drivers/net/wireguard/allowedips.c > build/allowedips.c git -C ~/linux show v6.12:drivers/net/wireguard/allowedips.h > build/allowedips.h mkdir -p build/selftest && : > build/selftest/allowedips.c gcc -O1 -I include -I build -o trie build/allowedips.c build/main.c ./trie
== 1. A получает 10.100.0.0/24 == insert вернул 0 ПИР-A 10.100.0.0/24 ПИР-B (none) == 2. B получает ТОТ ЖЕ 10.100.0.0/24 == insert вернул 0 ПИР-A (none) ПИР-B 10.100.0.0/24 10.100.0.2 -> ПИР-B == 3. Сброс. A: 10.100.0.0/24, B: 10.100.0.3/32 == ПИР-A 10.100.0.0/24 ПИР-B 10.100.0.3/32 10.100.0.2 -> ПИР-A 10.100.0.3 -> ПИР-B
Это исполняется настоящий код ядра, а не его пересказ.
Второй стенд — три network namespace с живыми туннелями. Оба клиента подняли handshake, у обоих AllowedIPs = 10.100.0.0/24:
<ПИР-A> (none) <ПИР-B> 10.100.0.0/24 --- A (10.100.0.2) -> 10.100.0.1 --- 3 packets transmitted, 0 received, 100% packet loss, time 2040ms --- B (10.100.0.3) -> 10.100.0.1 --- 3 packets transmitted, 3 received, 0% packet loss, time 2035ms rtt min/avg/max/mdev = 0.590/0.662/0.714/0.052 ms
Что сервер об этом думает — три пинга, три записи с шагом в секунду:
2026/08/17 00:05:50 IPv4 packet with disallowed source address from peer(lbW4…2VQY) 2026/08/17 00:05:51 IPv4 packet with disallowed source address from peer(lbW4…2VQY) 2026/08/17 00:05:52 IPv4 packet with disallowed source address from peer(lbW4…2VQY)
Третий прогон — против ядерного модуля, на другом ядре (6.18, не тот v6.12, по которому шёл разбор):
===== ШАГ 2. Пиру B выдан ТОТ ЖЕ префикс ===== код возврата wg set: 0 <ПИР-A> (none) <ПИР-B> 10.100.0.0/24 ===== ШАГ 3. Тот же случай, но у B префикс длиннее: 10.100.0.3/32 ===== <ПИР-A> 10.100.0.0/24 <ПИР-B> 10.100.0.3/32
Одно и то же в трёх местах: код ядра v6.12 в userspace, wireguard-go и живой модуль 6.18. Механизм не версионный.
Шесть кодовых баз, одно поведение

Общего автора здесь больше, чем кажется: копирайт Jason Donenfeld стоит и в ядре, и в wireguard-go, и в wireguard-nt, и в шапке обоих if_wg.c — BSD-шные драйверы выросли из его кода (git show f343f03:sys/dev/wg/if_wg.c | head -8). Так что «независимо сошлись» — не про эту историю. Интересно другое: AllowedIPs лежит в принципиально разных структурах данных, и ведут они себя одинаково.
реализация |
что под капотом |
|---|---|
ядро Linux |
своя trie, написанная под задачу |
wireguard-go |
своя trie, отдельно написанная на Go |
BoringTun (Cloudflare, Rust) |
чужой крейт |
FreeBSD |
штатная BSD-шная |
OpenBSD |
ART, собственная таблица маршрутизации OpenBSD |
|
своя trie — построчный порт ядерной, драйвер ядра Windows |
Шесть разных кодовых баз, пять структур данных, и единственная посторонняя по родословной реализация — BoringTun от Cloudflare. Вот FreeBSD, когда префикс уже занят:
} else if (node != aip->a_nodes) { free(aip, M_WG); aip = (struct wg_aip *)node; if (aip->a_peer != peer) { LIST_REMOVE(aip, a_entry); aip->a_peer->p_aips_num--; aip->a_peer = peer; LIST_INSERT_HEAD(&peer->p_aips, aip, a_entry);
sys/dev/wg/if_wg.c:600-607, FreeBSD main, f343f03
LIST_REMOVE плюс LIST_INSERT_HEAD — это list_move_tail из Linux, записанный макросами BSD: узел вынимают у прежнего пира, отдают новому, функция возвращает 0. OpenBSD в sys/net/if_wg.c:651-653 (557a527) делает то же поверх совсем другого дерева. А вот тот же фрагмент в драйвере ядра Windows:
if (NodePlacement(*Trie, Key, Cidr, Bits, &Node, Lock)) { RcuAssignPointer(Node->Peer, Peer); RemoveEntryList(&Node->PeerList); InsertTailList(&Peer->AllowedIpsList, &Node->PeerList); return STATUS_SUCCESS; }
driver/allowedips.c:242-248, wireguard-nt, 9ca1539
Три операции, три языка описания одного и того же: list_move_tail, LIST_REMOVE+LIST_INSERT_HEAD, RemoveEntryList+InsertTailList. Дальше по файлу — NodePlacement() с тем же if (Parent->Cidr == Cidr) { Exact = TRUE; } на :191, и FindNode() с тем же Found = Node в цикле на :137-138. Это не совпадение поведения, это построчный порт: 499 строк под GPL-2.0.
BoringTun остаётся единственной кодовой базой, где решение принимали заново.
BoringTun отличается интереснее всех. Его trie умеет сказать, что префикс занят:
pub fn insert(&mut self, key: IpAddr, cidr: u32, data: D) -> Option<D> {
boringtun/src/device/allowed_ips.rs:42, тег boringtun-cli-0.7.1
Option<D> — прежний владелец префикса, и mod.rs:347 выбрасывает его не глядя. А главное, у пира лежит своя копия списка (peer.rs:25), и её же отдаёт UAPI (peer.rs:152 → api.rs:188). Харнесс на настоящем типе из крейта:
device.insert вернул: Some("ПИР-A") <- прежний владелец, и он отбрасывается ПИР-A 10.100.0.0/24 ПИР-B 10.100.0.0/24 трафик на 10.100.0.2 -> Some("ПИР-B")
Оба пира показывают префикс, трафик идёт одному. В ядре хотя бы (none) намекает, что что-то произошло; здесь не намекает ничто.
Расходятся реализации только в том, как сообщают о проблеме — и то в мелочах:
Linux |
go |
BoringTun |
FreeBSD |
OpenBSD |
NT |
|
|---|---|---|---|---|---|---|
точное совпадение |
перезапись |
перезапись |
перезапись |
перезапись |
перезапись |
перезапись |
код возврата |
|
нет |
|
|
|
|
разная длина |
длиннейший |
длиннейший |
длиннейший |
длиннейший |
длиннейший |
длиннейший |
|
|
|
у обоих |
|
|
|
передача без пира |
|
молча |
молча |
|
|
таймаут |
Не все клетки одного качества, и это стоит сказать прямо. Linux и wireguard-go замерены целиком. У BSD прочитан только код: ни одной команды на них я не выполнял, вся их колонка — предсказание по if_wg.c. У wireguard-nt замерены перезапись и сосуществование узлов на живой Windows, а «длиннейший» взят из FindNode() (driver/allowedips.c:135-142): трафика на Windows я не пускал. Клетка BoringTun «у обоих» получена чтением UAPI (peer.rs:25 → api.rs:188) и воспроизведена харнессом на настоящем типе из крейта — но не сквозным прогоном, и почему, будет в конце.
И отдельно — MikroTik
RouterOS выносит запрет прямо в раздел Peers своего справочника: «Allowed-address range cannot overlap on one interface, so you need to set own range for each peer». Формулировка живёт на замороженной ветке документации — MikroTik переехал на manual.mikrotik.com, и там этой фразы я не нашёл. У wg(8) про это нет ни слова нигде.
Проверил на CHR 7.20.2 под qemu: два клиента на wireguard-go, оба пира на одном allowed-address.
PEERA allowed=10.100.0.0/24 rx=308 tx=92 hs=00:00:20 PEERB allowed=10.100.0.0/24 rx=308 tx=92 hs=00:00:20 пинг A (10.100.0.2): 4 packets transmitted, 0 received, 100% packet loss, time 3050ms пинг B (10.100.0.3): 4 packets transmitted, 4 received, 0% packet loss, time 3005ms PEERA allowed=10.100.0.0/24 rx=340 tx=92 hs=00:00:28 PEERB allowed=10.100.0.0/24 rx=852 tx=604 hs=00:00:28
Ошибки нет, hs= свежий у обоих, префикс показан у обоих — и трафик идёт только второму. Дальше интереснее: удаление PEERB префикс первому не возвращает, хотя в конфиге у PEERA он никуда не девался. Оживает пир только после set с тем же значением, которое там и так написано:
пинг A (10.100.0.2): 4 packets transmitted, 4 received, 0% packet loss, time 3004ms
Вендор, который вынес запрет в документацию, сам его не проверяет — и держит в print конфигурацию, которой в датапате нет.
Отдельная кодовая база под ядро Windows ведёт себя ровно так же:
windows: Майкрософт Windows 11 Pro 10.0.26200.0 ===== ШАГ 2. Пиру B выдан ТОТ ЖЕ префикс ===== код возврата wg set: 0 <ПИР-A> (none) <ПИР-B> 10.100.0.0/24
Единственное осязаемое расхождение — что видит приложение при передаче на адрес без пира. Linux отдаёт ENOKEY синхронно из sendmsg («Required key not available»), BSD — ENETUNREACH, а на Windows ping истекает по таймауту, без всякой ошибки. Один и тот же дроп, три разных симптома.
Что из этого следует
Один префикс — один пир, на всём интерфейсе. Не рекомендация, а свойство структуры данных: узел 0.0.0.0/0 в trie один, и два клиента с таким AllowedIPs на одном интерфейсе одновременно работать не могут.
wg showconf — источник истины, файл на диске — нет. Сравнивайте после правки: расхождение и есть след перезаписи, готовая проверка для CI. На BoringTun и RouterOS не поможет: там show печатает свою копию конфига, а на RouterOS префикс ещё и возвращается в датапат только перезаписью того же значения.
Автоматизация должна проверять пересечения сама — ни одна из шести реализаций этого не делает. Ровно на этом в июле 2026 чинили netbird: счётчик ссылок был ключован только по префиксу, и WireGuard оставался нацелен на удалённого пира.
При ротации ключа порядок обязателен. Новый пир с тем же AllowedIPs забирает префикс мгновенно, ещё до handshake. Сначала новый ключ на клиенте, потом свежий latest handshake на сервере, и только потом удаление старого пира.
rx_frame_errors на wg0 — вообще не про кадры. Счётчик растёт из двух мест receive.c, и оба про содержимое уже расшифрованного пакета: чужой src-адрес (:420) или не-IP внутри туннеля (:426). Второе на исправном туннеле не случается, так что практически это первое — разъехавшиеся AllowedIPs либо попытка подменить адрес. Мониторить стоит.
Чего я не знаю
Считать честно: исходники прочитаны у всех шести реализаций — ядро, wireguard-go, BoringTun, FreeBSD, OpenBSD, wireguard-nt. Исходников нет только у RouterOS: там измерено поведение снаружи и не прочитано ничего. Поведение замерено у четырёх — ядро, wireguard-go, wireguard-nt, RouterOS — шестью прогонами: allowedips.c из v6.12 в userspace, живой модуль 6.18, три netns на wireguard-go, Windows 11, RouterOS на конфиге и RouterOS на трафике. FreeBSD и OpenBSD не запускались вовсе. У BoringTun замерена trie харнессом, но сквозного прогона нет.
Копирайт на пяти реализациях из шести — один и тот же. Ядро, wireguard-go, wireguard-nt и оба if_wg.c несут имя Jason Donenfeld. Так что «шесть команд независимо пришли к одному решению» сказать нельзя, и я не говорю. Постороннее подтверждение здесь ровно одно — BoringTun.
ICMP я так и не увидел. device.c:227 вызывает icmp_ndo_send, но ENOKEY приходит из sendmsg раньше, и поймать ICMP на проводе не вышло.
Счётчик на Windows не атрибутирован. OutboundPacketErrors оказался равен 56 при двух пингах: у интерфейса был адрес и фоновый трафик. Что растёт — видно, от чего — не доказано.
Отсюда два вопроса, дорогой и бесплатный.
Дорогой. BoringTun на boringtun-cli-0.7.1 заведённого пира менять не умеет в принципе:
// Update an existing peer if self.peers.get(&pub_key).is_some() { // We already have a peer, we need to merge the existing config into the newly created one panic!("Modifying existing peers is not yet supported. Remove and add again instead.");
boringtun/src/device/mod.rs:318-321
api_set_peer доходит до update_peer без единой проверки на существование пира (api.rs:296 и api.rs:343), так что любой wg set по заведённому ключу — кроме remove — роняет демон. remove обрабатывается на пять строк выше паники (mod.rs:313-315), и только поэтому обходной путь вообще существует. Мой стенд на этом и падает — wg при этом печатает EPROTO, и самой паники в логе нет: boringtun уходит в фон, а его stderr стенд не ловит. Кто соберёт сценарий через remove + add и покажет wg show — закроет последнее белое пятно. По коду (peer.rs:25 → api.rs:188) префикс должен быть виден у обоих пиров.
Бесплатный. Один вопрос из памяти, без консоли: у вас в конфигах есть два пира с одинаковым AllowedIPs — и вы про это знали или узнали только что?)
Комментарии (50)

vitaliy0000
17.08.2026 01:25Простите, я совсем не понял, зачем нужно на два одинаковых peer-а один и тот же net+mask. Как wg поймёт, куда (в какой peer) нужно слать пакет, если у двух (или более) — 10.100.0.0/24? Обычная практика — начиная с бо́льших mask (/32 => /0) проверяется net: если совпало, отправляем туда. Два одинаковых net+mask не позволяют выяснить однозначно destination.
Для каких случаев это (один и тот же net+mask на разные peer-ы) необходимо и как предполагается такая сетевая работа?

lopej
17.08.2026 01:25Автор просто недопонял принцип работы wireguard и углубился в дебри нейрослопа)
* надеюсь issue еще не успел завести)
kotru21 Автор
17.08.2026 01:25Про изоляцию поправку принял, ответил выше. Спасибо
На тезис это не влияет, и его легко проверить. AllowedIPs лежат не у пира, а в одной на всё устройство trie, и второй пир с тем же префиксом перевешивает узел на себя, молча вынимая его у первого.
К /24 это отношения не имеет. На конфиге из одних /32 всё то же самое, просто пересечься двум /32 можно только буквально одним адресом, то есть при ротации ключа или перевыпуске клиента:
wg set wg0 peer $A allowed-ips 10.100.0.2/32
wg set wg0 peer $B allowed-ips 10.100.0.2/32;
echo rc=$? wg show wg0 allowed-ips
rc=0, в dmesg пусто, у A — (none). Минута на проверку. Если у вас получится иначе, покажите вывод — поправлю статью. Буду благодарен если объясните почему не прав

lopej
17.08.2026 01:25wg set wg0 peer $A allowed-ips 10.100.0.2/32
wg set wg0 peer $B allowed-ips 10.100.0.2/32;Допустим, пришел пакет с dst 10.100.0.2 Кому отправим? Пиру А или пиру Б?

kotru21 Автор
17.08.2026 01:25Пиру B. Последнему записавшему, детерминированно, я это писал двумя комментариями выше и не спорю.
Интереснее вторая половина, про которую вопрос обычно не задают: а что будет с пакетом ОТ A, с src 10.100.0.2?) Он дойдёт, аутентифицируется, расшифруется —и умрёт уже после этого, потому что lookup_src вернёт B, а не A (receive.c:404-409). Счётчик, в который он ляжет, называется rx_frame_errors: кадровая ошибка на интерфейсе, у которого нет ни кадров, ни физического уровня.
Поэтому со стороны сервера A выглядит полностью живым — endpoint на месте, handshake свежий, байты от него приходят и считаются, — а в его конфиге по-прежнему написано 10.100.0.2. На "кому отправим" ответ в одну строку. На "почему у клиента A нет сети при правильном конфиге и свежем handshake" ответа не даёт ни один инструмент, и вот об этом статья.

lopej
17.08.2026 01:25"почему у клиента A нет сети при правильном конфиге и свежем handshake"
Потому что у вас неоднозначная таблица роутинга. Про модель OSI надеюсь не надо пояснять?

kotru21 Автор
17.08.2026 01:25Таблица как раз однозначная, в этом всё и дело. После перезаписи в trie не два узла на 10.100.0.2, а один, и он смотрит на B. Ни при вставке, ни при поиске неоднозначности нет, ядро прекрасно знает, кому отдавать.
Неоднозначно не состояние роутинга, а намерение. два конфига претендуют на один адрес. Ядро снимает эту неоднозначность мгновенно и молча, а инструмент, который мог бы заметить её до применения, в неё не смотрит — ни wg, ни wg-quick пересечения не проверяют.
Про OSI пояснять не надо, она просто не отвечает на вопрос. Криптоключевая маршрутизация не L3: пакет отбрасывается не по таблице маршрутов, а по несовпадению src с ключом, которым он подписан, и уже после расшифровки, пройдя весь путь целиком.
И диагноз неоднозначная таблица вы ставите, уже зная механизм. У человека на входе есть только "у половины клиентов нет сети", правильный конфиг на диске и свежий handshake. Расстояние между этими двумя состояниями и есть статья.

lopej
17.08.2026 01:25И диагноз неоднозначная таблица вы ставите, уже зная механизм.
Первым же сообщением я указал на ерунду в конфиге, даже не вникая в ваши раскопки в сорцах wg.
У человека на входе есть только "у половины клиентов нет сети", правильный конфиг на диске и свежий handshake.
Человек занимает какую-то IT-должность? Уволить за профнепригодность. Конфиг - неправильный.

kotru21 Автор
17.08.2026 01:25Конфиг неправильный— тут спора нет и не было ни разу.
Первым сообщением вы указали, что у пира на сервере /24 вместо /32. Это справедливое замечание к примеру, но не диагноз. на двух одинаковых /32 всё ломается точно так же, мы это выше уже разобрали. Механизм —перевешивание узла при совпадении длины префикса — из "поставьте /32" не выводится.
Про уволить. Наступили на это: репортёр в OPNsense, который в итоге решил, что сам напутал в настройках, и закрыл свой issue; человек, заведший баг в VyOS; разработчики netbird, чинившие ровно этот механизм в продакшн-mesh-VPN 28 июля этого года — refcount был ключован только по префиксу. Cilium и Calico генерируют AllowedIPs списком на узел, и на wg show в кластере не смотрит никто. Это не люди с неправильным конфигом, это люди, которые пишут инструменты.
"Так делать не надо" — верно) и мне в соседней ветке справедливо указали, что это должно стоять в начале статьи, а не в выводах. Спасибо человеку. Только само по себе оно не отвечает на вопрос, почему этого не замечают месяцами.

lopej
17.08.2026 01:25на двух одинаковых /32
Не может быть два одинаковых /32

kotru21 Автор
17.08.2026 01:25Прогнал прямо сейчас, ядерный модуль, чистый интерфейс без конфига, без ключа устройства и без трафика:
# ip link add wg-test type wireguard # A=$(wg genkey | wg pubkey); B=$(wg genkey | wg pubkey) # wg set wg-test peer $A allowed-ips 10.100.0.2/32 # wg set wg-test peer $B allowed-ips 10.100.0.2/32; echo "rc=$?" rc=0 # wg show wg-test allowed-ips 2K/oFVzAT40/R1fLGNABLbGRUh/2K8n7pRvPTg5cuyU= (none) IgOtO/8kgYYd/YIRMe6advAtthVAeXDfYZn5NCW/Cjg= 10.100.0.2/32Ядро приняло вторую запись, вернуло 0 и ничего не сказало. У первого пира 10.100.0.2/32 больше нет.
Утверждение стоит разделить надвое. как "так не должно быть" — оно верное, и статья с ним не спорит ни в одной строке. Как "так не бывает" — выше консоль. Между не должно быть и ничто не мешает вся статья и помещается.в конфиге на диске у первого пира по-прежнему написано 10.100.0.2/32, в ядре у него (none), и о расхождении не сообщает никто.

lopej
17.08.2026 01:25да нельзя разным пирам назначать один и тот же ip!!! Ну что тут может быть неясно??? Ну напишите тогда в конфиг груб что-то типа "ололо, грузи мне линух", и потом недоумевайте - а чо оно не грузится?

kotru21 Автор
17.08.2026 01:25Согласен, нельзя. Статья не про то, можно ли, а про то, что будет, если это всё же произошло, и как об этом узнать.

lopej
17.08.2026 01:25Криптоключевая маршрутизация не L3: пакет отбрасывается не по таблице маршрутов, а по несовпадению src с ключом, которым он подписан, и уже после расшифровки, пройдя весь путь целиком.
Садись, два. Шифрование на прикладном уровне. Маршрутизация на сетевом уровне. WG только шифрует пакеты, маршрутизацией занимается ядро.

kotru21 Автор
17.08.2026 01:25wg0 — интерфейс без канального уровня вообще. В wg_setup() стоит dev->type = ARPHRD_NONE, dev->addr_len = 0, dev->hard_header_len = 0, флаги IFF_POINTOPOINT | IFF_NOARP. На живом модуле это выглядит так:
# ip link add wg-test type wireguard 3: wg-test: <POINTOPOINT,NOARP> mtu 1420 qdisc noop state DOWN mode DEFAULT group default qlen 1000 link/none # cat /sys/class/net/wg-test/type 65534link/none, флаг NOARP, и 65534 — это ARPHRD_NONE числом, уже без участия iproute2 в интерпретации. Ни MAC-адреса, ни ARP, ни кадра. Шифровать на L2 там нечего, потому что L2 там нет — на этом построен и эпизод статьи про rx_frame_errors.
Внутрь туннеля едет тоже не кадр. После расшифровки receive.c смотрит версию IP, и если это не IPv4 и не IPv6 — пакет уходит в dishonest_packet_type. Ethernet внутри WireGuard не ходит принципиально, этим он и отличается от tap-режима OpenVPN или от VXLAN. Снаружи всё это лежит в UDP, то есть в payload L4.
А здесь поправлю сам себя: "криптоключевая маршрутизация не L3" я написал неудачно. Точнее будет — не по таблице маршрутов ядра, а по отдельной привязке префикс -> ключ; уровень при этом ровно L3. src и dst берутся из IP-заголовка и сравниваются с префиксами тем же longest-prefix match. Необычен там не уровень, а то, что роль next-hop играет публичный ключ.
Но L5 из этого всё равно не получается. В самой OSI сетевой уровень определён через логическую адресацию и выбор маршрута, а сеансовый — через управление диалогом между приложениями. Выбор пира по dst-адресу подходит под определение L3 буквально. И писал я не «модель неправильная», а что она тут не помогает: как ни разложи туннель по уровням, на вопрос, почему администратор не узнаёт о перевешивании префикса, модель не отвечает.

lopej
17.08.2026 01:25что-то я уже устал вам объяснять базовые вещи.
allowed ips: 10.100.0.0/24#На сервере - НЕПРАВИЛЬНО!wg set wg0 peer $A allowed-ips 10.100.0.2/32
wg set wg0 peer $B allowed-ips 10.100.0.2/32
# по-отдельности нормально, вместе - НЕПРАВИЛЬНО!
kotru21 Автор
17.08.2026 01:25Так с этим я и не спорил ни в одном комментарии. Оба конфига неправильные, это я писал прямым текстом не раз.
Разница между нами ровно одна: вы считаете, что на этом разговор кончается, а я — что он с этого начинается)) Ядро такой конфиг принимает, возвращает 0 и не говорит об этом никому, а увидеть последствия можно только в wg show, куда смотрят последним.
Спасибо за ветку, без иронии. Поправку про изоляцию принял, про панели тоже. обе были по делу.

kotru21 Автор
17.08.2026 01:25Ни для каких, так делать не надо. Статья не про то, зачем так делают, а про то, что происходит, когда так вышло случайно, и почему это потом трудно опознать.
Алгоритм вы описали правильно, в коде ровно он. спуск по trie, found перезаписывается на каждом уровне, побеждает самый длинный совпавший префикс (allowedips.c:116-120). Обычный longest-prefix match.
Тонкость в другом: при одинаковой длине выбирать не из чего, потому что второго узла и не появляется. node_placement() выставляет exact только когда parent->cidr == cidr, и тогда вставка не создаёт узел, а перевешивает существующий:
rcu_assign_pointer(node->peer, peer); list_move_tail(&node->peer_list, &peer->allowedips_list); return 0;это allowedips.c:199-203
Узел уходит последнему записавшему, а из списка первого пира его вынимает list_move_tail. Поэтому у первого в wg show становится (none), хотя в файле на диске префикс никуда не делся. И return 0, то есть wg set отработал успешно и dmesg пустой.
Случайно это получается обычно тремя способами. Самый частый — 0.0.0.0/0 прописан каждому клиенту в конфиге сервера: каждый новый телефон забирает префикс себе, предыдущий замолкает. Второй — не обнулена хост-часть: пять пиров с 10.13.13.2/24 … 10.13.13.6/24, wg на каждого честно печатает Warning: AllowedIP has nonzero host part, а в ядро уходят пять одинаковых 10.13.13.0/24 (https://github.com/linuxserver/docker-wireguard/issues/349). Третий - ротация ключа: новый пир с тем же AllowedIPs забирает префикс сразу, ещё до хендшейка.
А на вопрос буквально: никак не поймёт и никому не скажет. insert возвращает 0, проверок на пересечения нет ни в ядре, ни в wg, ни в wg-quick.

lopej
17.08.2026 01:250.0.0.0/0 прописан каждому клиенту
Нет. это клиентские роуты. Вписываются тоже на клиенте.

kotru21 Автор
17.08.2026 01:25Так и есть, и в статье написано ровно это: место 0.0.0.0/0 в конфиге клиента, а не сервера. Я не говорю, что так надо. Я говорю, что так делают.
Механизм ошибки очень человеческий: в конфиге клиента секция [Peer] описывает сервер и содержит AllowedIPs=0.0.0.0/0. Дальше её зеркалят на сервер как есть, потому что выглядит симметрично, либо заполняют поле Allowed IPs в веб-морде тем же значением, которое только что видели у клиента.
Чем кончается, видно по треду на форуме OpenWrt, он так и называется — "only latest configured peer works": https://forum.openwrt.org/t/troubles-with-wireguard-only-latest-configured-peer-works-solved/183422 дословно:
"whenever I add a mobile phone as a peer, the phones added previously are no longer able to connect"
Каждый новый телефон забирает 0.0.0.0/0 себе, предыдущий замолкает
Ну и вопрос не в том, что кто-то не знает, где место у 0.0.0.0/0. Вопрос в том, что за эту ошибку ничего не ругается: ни wg, ни wg-quick, ни dmesg.

lopej
17.08.2026 01:25Каждый новый телефон забирает 0.0.0.0/0 себе, предыдущий замолкает

Это уже за гранью непонимания 
kotru21 Автор
17.08.2026 01:25Три "телефона", добавлены подряд, каждому 0.0.0.0/0:
# ip link add wg-test type wireguard # P1=$(wg genkey|wg pubkey); P2=$(wg genkey|wg pubkey); P3=$(wg genkey|wg pubkey) # for k in $P1 $P2 $P3; do wg set wg-test peer $k allowed-ips 0.0.0.0/0; echo "rc=$?"; done rc=0 rc=0 rc=0 # wg show wg-test allowed-ips etTtVHw4PGJ2vl19pTgSHcTou+7oVyhQXYjouM3qdRk= (none) +pg0ZEzTL3v1SG/L7CHjZlwQ9a4SOz35UmQio7GjQHc= (none) NiSaM97l9E21aQ54rlnybJDb0eHXXMDqI5UGBoJuQhw= 0.0.0.0/0Три успешных wg set подряд, префикс остался у последнего, у первых двух — (none).
Механизм тот же самый, что двумя комментариями выше на /32, и от длины префикса он не зависит: 0.0.0.0/0 — это точно такое же exact match, просто с cidr = 0. Тот же node_placement(), та же перезапись указателя.
Тред на форуме OpenWrt, откуда взят пример, называется only latest configured peer works, а жалоба в нём дословно такая: "whenever I add a mobile phone as a peer, the phones added previously are no longer able to connect". https://forum.openwrt.org/t/troubles-with-wireguard-only-latest-configured-peer-works-solved/183422 Человек описал этот вывод за два года до статьи, своими словами и не открывая исходников.

lopej
17.08.2026 01:25В конфиг сервера 0.0.0.0/0 ? Ну извините, дальше я материться буду.

kotru21 Автор
17.08.2026 01:25Так и я о том же — это неправильно. Только это не моя выдумка, а конфиг из треда по ссылке выше (вот он https://forum.openwrt.org/t/troubles-with-wireguard-only-latest-configured-peer-works-solved/183422), где человек именно так и сделал.

lopej
17.08.2026 01:25Это клиентский конфиг, а не серверный.

kotru21 Автор
17.08.2026 01:25Нет, серверный. Вот он из того треда, дословно:
config interface 'vpn' option proto 'wireguard' option private_key '...' option listen_port '...' list addresses '10.11.11.1/24' config wireguard_vpn option description 'mobile1' list allowed_ips '0.0.0.0/0' list allowed_ips '10.0.0.0/8' config wireguard_vpn option description 'mobile2' list allowed_ips '10.0.0.0/8' list allowed_ips '0.0.0.0/0'private_key, listen_port и адрес 10.11.11.1/24 на интерфейсе — это хаб. Секции wireguard_vpn — телефоны в роли пиров, и у каждого 0.0.0.0/0 плюс 10.0.0.0/8. Два одинаковых префикса на двух пирах, ровно тот случай.
И принятое в треде решение ( https://forum.openwrt.org/t/troubles-with-wireguard-only-latest-configured-peer-works-solved/183422 ) — ровно то, о котором вы всё это время говорите: на роутере каждому пиру свой /32 (10.11.11.101/32, .102/32, .103/32), а 0.0.0.0/0 остаётся только в конфигах телефонов.
Так что расходимся мы не в том, как правильно, а в том, стоит ли писать о том, как делают неправильно.

lopej
17.08.2026 01:25За заборе хуй написано, а там дрова...
10.0.0.0/8 вообще-то замечательно входит в 0.0.0.0/0
И если кто-то себе выдумал, что это серверный конфиг - я хочу ему бить по рукам

vitaliy0000
17.08.2026 01:25Ни для каких, так делать не надо.
Этого в статье вообще нет (как минимум, в начала; или я не нашёл).
-=-
Статья не про то, зачем так делают, а про то, что происходит, когда так вышло случайно,
Строго говоря, этого тоже нет в статье — что это информация для просто интересующихся, а не про то, что это статья о баге, который разработчики не хотят чинить.
-=-
и почему это потом трудно опознать.
Ну, не знаю… Два одинаковых AllowedIps в config-е и/или отсутствие нужного AllowedIps для неработающего peer-а явно должно намекать на то, что на peer не идёт трафик (идёт на другой, на котором есть этот net+mask), из-за чего есть проблема.
Кажется, для этого не нужно лезть в исходный код ядер (или их модулей).
-=-
Моё недовольство — в том, что статья начинает идти в дебри и разбор исходного кода ядер (модулей), при том, что в самом начале нет информации, что это «археологические раскопки» и просто не надо так делать с объяснением (с точки зрения формальной логики) почему это логическая ошибка на уровне построения сети + быстрое описание «как найти ошибку» по
wg dumpили разборе config-аgrep/awk(нахождение одинаковых AllowedIps).-=-
P.S.:
при одинаковой длине выбирать не из чего
Я спрашивал про логическую часть, а не про детали реализации: если бы net+mask оставался на обоих peer-ах — как бы предполагалась работа routing-а внутри wg и какие бы статьи были написаны про debug такого состояния?

kotru21 Автор
17.08.2026 01:25По первым двум пунктам вы правы, принимаю. "Один префикс — один пир на всём интерфейсе" у меня стоит в выводах, то есть в самом конце, а должно стоять в начале. И жанр не объявлен: читатель сам догадывается, разбор это, багрепорт или предупреждение. Это редакторская ошибка, спорить не буду, надо будет поучиться лучше писвть, благодарен за такое
А про должно намекать поспорю — не логикой, а тем, как оно выглядело у людей. Автор issue в OPNsense закрыл его сам, решив, что напутал в настройках, механизм он так и не узнал. В VyOS завели как баг и закрыли как Invalid. На Server Fault ответ свёлся к "уберите подсети", без объяснения, почему у одних пиров пропадает, а у других нет. netbird чинил это в проде в июле этого года. Логика тут действительно несложная, беда в том, что до неё не доходят.
И grep по конфигу ловит не всё. Вот два способа на одном файле, где у пяти пиров стоит 10.13.13.2/24 … 10.13.13.6/24:
grep -h AllowedIPs wg0.conf | tr -d ’ ’ | sed ‘s/AllowedIPs=//’ | tr ‘,’ ‘\n’ | sort | uniq -d -> пусто, все строки разные
grep -hoP ‘AllowedIPs\s*=\s*\K.*’ wg0.conf | tr -d ’ ’ | tr ‘,’ ‘\n’ | python3 -c ‘import sys,ipaddress for l in sys.stdin: l = l.strip() if l: print(ipaddress.ip_network(l, strict=False))’ | sort | uniq -d -> 10.13.13.0/24
Вся разница в strict=False: он обнуляет хост-часть ровно так же, как это делает wg перед отправкой в ядро. Первый вариант — тот самый grep/awk, о котором вы пишете, и этот случай он не видит.
Со стороны ядра детектор короче:
wg show wg0 allowed-ips | grep ‘(none)’
Пир с (none) — это пир, у которого префикс забрали. Пустой вывод и есть проверка для CI.
Про P.S. Это не гипотетика, такая реализация существует. В BoringTun у пира лежит своя копия списка, и по коду UAPI отдаёт префикс обоим пирам сразу (peer.rs:25 -> api.rs:188); на настоящем типе из крейта это воспроизводится харнессом, сквозного прогона у меня нет, в статье это оговорено. Маршрутизация от такого хранения двойной не становится,пакет всё равно уходит в один туннель, выбор делает общая таблица. То есть "оставить у обоих"— не альтернативная семантика роутинга, а альтернативная семантика отображения, и она хуже. В ядре хотя бы (none) намекает, что что-то произошло; там не намекает ничто. RouterOS ведёт себя так же, и там сверх того удаление второго пира не возвращает префикс первому — нужен повторный set тем же самым значением. вот это уже замерено, на CHR 7.20.2.

lopej
17.08.2026 01:25exact match (A: /24, B: тот же /24 -> строка A пропадает)
А траффик куда должен идти при двух ИДЕНТИЧНЫХ роутах? К господу богу, чтобы он сам там разрулил этот несчастный траффик?

kotru21 Автор
17.08.2026 01:25Никуда больше и не должен, я с этим не спорю) Едет он последнему записавшему, и это не как бог решит, а вполне детерминированно. Я как раз с обратным и спорю: defguard в своём разборе AllowedIPs пишет "undefined behavior" и "could go to either peer", хотя ничего неопределённого там нет.
Вопрос не в том, куда поедет трафик, а в том, узнает ли об этом администратор. Сравните с обычной таблицей маршрутов: ip route add на занятый префикс отвечает RTNETLINK answers: File exists, и чтобы перебить запись, надо явно попросить — ip route replace. У wg set второго режима нет вообще, любая запись это replace. Код возврата 0, dmesg пуст, в .conf у первого пира префикс по-прежнему стоит, а в ядре его уже нет.
И наружу это выходит не как конфликт конфигурации. Пир не отваливается, endpoint на месте, handshake свежий, байты от него приходят и считаются. Выглядит как "у половины клиентов почему-то пропал доступ", причём в конфиге, куда идут смотреть первым делом, всё написано правильно.
Статья про этот разрыв, а не про то, что trie обязана разрулить два одинаковых префикса

lopej
17.08.2026 01:25trie обязана разрулить два одинаковых префикса
и как вы себе это представляете?

kotru21 Автор
17.08.2026 01:25Никак не представляю, вы процитировали ровно то, что я отрицал. Полная фраза была "статья про этот разрыв, а не про то, что trie обязана разрулить два одинаковых префикса". Разруливать там нечего, и я этого нигде не требую
Требуется другое. сказать вслух, что произошла замена. Дешевле всего это делается даже не в ядре, а в userspace. wg setconf получает весь список пиров разом, и проверка пересечений там стоит одного прохода по такому же дереву — warning в stderr, рядом с уже существующим про nonzero host part. Половины историй с "allowed ips: (none)" после этого просто не было бы. В ядре хватило бы ratelimited-строки в лог при перевешивании узла: механизм рядом уже стоит, им печатается unallowed src IP.
А без правок апстрима работает то, что у меня в выводах: после применения сравнивать wg showconf с тем, что на диске. Разошлось — значит префикс кто-то забрал.

Abyss777
17.08.2026 01:25Так вот почему у меня ECMP (equal cost multiple path) не работает через wireguard… И все выше спросившие почему там не /32 на каждом пире: да потому что там много подсетей! Которые рулятся через OSPF. И пакет из дальней подсети может прийти как через один пир, так и через другой, и вообще через оба одновременно.
Как это обойти? Кроме как не использовать wireguard?

lopej
17.08.2026 01:25еще один вундеркинд. Если за wg у тебя еще есть локальные сетки - в iptables/nftables прописывай маскарадинг.

kotru21 Автор
17.08.2026 01:25Маскарадинг эту задачу не решает.
Он не про тот конец) проблема на передаче: 10.50.0.0/24 в trie принадлежит ровно одному пиру, и NAT этого не меняет — выбор пира по dst остаётся единственным, а значит второго пути как не было, так и нет. Маскарадинг переписывает src у трафика, уходящего в туннель, то есть лечит в лучшем случае проверку источника на приёме, попутно скрывая от центра настоящие адреса дальних сетей.
И заодно ломает то, ради чего там OSPF: NAT работает только для соединений, инициированных изнутри. Достучаться из центра до хоста в дальней подсети после маскарадинга уже нельзя, а анонсировать эти подсети незачем — за NAT их всё равно не видно.
Рабочий вариант это по wg-интерфейсу на соседа и ECMP в таблице маршрутов ядра. Тогда и multipath настоящий, и адресация сквозная и OSPF работает как обычно.

Abyss777
17.08.2026 01:25Я еще и вундеркинд… А если мне не нужен маскарадинг? Мне нужна нормальная отказоустойчивая маршрутизируемая сеть, чтоб из каждой точки любая другая точка была доступна по своему IP, а не через SNAT/DNAT.

kotru21 Автор
17.08.2026 01:25Да, это оно. И вы описали случай точнее, чем я в статье: у вас не ошибка конфигурации, а требование, которое AllowedIPs выразить не может в принципе.
Причина ровно та, что в разборе: trie одна на устройство, и у узла ровно один указатель на пира (device.h:48-50). "Сеть доступна через двух пиров" в такой структуре не записывается — второй wg set просто заберёт префикс себе. Приём ломается симметрично: пакет из дальней подсети, пришедший от «неправильного» пира, отбрасывается как dishonest_packet_peer, потому что lookup_src вернёт другого. ECMP внутри одного wg-интерфейса невозможен не по недосмотру, а по устройству.
Лечится подъёмом ECMP на уровень выше — в таблицу маршрутов ядра, которая multipath умеет: по интерфейсу на соседа, у каждого ровно один пир и AllowedIPs = 0.0.0.0/0. Проверил на живом модуле, ядро 6.18.33.2-microsoft-standard-WSL2. Сразу оговорюсь:это проверка конфигурации и таблицы маршрутов, трафик через туннели я не гонял — эндпоинтов и хендшейка в этом прогоне нет
#1. ОДИН интерфейс, два пира, обоим 10.50.0.0/24 — оба wg set вернули 0 V0lRScKAfhrQ3jlPWb4Htxmy7aV30umdROXVVjVIIQ0= (none) CYO+1ZvA00gHXFji14e9U/7WTVnmGV8bC3cOyQEeQgk= 10.50.0.0/24 # 2. ДВА интерфейса, по пиру на каждом, обоим 0.0.0.0/0 wg-a: V0lRScKAfhrQ3jlPWb4Htxmy7aV30umdROXVVjVIIQ0= 0.0.0.0/0 wg-b: CYO+1ZvA00gHXFji14e9U/7WTVnmGV8bC3cOyQEeQgk= 0.0.0.0/0 # 3. ip route add 10.50.0.0/24 nexthop dev wg-a weight 1 nexthop dev wg-b weight 1 10.50.0.0/24 nexthop dev wg-a weight 1 nexthop dev wg-b weight 1 # 4. ip route get 10.50.0.7 dev wg-b src 10.0.2.1 uid 0 10.50.0.8 dev wg-a src 10.0.1.1 uid 0 10.50.0.9 dev wg-a src 10.0.1.1 uid 0 10.50.0.10 dev wg-b src 10.0.2.1 uid 0 10.50.0.11 dev wg-b src 10.0.2.1 uid 0Один и тот же префикс на одном устройстве даёт (none) у первого пира, а на двух устройствах два 0.0.0.0/0 живут спокойно, и ядро раскладывает по ним адреса. Trie у каждого устройства своя — то есть работает это по той же причине, по которой ломалось. Криптоключевая маршрутизация при этом вырождается в " от этого пира принимаем всё", что для транзитного линка и требуется, а путь выбирают ядро и OSPF поверх обычных p2p-интерфейсов. Цена — по интерфейсу и по UDP-порту на соседа, listen-port два wg-устройства не делят.
Что теряется: проверка src средствами AllowedIPs — с /0 она не фильтрует ничего. Если анти-спуфинг нужен, он переезжает в rp_filter или nftables. Со строгим rp_filter при multipath, кстати, всё нормально, вопреки распространённому мнению: __fib_validate_source() зовёт fib_info_nh_uses_dev(), а тот обходит все нексхопы маршрута и принимает пакет, пришедший с любого из них. Strict ломается не на ECMP как таковом, а на настоящей асимметрии, когда обратный маршрут — не тот же multipath.
Если очень хочется остаться на одном интерфейсе — тогда не гонять маршрутизацию через AllowedIPs вообще: WireGuard как транспорт с /32 между эндпоинтами, а внутрь GRE или VXLAN, и OSPF уже на них. тяжелее и с накладными расходами, зато никаких ограничений на динамическую маршрутизацию.

lopej
17.08.2026 01:25завязывай с нейрослопом. Это уже за гранью. У WG всего-то 200 строчек кода, а нейронка и готова уже "разползтись мысию по древу"
lopej
А какого черта (извините) вы пиру на сервере указываете не конкретный адрес (/32), а целую сеть (/24)?
AllowedIPs на клиенте и сервере выполняют несколько разные функции.
ЕМНИП, сам wg даже будет сыпать warning, если в секции [peer] на сервере указывать что-то кроме /32
На сервере - нет.
kotru21 Автор
Спасибо за честную критику!)
но давай разбираться
Про /24 у пира на сервере: само по себе это не ошибка— обычный паттерн "пир = шлюз в сеть, а не хост" (site-to-site, филиал). Даже в каноническом примере из wg-quick(8) "for use on a server" у пиров стоят подсети наравне с /32: AllowedIPs = 10.192.122.4/32, 192.168.0.0/16. В статье есть и обратный пример — у OPNsense три пира на одном /56 давали (none) у двух из трёх, а с разными /62, /63,/64 в trie остаются все три. Подсеть на пира — нормально, пока не совпадает буквально с чужой.
/24 в моём примере выбран специально чтоб показать на одной паре сразу два сценария: exact match (A: /24, B: тот же /24 -> строка A пропадает) и longest-prefix-match (A: /24, B: /32 внутри него -> остаются оба, трафик расходится по длине префикса). На двух /32 с разными адресами пересечения бы просто не возникло, показывать было бы нечего.
Про warning: он есть, но не тот. wg setconf/wg-quick действительно ругаются — в config.c: "Warning: AllowedIP has nonzero host part: %s/%s", — только проверка там не про длину маски и не про роль интерфейса, а про то, не остались ли в хост-части ненулевые биты, то есть согласован ли адрес с собственной маской. 10.13.13.2/24 — warning, 10.100.0.0/24 — нет: у /32 хост-части нет по определению, а .0/24 — корректный адрес сети. Живой пример именно этой путаницы — issue в docker-wireguard: пять пиров получили 10.13.13.2/24…10.13.13.6/24, warning на каждого, а после маскирования все пять — один и тот же 10.13.13.0/24; wg show закономерно показывает (none) у всех, кроме последнего применённого. Это тот же механизм, что в статье, просто пойманный не мной. А между пирами config.c по-прежнему ничего не сравнивает — grep по overlap/duplicate/conflict/collid в tools пуст, роль тут ни при чём.
Про "разные функции": согласен по сути, не по механизму. Роли "клиент/сервер" в коде нет: один и тот же lookup_dst на передаче и lookup_src на приёме отрабатывает одинаково по обе стороны туннеля (receive.c:404-409, device.c:153-155). Разная не функция, а конвенция поверх неё: на хабе с несколькими пирами AllowedIPs держат узким — от этого зависит и обратная маршрутизация, и изоляция пиров друг от друга (чужой src дропается в rx_frame_errors); на споке пир обычно один, изолировать не от кого. Кейс с 0.0.0.0/0 у каждого клиента на сервере из статьи (OpenWrt-тред) — ровно та ошибка, о которой вы предупреждаете, только в чистом виде.
lopej
вот ваша ошибка. Нет никакой изоляции. Приведите конфиг к нормальному виду (с /32 адресами пиров) и убедитесь, что связь между пирами никуда не пропала.
Если нужна изоляция клиентов, то фаерволл в помощь:
nft add rule inet filter forward iifname "wg0" oifname "wg0" dropkotru21 Автор
Здесь вы правы, а я нет
Моё изоляция пиров — неудачное выражение, и в том виде, как я его написал, утверждение получается неверное. Связность A <-> B через хаб определяют форвардинг и правила на нём — ровно ваш nft … drop, — а не AllowedIPs. С /32 у каждого пира пакет от A с src 10.100.0.2 на dst 10.100.0.3 проходит проверку источника, отдаётся в стек, форвардится обратно в wg0 и уходит к B: lookup_dst находит его по /32. Пингуется, да.
Что /32 на хабе действительно даёт —это не изоляция, а привязка адреса к ключу: A не может прислать пакет с чужим src. wg_allowedips_lookup_src() вернёт B, routed_peer != peer, и пакет умирает уже после расшифровки, в rx_frame_errors (receive.c:404-420). Плюс однозначный обратный путь и невозможность забрать чужой префикс. Получается, это валидация источника, а не запрет связи. называть это изоляцией не следовало.
В статье, кстати, этого слова нет — оно моё, из комментария. Поправку принял, спасибо. + за то, что объясняете, мне это важно.
lopej
О сколько нам открытий чудных
Готовят просвещенья дух,
И опыт, сын ошибок трудных,
И гений, парадоксов друг,
И случай, бог изобретатель…
kotru21 Автор
И случай, бог изобретатель
вот про него, собственно, и текст. Руками два пира на один префикс никто не прописывает, оно получается само: шаблоном, панелью, ротацией ключа.
За правку про изоляцию спасибо, она по делу.
lopej
странные панели, однако)
kotru21 Автор
Самые обычные, к сожалению)
Механизм везде один
форма подставляет адрес клиента вместе с маской интерфейса — 10.13.13.2/24. Человек читает это как "адрес в подсети /24", wg читает как префикс 10.13.13.0/24, и он один на всех.
Случаи, все из статьи. linuxserver/docker-wireguard: пять пиров с 10.13.13.2/24 … 10.13.13.6/24, warning на каждого, в ядре один префикс на всех (https://github.com/linuxserver/docker-wireguard/issues/349). OPNsense: три пира на одном fd00:1234:5678::/56, у двух (none) (https://github.com/opnsense/core/issues/9007). VyOS: «allowed-ips is overwritten», заведён как баг, закрыт как Invalid — потому что это не VyOS (https://vyos.dev/T2735). Тред на форуме OpenWrt: 0.0.0.0/0 каждому телефону, каждый новый гасит предыдущий. netbird в июле этого года чинил refcount, ключованный только по префиксу (https://github.com/netbirdio/netbird/pull/6799).
Ни в одном из них человек не прописывал два одинаковых префикса руками.
lopej
Юзал wireguard-ui, 3x-ui - ни одна из них не ставила для пиров /24
kotru21 Автор
Таблица, по которой принимается решение, однозначная, в этом всё и дело. После перезаписи в trie не два узла на 10.100.0.2, а один, и он смотрит на B. Ни при вставке, ни при поиске развилки не возникает: ядро точно знает, кому отдавать. Собственно, будь узлов два, префикс показывался бы у обоих пиров — так себя ведёт BoringTun, где у пира лежит своя копия списка. В ядре у A стоит (none) именно потому, что узел один и он ушёл.
Неоднозначно не состояние роутинга, а намерение. два конфига претендуют на один адрес. Ядро снимает эту неоднозначность мгновенно и молча, а инструмент, который мог бы заметить её до применения, в неё не смотрит — ни wg, ни wg-quick пересечения не проверяют.