Техническая заметка · сетевой транспортTechnical note · network transport
WireGuard поверх TURN: одна сессия, несколько путей.WireGuard over TURN: one session, several paths.
Как я свёл параллельные TURN-соединения к одному конечному адресу UDP, добавил ограниченное упорядочивание пакетов и сохранил туннель при переходе телефона между Wi-Fi и мобильной сетью.How I aggregated parallel TURN connections into one UDP endpoint, added bounded packet reordering and kept the tunnel alive while a phone moved between Wi-Fi and mobile data.
Первая версия прокси уже умела передавать локальный UDP через TURN. Для одного соединения этого хватало. После появления нескольких параллельных потоков выяснилось, что простое циклическое распределение ломает как раз то, ради чего они нужны: WireGuard видит разные серверные сокеты, меняет конечный адрес, а пакеты приходят в другом порядке.
Я разработал серверный прокси и Android-клиент. Совместимость со сторонними клиентами осталась обязательным условием: действующие приложения для Android и iPhone нельзя было выключить ради новой схемы.
01Локальный входWireGuard → транспортный модуль
02Пул линийсоединения TURN · UDP / TCP
03Агрегацияодна сессия · один серверный сокет
04Смена сетиновая линия готова до закрытия старой
Как клиент и сервер передают пакет
В этой схеме WireGuard отвечает за туннель и шифрование пользовательского трафика. TURN доставляет пакеты через промежуточное реле, а транспортный прокси соединяет TURN с обычным UDP-входом WireGuard на сервере. Эти три слоя решают разные задачи и не подменяют друг друга.
Клиент читает профиль. Это обычная конфигурация WireGuard с дополнительными комментариями #@wgt:. Из них приложение узнаёт адрес транспортного прокси, ссылку для получения временных реквизитов TURN, число линий и режимы транспорта. Перед запуском endpoint WireGuard заменяется на локальный адрес 127.0.0.1:<порт>, а MTU уменьшается до 1264 с учётом внешних оболочек.
Android создаёт системный туннель. Приложение поднимает VpnService и TUN-интерфейс. Сокеты DNS, HTTP и TURN исключаются из этого туннеля через VpnService.protect(), иначе соединение с реле попыталось бы пройти через ещё не готовый WireGuard и замкнулось бы само на себя.
Транспортный модуль получает маршрут через TURN. По ссылке на звонок клиент запрашивает временные логин и пароль, затем создаёт TURN allocation. Эти реквизиты дают право пользоваться реле, но не являются ключами WireGuard. Сначала проверяется TURN поверх UDP; если сеть его не пропускает, та же линия поднимается через TCP.
Формируется пул линий. Каждая готовая линия — это отдельная TURN allocation и DTLS-сессия от телефона через реле до транспортного прокси. Реле пересылает дейтаграммы между сторонами, но не становится сервером WireGuard. Клиент ждёт готовности хотя бы одной линии и только после этого запускает туннель.
Исходящий пакет попадает в локальный транспорт. Приложение пишет данные в TUN, WireGuard шифрует их и отправляет получившуюся дейтаграмму на локальный порт. Транспортный модуль выбирает готовую линию, помещает пакет в DTLS и передаёт его через TURN. До транспортного прокси доходит уже зашифрованный пакет WireGuard, а не открытый трафик приложения.
Прокси сводит линии в одну серверную сессию. После снятия внешней оболочки он читает UUID и номер линии из служебного префикса proxy_v2. Все линии с одним UUID получают общий UDP-сокет до локального WireGuard backend. Поэтому сервер WireGuard видит стабильный адрес отправителя, а не новый endpoint для каждого TURN-соединения.
Ответ проходит тот же путь в обратную сторону. WireGuard backend возвращает зашифрованную дейтаграмму в общий сокет. Прокси выбирает подходящую линию, клиент принимает ответы со всего пула, ограниченно восстанавливает порядок и пишет пакет обратно на локальный UDP-адрес WireGuard. После проверки и расшифрования WireGuard отдаёт данные в TUN и приложению.
Отказ одной линии не останавливает весь туннель. Линия с растущей очередью, потерями или просроченным проверочным ответом исключается из выбора и пересоздаётся. Пока в пуле остаётся готовый путь, локальный endpoint WireGuard и логическая серверная сессия не меняются.
Таким образом, отдельно живут состояние туннеля WireGuard, локальная очередь клиента, TURN-соединения, логическая сессия прокси и UDP-сокет до серверного WireGuard. Если считать каждую линию самостоятельной сессией, сервер увидит несколько исходных адресов UDP и WireGuard начнёт постоянно переключаться между ними.
Одна логическая сессия — один UDP-сокет
В proxy_v2 Android перед обычными пакетами отправляет 17 байт: UUID логической сессии и номер потока. Старый сервер считает такой префикс посторонним коротким пакетом и пропускает следующие WireGuard-пакеты как раньше. Новый сервер по UUID находит общую сессию, подключает к ней очередную линию и создаёт UDP-сокет до сервера WireGuard только один раз.
Префикс логической UDP-сессии
session_udp.go
const udpSessionPrefaceLen = 17
type udpSessionHello struct {
id [16]byte
streamID byte
supportsReorder bool
}
Восходящие пакеты всех линий записываются в общий сокет под одной блокировкой. Обратный трафик читается одним циклом. Так WireGuard продолжает видеть один конечный адрес, даже если транспорт пользуется несколькими TURN-реле.
Короткие серии вместо случайного распределения
Разбрасывать каждый следующий пакет по новой линии нельзя: задержка на них отличается, поэтому TCP внутри туннеля получает лишние повторы, а UDP-приложения — скачки порядка. Сервер передаёт короткую серию пакетов по одной линии и меняет её на естественной паузе или когда очередь выбранной линии становится хуже соседних.
Новый Android-клиент сообщает отдельным флагом, что умеет ограниченно переупорядочивать пакеты. Только после этого сервер распределяет нисходящий трафик между линиями. Старый Android с 17-байтовым префиксом тоже получает единый серверный сокет, но обратный поток закрепляется за одной линией. iPhone и proxy_v1 продолжают работать по прежней схеме.
Возможность включается со стороны клиента. Сервер не предполагает, что старое приложение умеет собирать пакеты обратно в правильном порядке.
Пул линий реагирует на сеть
Клиент начинает с полного набора соединений, но не держит их все активными без причины. После простоя пул сжимается до четырёх линий. При нагрузке он учитывает размер очереди, задержку, потери и временные штрафы, а затем возвращает нужную ёмкость.
На Android основной транспорт — TURN поверх UDP. Если текущая сеть его не пропускает, остаётся совместимый переход на TCP. Это именно резерв: TCP поверх TCP хуже переносит потери, поэтому делать его обычным путём невыгодно.
Новая сеть готовится до закрытия старой
Android сообщает о смене физической сети раньше, чем старые сокеты окончательно перестают отвечать. Клиент создаёт новое поколение потоков на появившейся сети, ждёт готовности хотя бы одной линии, переключает локальный транспорт и только затем закрывает старое поколение.
Если подготовка новой сети не удалась, клиент не оставляет туннель привязанным к мёртвому поколению сокетов, а выполняет обычный перезапуск транспорта. Этот запасной путь медленнее, зато состояние остаётся однозначным.
Проверка живости и штатное завершение
В сервер добавлен небольшой проверочный пакет с отдельной сигнатурой. Он проходит по транспортному пути и не попадает на сервер WireGuard. Клиент помечает устаревшие линии, не выбирает их для новых серий и может восстановить пул без остановки всего туннеля.
При обновлении контейнера сервер перестаёт принимать новые сессии, но даёт установленным соединениям время завершиться. После SIGTERM жёсткое закрытие происходит только по тайм-ауту. Такой порядок полезнее мгновенного перезапуска: мобильный клиент успевает выбрать другую линию, а короткие сеансы заканчиваются без обрыва.
Зачем нужен WRAP
WRAP — необязательная внешняя оболочка для сетей, которые нестабильно обращаются с обычными DTLS-пакетами. Перед отправкой в TURN клиент оформляет DTLS-запись как RTP-подобный кадр и защищает всю оболочку ChaCha20-Poly1305 с общим ключом. Сервер проверяет её целостность, снимает WRAP и только затем передаёт содержимое в DTLS.
WRAP не шифрует пользовательский трафик вместо WireGuard, не заменяет DTLS и не даёт доступ к серверу. Без совпадающего ключа клиент и прокси просто не смогут разобрать внешние кадры. Режим включается только на обеих сторонах и нужен для совместимого транспортного кадрирования, а не как ещё один туннель.
Перенос рабочего экземпляра на новый узел
Перед переносом я проверил архитектуру целевого узла, свободный UDP-порт, имена Compose-проекта и контейнеров, версию Docker и отсутствие пересечений с уже работающими службами. На новый сервер перенёс тот же собранный образ и сверил контрольные суммы конфигурационных файлов. Так изменение площадки не смешалось с обновлением кода.
WireGuard backend развернул отдельно и скопировал только нужные правила. Посторонние туннели и маршруты с исходного узла в новый контур не попали. Закрытые ключи, клиентский профиль и QR остались вне Git с правами 0600; в журнал команд их содержимое не выводилось.
Сначала я подтвердил доступность внешнего UDP-порта безопасным тестовым пакетом и ростом счётчика Docker NAT. Затем импортировал обычную конфигурацию WireGuard с транспортными комментариями и проверил реальный handshake. Эта проверка поймала ошибку формата QR: мобильное приложение ждало стандартные секции [Interface] и [Peer], а не пользовательскую URI-схему.
Маршрутизация должна приниматься на клиенте
Сервер может заблокировать выбранный трафик после входа в WireGuard, но уже не способен вернуть его в прямой сетевой путь телефона. Поэтому раздельная маршрутизация находится в Android-клиенте. Профиль с явным комментарием включает локальные GeoIP/GeoSite-списки; обычный профиль сохраняет прежнее поведение.
Для такого профиля списки загружаются и полностью проверяются до запуска туннеля. Если данных нет или они повреждены, соединение не включается. Запрет запуска при ошибке действует только для профилей, которые запросили эту политику; остальные пользователи не зависят от загрузки геобазы.
Подписка на профиль и отзыв устройства
Каждое устройство получает отдельную ссылку подписки. Android проверяет обновление при запуске, по расписанию и вручную; составной набор обновляется целиком, а не по одному выбранному туннелю. Ответы 401, 403, 404 и 410 отключают подписку локально.
На сервере отзыв идемпотентен: повторный запрос не создаёт новое состояние. Запись устройства отключается, его WireGuard peer удаляется из backend, а ссылка начинает отвечать 410 Gone. Старый формат iPhone при этом не менялся.
Что осталось за границей решения
TURN остаётся внешней зависимостью: формат реквизитов, квоты и доступность реле могут измениться;
несколько линий не создают пропускную способность из воздуха — они помогают использовать разные пути и переживать деградацию отдельных соединений;
bounded reorder ограничивает перестановку, но не скрывает долгую потерю пакетов или перегруженный канал;
make-before-break работает, когда Android успевает увидеть новую сеть до полного исчезновения старой;
рабочий handshake проверяется только с реальным клиентом: открытый порт и счётчик NAT не заменяют эту проверку.
Результат
Несколько TURN-потоков обслуживают одну логическую WireGuard-сессию и один конечный адрес сервера. Новый клиент получает управляемое распределение трафика и смену сети без раннего разрыва, а сторонние клиенты Android и iPhone продолжают работать без обязательного обновления протокола.
The first proxy version could already carry local UDP traffic over TURN. That was enough for one connection. Once several parallel streams were added, plain round-robin broke the property they were meant to improve: WireGuard saw several server sockets, roamed between endpoints and received packets out of order.
I developed the server proxy and Android client. Compatibility with third-party clients remained a hard requirement: existing Android and iPhone applications could not be retired for the new transport.
01Local inputWireGuard → transport module
02Lane poolTURN allocations · UDP / TCP
03Aggregationone session · one backend socket
04Handovernew lane before the old one closes
How the client and server carry a packet
WireGuard owns the tunnel and encrypts user traffic. TURN carries packets through an intermediate relay, while the transport proxy connects TURN to an ordinary UDP WireGuard endpoint on the server. These three layers solve different problems and do not replace one another.
The client reads the profile. It is a standard WireGuard configuration with additional #@wgt: comments. They provide the transport-proxy address, the link used to obtain temporary TURN credentials, the lane count and transport modes. Before startup, the WireGuard endpoint is changed to 127.0.0.1:<port> and the MTU is reduced to 1264 to account for the outer framing.
Android creates the system tunnel. The application starts a VpnService and TUN interface. DNS, HTTP and TURN sockets are excluded from that tunnel with VpnService.protect(); otherwise the connection to the relay would be routed into WireGuard before WireGuard itself had a working transport.
The transport module obtains a TURN path. The client uses the call link to request a temporary username and password, then creates a TURN allocation. Those credentials authorise relay use; they are not WireGuard keys. TURN over UDP is attempted first, with a bounded fallback to TCP when the current network blocks UDP.
The client builds a lane pool. Every ready lane consists of a TURN allocation and a DTLS session from the phone through the relay to the transport proxy. The relay forwards datagrams between the two endpoints; it does not become the WireGuard server. The tunnel starts after at least one lane is ready.
An outbound packet enters the local transport. An application writes to TUN, WireGuard encrypts the data and sends the resulting datagram to the local port. The transport module selects a healthy lane, places the packet in DTLS and sends it through TURN. The transport proxy therefore receives an encrypted WireGuard packet, not plaintext application traffic.
The proxy aggregates lanes into one server-side session. After removing the outer framing, it reads the logical UUID and lane number from the proxy_v2 preface. Every lane carrying that UUID shares one UDP socket to the local WireGuard backend. WireGuard sees a stable sender endpoint instead of a new one for every TURN connection.
The reply follows the same path in reverse. The WireGuard backend writes an encrypted datagram to the shared socket. The proxy selects a suitable lane; the client receives replies from the whole pool, performs bounded reordering and writes the packet back to WireGuard's local UDP address. WireGuard verifies and decrypts it, then delivers the data through TUN to the application.
A failed lane does not stop the whole tunnel. A lane with queue pressure, loss or a stale probe response is removed from selection and rebuilt. As long as the pool retains a ready path, neither WireGuard's local endpoint nor the logical server session changes.
The WireGuard tunnel, client queue, TURN connections, logical proxy session and UDP socket to the server-side WireGuard endpoint are therefore separate pieces of state. Treating every lane as its own session would expose several source endpoints and make WireGuard roam between them.
One logical session, one UDP socket
In proxy_v2, Android sends a 17-byte preface before ordinary packets: a logical-session UUID and a stream number. An old server treats it as an unrelated short packet and handles subsequent WireGuard packets as before. The new server uses the UUID to find a shared session, attaches the lane and creates the backend socket only once.
Logical UDP session preface
session_udp.go
const udpSessionPrefaceLen = 17
type udpSessionHello struct {
id [16]byte
streamID byte
supportsReorder bool
}
Uplink packets from every lane are serialised into the shared backend. A single loop reads the return path. WireGuard therefore sees one endpoint even when the transport uses several TURN relays.
Stable flowlets instead of packet round-robin
Sending every next packet over a different lane is unsafe because their latency differs. TCP inside the tunnel produces avoidable retransmissions and UDP applications see reordered bursts. The scheduler keeps a short stable flowlet and changes lanes at its boundary or when the current queue becomes worse than its neighbours.
The new Android client advertises a capability flag for bounded reordering. Only then does the server stripe downlink traffic. Older Android builds with the 17-byte preface still receive a single backend socket, but their return path remains pinned to one lane. iPhone and proxy_v1 keep the legacy behaviour.
The client explicitly enables the feature. The server never assumes that an old application can reconstruct packet order.
An adaptive lane pool
The client starts with the full connection set but does not retain every lane without traffic. After an idle period the pool contracts to four. Under load, selection accounts for queue depth, RTT, loss and temporary penalties, then restores capacity when needed.
TURN over UDP is the Android default. TCP remains a compatible fallback for networks that block UDP. It is deliberately secondary: TCP over TCP reacts poorly to packet loss and should not be the ordinary path.
Moving between Wi-Fi and mobile data
Android reports a physical-network change before the old sockets necessarily fail. The client creates a new stream generation on the new network, waits for at least one ready lane, switches the local transport and only then closes the old generation.
If preparation fails, the client falls back to a conventional transport restart rather than leaving the tunnel bound to a dead socket generation. It is slower, but its state is unambiguous.
Health checks and graceful shutdown
The server has a small echo probe with its own magic packet. It tests the transport path without reaching the WireGuard backend. The client marks stale lanes, avoids selecting them for new flowlets and can rebuild pool capacity without stopping the tunnel.
During a container update the server stops admitting new sessions but allows established ones to finish. SIGTERM starts a drain and force-closes only after a timeout. Mobile clients get time to select another lane and short sessions can complete normally.
What WRAP is for
WRAP is an optional outer envelope for networks that handle ordinary DTLS packets unreliably. Before TURN transmission, the client frames a DTLS record to resemble RTP and protects the complete envelope with ChaCha20-Poly1305 under a shared key. The server authenticates and removes WRAP before passing the contents to DTLS.
WRAP does not encrypt user traffic in place of WireGuard, replace DTLS or grant server access. A client and proxy without the same key simply cannot decode the outer frames. It must be enabled at both ends and exists for compatible transport framing, not as another tunnel.
Moving the live instance to another node
Before migration I checked the target architecture, UDP port availability, Compose and container-name collisions, the Docker version and overlap with existing services. I transferred the exact built image and compared configuration checksums, keeping the host move separate from a software release.
The WireGuard backend was deployed separately, with only the required rules. Unrelated tunnels and routes from the source host were not copied. Private keys, the client profile and QR stayed outside Git with mode 0600, and their contents were not printed into command logs.
I first confirmed public UDP reachability with a harmless packet and a Docker NAT counter, then imported an ordinary WireGuard profile with transport comments and verified a real handshake. That last check caught a QR-format error: the mobile app expected standard [Interface] and [Peer] sections, not a custom URI scheme.
Routing belongs on the client
A server can drop selected traffic after it enters WireGuard, but cannot put it back onto the phone's direct network path. Split routing therefore lives in the Android client. An explicit profile comment enables local GeoIP/GeoSite lists; profiles without it keep their old behaviour.
Profiles using this policy download and fully validate the lists before tunnel start. Missing or corrupt data prevents that connection from starting. The fail-closed rule applies only to profiles requesting the policy; other users do not depend on the Geo database.
Profile subscriptions and device revocation
Every device receives an individual subscription URL. Android checks for updates at startup, on schedule and manually; a bundle is updated as a unit rather than one selected tunnel at a time. Responses 401, 403, 404 and 410 disable the subscription locally.
Server-side revocation is idempotent. The device record is disabled, its WireGuard peer is removed from the backend and the subscription returns 410 Gone. The existing iPhone format remains unchanged.
Limits
TURN remains an external dependency whose credential format, limits and availability can change;
multiple lanes do not invent bandwidth; they help use distinct paths and survive a degraded connection;
bounded reordering cannot hide a long loss burst or a congested link;
make-before-break depends on Android seeing the new network before the old one disappears entirely;
a live handshake still requires a real client; an open port and NAT counter are not substitutes.
Result
Several TURN streams now serve one logical WireGuard session and one backend endpoint. The new client gains capability-gated striping and make-before-break handover, while third-party Android and iPhone clients continue to work without a mandatory protocol update.
Живой контур транспортаLive transport pathПРОФИЛЬ · ПОЛИТИКА / ПОДПИСКА01 / 04
Демонстрация четырёх участков: доставка профиля, создание пула TURN-линий, агрегация в одну серверную UDP-сессию и переключение на новое поколение сокетов до закрытия старого.Four stages: profile delivery, TURN lane allocation, aggregation into one server-side UDP session and handover to a new socket generation before the old one closes.
ПродолжитьContinue
К панели управления RabbitHole.Continue to the RabbitHole control panel.