Firezone выпустила sanstun: sans-IO реализацию WireGuard на Rust

Firezone выпустила sanstun: sans-IO реализацию WireGuard на Rust

Firezone объявила о sanstun, реализации конечного автомата WireGuard на Rust по принципу sans-IO. Авторы заявляют, что библиотека оптимизирована по удобству, безопасности и производительности и даёт параллельно-дружественный (parallel-friendly) идиоматичный API. Код открыт под лицензией Apache 2.0, пакет доступен на crates.io.

Sans-IO, это подход, при котором ввод-вывод и побочные эффекты строго отделены от состояния и «бизнес-логики». Под вводом-выводом здесь понимаются не только файлы и сеть, но и время со случайностью. Такие компоненты хорошо собираются друг с другом, их проще тестировать, они избегают проблемы «окраски функций» (function coloring) и хорошо сочетаются с правилами владения в Rust. Сам подход не привязан к Rust.

История: сначала Firezone взяла за основу WireGuard-реализацию boringtun и вела собственный форк. Со временем в нём накопились исправления ошибок и новые API; чтобы патчи можно было отправлять в основной репозиторий, команда сохраняла старый API нетронутым, но по мере роста требований продукта держать 100% совместимость стало всё труднее. Одним из первых больших изменений был полностью sans-IO учёт времени (все новые функции с суффиксом _at принимают Instant), это нужно для точной симуляции всей системы. Вторым стала настройка внутренней буферизации: boringtun сам устанавливает сессию при отправке пакета, но для этого нужно вызывать decapsulate с датаграммой нулевой длины (неинтуитивно), появляются лишние аллокации, а размеры буферов нужно настраивать. Вывод авторов: библиотека вообще не должна буферизовать. Полный список правок, более 70 PR даже без обновлений зависимостей. После более чем двух лет работы с boringtun команда решила собрать API так, как он должен выглядеть, получился sanstun.

Основные операции API: приём (Tunnel::handle_handshake_initiation, handle_handshake_response, handle_cookie_reply, begin_decapsulate и commit_decapsulate), отправка (Tunnel::begin_encapsulate), таймеры (handle_timeout и poll_timeout) и события (poll_event). Из-за отсутствия внутренней буферизации нет требования вызывать функции в цикле; если сессии ещё нет, пакет должен буферизовать вызывающий код, а новое рукопожатие можно начать в любой момент через Tunnel::initiate_handshake.

Архитектура отражает протокол. В WireGuard четыре типа сообщений: инициация рукопожатия, ответ на него, ответ с cookie и зашифрованные данные. Каждая сессия начинается с обмена ключами Диффи, Хеллмана, который достаточно дорог, поэтому протокол использует механизм cookie: он заставляет удалённую сторону доказать владение заявленным исходным IP и позволяет ограничивать частоту рукопожатий по адресу. Такие лимиты действуют на весь интерфейс, а не на отдельный туннель. В sanstun это отражено типами Interface (один ключ) и Tunnel (соединение двух интерфейсов). Все входящие пакеты проходят через Interface::receive, единственный публичный API, разбирающий сырую датаграмму. Он применяет лимиты, при нагрузке возвращает cookie-ответ, а иначе, ReceivedMessage с отдельным вариантом на каждый тип сообщения. Инициации рукопожатия маршрутизируются по статическому публичному ключу инициатора, остальные сообщения несут TunnelId (библиотека извлекает его из верхних 24 бит индекса туннеля). Конструкторы типов вроде HandshakeInitiation доступны только внутри крейта, так что обойти ограничитель частоты нельзя.

Главная особенность, параллельное шифрование. Между туннелями WireGuard состояния не разделяет, поэтому распараллелить работу по пирам просто. Но внутри одного туннеля, который несёт много потоков (уникальных 5-кортежей) на один хост или подсеть, обработка идёт последовательно и упирается в скорость ChaCha20-Poly1305. Счётчик пакетов привязан к сессии и при многопоточном доступе требовал бы синхронизации. Поэтому sanstun отделяет учёт от самого шифрования: begin_encapsulate ничего не шифрует, он проверяет текущую сессию, резервирует номер пакета, двигает состояние таймеров и возвращает PendingEncapsulate, у которого метод encrypt выполняет основную работу. Этот объект реализует Send и может быть передан в другой поток.

Микробенчмарки (cargo bench data_path, формат criterion): begin_encapsulate занимает около 20,8 нс независимо от размера пакета, encrypt, около 226 нс для 64 байт и около 934 нс для 1280 байт. Для пакета в 1280 байт учётная часть составляет лишь 2% стоимости шифрования нового пакета, что, по словам авторов, намекает на потенциальное ускорение более чем в 45 раз при выносе шифрования в другой поток. Они сами оговаривают, что на практике это недостижимо: цифра не учитывает передачу PendingEncapsulate между потоками и прочие накладные расходы. Тем не менее вынос криптографии на рабочие потоки, по их оценке, даёт заметный выигрыш в пропускной способности. Как именно распараллеливать, решает вызывающая сторона; можно вообще вызывать encrypt и decrypt прямо на месте. Всё, что связано с Interface, остаётся однопоточным, поэтому ограничитель частоты работает без блокировок и атомарных счётчиков.

Итог: форк firezone/boringtun объявлен устаревшим. Авторы адресуют библиотеку тем, кто строит что-либо на WireGuard, VPN, mesh-сеть и так далее; код на GitHub, замечания и pull request принимаются.

Ключевые факты

  • sanstun, открытая (Apache 2.0) sans-IO реализация конечного автомата WireGuard на Rust; пакет доступен на crates.io.
  • Появилась после более чем двух лет использования и патчинга boringtun (свыше 70 PR без учёта обновлений зависимостей); форк firezone/boringtun теперь устарел.
  • Библиотека не буферизует пакеты и не берёт на себя время и случайность: если сессии нет, пакет буферизует вызывающий код.
  • begin_encapsulate лишь ведёт учёт (около 20,8 нс), а шифрование выполняет PendingEncapsulate::encrypt, который можно передать в другой поток; для пакета 1280 байт учёт, 2% стоимости, теоретический предел ускорения, более 45x, на практике недостижимый.
  • Весь входящий трафик идёт через Interface::receive, который применяет ограничитель частоты рукопожатий; обойти его через публичный API нельзя.

Почему это важно

WireGuard лежит в основе многих VPN и mesh-сетей, а пользовательских (userspace) реализаций на Rust немного. Здесь команда, годами поддерживавшая форк boringtun, выносит накопленный опыт в отдельную библиотеку с переосмысленным API. Для разработчиков главный интерес, разделение учёта и шифрования, позволяющее распараллелить обработку внутри одного туннеля, где трафик многих потоков иначе идёт последовательно и ограничен скоростью ChaCha20-Poly1305. Второй момент, API, в котором важные инварианты (ограничитель частоты, правильная маршрутизация пакетов по туннелям) закреплены типами и проверяются на этапе компиляции.

Кому это важно

Тем, кто строит сетевые продукты на WireGuard в пользовательском пространстве: VPN, mesh-сети, шлюзы. Особенно, Rust-разработчикам, которым нужны детерминированные тесты и симуляция всей системы (время передаётся извне), и тем, кто сейчас использует boringtun или его форк Firezone. Идеи sans-IO применимы и в других языках, так что материал полезен и тем, кто проектирует сетевые библиотеки не на Rust.

Как это применить

Библиотека доступна на crates.io, код на GitHub под лицензией Apache 2.0. Весь входящий трафик пропускайте через Interface::receive и передавайте результат нужному Tunnel; исходящие пакеты отправляйте через Tunnel::begin_encapsulate. Если сессии нет, буферизовать пакет и при необходимости инициировать рукопожатие (Tunnel::initiate_handshake) придётся самостоятельно. Для начала можно вызывать encrypt и decrypt прямо на месте, без параллелизма, а затем перенести PendingEncapsulate на рабочие потоки. Оценить эффект разделения на своём железе можно командой cargo bench data_path в репозитории sanstun.

Можно ли доверять

Источник, анонс самих разработчиков, поэтому оценки удобства и производительности нужно воспринимать как заявления авторов. Приведены только микробенчмарки отдельных функций; сквозного замера пропускной способности нет, нет и сравнения с boringtun или другими реализациями, не указано оборудование, на котором шли замеры. Цифра 45x, теоретический предел, и авторы сами оговаривают, что на практике её не достичь. Аудит безопасности в тексте не упоминается.

Риски и подводные камни

Без внутренней буферизации вся забота о пакетах, отправленных до появления сессии, ложится на вашу программу. Параллельная обработка добавляет накладные расходы на передачу PendingEncapsulate между потоками, поэтому реальный выигрыш будет заметно меньше теоретического. Для тех, кто сидит на форке firezone/boringtun, он объявлен устаревшим, то есть придётся переходить на новый API. Из текста не следует, что устарел сам upstream boringtun: устаревшим назван только форк Firezone. В тексте также не указан номер версии или дата выпуска sanstun.