The guard refused to start an AmneziaWG endpoint whose detour chain reached a WireGuard one, and refused silently: not an error, just started=false, after which every dial failed with "WireGuard is not ready yet". A selector hook went further and suspended an already-working node the moment its group switched to a WireGuard member. It existed because AmneziaWG inside WireGuard hung the kernel on Android. We do not ship Android, upstream dropped the guard once the cause was gone, and the cure landed here yesterday — the ClientBind reserved-gate plus the submodule pin that carries its twin. So the tree held both the cure and the prohibition on using it, and the configuration simply did not come up while looking like a node that "just does not work". Also takes the two fixes that belong with it. ClientBind.conn was read on a lock-free fast path and written under a mutex; upstream found that race with the same end-to-end test we wrote yesterday, so we had taken one half of a pair again. And the outer WireGuard UDP socket forced DF, unlike direct, hysteria and tuic — with encapsulation the datagram regularly exceeds the path MTU and the kernel drops it instead of fragmenting, a symptom indistinguishable from the bug we spent yesterday on. The race needed its own test: the existing e2e run did not flag it under -race even at -count=15. Eight goroutines over both connect branches reproduce it deterministically, naming the lock-free read and the guarded write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
SPEC: 007 — AWG_OVER_WIREGUARD_DETOUR_GUARD
| Поле | Значение |
|---|---|
| Тип | B (bug) |
| Статус | C (complete) — guard снят (см. баннер ниже) |
⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
Оба guard'а (Start-guard в
protocol/wireguard/endpoint.goи selector-guard вprotocol/group/awg_selector_guard.go) удалены, вместе с их adapter-хуками (OutboundManager.ConsumersOf,AmneziaWGSuspendable). Апстрим снял их коммитом5fa3a0a17; сюда снятие приехало отдельно.Почему. Зависание было Android-специфичным (
Libbox.newServiceне возвращал управление). Android для shater не платформа и ей не станет — мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для самой AWG-за-detour связки у нас уже есть: reserved-clear gate вClientBind(d971eb85e+ пин сабмодуля7d15f33), без которого AWG не поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет на его применение.Чем это было плохо на практике. Guard отказывал молча: не ошибкой, а
started=false, после чего каждый дозвон падал с «WireGuard is not ready yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как отклонённая, а как «нода почему-то не работает».Регрессия:
protocol/wireguard/awg_over_wireguard_start_lx_test.go(with_gvisor && with_awg) — AWG-эндпоинт сdetourна outbound типаwireguardдоходит до PostStart и поднимаетstarted. До снятия guard'а тест краснел.Осталось: сквозной прогон на железе (AWG поверх реального WG-хопа).
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
AmneziaWG-endpoint (источник с AWG-полями) имеет detour на любой
WireGuard-based endpoint — плоский WireGuard или AmneziaWG. По пакетам это
AWG-трафик, инкапсулированный внутри WireGuard-туннеля; на Android такая связка
вешает ядро. detour AWG-ноды на не-WireGuard outbound (VLESS, Trojan,
direct, …) — рабочий сценарий и остаётся разрешённым.
Баг в фиче 003 AWG2_CLIENT_ENDPOINT нашей дельты (AWG-проводка + merged-форк wireguard-go), не upstream → в скоупе по CONSTITUTION §3.1.
1. Проблема / контекст
Матрица из тестов на устройстве (автор):
Источник (detour-ит) |
Цель | Результат |
|---|---|---|
| AWG | WG (плоский WireGuard) | ❌ беда (ядро виснет / handshake не уходит) |
| AWG | AWG | ❌ беда |
| AWG | VLESS | ✅ работает |
| WG (без AWG-полей) | AWG | ✅ работает |
Вывод: триггер — источник AWG + цель = любой WireGuard-туннель, а не «два junk-слоя».
По пакетам — AWG внутри WG. По конфигу — «у ноды AWG прописан detour на WG/AWG».
Механика (статический разбор submodules/wireguard-go):
SendHandshakeInitiation (device/send.go) синхронно генерирует junk и зовёт
SendBuffers → bind.Send() без таймаута на запись, удерживая
device.net.RLock(). Когда AWG-трафик заворачивается в WireGuard-устройство,
запись блокируется на нижнем туннеле; на Android (нет watchdog) это проявляется
как зависание. Точную первопричину статикой не доказали — для guard это и не
нужно: цель — быстро отклонять заведомо опасную связку, пока (отдельной
задачей) не вылечена сама блокировка в wireguard-go.
2. Цель
AWG-нода с detour на WireGuard-based цель не поднимает соединение.
Поведение — вариант B (согласовано с автором и
LxBox §128):
ядро стартует, остальные узлы работают, эта нода не встаёт, ошибка в логе. Не
крашимся.
Ревизия 2026-06-16 (см. IMPLEMENTATION_REPORT): реализация прошла итерации.
- lx.8 — ленивый guard в
DetourDialer.init()(на первом dial). Field-тест показал: не срабатывает — AWG→WG виснет синхронно вEndpoint.Start(резолв peer-домена через detour + junk-handshake), до первого dial. Удалён (непроверяем в UI,sync.Once-кэш не ловит смену селектора).- lx.9 — Start-guard в
protocol/wireguard.Endpoint.Start(статический обход транзитивной detour-цепи; device не поднимается). Field-verified на Android.- selector-guard в
protocol/group(SelectOutbound) — закрывает случай «селектор по середине», который Start-guard статически пропускает: при переключении селектора на член, ведущий к WireGuard, до коммита выбора гасятся (suspend →device.Down,started=false) все AmneziaWG-потребители, что detour-ят на эту группу (транзитивно вверх черезConsumersOf).Итог: два дополняющих guard'а — Start-guard (статический, прямая цепь) + selector-guard (рантайм, переключение селектора). Оба дают вариант B и не крашатся. Гашение selector-guard'ом — до переключения, поэтому AWG-потребитель опущен раньше, чем группа укажет на WG → гонки нет.
3. Требования
3.1 Критерий «источник»
- Источник-триггер — AWG-endpoint:
option.AmneziaWGOptions.IsSet()(любое AWG-поле). Плоский WG источником-триггером не является (WG→AWG разрешён).
3.2 Критерий «цель»
- Цель-триггер — любой WireGuard-based outbound:
Type() == C.TypeWireGuard(один тип"wireguard"покрывает и плоский WG, и AWG — AWG отличается лишь набором полей, тип тот же). Решение автора: детектировать по типу. - Цепочка обходится транзитивно по
detour(черезOutboundManager.OutboundDependencies()): AWG→X→…→WG ловится на любой глубине. Защита от циклов — set посещённых тегов.
- На группе (selector/urltest) Start-guard обход останавливается — выбранный член рантайм-зависим; этот случай ловит selector-guard (§3.3). Не-WireGuard цели (VLESS и т.д.) — не триггер.
3.3 Где ловить — два guard'а
(a) Start-guard — protocol/wireguard.Endpoint.Start (стадия StartStateStart),
до w.endpoint.Start(). Зависание происходит синхронно в Start (резолв
peer-домена через detour + junk-handshake), до первого dial — ленивый guard туда не
успевает (доказано на lx.8). Источник — Endpoint.awgActive
(AmneziaWGOptions.IsSet()); цель — awgDetourChainReachesWireGuard(...)
(транзитивный обход detour, на группе останавливается). Поведение — вариант B:
device не поднимается, started=false, return nil (НЕ error — иначе abort
инстанса), ошибка в лог. Все outbound'ы зарегистрированы до любого Start.
(b) selector-guard — protocol/group.Selector.SelectOutbound, до коммита
выбора (s.selected.Store). Если новый член ведёт к WireGuard
(chainReachesWireGuard: сам тип / detour вниз / вложенные группы), идём вверх
по OutboundManager.ConsumersOf (reverse-deps, транзитивно: AWG→vless→group) и для
каждого потребителя с IsAmneziaWG() зовём SuspendAmneziaWG() (device.Down,
started=false). Гашение до Store → к моменту переключения AWG уже опущен,
его reconnect вернёт «not ready» → junk в WG не уйдёт (гонки нет). Лог — при
реальном гашении (CompareAndSwap(true,false)), без спама на повторных переключениях.
3.4 Изоляция (CONSTITUTION §3.2–3.3)
- Start-guard —
// lx:блоки вprotocol/wireguard/endpoint.go+transport/wireguard/endpoint.go(Suspend()); тестawg_start_guard_test.go. - selector-guard — новый файл
protocol/group/awg_selector_guard.go+ один вызов вselector.go; маркерadapter.AmneziaWGSuspendableиOutboundManager.ConsumersOfвadapter/outbound.go+adapter/outbound/manager.go(// lx:); тестawg_selector_guard_test.go. common/dialer/{detour,dialer}.goревизией 2026-06-16 возвращены к upstream.- Поведение без
with_awgне меняется: AWG-конфиг отвергается раньше; для плоского WGawgActive=false/IsAmneziaWG()==false→ guard'ы no-op.
4. Критерии приёмки
- AWG
detour→WG и AWGdetour→AWG: узел не поднимается, ошибка в лог; ядро и прочие узлы живут (вариант B). ✅ field-verified на Android lx.9. - AWG
detour→VLESS и WGdetour→AWG: проходит. - AWG→X→…→WG (транзитивно по detour): ловится Start-guard'ом; циклы не виснут.
- Переключение селектора на WG-член при AWG-потребителе: AWG suspend до выбора; не-AWG потребители не трогаются; переключение на не-WG ничего не гасит.
- Юнит-тесты зелёные:
awgDetourChainReachesWireGuard(Start-guard) +chainReachesWireGuard/suspendAmneziaWGConsumers(selector-guard). go build ./...без тегов — ок; сборка сwith_awg— ок;gofmt -lпусто.
5. Вне скоупа
- Гонка selector-guard: закрыта порядком (гасим до
Store). Остаётся теоретический случай, если потребитель переподключается строго между нашим suspend и его собственным dial в том же тике — практически невозможен, т.к. suspend синхронен и до коммита выбора. - Лечение первопричины (таймауты/неблокирующая отправка junk в
submodules/wireguard-go) — отдельная будущая задача. - Цепочки через route-rule action, а не
detour— вне скоупа.
6. Ссылки
- Фича 003 AWG2_CLIENT_ENDPOINT
- LxBox §128 — образец поведения «вариант B» (ленивая detour-ошибка, ядро живёт):
Leadaxe/LxBox/docs/spec/tasks/128-force-direct-out-detour.md submodules/wireguard-go/device/send.go(junk-генерация в SendHandshakeInitiation)- Образец detour-проверки:
common/dialer/detour.go(empty-direct, upstreamfb622ccb)