Свой VPN на Rust: как я спорил с сетью, TLS и самим собой

Свой VPN на Rust: как я спорил с сетью, TLS и самим собой

Если совсем коротко: я написал свой VPN на Rust — побайтово-корректный TLS 1.3 с заёмом чужого сертификата, гибридное постквантовое рукопожатие, пять режимов маскировки и под 700 Мбит/с на скачивание. Ниже — как это устроено изнутри и десяток шишек, на которых я учился. Но начать хочется издалека.

Сначала — небольшое признание. Сети я люблю с детства, и до сих пор ICMP echo reply — обычный ответ на ping — где-то в глубине души вызывает почти детский восторг: пакет ушёл, пакет вернулся, и сразу становится чуть спокойнее. Любовь эта родилась в начале двухтысячных, когда мы лазили по чердакам микрорайона и тянули свою сеть — тогда домашнего интернета ещё не было вовсе. Свитчи покупали на свои кровные, самые дешёвые из возможных, — и после каждой грозы несколько сегментов гарантированно «отлетало» (гроза была нашим бесплатным стресс-тестом). А сеть, между прочим, выросла совсем не маленькая — за две сотни машин. И это было что-то поистине магическое: сидя по своим квартирам, мы болтали в общем чате, качали что-то с файлопомойки и играли в CS. Зарубы порой заканчивались стычками за гаражами, но это уже совсем другая история. С тех пор любовь к сетям никуда не делась — она, по сути, и привела меня сюда.

Началось всё с прозаичной задачи. Связать распределенные сервера в разных точках земного шара в одну приватную сеть управления. Я перепробовал готовые решения и каждый раз упирался в одно и то же. В разных локациях стабильно работало разное: где-то жил один протокол, где-то другой, а где-то выручал только прокси с натянутым поверх TUN через сторонний костыль. В итоге вся сеть держалась на мешанине разных решений — стопке подпорок, которую совсем не хотелось тащить дальше, и это сильно удручало. И в какой-то момент сработал знакомый многим самообман: «да я сам напишу, там же ничего сложного» — так и было принято решение собрать продукт, который закрывал бы мои собственные потребности целиком — и даже больше.

Сложного там оказалось предостаточно. И началось всё скромно — с той самой задачи: чтобы серверы видели друг друга, и не более того. Как только это заработало, захотелось дотянуть туннель до телефона, потом до рабочего ноутбука, а в какой-то момент я поймал себя на том, что собираю уже не «связку для парка серверов», а полноценный VPN под все платформы. Так у проекта и появились нативные клиенты под Android, Windows и macOS. Теперь это рабочий кросс-платформенный туннель на Rust-ядре. Проект называется Qeli , исходники открыты. Это не маркетинг и не «смотрите какой я молодец», а честный технический разбор: архитектура, криптография, набор транспортных режимов и, главное, те места, где я набил шишки. Шишек будет много — без них статья была бы враньём.

Про имя — всё просто. Был свободный домен qeli.ru , короткий и удобный. Расшифровку придумал уже потом, под готовое название: Q uick E asy L ink IP — «быстро и просто связать по IP». То есть имя появилось раньше смысла — но «связать по IP» и есть то, с чего всё начиналось, так что расшифровка неожиданно попала в точку.

Сразу оговорюсь про позиционирование: это нейтральное сетевое ПО для приватного транспорта и self-host. Всё, что ниже, я рассматриваю как инженерную задачу про устойчивость к пассивному анализу трафика и согласованность сетевого отпечатка. Ответственность за правомерность применения — на пользователе.

TL;DR. Qeli — кросс-платформенный VPN: ядро и сервер на Rust, нативные клиенты под Android/Windows/macOS. Крипто-ядро одно на всех (через FFI/JNI), а не продублировано по платформам. Гибридное пост-квантовое рукопожатие (X25519 + ML-KEM-768). Пять транспортных обёрток одного и того же туннеля — от голого plain до побайтово-корректного TLS 1.3 ( reality-tls ) с заёмом чужого сертификата. На быстром стенде — под 700 Мбит/с на скачивание. Ниже — архитектура, криптография и десяток шишек, на которых я учился. Исходники открыты: https://github.com/litvinovtd/qeli

Зачем городить своё

Резонный вопрос, и я сам себе его задавал не раз. WireGuard прекрасен, OpenVPN проверен временем. Но у меня было три причины, и все они в итоге оправдались:

Понять каждый байт. Лучший способ разобраться в протоколе — реализовать его и сломать об реальную сеть. Чтение RFC помогает; чтение RFC, когда твой код не сходится с тестовым вектором, помогает в десять раз сильнее.

Понять каждый байт. Лучший способ разобраться в протоколе — реализовать его и сломать об реальную сеть. Чтение RFC помогает; чтение RFC, когда твой код не сходится с тестовым вектором, помогает в десять раз сильнее.

Одно поведение на всех платформах. Не зоопарк клиентов, а одно ядро с одинаковой логикой и одинаковым отпечатком.

Одно поведение на всех платформах. Не зоопарк клиентов, а одно ядро с одинаковой логикой и одинаковым отпечатком.

Гибкий транспорт. Возможность по-разному упаковывать один и тот же зашифрованный туннель — от голого минимального оверхеда до полноценного TLS 1.3.

Гибкий транспорт. Возможность по-разному упаковывать один и тот же зашифрованный туннель — от голого минимального оверхеда до полноценного TLS 1.3.

Последний пункт стал и самым сложным, и самым интересным. С него и начну по существу — но сначала пара слов про каркас.

Ядро — Rust, асинхронность на tokio. Сервер живёт под Linux: интерфейс TUN заводится напрямую через libc -ioctl, транспорт — TCP и UDP одновременно (разные профили на разных портах). Клиенты нативные: Android на Kotlin, Windows на C#/WPF, macOS на C#/Avalonia.

Главное архитектурное решение выросло из одного и того же бага, который я ловил снова и снова: стоит поправить протокол на сервере — рукопожатие, key schedule, формат auth-сообщения, — как какой-нибудь клиентский порт, вручную переписанный на Kotlin или C#, отстаёт, и рукопожатие перестаёт сходиться (в логах — невалидный auth-proof или «не те» ключи). Выловив это в четвёртый раз, я сделал вывод: криптографическое ядро не дублируется на каждой платформе . Rust компилируется в нативную библиотеку ( .so / .dll / .dylib ), отдаёт тонкий FFI (C ABI для C#, JNI для Kotlin), и самый тонкий код — TLS-стейт-машина, key schedule, пост-квантовый KEM — у всех клиентов физически один и тот же. Подробности — в разделе про реконнект; часть тамошних багов выросла ровно из этого рассинхрона.

Конфигурация — плоский INI с профилями. Я начинал с JSON, был период TOML, и в итоге выпилил оба: для конфига сетевой службы плоский ключ-значение читается лучше всего, а round-trip через веб-панель проще. Управление — панель на axum: дашборд, пользователи, живые сессии, выдача конфигов профилей через qeli:// -ссылки и QR.

Криптография: рукопожатие и пост-квант

Базовый набор — крейты RustCrypto плюс rustls / ring . Ничего экзотического, всё по стандартам:

обмен ключами — X25519 ;

обмен ключами — X25519 ;

симметрика — ChaCha20-Poly1305 (AEAD);

симметрика — ChaCha20-Poly1305 (AEAD);

вывод ключей — HKDF ;

вывод ключей — HKDF ;

пароли в панели — Argon2id .

пароли в панели — Argon2id .

Аутентификация построена вокруг channel binding , и порядок здесь жёсткий: сначала личность доказывает сервер. Его proof завязан на статический ключ, эфемерный ключ сессии и хэш транскрипта рукопожатия ( ClientHello ‖ ServerHello ‖ Certificate ‖ Finished ) — перехваченный в одной сессии, в другой он уже бесполезен. И только потом клиент отдаёт учётные данные — но уходят они уже внутри установленного канала (зашифрованного, на ключах из эфемерного обмена и статики сервера), а не отдельным открытым сообщением. Поэтому в трафике нет ничего, что можно подобрать офлайн по словарю: сам пароль сервер проверяет у себя через Argon2id. Представься сервер вторым — MITM мог бы выманить пароль, ничего не зная о настоящем сервере.

Отдельная история — гибридный пост-квантовый обмен . Внутренние ключи данных я вывожу не из чистого X25519, а из комбинации с ML-KEM-768 (это NIST FIPS 203, бывший Kyber):

Смысл прозаичен — защита по модели harvest-now-decrypt-later : даже если сегодня кто-то пишет шифротекст «в стол» в расчёте на будущий квантовый компьютер, классической X25519-доли ему не хватит. В рукопожатии гибрид оформлен как группа X25519MLKEM768 (по черновику draft-ietf-tls-ecdhe-mlkem — спецификация ещё не финализирована, это держу в голове), ML-KEM-доля идёт первой, как в свежих сборках Chrome. Сервер требует её для всех режимов, кроме явного «голого»: классического fallback нет, чтобы даунгрейд был невозможен.

Самое неприятное здесь было то, что для C#- и Kotlin-клиентов ML-KEM толком неоткуда взять: ни BouncyCastle, ни встроенный в .NET MLKem (он привязан к версии ОС) не годились. Решение — тот же приём, что и со всем крипто: нативный мост к Rust-крейту ml-kem через FFI/JNI. Сознательно: один проверенный код, четыре платформы, никаких «почти совместимых» реализаций — ценой сборки нативной библиотеки под каждую цель.

И немного гигиены, которую обычно не показывают: приватные ключи и промежуточные байты живут в Zeroizing и чистятся при дропе, сравнения секретов — константно-временные (крейт subtle , ct_eq вместо != ). Счётчик пакетов не передаётся в открытом виде: каждый nonce прогоняется через 96-битную перестановку (сеть Фейстеля), так что монотонного «+1 на пакет» в дампе не видно, а уникальность nonce сохраняется — значит, повторное использование исключено. Перестановка обратима (это биекция), поэтому приёмник восстанавливает исходный номер пакета — по нему и работает анти-replay. Защита от повторов построена на двух уровнях: на дата-плоскости — скользящее окно по номеру пакета (2048 пакетов, как у WireGuard); в режиме reality-tls сервер вдобавок помнит принятые session_id — повтор того же токена в пределах ±120 секунд распознаётся и обрабатывается отдельно (захваченный ClientHello, переигранный заново, прозрачно проксируется на upstream, как чужой).

Транспортные режимы: один туннель, разные обёртки

Вот здесь самое содержательное. Один и тот же зашифрованный поток может упаковываться несколькими способами. Я называю это транспортными режимами; инженерная мотивация — устойчивость к пассивному анализу и согласованность отпечатка. Грубо говоря, поток не должен выглядеть как высокоэнтропийная аномалия там, где наблюдатель ждёт структуры.

Один и тот же зашифрованный туннель упаковывается пятью способами; конфиденциальность и аутентификацию всегда обеспечивает внутреннее ядро, меняется только внешняя обёртка.

plain — голый зашифрованный туннель без обёртки. Минимальный оверхед, максимальная скорость. Кадр в трафике — [u16 len][nonce][ciphertext] . Честный режим для доверенной сети, но структурно это «просто шифрованные байты».

fake-tls — тот же поток, обёрнутый в форму TLS-записей (заголовок application-data 0x17 0x03 0x03 ), а рукопожатие открывается ClientHello с GREASE и рандомизированным порядком расширений, так что отпечаток варьируется от сессии к сессии. Важно: это имитация формы , а не настоящий TLS — TLS-образные сообщения рукопожатия здесь лишь оболочка для моего собственного обмена ключами, а шифрует всё тот же внутренний AEAD, что и в plain . Полноценный, побайтово-корректный TLS-стек есть только в reality-tls (ниже).

obfs — весь TCP-поток оборачивается в потоковую XOR-обфускацию на ChaCha20 (ключ из PSK, nonce’ы обмениваются в начале). Сверху — короткий префикс в виде WebSocket-Upgrade: первые байты соединения — печатаемый HTTP-текст. Это снимает простую энтропийную эвристику «полностью случайный поток с первого байта». Внутренний AEAD при этом никуда не девается — XOR чисто внешний, любая подмена ломает Poly1305 и пакет дропается.

reality-tls — то, на что ушло больше всего сил. Это настоящее, побайтово корректное рукопожатие TLS 1.3 (RFC 8446), написанное руками, а не обёртка вокруг чужой библиотеки на стороне клиента.

Да, я знаю правило «не пишите свою криптографию». Поэтому самописный TLS здесь — не граница безопасности, а слой ради корректного отпечатка. За конфиденциальность и аутентификацию отвечает внутренний AEAD-канал с channel binding: даже если в моём TLS-стеке найдётся изъян, секретов он не вскроет. А корректность самого рукопожатия я проверяю автотестами — в том числе завершая настоящее TLS 1.3 против эталонного rustls.

Да, я знаю правило «не пишите свою криптографию». Поэтому самописный TLS здесь — не граница безопасности, а слой ради корректного отпечатка. За конфиденциальность и аутентификацию отвечает внутренний AEAD-канал с channel binding: даже если в моём TLS-стеке найдётся изъян, секретов он не вскроет. А корректность самого рукопожатия я проверяю автотестами — в том числе завершая настоящее TLS 1.3 против эталонного rustls.

Резонный вопрос: почему не взять готовый rustls и не мучиться? Я его и взял — на сервере (терминация) и как эталон в тестах. А клиентское рукопожатие пришлось писать руками по одной причине: любая готовая библиотека собирает ClientHello по-своему, и в дампе он выглядит как сама библиотека, а не как Chrome. Для reality-tls это провал — весь смысл в побайтовом совпадении JA4 с браузером, а такого контроля над сборкой (порядок расширений, GREASE, гибридный key_share ) обёртка через connect() не даёт. Пост-квант тут ни при чём: даже когда rustls научился X25519MLKEM768, его отпечаток всё равно остался бы «не Chrome».

А почему тогда не взять готовый REALITY — из Xray или sing-box, они же зрелые и обкатаны? Причина архитектурная (подробнее в эпилоге): мне нужен был reality-tls не отдельным тоннелем, а одним из транспортных режимов внутри моего же ядра — поверх моего L4-протокола, аутентификации (channel binding + PQ) и multipath, одним бинарником на всех платформах через общий Rust-core. Готовый REALITY несёт собственный протокол и тянет за собой отдельный рантайм на каждый клиент — это снова «конструктор из кубиков», от которого я и уходил. Заменить Xray я при этом не претендую: для максимально обкатанной защиты он остаётся разумным выбором — а паритета по главному, заёму настоящего сертификата у легитимного узла, удалось добиться (как именно — ниже).

Как это устроено:

клиент шлёт байт-точный Chrome ClientHello (совпадающий JA4-отпечаток), включая гибридный PQ key_share ;

клиент шлёт байт-точный Chrome ClientHello (совпадающий JA4-отпечаток), включая гибридный PQ key_share ;

key schedule — HKDF-Expand-Label, traffic keys, Finished — реализован с нуля и сверен побайтово с тестовыми векторами RFC 8448 (это та самая «Example Handshake Traces for TLS 1.3»);

key schedule — HKDF-Expand-Label, traffic keys, Finished — реализован с нуля и сверен побайтово с тестовыми векторами RFC 8448 (это та самая «Example Handshake Traces for TLS 1.3»);

сервер терминирует настоящий TLS и заимствует у настраиваемого upstream-узла форму ServerHello (cipher suite, группу key_share , порядок расширений) и реальную цепочку сертификата — снимает её полным рукопожатием с этим узлом и периодически обновляет (это режим handrolled=true ; дефолт проще — self-signed, маскировка слабее, зато без обращения к upstream);

сервер терминирует настоящий TLS и заимствует у настраиваемого upstream-узла форму ServerHello (cipher suite, группу key_share , порядок расширений) и реальную цепочку сертификата — снимает её полным рукопожатием с этим узлом и периодически обновляет (это режим handrolled=true ; дефолт проще — self-signed, маскировка слабее, зато без обращения к upstream);

свой клиент сервер опознаёт криптографически по токену в session_id , а не эвристикой. Соединение без валидного токена прозрачно проксируется на тот самый upstream — и любой, кто постучится, увидит ровно его: настоящий сертификат, настоящий ServerHello;

свой клиент сервер опознаёт криптографически по токену в session_id , а не эвристикой. Соединение без валидного токена прозрачно проксируется на тот самый upstream — и любой, кто постучится, увидит ровно его: настоящий сертификат, настоящий ServerHello;

после рукопожатия сервер досылает пару post-handshake NewSessionTicket, как настоящий TLS 1.3-сервер: тот, кто не шлёт ни одного, уже этим выделяется. Клиент их просто игнорирует — сессии он не резюмирует, тикеты здесь чисто ради правдоподобия.

после рукопожатия сервер досылает пару post-handshake NewSessionTicket, как настоящий TLS 1.3-сервер: тот, кто не шлёт ни одного, уже этим выделяется. Клиент их просто игнорирует — сессии он не резюмирует, тикеты здесь чисто ради правдоподобия.

То есть для пассивного наблюдателя видимое рукопожатие в дампе неотличимо от соединения с легитимным публичным сервисом, а данные едут уже внутри установленного TLS.

Сервер заранее снимает форму ServerHello и цепочку сертификата с upstream, опознаёт своего клиента по токену в session_id , а любого чужого прозрачно проксирует на тот же upstream.

QUIC — UDP-транспорт, оформленный по форме QUIC (заголовки long/short-header). Это лёгкая маскировка структуры датаграммы, а не полноценный QUIC/HTTP/3-стек.

Выравнивание формы потока — поверх любого режима можно включить темпоральную нормализацию: cover-трафик в простое. Cover-пакет — зашифрованная запись с пустым payload, которую приёмник молча дропает, как heartbeat. Поэтому фича не ломает совместимость и работает со старыми клиентами. Паузы между cover-пакетами экспоненциальные (Пуассон, а не метроном), объём ограничен token-bucket’ом. Тонкая материя, на которой я споткнулся, — об этом в отдельном разделе, потому что первая версия сделала ровно наоборот.

Самый дорогой баг: рассинхрон фрейминга

Поначалу меня преследовала странность: туннель работал, но упирался в ~300 Мбит/с и периодически падал с PacketTooLarge . Я грешил на крипто, на буферы, на сеть. А причина пряталась в одной строчке про отмену future.

Чтение TLS-записи жило прямо в tokio::select! :

Беда в том, что read_tls_record не cancellation-safe : под двунаправленной нагрузкой select! отменял наполовину прочитанную запись, часть байтов уже была вынута из сокета — и фрейминг съезжал. Дальше парсер видел мусор на месте длины и честно сообщал PacketTooLarge . Это и был «потолок»: не пропускная способность, а постоянные срывы.

Лечится концептуально просто, но дошёл я не сразу: чтение нужно вынести в отдельную задачу , которую никто не отменяет, а в select! гонять уже разобранные пакеты через канал. После этого PacketTooLarge исчез, а TCP сразу подскочил до ~440↑/~495↓ Мбит/с. Урок намертво: в select! можно класть только cancellation-safe future, и «вроде работает» — не критерий.

Грабли конфигурации: тихий ноль от serde

Сюжет, который ловил меня раза четыре в разных обличьях. У структур конфига был #[derive(Default)] , и если в профиле забыть секцию [performance] , serde молча подставлял нули. А ноль в сетевой службе — это бомба замедленного действия:

keepalive_secs = 0 → setsockopt отдаёт EINVAL , сервер не стартует;

keepalive_secs = 0 → setsockopt отдаёт EINVAL , сервер не стартует;

handshake_timeout = 0 , max_clients = 0 → профиль «поднялся», но не пускает никого;

handshake_timeout = 0 , max_clients = 0 → профиль «поднялся», но не пускает никого;

tun_buffer_size = 0 → libc::read(fd, buf, 0) возвращает 0 → TUN-reader трактует это как EOF и немедленно умирает → 100% потерь при зелёном логе .

tun_buffer_size = 0 → libc::read(fd, buf, 0) возвращает 0 → TUN-reader трактует это как EOF и немедленно умирает → 100% потерь при зелёном логе .

Последний случай особенно мерзкий в бенчмарках: всё запускается, ничего не падает, трафик не идёт. Понял я это не сразу — что всё это время читаю в нулевой буфер. Вывод скучный, но рабочий: не доверять Default для критичных числовых полей. Явная валидация профиля при старте с внятной ошибкой вместо тихого нуля — это те самые проверки, что экономят часы.

Надёжность реконнекта: где зависает мобильный клиент

Вот мы и добрались до той самой нестабильности, ради борьбы с которой всё затевалось. Когда я наконец разобрался, причин оказалось несколько, и все поучительные.

TUN-reader держал клиент мёртвым. Чтение из TUN — блокирующий libc::read в spawn_blocking . В простое он крутился WouldBlock → sleep и проверял управляющий канал только после успешного чтения. Когда соединение рвалось и основной цикл закрывал канал, reader об этом не узнавал — await на его хендле висел вечно, функция connect_and_run не возвращалась, реконнекта не было в принципе. Фикс — Arc<AtomicBool> , который reader проверяет на каждой итерации.

Сервер не замечал мёртвого клиента. Запись в онемевший TCP-сокет (сменилась сеть, RST не дошёл) не падает минутами. Добавил RX-liveness: нет данных от другой стороны дольше 3 × heartbeat — рвём и переподключаемся. И тут же поймал тонкость: heartbeat бился по last_act , который обновлялся в том числе на приёме, поэтому heartbeat’ы клиента сами держали last_act свежим, сервер молчал, а клиент рвал связь по ложному таймауту. Развёл на два счётчика — last_tx и last_rx .

Реконнект с нового IP отбивался. Классика мобильности: переключение Wi-Fi↔LTE даёт новый адрес, старое TCP-соединение зависает без FIN/RST, сервер держит stale-сессию и при max_sessions=1 отклоняет законный реконнект. Решение — supersede: перед проверкой лимита эвиктим прошлые сессии этого пользователя, newest wins. А чтобы это не мешало мульти-девайсу, сессии ключуются не по логину, а по паре «логин + device-id» (стабильный 16-байтный идентификатор устройства): один аккаунт спокойно живёт на телефоне и ноутбуке разом — у каждого свой tun-IP, — а реконнект с нового адреса вытесняет только прошлую сессию того же устройства, не задевая остальные. Старый клиент без device-id откатывается на ключ-по-логину (одна сессия на аккаунт) — обратная совместимость цела.

Android добавил своих специфик. VpnService отдаёт TUN-дескриптор в неблокирующем режиме; мой upload-цикл после стартовых служебных пакетов получал из read() ноль и трактовал его как EOF — то есть клиент вообще никогда не отправлял данные, сервер видел RECV = 0 . Лечение — сбросить O_NONBLOCK через Os.fcntlInt и различать len < 0 (ошибка) и len == 0 (просто нет данных). А ещё был шторм реконнектов: cancelChildren() в finally убивал саму корутину переподключения, delay() мгновенно бросал CancellationException — получался busy-loop.

Эти баги объединяет не общий корень, а общая тема — надёжность на реальной мобильной сети: обрывы без RST, смена адреса, специфика платформ. Но часть из них, особенно на Android, выросла ещё и из того, что Kotlin-«порт» протокола отставал от Rust-сервера, — это и подтолкнуло меня схлопнуть крипто в единое нативное ядро.

И, конечно, DNS. Клиент, который перезаписывает /etc/resolv.conf и не восстанавливает его после краша, — это зло. Пришлось делать персистентный бэкап, идемпотентный захват (не затирать оригинал при реконнекте), поддержку симлинка, само-лечение при старте и восстановление по SIGINT / SIGTERM .

Кросс-платформа: одно ядро, три тулчейна

Единое ядро красиво на бумаге, а на практике это три разных способа кросс-компиляции одной библиотеки:

Android — cargo-ndk , таргеты aarch64 / x86_64-linux-android . Тонкость: у Android target_os = "android" , а не "linux" , поэтому Linux-специфичные модули — серверный код и создание TUN через ioctl — отсекаются из сборки .so сами собой через cfg . Сам TUN-дескриптор на Android никуда не девается: его выдаёт VpnService , а ядро лишь читает и пишет в него.

Android — cargo-ndk , таргеты aarch64 / x86_64-linux-android . Тонкость: у Android target_os = "android" , а не "linux" , поэтому Linux-специфичные модули — серверный код и создание TUN через ioctl — отсекаются из сборки .so сами собой через cfg . Сам TUN-дескриптор на Android никуда не девается: его выдаёт VpnService , а ядро лишь читает и пишет в него.

Windows — x86_64-pc-windows-gnu плюс mingw-w64 .

Windows — x86_64-pc-windows-gnu плюс mingw-w64 .

macOS — самое неожиданное: cargo-zigbuild собирает universal-бинарник ( arm64 + x86_64 ) прямо на Linux. Zig несёт стабы macOS libSystem , так что Xcode SDK на сборочной машине не нужен вообще.

macOS — самое неожиданное: cargo-zigbuild собирает universal-бинарник ( arm64 + x86_64 ) прямо на Linux. Zig несёт стабы macOS libSystem , так что Xcode SDK на сборочной машине не нужен вообще.

После каждой сборки я проверяю nm -D / objdump , что нужные символы ( qeli_realtls_* , qeli_mlkem_* ) на месте — чтобы линкер не выкинул их dead-code-элиминацией.

Отдельно про приём, которым я проверял себя: клиентский TLS-стек я гонял не только в loopback, но и против эталонного rustls — настоящий сертификат на лету через rcgen , TLS 1.3, AES-128-GCM. Если rustls принимает мой Chrome-ClientHello, присылает Certificate и CertVerify, а я верифицирую его Finished и мы обмениваемся данными в обе стороны — значит, моё рукопожатие настоящее, а не «похожее». Loopback такого не докажет.

Производительность: где на самом деле узкое место

Долго я был уверен, что упираюсь в крипто на одном ядре — так говорил ps %cpu (~34%). Оказалось, это артефакт инструмента : ps %cpu усредняет по времени жизни процесса и недооценивает мгновенную нагрузку. Замер через дельту /proc/<pid>/stat показал другое: один туннель на двухъядерном стенде ест ~1.5 ядра из 2 , и крипто давно параллельно — каждое соединение это свой tokio::spawn , decrypt в reader-задаче, encrypt в writer-задаче, без глобального мьютекса.

Реальный потолок — последовательная TUN-помпа: один блокирующий reader, один writer-drain, один роутер, через которые воронкой идёт весь трафик. Диагноз железный: при двунаправленной нагрузке тратится тот же CPU, что и при односторонней, но при вдвое меньшей суммарной скорости — подпись серийной стадии. Лечение — распараллелить помпу: multi-queue TUN ( IFF_MULTI_QUEUE , N дескрипторов на одно устройство, шардинг по hash(addr) % N ) и SO_REUSEPORT для UDP. Контролируемый A/B на двухъядерной лабе дал +18% по агрегату двух туннелей (607 → 718 Мбит/с), скромно — но только потому, что на том же хосте крутился iperf-приёмник и съедал ядра; одиночный поток от очередей не ускоряется — он упирается в свою decrypt-задачу, а не в TUN-помпу.

Теперь честные числа. На быстром двухъядерном стенде single-stream упирается скорее в сеть, чем в CPU:

Двухъядерный стенд, single-stream, чистый прогон без фоновой нагрузки. UDP меряется отдельно (см. ниже).

UDP — ~400 Мбит/с при потерях <0.5%. obfs стабильно на 10–15% медленнее за счёт второго слоя шифрования. reality-tls отстаёт сильнее — за полноценный вложенный TLS приходится платить (двойной AEAD), но даже так это уже не «вдвое медленнее», как было в ранних версиях.

А вот на скромном одноядерном VPS картина другая — там всё CPU-bound. Даже лёгкий fake-tls грузит единственное ядро на ~90%, два потока выводят его в полку, и агрегатный потолок — около 311 Мбит/с . Это про слабую VM, а не про быстрый стенд с графика выше: там те же режимы (включая reality-tls — 418↓/518↑) идут кратно быстрее. А на единственном ядре reality-tls ожидаемо тяжелее всех. Вывод, который я зафиксировал в ROADMAP честно: если ядер физически нет, добавить их кодом нельзя — рычаг для пропускной способности это более ёмкая VM и аплинк, а не очередная оптимизация. Цена рефактора крипто-плоскости, к слову, ~+3–5% CPU на ядро (по /proc -зонду, а не по обманчивому ps %cpu ).

Главный урок раздела даже не про TUN, а про методологию: сначала профилировать (где CPU, где ждём), потом оптимизировать . Один раз я переписал read-path под батч-дешифровку — и не сдвинул ничего (317 → 322 → 309 Мбит/с, чистый шум), потому что чинил не то место. А реальный прирост download у reality-tls дал переход на мой собственный серверный терминатор. Тут стоит пояснить: терминаторов на сервере два — дефолтный на rustls (с self-signed сертификатом) и опциональный hand-rolled, который и нужен, чтобы заимствовать настоящую цепочку сертификата upstream (та самая форма ServerHello — см. выше). rustls при этом никуда не делся: он остаётся рабочим терминатором по умолчанию и эталоном в тестах. А включив hand-rolled ради заёма сертификата, я наткнулся на бонус: на этом профиле нагрузки он оказался быстрее rustls и поднял скачивание примерно на 30% (321 → 417 Мбит/с). Почему именно — рискну предположить: мой терминатор идёт ровно одним путём (один cipher-suite, без универсальности rustls ), меньше абстракций и ветвлений на горячем пути. До конца я этот выигрыш не профилировал, так что оставляю как наблюдение, а не как доказанный механизм. Но мораль приятная: иногда правка ради одной цели бесплатно закрывает другую.

Бондинг потоков: один IP, несколько соединений

TCP поверх TCP — известная боль: теряется пакет во внешнем соединении, встаёт и внутреннее, ретрансмиты складываются, и один поток упирается в потолок куда раньше, чем позволяет канал. На reality-tls это особенно обидно — вложенный TLS усугубляет. Ответ — не один туннель, а несколько параллельных TCP-соединений, агрегированных в одну сессию (один tun-IP): исходящие пакеты раскидываются по ним round-robin.

Механика простая. Первое соединение проходит обычную аутентификацию, сервер пушит в auth-ok session_token , а остальные презентуют JOIN_MAGIC ‖ token ‖ index — сервер отвечает JOINOK и подшивает их в ту же сессию. Каждое соединение делает свой полноценный обмен ключами, то есть своё nonce-пространство из коробки — никакого переиспользования. Режима два: FIXED (открыть сразу N) и ADAPTIVE (наращивать 1→N, пока растёт throughput, и встать на плато). Умер один поток — он просто выбывает из round-robin, туннель живёт на оставшихся; рвём только когда упал последний.

Бонус для DPI: несколько TLS-соединений к одному хосту — ровно то, что делает браузер, открывая 6+ параллельных TLS. То есть бондинг не только разгоняет, но и выглядит естественнее одного долгого потока.

И нюанс справедливости: лимит полосы у пользователя — это один общий token-bucket на всю сессию, а не на каждое соединение. Бондинг обходит потолок TCP-over-TCP, но не выданную квоту — все потоки черпают из одного ведра (счётчик токенов может уходить в минус, так что короткие всплески усредняются).

Теперь замеры. Гонял на эмулированном канале ( tc netem — задержка плюс потери), потому что именно там бондинг и должен себя проявить: на чистой лабе TCP-over-TCP не деградирует, и агрегировать попросту нечего. Тест сразу показал важную деталь — бондинг ускоряет только тот трафик, что идёт сразу по нескольким соединениям приложения (как браузер, открывающий 6+ запросов разом). Дело в раздаче: каждое соединение приложения целиком привязывается к одному из бондинг-каналов по flow-хэшу — это специально, чтобы не вносить переупорядочивание, от которого внутреннему TCP только хуже. Поэтому одно-единственное соединение быстрее не станет (оно едет по одному каналу), а вот восемь параллельных равномерно лягут на все каналы. Замер на восьми параллельных соединениях, канал RTT 80 мс / потери 0.1%: через один бондинг-канал суммарно идёт ~50–65 Мбит/с на скачивание, а через четыре склеенных — ~260–305, то есть примерно впятеро больше, устойчиво между прогонами. На чистом канале — паритет, там бондинг и не нужен (упор всё равно в TUN-помпу). Лекарство ровно от того, ради чего задумано — от плохого канала. Механизм задеплоен на всех TCP-режимах.

И тот же JOIN-механизм — фундамент следующего шага, бесшовного роуминга (в планах на 0.8.0). Сегодня смена Wi-Fi↔LTE это быстрый реконнект с повторным рукопожатием (и повторным Argon2); хочется make-before-break — поднять поток по новой сети до смерти старой и переподцепиться без повторного auth. Для UDP/QUIC к этому ведёт миграция соединения по connection-id (он уже едет в каждом пакете), для TCP — как раз grace-период плюс JOIN-resume.

Выравнивание формы потока: как «улучшение» оказалось вредом

История, которая научила меня не верить интуиции без измерения.

Идея cover-трафика сработала с первого раза. Живой capture показал ~22 cover-пакета за 6 секунд простоя, 21 разный межпакетный интервал (57–841 мс, среднее ≈ заданному в конфиге) — нормальное пуассоновское распределение, а не метроном. Хорошо.

А вот «stealth» — попытка пригладить форму под нагрузкой rate-cap’ом — в первой, наивной версии сделала ровно обратное . Замер показал: вариативность межпакетного интервала схлопнулась — коэффициент вариации (CV) упал с 2.52 до 0.10, то есть ровный rate-cap превратился в идеальный метроном, новый отчётливый признак, которого раньше не было. Я добавил «улучшение», которое выделяло поток сильнее, чем его отсутствие.

Причина — в устройстве select! : под нагрузкой data-ветка всегда готова, cover голодает, а rate-cap «спал» внутри data-ветки и ровно размазывал отправку. Спасло то, что измерительный харнес поймал это до релиза. Переделал: вместо гладкого rate-cap — заполнять паузы джиттер-cover’ом, чтобы тайминг стал бурстовым. После фикса доля full-MTU упала со 100% до 81%, CV вырос до 1.04. По всем осям лучше базовой линии.

Бонусом ловил чисто Rust’овую тонкость: нельзя держать ThreadRng (он !Send ) через .await в select! — future перестаёт быть Send . Лечится инлайновым rand::thread_rng() на каждый вызов. И вывод, который я теперь повторяю как мантру: любая нормализация формы без харнеса валидации — ставка вслепую . Плохая модель добавляет новый признак вместо того, чтобы убрать старый. Поэтому всё это строго opt-in.

Безопасность по краям

Ядро вышло крепким, а дыры, как обычно, прятались на периферии. Несколько типовых находок из аудитов (своих и внешних):

OOB-read в разборе DHCP-опций — специально сформированный пакет ронял процесс при panic = abort . Bound-check и тесты. DHCP по умолчанию выключен, но это не оправдание.

OOB-read в разборе DHCP-опций — специально сформированный пакет ронял процесс при panic = abort . Bound-check и тесты. DHCP по умолчанию выключен, но это не оправдание.

DNS spoofing — recv-цикл принимал любой ответ, не сверяя адрес источника и txid. Теперь валидируются оба, есть общий дедлайн против флуда.

DNS spoofing — recv-цикл принимал любой ответ, не сверяя адрес источника и txid. Теперь валидируются оба, есть общий дедлайн против флуда.

OOB-panic на усечённом QUIC-заголовке — bound-check плюс fuzz-тест.

OOB-panic на усечённом QUIC-заголовке — bound-check плюс fuzz-тест.

Анти-амплификация UDP — сервер не отвечает своим (бóльшим) рукопожатием, пока первая датаграмма клиента не дотянет до 1200 байт. Так нас нельзя превратить в усилитель для отражённой DDoS со спуфингом источника; легитимный клиент честно паддит свой первый пакет.

Анти-амплификация UDP — сервер не отвечает своим (бóльшим) рукопожатием, пока первая датаграмма клиента не дотянет до 1200 байт. Так нас нельзя превратить в усилитель для отражённой DDoS со спуфингом источника; легитимный клиент честно паддит свой первый пакет.

Панель — cookie-сессия с подписью HMAC (ключ выводится из хеша пароля, переживает рестарт, смена пароля рвёт сессии), встроенный TLS, IP-allowlist, security-заголовки с HSTS, fail-closed. Перебор пароля гасится тарпитом — растущей задержкой ответа, — но админ-аккаунт намеренно никогда не лочится наглухо: иначе атакующий простым флудом неверных паролей запер бы и самого владельца (вышел бы DoS вместо защиты).

Панель — cookie-сессия с подписью HMAC (ключ выводится из хеша пароля, переживает рестарт, смена пароля рвёт сессии), встроенный TLS, IP-allowlist, security-заголовки с HSTS, fail-closed. Перебор пароля гасится тарпитом — растущей задержкой ответа, — но админ-аккаунт намеренно никогда не лочится наглухо: иначе атакующий простым флудом неверных паролей запер бы и самого владельца (вышел бы DoS вместо защиты).

И fuzzing: отдельные таргеты на парсеры, которые гоняются в CI ночным cron’ом. Самый ценный класс багов у меня — именно паники на усечённых входах, и fuzz ловит их лучше всего.

Отдельно про доверие и ключи, потому что это тоже периметр. У каждого профиля свой статический identity-ключ ( /etc/qeli/identity/<profile>.key , права 0600 ); их видно командой qeli show-identity и крутится qeli rotate-identity <profile> . Авторизацию можно ограничить набором профилей — пользователь, заведённый для одного профиля, на чужом порту получит отказ. Клиент пиннит публичный ключ сервера: при первом подключении работает TOFU (trust on first use) с персистентным known_hosts , дальше — строгая сверка, и при несовпадении соединение рвётся с явным SERVER KEY MISMATCH . Есть и жёсткий режим, где сервер вообще не светит свой статический ключ в ответе, а требует, чтобы клиент доказал знание заранее запиннованного ключа, — тогда отпечаток идентичности не получить даже активным зондированием. На клиенте к этому прилагается kill-switch : если туннель падает, трафик блокируется (на Linux через iptables , на Windows через WFP, на macOS через pf ), чтобы во время реконнекта ничего не утекло мимо.

Про процесс ещё одно. Внешние аудиты приносили смесь реальных находок и ложных срабатываний — часть «дыр» оказывалась несуществующей при чтении кода (например, «у obfs нет MAC», хотя внутренний AEAD никто не отменял). Полезный навык — спокойно разбирать каждый пункт по коду: чинить реальное, аргументированно отклонять ложное, а не бросаться править всё подряд.

Мелочи, которых мне не хватало: push с сервера

У WireGuard есть осознанная аскеза: плоскость данных предельно чистая, никакого управляющего канала сверх Noise-рукопожатия. Это красиво и быстро, но у этой чистоты есть оборотная сторона — сервер не может ничего сообщить клиенту . MTU, DNS, список маршрутов ( AllowedIPs ) живут в конфиге каждого клиента, и поменять топологию сети значит отредактировать и заново раздать N файлов. Для одного ноутбука нормально; для парка клиентов — рутина и источник рассинхрона.

Мне хотелось наоборот: чтобы профиль на сервере был единственным источником правды, а клиент приезжал почти пустым и добирал настройки сам. Поэтому в ответном auth-ok (внутри уже зашифрованного и аутентифицированного канала, так что это не ослабление безопасности) сервер вручает клиенту весь набор: выданный IP, MTU, маршруты, DNS-резолвер, период heartbeat и даже параметры транспортного режима. Поменял маршруты, DNS или MTU на сервере — весь парк подхватил при следующем коннекте, без перераздачи конфигов. Каждая из этих мелочей принесла свою шишку (full-tunnel, который молча не перебивал физический default ; пушенный DNS, указывавший «в пустоту»; унаследованный TUN-fd без CLOEXEC ), но разбор каждой тянет на отдельную страницу — оставлю на README.

Рядом — две вещи, сильно экономящие нервы: онбординг через qeli:// и QR (команда qeli add-client выдаёт готовый профиль, пользователь импортирует его одним сканом) и горячая перезагрузка по SIGHUP — добавить пользователя или сбросить сессию можно без рестарта, остальные сессии не рвутся.

Ничего из этого не тянет на отдельную фичу для пресс-релиза. Но именно из таких мелочей складывается разница между «туннелем, который я раздаю парку устройств одной ссылкой и правлю в одном месте» и «стопкой конфигов, которую я синхронизирую руками». WireGuard сознательно отдаёт это наружу — мне же хотелось, чтобы про сеть думал сервер, а не пользователь.

Как это выглядит

Управление и клиентов я старался сделать так, чтобы не приходилось лезть в конфиги руками. Несколько живых скриншотов. Клиенты (десктоп и Android) умеют светлую и тёмную тему — ниже веб-панель и десктоп показаны в светлой, Android — в тёмной.

Веб-панель администратора — axum плюс Alpine.js, без тяжёлого фронтенда: «быстрый старт» сервера выбором режима маскировки, дашборд со статистикой, профили, живая таблица подключённых клиентов, пользователи и выдача qeli:// -ссылок с QR.

Дашборд: быстрый старт по режиму маскировки, метрики, профили и подключённые клиенты.

Десктоп-клиент (Windows / macOS) — один и тот же C#-интерфейс на обеих ОС. Слева список профилей, справа статус соединения, метрики (приём/отдача/сессия/IP туннеля) и журнал с пошаговым логом рукопожатия.

Подключение reality-tls: в журнале виден весь путь — настоящий TLS 1.3, гибридный ClientHello, проверка идентичности, Auth OK.

Android — нативный клиент на Kotlin. Главный экран — круглое кольцо tap-to-connect; профили и живой лог вынесены на отдельные вкладки.

Android (тёмная тема), слева направо: кольцо tap-to-connect со статусом и скоростями; список профилей (все режимы маскировки на портах одного сервера); живой лог рукопожатия — ClientHello (1438 B, hybrid X25519+ML-KEM), проверка идентичности, Auth OK, multipath.

Эпилог: почему именно один бинарник

Возможно, вы спросите — зачем городить своё, если можно собрать из готовых кубиков. Я долго смотрел на эти кубики, и вот что увидел.

Почти всё популярное — это одна из двух форм. Либо прокси : SOCKS5, HTTP CONNECT, семейство shadowsocks. Они работают на транспортном/прикладном уровне и дают тебе порт, а не сетевой интерфейс. Чтобы получить системный L3-туннель, поверх докручивают tun2socks или отдельную TUN-обёртку — и в итоге у тебя два процесса, виртуальный интерфейс, приклеенный к прокси, и костыли маршрутизации между ними. Либо это комплекс : один демон для транспорта, подключаемый плагин для обфускации, что-то третье для маршрутов, NAT и DNS. Конфигурация размазана по нескольким процессам, и каждый стык — потенциальная точка отказа.

Готовые решения — это прокси плюс обвязка из нескольких процессов и клей между ними; qeli держит весь data-plane в одном бинарнике с единым конфигом.

Мне не хотелось ни того, ни другого. Я хотел один бинарник, который сам владеет L3-интерфейсом (TUN, IP-пакеты), при необходимости умеет L2 (TAP, Ethernet-кадры), и сам делает всё остальное — крипто, фрейминг, маршрутизацию, NAT, DNS — от начала до конца. Без tun2socks , без плагин-хоста, без клея. Полноценная плоскость данных в одном процессе. Один конфиг, hot-reload по SIGHUP без разрыва сессий.

Это ограничение и продиктовало почти каждое решение в проекте. В том числе — почему я писал TLS-стек руками, а не брал готовую обёртку: я не хотел быть обёрткой над чужой обёрткой. Когда сам отвечаешь за каждый байт от TUN-дескриптора до сокета, у тебя нет «магии на стыке» — есть только твой код, который ты можешь профилировать, чинить и понимать целиком.

Получилось ли «без костылей» идеально? Нет, конечно — выше десяток шишек тому свидетель. Но архитектурно это ровно то, чего я хотел: цельный data-plane, а не конструктор. Писать свой туннель с нуля — занятие отрезвляющее: начинаешь с «там же ничего сложного», а заканчиваешь побайтовой сверкой с тестовыми векторами.

Проект живой и открытый — Rust плюс Kotlin/C#; ядро и сервер под AGPL-3.0 (чтобы из проекта нельзя было собрать закрытый SaaS, не вернув правки в опенсорс), нативные клиенты под MPL-2.0 (мягкий копилефт — чтобы клиентов можно было публиковать в магазинах приложений, где у строгого копилефта проблемы с условиями сторов), исходники на GitHub: https://github.com/litvinovtd/qeli

И последнее, ради чего всё это и пишется. Сразу честно: текущая версия — бета. Ядро я считаю зрелым и сам гоняю его каждый день, но проект активно развивается, и шероховатости на периферии я не исключаю — собственно, поэтому свежий взгляд и реальные условия эксплуатации сейчас ценнее всего.

И ещё, чтобы снять вопрос заранее: проект большой, с колоссальным объёмом работы, и да — в работе я активно пользовался ИИ. Как ускорителем на рутинном коде, кросс-платформенном клее, черновиках доков и как вторым взглядом на ревью. Но это инструмент, а не автор. Собственно, поэтому статья и получилась про шишки и «почему так», а не про «смотрите, что сгенерировалось».

А в мыслях — ещё ворох допилов, которых в обычном VPN обычно не встретишь:

конфиги сервера и пользователей из БД — чтобы поженить VPN с уже заведёнными пользователями и системами, а не вести ещё одну базу руками;

конфиги сервера и пользователей из БД — чтобы поженить VPN с уже заведёнными пользователями и системами, а не вести ещё одну базу руками;

авторизация через RADIUS и LDAP / Active Directory ;

авторизация через RADIUS и LDAP / Active Directory ;

авто-подбор транспорта под сеть — клиент сам прощупывает канал и выбирает рабочий режим маскировки, а если текущий начинает резаться, переключается на лету (plain ⇄ fake-tls ⇄ reality-tls ⇄ obfs ⇄ QUIC), без ручной возни с профилями;

авто-подбор транспорта под сеть — клиент сам прощупывает канал и выбирает рабочий режим маскировки, а если текущий начинает резаться, переключается на лету (plain ⇄ fake-tls ⇄ reality-tls ⇄ obfs ⇄ QUIC), без ручной возни с профилями;

API для управления сервером и пользователями + получение метрик (Prometheus) и вебхуки на connect/disconnect — чтобы заводить пользователей и профили из CI/IaC, а не кликами, и видеть VPN в общем мониторинге и алертинге;

API для управления сервером и пользователями + получение метрик (Prometheus) и вебхуки на connect/disconnect — чтобы заводить пользователей и профили из CI/IaC, а не кликами, и видеть VPN в общем мониторинге и алертинге;

Что-то про интеграцию с уже работающей инфраструктурой, что-то про охват платформ и устойчивость к сети — идей, чем заняться дальше, хватает с запасом.

Главное — проект открыт для участия. Если тема зацепила, присоединяйтесь: разверните у себя и протестируйте на своих каналах и устройствах, пришлите баг-репорт или pull request, поспорьте об архитектуре в issues, предложите сценарий, до которого у меня не дошли руки. Отдельно ищу человека с аккаунтом разработчика Apple, который помог бы выложить iOS-клиент в App Store. Ссылки на репозиторий и контакты — выше; буду рад каждому, кому это интересно. А если до пул-реквестов руки не дойдут — звезда на GitHub тоже помогает проекту найти своих.

P.S. И знаете, что грело сильнее зелёного гейта тестов и красивых цифр в бенчмарке? Те короткие мгновения, когда после очередной ночной отладки туннель вдруг оживал и впервые отвечал на ping, — и ко мне возвращалось то самое ощущение магии: будто я снова тяну кабель по чердаку…

← Cybersecurity