NAT traversal в embedded P2P-мессенджере на Go: почему overlay routing, а не STUN/TURN/ICE

от автора

Несколько месяцев пилю embedded P2P-мессенджер на Matrix-протоколе как личный pet-проект в свободное от основной работы время. Стек: форк Dendrite (Matrix homeserver на Go), Pinecone overlay routing от matrix.org research, gomobile bind для упаковки в .aar и .xcframework, modernc.org/sqlite вместо CGO-варианта (иначе gomobile капризничает). Не туториал и не “hello world на gomobile”, а серьёзная архитектурная амбиция в свободное время. Делюсь reasoning’ами почему такие архитектурные выборы и где они начинают течь.

Без обещаний неубиваемости. Проект в активной разработке, на этапе интеграции в клиентское приложение поверх Rust SDK matrix.org. Цифры приведу с явной маркировкой “где замерено на моём стенде, где плановая оценка, что ещё не проверено”. Production-NAT-кейсы (CGNAT, реальные мобильные сети) — впереди в следующем рывке. Если что-то принципиально новое всплывёт — напишу продолжение.


Что под капотом моего стенда

Стек, который сейчас собран и работает у меня дома:

  • Matrix-протокол, форк Dendrite (Matrix homeserver на Go).

  • Embedded на мобильнике через gomobile bind: .aar для Android, .xcframework для iOS.

  • modernc.org/sqlite вместо mattn/go-sqlite3 (pure Go, без CGO — иначе gomobile в продакшен-сборке начинает капризничать).

  • Pinecone от matrix.org как overlay-роутинг.

  • Клиентское приложение поверх Rust SDK matrix.org делает E2E (Olm/Megolm), моя Go-библиотека в шифрование не лезет — только маршрутизирует.

  • NATS JetStream встроен в Dendrite как async-брокер событий.

Это библиотека-обёртка, не shipping-мессенджер. Тестовые приложения с двумя кнопками собирал под Android и iOS чтобы проверить connectivity между устройствами. Финальное product-приложение клиентской части — в планах, пока что библиотека собирается, интегрируется в клиент через bridge, и проверяется на demo-сборках.

Что уже работает: P2P-обмен сообщениями между двумя устройствами в одной Wi-Fi сети, cross-platform (Android ↔ iOS прошёл), fallback на Matrix-сервер при недоступности P2P, multi-device sync, SSL pinning к серверу через SHA-256 SPKI. Что сейчас в работе: двусторонний handshake-протокол через два custom Matrix event types для включения P2P-режима, мультидевайс через overlay-форвардинг.


Четыре варианта на столе

Когда сел проектировать NAT traversal, рассматривал четыре класса подходов. Без оценочных суждений в этом разделе, оценки дальше:

STUN/TURN/ICE (WebRTC-style). Классика. Клиенты обмениваются ICE candidates через signaling channel, пробивают NAT через STUN, при провале гонят трафик через TURN-сервер. Подробные русскоязычные разборы — WebRTC для всех и каждого (перевод webrtcforthecurious.com) и 5 ошибок при разработке WebRTC звонков от Voximplant.

libp2p. Модульный P2P-стек от Protocol Labs, используется в IPFS/Filecoin/Kubo. Kademlia DHT для discovery, AutoNAT v2 для определения reachability, DCUtR (Direct Connection Upgrade Through Relay) для hole punching, Circuit Relay v2 для fallback. Реализации на Go и Rust. Русскоязычные обзоры — P2P на Go: библиотека libp2p от Otus и flagship-перевод 2021 Азбука libp2p от Textile.

Overlay routing крипто-адресуемых сетей. Yggdrasil, cjdns, Hyperboria. У каждого узла IPv6-адрес производный от ed25519-ключа, маршрутизация по spanning tree (Yggdrasil) или по локально-вычисляемым координатам в виртуальном пространстве. NAT traversal неявный: узел открывает outbound TCP до bootstrap-пира, и связность достигается тем что 100% участников держат хотя бы одно outbound-соединение. Подробно — Что такое Yggdrasil Network от @Revertron (мейнтейнер Android-клиента) и Yggdrasil как встраиваемая библиотека от @AsciiMoth (форк yggdrasil-go).

Overlay routing matrix.org-style (Pinecone). Концептуально близко к Yggdrasil, но протокол спроектирован специально под Matrix federation. Двухслойная маршрутизация: SNEK (Sequentially Networked Edwards Key) для известных адресатов, spanning tree как fallback и для bootstrap’а. Open source — github.com/matrix-org/pinecone. На Habr не разобран, белое пятно.

Mesh-VPN корпоративного класса (Tailscale, ZeroTier, NetBird) тоже посмотрел — не подошли по сценарию (это VPN-доступ к собственным ресурсам, не peer-to-peer messaging). Подход librats + Hyperswarm + DCUtR из P2P в РФ: почему нужна система, а не протокол (kda2210, апрель 2026) — третий путь, DHT с hole-punching. Не делал бенчмарков, но в той статье есть конкретные измерения “1.4 MB RAM при 100 пирах vs 400 MB у JS-libp2p”.


Почему выпилил STUN/TURN/ICE

Не из идеологии. Конкретно под этот use case шесть причин:

1. Нет естественного signaling channel. STUN/TURN/ICE предполагает что у клиентов уже есть способ обменяться SDP-offer/answer и ICE candidates. В P2P-мессенджере на Matrix-стеке такой канал, технически, есть — Matrix-сервер. Но архитектурная идея конкретно в том чтобы НЕ зависеть от него постоянно: сервер используется как “телефонная книга” pinecone-ключей и резервный канал доставки, не как signaling. Запустить overlay через сервер один раз для регистрации ключа и дальше коммуницировать через overlay — архитектурно чище.

2. CGNAT в мобильных сетях. МТС, Tele2, Билайн через UE-инициированный CGNAT по сути дают симметричный NAT. Hole punching через STUN там не работает. Все WebRTC-проекты в этом сценарии падают на TURN. Это значит обязательный production-grade TURN-фарм и постоянные расходы на relay-трафик. Автор статьи про Jami в России прямо подтверждает: “встроенные TURN-серверы turn.jami.net недоступны (подтверждено тестами)”. TURN-инфраструктуру всё равно придётся поднимать (relay-узлы в следующем рывке), но как часть overlay-семантики, а не отдельную WebRTC-инфру.

3. Бесплатные публичные STUN — известная ловушка. Voximplant в 5 ошибках при разработке WebRTC называет это ошибкой №1: “Не надейтесь на бесплатные STUN сервера (например, широко известные stun.l.google.com:19302) и тем более на ‘бесплатные’ TURN сервера”. Если строить production-grade мессенджер — нужен свой STUN/TURN. Это снова инфра.

4. Размер deps на embedded. Pure-Go WebRTC-стек (pion/webrtc + ICE-агент) тащит несколько мегабайт. Для серверной разработки это ничего, для мобильной библиотеки каждый мегабайт критичен. У меня и так с Dendrite + Pinecone production-сборка .aar выходит в районе 35-40 MB. Добавлять полноценный ICE-stack ради одного use case (peer-to-peer соединение) — перебор.

5. Мультидевайс плохо ложится на peer-to-peer connection. STUN/TURN/ICE — про установление соединения между двумя точками. У одного юзера может быть несколько устройств (телефон, планшет), и сообщение от собеседника должно дойти на все активные. Это или N×M ICE-соединений (плохо), или relay через signaling, что снова возвращает к Matrix-серверу как единой точке доверия, чего и пытаюсь избежать.

6. Synchronous offer/answer. WebRTC signaling предполагает синхронность установки сессии. Это нормально для video-call’а, но плохо ложится на async-семантику мессенджера, где сообщение может прийти через несколько часов после отправки, и где соединение между двумя пирами либо есть постоянно, либо его нет.

Это не значит что WebRTC плохой. Для video calling, browser-based applications, screen sharing — идеален. Просто у меня другая задача: persistent multihop routing между подписанными ключами, а не peer-to-peer media stream.


Почему не libp2p

С libp2p отдельная история. Не подошёл по другим причинам:

1. Heavyweight для embedded. go-libp2p тащит Kademlia DHT, GossipSub, Noise/TLS handshake stack, несколько muxer’ов (Yamux, mplex), несколько транспортов. Для серверного P2P это нормально, для мобильной библиотеки набегает несколько MB только на dependency tree, и большая часть функционала мне не нужна (своя discovery через registry на Matrix-сервере, своя federation-семантика Matrix).

2. AutoNAT v2 на проде ещё прохладен. В апреле-мае 2026 опубликован cross-implementation audit от ProbeLab с конкретным root-cause: dialerHost в AutoNAT v2 наследует UDP black hole detector в read-only mode, и для свежих узлов (с пустой историей) detector трактует “пробное соединение неуспешно” как “соединение в blacklist”. Клиент остаётся в Unknown reachability state навсегда. Полный отчёт — github.com/probe-lab/autonat-perf-audit. Пробовал отправить fix в libp2p/go-libp2p (PR #3505), мейнтейнер закрыл с обоснованием что v2 не должен повторять архитектурный workaround от v1 — это, говорит, latch existing behavior, а не design pattern. Нормальная design conversation, но иллюстрирует что connectivity-стек ещё не стабилен.

3. Нет нативного fit с Matrix federation. Matrix S2S API — HTTP-like запросы между серверами. libp2p даёт streams. Заворачивать Matrix в libp2p streams значит писать кастомный wrapper и поддерживать его. Pinecone специально проектировался matrix.org research как HTTP-transport-compatible: federation идёт почти-обычными HTTP-запросами поверх overlay.

4. DCUtR требует Circuit Relay. Direct Connection Upgrade Through Relay сначала устанавливает соединение через relay, потом пытается upgrade’нуть до direct — это работает, но требует инфраструктуру relay-узлов и retry-цикл с известными граничными условиями. Те же relay-узлы у меня всё равно появятся (в следующем рывке), но в Pinecone-семантике (где relay — это просто узел overlay с лучшим reach), а не как отдельный libp2p-слой.

Снова — не приговор libp2p. IPFS, Filecoin, Kubo живут на нём, ProbeLab в общем заключении говорит что протокол работает. Для моего use case — overhead и mismatch архитектурной семантики.


Что такое overlay routing и Pinecone-специфика

Overlay routing в одной фразе: каждый узел открывает outbound TCP/WebSocket к одному из известных bootstrap-пиров, дальше трафик ходит многохопно через виртуальную сеть поверх обычного TCP. NAT проходится потому что соединение исходящее изнутри NAT’а — правило трансляции остаётся активным пока кому-то нужно отвечать через ту же сессию. Hole punching не нужен.

Это не магия и не “беспроигрышная альтернатива” — просто другой набор trade-off’ов. Overlay-подход переносит сложность из “договориться о direct connection между двумя пирами за NAT’ом” в “обеспечить связность виртуальной сети”. Yggdrasil делает это через крипто-spanning-tree. Pinecone — через комбинацию SNEK + spanning tree.

SNEK в одном абзаце. Sequentially Networked Edwards Key. Алгоритм построения виртуальной линии узлов, упорядоченной по ed25519-ключам. Каждый узел периодически отправляет bootstrap-сообщение в направлении возрастания ключа, ближайший по ключу сосед регистрирует себя как “descending” peer. В результате — двунаправленная линейная структура, по которой можно дойти от любого узла до любого через логарифмическое число хопов от размера сети. Реализация лежит в router/state_snek.go, периодический интервал bootstrap’а — 5 секунд.

Spanning tree. Параллельный механизм. Один узел сети объявляет себя root’ом (по наибольшему ed25519-ключу — детерминированно), root шлёт периодические TreeAnnouncement фреймы, узлы выстраивают tree относительно root’а. Координаты узла в tree — путь от root’а как вектор portID. Когда SNEK-путь неизвестен (свежий узел, ещё не накопил history), трафик идёт через tree.

Пять типов фреймов. Keepalive, TreeAnnouncement, Bootstrap (для SNEK setup), Traffic (пользовательский), WakeupBroadcast. Каждый фрейм имеет 10-байтный header с magic 0x70696e65 (ASCII pine). Описание — в types/frame.go.

Транспорты. Pinecone поддерживает Pipe (для тестов), Multicast (UDP в LAN), Bonjour (mDNS на macOS/iOS), Remote (TCP/WebSocket с TLS — основной production-транспорт), Bluetooth (через gomobile binding на мобильниках). Выбор маршрута приоритизирует Multicast < Remote < Bluetooth.

Самое главное для NAT-аргумента. В Pinecone нет STUN-клиента. Нет TURN-сервера. Нет ICE candidate negotiation. Цитата из README:

Pinecone peering connections look like regular TCP or WebSocket connections and will work fine through firewalls or NATs. If you make an outbound connection to a static node, you will still be able to receive incoming Pinecone traffic over that peering.

То есть: мобильный клиент знает адрес одного-двух bootstrap-узлов (свои Relay в дальнейшем плане или публичные узлы matrix.org для текущего PoC), открывает к ним outbound TCP+TLS, NAT создаёт трансляцию. Другие узлы overlay-сети узнают этот клиент через распространение TreeAnnouncement и шлют ему трафик через тот же канал обратным путём. Hole punching не нужен — связность достигается тем что 100% клиентов хотя бы один outbound держат всегда.

Упрощаю, конечно. В реальной сети есть очереди фреймов, watermarks для дедупликации, backpressure-механизмы, выбор маршрута между tree и SNEK при наличии обоих. Деталей — в Pinecone-репозитории, подкаталог docs/. Там же есть симулятор — cmd/pineconesim/, поднимает виртуальную сеть с веб-визуализацией на localhost:65432. Удобно если хочется поиграться с топологией.


Как это собирается в стек

Архитектура с Go-стороны:

Клиент (мобильное приложение на Rust SDK matrix.org)        │        ▼ через gomobile bridge[Go-библиотека: моя обёртка]   ├── Embedded Dendrite (Matrix homeserver)   │     └─ Matrix federation API   │         └─ через Pinecone overlay routing   │             └─ TCP/WebSocket → bootstrap peer (outbound из NAT)   │   ├── REST-клиент к Matrix-серверу (publish/get/deactivate Pinecone-ключа в registry)   │     └─ через HTTPS с SSL pinning   │   └── Колбэки в клиент (handshake events, lifecycle статусы)

Клиент через Rust SDK хранит расшифрованную историю и делает Olm/Megolm. Моя Go-библиотека в шифрование не лезет — маршрутизирует обёрнутые matrix-events через overlay, и всё.

Архитектурные решения, на которые сел после reasoning:

1. Защита per-chat, а не per-account. Юзер включает “щит” в конкретном чате через UI, не одной кнопкой на весь аккаунт. Pinecone-узел запускается один раз при первом щите в любом чате, переиспользуется для всех защищённых чатов, останавливается когда юзер выключает последний щит. Это даёт юзеру granular контроль и не заставляет постоянно держать P2P-стек для обычной переписки. Запуск идемпотентен: повторные вызовы из разных мест UI не пересоздают ключ и не делают повторных публикаций.

2. Двусторонний handshake через Matrix-события. Включение защиты идёт через стандартный Matrix Client API: два custom event types — один для запроса включения P2P-режима, второй для ответа. В payload — pinecone-ключ инициирующей стороны. Только после обоюдного согласия активируется overlay-доставка для этой комнаты. Это обходит проблему “первое сообщение через overlay требует уже знать peer’s key” — до handshake’а overlay-узел может быть даже не запущен.

3. modernc.org/sqlite вместо CGO-SQLite. Критично для gomobile. Стандартный mattn/go-sqlite3 — CGO-биндинг к libsqlite3.c. gomobile bind с CGO собирается, но требует Android NDK toolchain, увеличивает размер артефактов на несколько MB, и периодически ломается при обновлении gomobile (последний раз попал на это пару месяцев назад — дебаг-сценарий с lipo и манипуляциями над .xcframework). modernc.org/sqlite — pure Go, libsqlite3 транспилирован через c2go. Trade-off: примерно 5-10% медленнее на write-heavy workload (синтетика), зато gomobile собирается чисто без NDK-плясок.

4. Мультидевайс синхронизация через overlay, а не через Matrix-сервер. У одного юзера может быть несколько устройств. Каждое регистрирует свой Pinecone-ключ в registry на сервере. При получении overlay-сообщения от собеседника моя библиотека находит другие свои устройства через registry и форвардит им то же зашифрованное (Rust SDK расшифрует на их стороне) сообщение через Pinecone, с меткой forwarded:true для защиты от петель. Matrix-сервер в синхронизации между своими устройствами не участвует. Дедупликация на принимающей стороне — по Matrix event_id. Это standard multicast-style sync с loop-protection меткой, у Yggdrasil примерно та же идея.

5. SSL Pinning к Matrix-серверу через SHA-256 SPKI. Все HTTPS-запросы проверяют SHA-256 хеш Subject Public Key Info через кастомный http.Transport.VerifyPeerCertificate. Два хеша вшиты в код: основной + резервный. Это нужно для ротации сертификата без обновления приложения: вводим новый сертификат, в переходный период оба хеша валидны, через несколько недель приложение обновляется и primary заменяется. Известный паттерн, но в pure-Go реализации деталей хватает — возможно, отдельная статья.


Замеры на моём стенде

Disclaimer перед таблицами: цифры — с моего тестового стенда, не из синтетических бенчмарков. Это PoC-сборки, не production-готовый артефакт. Production-NAT-кейсы (CGNAT, реальные мобильные сети) ещё впереди. Кроме того, не все измерения одинакового качества: iOS-цифры замерял на реальном устройстве через Xcode Instruments, для Android реальных замеров на флагмане пока нет, эмулятор недостоверен.

iOS, 30-минутный тест, iPhone 16 Pro Max:

Метрика

Значение

RAM

6 MB

CPU при отправке/получении

3%

CPU средняя нагрузка

1%

Батарея

0.1%/час

Размер артефактов:

Артефакт

Значение

Замечание

.aar production

35-40 MB

Релизная сборка без debug-символов

.aar debug

около 68 MB

С debug-символами, тестовые сборки

.xcframework debug

161 MB

Два слайса (device arm64 + simulator arm64), каждый около 80 MB. Production-замер не делал

Android RAM/CPU отдельно. Замеры пока только на эмуляторе — получаю около 120 MB RAM и ~0.5% CPU при средней нагрузке. Эмулятор делит ресурсы с хост-системой и эти числа недостоверны для реального устройства. На реальном Android-флагмане замеры в планах после релиз-сборки. На уровне гипотезы — ожидаю в районе 70-110 MB RAM на Snapdragon 8 Gen 2-3 классе, но это вилка, не измерение.

Из чего основной вес .aar. Главный вклад: NATS JetStream (встроенный async-брокер событий внутри Dendrite, не вырезается — критичен для federation flow), gomatrixserverlib (Matrix-логика как таковая), crypto (Olm/Megolm библиотеки), Bleve (поисковый индекс — на 6 MB, в плане вырезать через build tags после стабилизации функционала). Pinecone сам по себе небольшой — около 1-2 MB чистого кода.

Cross-platform connectivity подтверждён. Android и iOS на реальных устройствах в одной Wi-Fi обмениваются сообщениями через Pinecone multicast. Cross-network (через bootstrap peer в другой сети) пока тестировал только в локальном setup’е с docker-сетью. Реальная мобильная сеть с CGNAT — впереди.

Multi-device fallback flow. Полный fallback реализован: если overlay не доходит (peer offline, нет route’а, timeout) — моя библиотека возвращает типизированную ошибку, и клиент отправляет сообщение стандартным Matrix-каналом через сервер. Шифрование (Olm/Megolm) делает Rust SDK независимо от overlay. То есть worst-case — обычный Matrix-клиент с E2E. Best-case — overlay-доставка вообще мимо сервера.

80-90% direct P2P без relay. Это плановая цифра из архитектурного видения, основана на статистике других overlay-сетей. Не моя измеренная. До production-relay-инфраструктуры и теста на реальном CGNAT подтвердить не могу.


Что overlay-подход не закрывает

Раздел про ограничения — обязательный, без него получится “магическая альтернатива WebRTC”, которой не бывает. Что overlay-подход на текущий момент не закрывает или закрывает с оговорками:

1. CGNAT в мобильных сетях. Когда оператор гонит всех клиентов через один шлюз, outbound TCP от мобильника к bootstrap-peer’у работает (правило трансляции открывается), но второй мобильник в той же сети не может стать сам bootstrap’ом для других. Нужны public-IP relay’и где-то снаружи. Это явная цена overlay-подхода: relay-узлы не “опционально”, они часть боевой архитектуры. У Tailscale это DERP-серверы, у I2P — Tor-relay’и, у Yggdrasil — публичные peer-lists, у Pinecone это будут мои relay-узлы в следующем рывке. Никто этого не избежал.

2. Cross-network NAT traversal в реальных условиях ещё не проверен. PoC работает на одной Wi-Fi через Pinecone multicast. Связность через bootstrap-peer в другой сети — тестировал в локальном Docker-setup’е, но реальную мобильную сеть с CGNAT ещё не гонял. Это в плане. Если что-то всплывёт принципиально неожиданное — напишу честно.

3. Invite routing нестабилен. Известная боль: invite-flow между Matrix-серверами через Pinecone overlay не всегда корректно маршрутизируется через многохоп. В PoC обхожу через создание комнаты без invite + join по room_id (юзер копирует room_id, не получает invite-event). Production-fix потребует или deeper rework Dendrite’s federation handler’а, или явного изменения UX.

4. iOS background — 30 секунд жизни процесса. Это не overlay-проблема, это Apple. Любое мобильное приложение в фоне убивается через ~30 секунд, все TCP-соединения дропаются. Решается стандартным паттерном: APNs push → Notification Service Extension → wake-up Pinecone-узла в lite-режиме на короткий срок, чтение pending overlay-сообщений, сохранение в Rust-SDK-store для UI. У Signal и Element X точно тот же подход. NSE имеет жёсткий 30-секундный лимит на работу, поэтому lite-режим Pinecone должен подняться за <5 секунд.

5. Android background — vendor wars. Foreground Service с уведомлением держит overlay-узел постоянно. Но на Xiaomi/Huawei/Samsung-оболочках Foreground Service всё равно убивается battery saver’ом, если юзер не добавил приложение в исключения. Это не моя вина, но юзеры воспринимают как мою. Понадобится отдельный гайд “добавьте в исключения battery saver’а на вашем телефоне”. Все мессенджеры в этом контексте в одной лодке, эталонная ссылка — dontkillmyapp.com.

6. Размер артефактов. .aar 35-40 MB production, .xcframework 161 MB debug — это существенно больше чем у lightweight WebRTC-стека (несколько MB). Стоит ли overlay-подход этого веса — зависит от того, готов ли продукт не зависеть от своего сигнального сервера. Для большинства мессенджеров — нет, дешевле взять WebRTC и держать TURN-фарм. Для моего use case (privacy-first, anti-blocking, мультидевайс) — да.


Резюме

Overlay routing — не новая магическая альтернатива WebRTC, и не претендует. Это другой набор trade-off’ов:

  • меньше зависимость от STUN/TURN-инфраструктуры → больше зависимость от bootstrap/relay-узлов

  • меньше per-connection negotiation → больше per-network maintenance

  • меньше CGNAT-головной боли в connectivity → больше архитектурного веса в саму библиотеку

Под мой сценарий (privacy-first P2P-мессенджер на Matrix, embedded на мобильнике, мультидевайс) — работает. Под другие сценарии (browser-based, video conferencing, screen sharing) — не подойдёт, лучше WebRTC.

Если хочется поковырять самостоятельно — Pinecone open source, есть симулятор в cmd/pineconesim/ который поднимает виртуальную overlay-сеть на сотни узлов с веб-визуализацией. Удобно потрогать как трафик расходится при разной топологии. Yggdrasil тоже хороший entry-point если интересен этот класс задач, и AsciiMoth недавно написал отличный разбор Yggdrasil как библиотеки — похожая ниша, другая реализация.

После следующего рывка (relay-инфраструктура, CGNAT cross-network тестирование, push-flow для iOS) — посмотрю что вылезет в реальных мобильных сетях. Может, напишу продолжение с замерами уже на CGNAT и доли direct-vs-relay. А пока — спасибо что дочитали)


Ссылки

Pinecone и сопредельное:

По NAT traversal и hole punching:

libp2p:

Соседние работы на Habr:

ссылка на оригинал статьи https://habr.com/ru/articles/1043134/