Это опять Revertron, в прошлой статье я разобрал, как устроена сеть Yggdrasil: криптоадресация, spanning tree, жадная маршрутизация, bloom-фильтры. Я обещал, что это первая статья цикла - и обещал рассказать новости. А новостей много: я переписал Yggdrasil с Go на Rust. Это и есть Yggdrasil-ng (next generation).

Сразу спойлер: рерайт у меня занял 3.5 дня. После этого я неделю чинил один баг. И на этом проекте я уже собрал Android-мессенджер. Обо всём по порядку.

Откуда вообще взялась эта идея

У меня есть свой проект - мессенджер Mimir для Android, основанный на Yggdrasil. До недавнего времени сетевая часть в нём работала через Go-библиотеку yggquic (Yggdrasil с QUIC внутри). Я туда регулярно вносил правки, но… делал это через веб-интерфейсы разных нейросетей - ChatGPT, Kimi и других. Кидаешь файл, просишь поменять, копируешь обратно. Каждое изменение - отдельный ритуал.

С каждым месяцем мне становилось всё сложнее: код Go-библиотеки рос, держать его в голове целиком я не мог, нейронкам приходилось давать всё больше контекста, а ошибок всё равно становилось больше. Проект развивался медленно, я выгорал.

Потом по работе я попробовал Claude Code - это CLI-инструмент от Anthropic, который интегрируется с IDE (у меня RustRover от JetBrains плюс Android Studio). Принципиальное отличие - Claude видит весь проект сам, читает файлы, запускает тесты, правит код прямо в репозитории. Не “скопируй мне функцию, я её перепишу”, а “вот задача, разберись и сделай”. Я вдохновился разработкой с LLM как таковой, причём на новом уровне - не для генерации отдельных функций, а для реальной работы над проектами.

А в русскоязычном сообществе Yggdrasil давно ходила шутка: “надо бы переписать Yggdrasil на Rust”. Несколько лет об этом смеялись. Браться за такой крупный проект без знания Go было страшно - Yggdrasil это не “hello world”, это сетевой стек с криптографией, async I/O, CRDT-маршрутизацией и особенно с каким-то кастомным актор-фреймворком.

И в один вечер опять зашёл разговор на эту тему, я прикинул то, насколько легче мне будет разрабатывать мессенджер, и психанул! Склонировал три репозитория - yggdrasil-go (сам демон), ironwood (библиотека маршрутизации) и phony (actor framework, на котором построен Go-код). Открыл Claude Code в этой папке. Запустил команду /init, и понеслось!

3,5 дня на рерайт

Это не опечатка. От git init Rust-проекта до работающего демона, который пингует Go-узлы по сети - три с половиной дня.

Конечно, “работающий” - это не “production-ready”. Тестов было мало, фич меньше, чем в Go-версии, кое-где вместо нормальной обработки ошибок стояло unwrap() (этот пассаж написала нейронка, на самом деле она такого не допускала ;) ). Но базовая сеть работала: Rust-узел подключался к публичным пирам, строил spanning tree, искал маршруты через bloom-фильтры, шифровал трафик и обменивался пакетами с Go-узлами.

Что важно понять: я сам не писал код. Я давал задания. Всё началось с анализа кода на Го. Я не помню точный промпт, но он был примерно из трёх предложений. Claude долго жевал гошные исходники и выдал некий обширный план того, как будет переписывать проект на Rust. Я очень внимательно читал план, чтобы нейронка не пошла по неверному пути прямо с самого начала, ибо дальше будет сложнее развернуться. Упирался в лимиты Claude Pro, приходилось делать перерывы на кофе, еду и даже какие-то дела по дому. Ложился спать в шесть утра. Да, наверное именно поэтому получилось 3,5 суток на перевод.

Получилось 7 фаз работы:

  • Фаза 1. Фундамент: типы, криптография, wire-протокол, конфиг.

  • Фаза 2. Маршрутизация: spanning tree, bloom-фильтры, pathfinder, роутер.

  • Фаза 3. Сеть: peers, ядро, публичный API.

  • Фаза 4. Шифрование: сессии, рэтчетинг ключей, подписи.

  • Фаза 5. Интеграционные тесты.

  • Фаза 6. Yggdrasil-демон поверх ironwood: TCP-транспорт, TUN, админ-сокет.

  • Фаза 7. Критические фичи: backpressure очередей, оптимизация keepalive.

(Это подсказала нейронка, я уже точно не помню.)

Местами Claude Code справлялся блестяще. Например, он сам обнаружил, что в моей реализации bloom-фильтра нужен тот же алгоритм хеширования (FNV-128), что и в Go-библиотеке bits-and-blooms, иначе Rust-узел не сможет договориться с Go-узлом. Растовые крейты почему-то использовали другие реализации.

Местами было тяжелее. Особенно с конвертацией ключей Ed25519 → Curve25519: в Go-коде использовался кастомный bilinear map (u = (1+y)/(1-y) mod p), и стандартные Rust-крейты так не умеют (так подумал Клод). Пришлось реализовать big-integer арифметику вручную и проверять побайтово, что результат совпадает с Go. Но потом стало понятно, что реализация дичайше медленная! Пришлось хорошо поискать и найти стандартный curve25519-dalek, который это делает быстро.

Через 3,5 дня я подключился к публичной сети Yggdrasil с Rust-узла. Пинговал известные серверы, открывал сайты внутри сети - всё работало. Установил ноду на один из домашних серверов.

Неделя на один баг

А потом начался ад.

Симптом был издевательский: первый час всё работало нормально. Дальше - постепенная деградация. Сначала отваливались один-два узла, потом часть сети, потом маршруты начинало плющить совсем, и сеть шатало (извините). Перезапустишь - снова час всё в порядке, потом опять хрень. На маленьких тестовых сетях (3–5 узлов) баг не воспроизводился вообще: всё чисто. Только в реальной публичной сети с сотнями пиров (на моём публичном пире), и только когда пир проработает достаточно долго.

Я неделю ковырялся по этому поводу вместе с Claude. Перерывали Go-исходники, сравнивали логи, добавляли отладочные сообщения, гоняли захваты пакетов. Несколько раз казалось, что нашли - патч применили, узел подержали час, баг вернулся. Это было особенно обидно, потому что весь остальной код, переписанный с Go, работал правильно - handshake, шифрование, bloom-фильтры, pathfinder.

В итоге всё свелось к пяти строчкам в обработчике пира.

В Yggdrasil узлы периодически обмениваются “подписями” (SigReq/SigRes) - это часть протокола построения spanning tree. Заодно по разнице между отправкой запроса и получением ответа измеряется RTT (время до пира). Этот RTT потом используется при выборе родителя в дереве и вычислении наилучшего пути: при прочих равных предпочитается пир с меньшей задержкой.

Так вот, в моей реализации время отправки засекалось так:

async fn peer_reader(...) {
    let mut reader = BufReader::new(conn_read);
    let sig_req_send_time = Instant::now();  // ← один раз на весь reader
    loop {
        //...
        // на каждый входящий SigRes:
        let rtt = sig_req_send_time.elapsed();
        router.handle_response(peer_id, &peer_key, &res, rtt);
    }
}

Заметили? sig_req_send_time создавалась один раз, при старте обработчика пира. И дальше elapsed() от неё измерял не “время с момента отправки запроса”, а время с момента подключения к этому пиру.

Что это означало на практике:

  • Только подключились - RTT 0 секунд, всё норм.

  • Через минуту - RTT каждого ответа от этого пира 60 секунд.

  • Через 10 минут - 600 секунд.

  • Через час - 3600 секунд.

Алгоритм Spanning tree смотрел на эти цифры и принимал решения: “у пира A задержка час, у пира B - два часа, выберу A”. Потом тот же узел через минуту: “у A - час и одна минута, у B - две и одна минута”. Дерево начинало плыть, координаты узлов разъезжались, и маршруты ломались - причём тем сильнее, чем дольше узел работал. На свежем подключении всё было ок, поэтому на коротких тестах баг не ловился.

Фикс - буквально хранить время отправки по каждому SigReq отдельно, в HashMap<PeerId, Instant> внутри роутера. Когда отправляем запрос - записываем время. Когда приходит ответ - берём оттуда. Дельта получается осмысленная.

Мораль простая. LLM сегодня может за несколько дней переписать сетевой стек средней сложности - это правда, я это видел своими глазами. Но это не значит, что человек больше не нужен. Большой проект Claude Code тащит на себе уверенно. А на тонком семантическом нюансе в пяти строках - где Instant::now() стоит не в той функции - может споткнуться так, что баг будет ловиться неделю. Особенно если он не воспроизводится на коротких прогонах, и обнаружить его можно только в живой сети.

И ещё одна вещь, важная для понимания. Это не баг “Claude написал плохо”. В Go-коде это решено правильно (там время отправки хранится в структуре пира). Но при переписывании логики на Rust LLM “оптимизировал”: подумал, что один раз засечь время хватит, ведь reader всё равно читает только этого пира.

Что нового в Yggdrasil-ng

Раз уж я переписывал, я не стал слепо копировать Go-версию. Несколько вещей в Rust-варианте сделаны иначе или добавлены с нуля.

Единый бинарник. В оригинале два исполняемых файла: yggdrasil (демон) и yggdrasilctl (управление). В Rust-версии это один бинарник. Запускаешь без аргументов - это демон. Запускаешь с yggdrasil getPeers - это команда управления. Удобнее ставить, удобнее писать скрипты.

Конфиг в формате TOML. Для проектов на Rust это идиоматичный формат, и я помню тысячи раз за эти семь лет как люди совершали простейшие ошибки в HJSON, который использует гошная версия.

Параметры запуска. Их я тоже переделал, сделал двойные минусы для длинных версий параметров и тому подобное.

Бан-лист по IP. Если с какого-то IP-адреса три раза подряд приходит битый handshake - этот IP временно банится. Защита от шумных или некорректно настроенных пиров, которые иначе забивают логи.

И главное - CKR.

CKR: VPN через Yggdrasil

CKR - Crypto-Key Routing. Идея простая: вы прокидываете обычные IPv4 или IPv6-подсети поверх mesh-сети Yggdrasil, маршрутизируя их по ключу через конкретный узел.

Зачем это нужно - несколько типичных сценариев:

  • Многоузловой VPN. Произвольное число узлов, можно создать свою IPv4-локалку внутри Yggdrasil, о которой знают только ваши узлы. Можно играть в старые сетевые игры, не поддерживающие IPv6, как Hamachi - помните такой?

  • Site-to-site. Соединить две локальные сети (например, домашнюю и офисную) - устройства в обеих сетях видят друг друга по обычным локальным IP, как будто они в одной подсети, а под капотом трафик идёт через Yggdrasil.

  • Exit-node. Один узел в сети раздаёт через себя “обычный” интернет - например, у вас сервер в неавторитарной стране, и весь ваш трафик идёт через Yggdrasil до сервера, а оттуда уже в интернет.

Технически это работает так: в конфиге клиента вы пишете соответствие “такая-то подсеть → такой-то ключ” (подсетью может быть 10.0.0.0/24 или даже 0.0.0.0/0). Когда на TUN-интерфейс приходит пакет с адресом из этой подсети, Yggdrasil не дропает его, а заворачивает в свой собственный зашифрованный туннель и отправляет узлу-владельцу подсети. Тот разворачивает и пускает в свою локальную сеть (или в интернет, если это exit-node).

Это полноценная альтернатива WireGuard или OpenVPN (никому не говорите!). И намного легче делать Multi-hop VPN (или Double VPN), когда вы входите в сеть через один IP, а в интрнет выходите через другой. Пакеты летят через всю сеть.

CKR работает на Linux, Windows и macOS (и как клиент на Android). Маршруты в системе устанавливаются автоматически через кросс-платформенную библиотеку route_manager - никаких ручных ip route add.

Приложения на новом стеке

После того как Rust-демон стал стабильным, я вернулся к мессенджеру Mimir. Старая Go-библиотека yggquic (QUIC-стек внутри Yggdrasil) пошла на свалку. Вместо неё теперь - отдельный Rust-крейт ygg_stream, построенный поверх Yggdrasil-ng.

ygg_stream - это транспортный слой поверх Yggdrasil. Он реализует TCP/KEY: TCP-подобный протокол, в котором вместо IPv4/IPv6-адресов используются 32-байтные ed25519-ключи. Полная семантика TCP (3-way handshake, надёжная упорядоченная доставка, FIN/RST, congestion control по Reno, RTT по Jacobson/Karels), плюс UDP-подобные датаграммы - fire-and-forget, без рукопожатия. Адресацию обеспечивает сам Yggdrasil, шифрование - тоже его, поверх ничего лишнего не накручивается (а в случае с QUIC добавлялся ненужный оверхед с сертификатами и шифрованием).

Mimir получает из этого крейта обычные “соединения” и “датаграммы”, и вся прикладная логика мессенджера работает с ними так же, как раньше с QUIC-соединениями из Go. API получился практически идентичный старому: Node.connect(key, port), Conn.read/write, send_datagram.

В Android-проект ygg_stream экспортируется через UniFFI - инструмент Mozilla (используется даже в Firefox for Android), который из публичного API Rust-крейта генерирует биндинги под Kotlin/Swift/Python. Один раз пишешь Rust - получаешь готовые обёртки. Сборка идёт через cargo ndk - нативные .so-файлы плюс сгенерированные .kt-обёртки.

Mimir - это “лёгкий” сценарий: приложение использует только клиентскую часть стека, без TUN-интерфейса. Полный узел внутрь мобильного приложения с TUN-ом - это уже Yggdrasil-ng для Android. Это второе приложение, которое я сейчас разрабатываю - аналог официального Android-клиента Yggdrasil (я тоже его мейнтейнер, и у которого внутри Go-демон), только с Rust-сердцем. Здесь уже доступны все “взрослые” фичи: TUN-интерфейс, внутрисетевые DNS-серверы с блокировкой рекламы и аналитики, и главное - CKR. То есть с телефона можно поднять туннель к серверу и пустить через него весь трафик устройства, или объединить телефон с домашней сетью site-to-site.

В опенсорс это приложение я пока не выложил - думаю, как его правильно распространять. На сегодня оно живёт в виде бесплатных APK-сборок, которые я выкладываю в Telegram-чат сообщества.

И буквально сегодня я доделал ещё одну важную вещь, которая стоит отдельного упоминания. Раньше у меня TUN-интерфейс читался и писался с Kotlin-стороны: Android поднимает TUN, Kotlin-код в цикле читает из него пакеты, передаёт в Rust-библиотеку, получает обратно пакеты, отправляет в TUN. Каждый пакет совершал минимум четыре пересечения JNI-границы и проходил через Java-аллокаторы.

Сегодня я перенёс работу с TUN целиком внутрь Rust-крейта: FileDescriptor отдаётся в Rust один раз, дальше всё (чтение, шифрование, маршрутизация, расшифровка, запись) живёт на Rust-стороне без участия JVM. Результат - прирост скорости в 9–10 раз (пропускная способность через CKR-туннель), резко меньшее потребление памяти и почти полное снятие нагрузки с GC.

Что дальше

Это вторая статья цикла. В следующих хочу разобрать конкретные технические темы поглубже - на выбор:

  • Шифрование сессий - как именно работает сессионный handshake, рэтчетинг ключей, почему Ed25519 приходится конвертировать в Curve25519 и как это устроено.

  • Pathfinder изнутри - bloom-фильтры, path lookup, broken paths.

  • CKR на практике - отдельная статья с конкретными конфигами: как поднять exit-node, как сделать site-to-site, какие подводные камни.

  • Android-разработка с UniFFI - для тех, кто хочет встраивать Rust-код в мобильные приложения.

Что больше интересно - пишите в комментариях, расставлю приоритеты по запросам.

Спасибо, что дочитали. Yggdrasil-ng - открытый проект, исходники здесь, баг-репорты приветствуются, PR-ы не очень.

Комментарии (23)


  1. nitro80
    25.04.2026 03:09

    Автор, похоже в https://t.me/Yggdrasil_ru меня забанили, как туда попасть?


    1. Revertis Автор
      25.04.2026 03:09

      Писать мне в личку. Да, надо быть осторожным, мой бот на входе даёт только 30 секунд на ввод цифр.


  1. qazpoi12
    25.04.2026 03:09

    Огромное спасибо за статью! Теперь понял, что такое CKR и зачем он нужен.


    1. Revertis Автор
      25.04.2026 03:09

      Ну вот, значит уже не зря писал.


  1. Dhwtj
    25.04.2026 03:09

    Если сделали новые крейты то опубликуйте их в git и на crates.io

    Дело, похоже, полезное. Ну не люблю я Go.

    P.S. ночью надо спать


  1. Dhwtj
    25.04.2026 03:09

    Yggdrasil переписан, конечно, не весь. Только то что нужно непосредственно в проекте. Просто по объёму кода понятно.


    1. Revertis Автор
      25.04.2026 03:09

      Да, я не стал добавлять транспорты QUIC и WS, о добавлении которых автор yggdrasil-go уже жалеет.


      1. ufm
        25.04.2026 03:09

        Добавь, пожалуйста, автопиринг. Без него - беда-печаль-огорчение.


        1. Revertis Автор
          25.04.2026 03:09

          Что за автопиринг?


          1. ufm
            25.04.2026 03:09

            MulticastInterfaces


            1. Revertis Автор
              25.04.2026 03:09

              В мастере уже месяц, наверное. Но в релизе этой ночью вышло, да.


  1. gudvinr
    25.04.2026 03:09

    But why?


    1. Revertis Автор
      25.04.2026 03:09

      Я в статье хорошо это объяснил.


  1. Xelld
    25.04.2026 03:09

    Интересно, пишите ещё :)

    Я бы почитал про pathfinder и вообще про сходимость сети подробнее.


  1. casper_por
    25.04.2026 03:09

    Отличная работа! С точки зрения выбирания что дальше - интересно всё, но в данный момент больше интересует CKR на практике ввиду новых ограничений, ну и так же очень рассчитываю на полную транспортную совместимость в перспективе с go версией


  1. NickUkoloff
    25.04.2026 03:09

    ох уж это неутомимое желание переписать всё на раст...
    особенно с помощью ллмки


    1. Revertis Автор
      25.04.2026 03:09

      Каковы минусы?


      1. gudvinr
        25.04.2026 03:09

        Всё на раст

        https://www.opennet.ru/opennews/art.shtml?num=65278

        С помощью ллмки


        1. Revertis Автор
          25.04.2026 03:09

          Ну, во-первых, я считаю, что Cursor'ом нельзя пользоваться, он сделан для бездумной генерации лапши. Такой интерфейс, ничего не поделаешь. Во-вторых, конечно надо следить за Клодом, ставить рамки и ограничения и так далее.


  1. kujoro
    25.04.2026 03:09

    то есть поставить этот через гайд ацетона не получится?


    1. Revertis Автор
      25.04.2026 03:09

      Не знаю что вы имеете ввиду.


      1. kujoro
        25.04.2026 03:09

        https://habr.com/ru/articles/567012/


        1. Revertis Автор
          25.04.2026 03:09

          Нет, всё так же. Только формат конфига чуть отличается.