Технический разбор: архитектура, криптография, транспортные режимы и десяток шишек
Если совсем коротко: я написал свой VPN на Rust — побайтово-корректный TLS 1.3 с заёмом чужого сертификата, гибридное постквантовое рукопожатие, пять режимов маскировки и под 700 Мбит/с на скачивание. Ниже — как это устроено изнутри и десяток шишек, на которых я учился. Но начать хочется издалека.
Сначала — небольшое признание. Сети я люблю с детства, и до сих пор ICMP echo reply — обычный ответ на ping — где-то в глубине души вызывает почти детский восторг: пакет ушёл, пакет вернулся, и сразу становится чуть спокойнее. Любовь эта родилась в начале двухтысячных, когда мы лазили по чердакам микрорайона и тянули свою сеть — тогда домашнего интернета ещё не было вовсе. Свитчи покупали на свои кровные, самые дешёвые из возможных, — и после каждой грозы несколько сегментов гарантированно «отлетало» (гроза была нашим бесплатным стресс-тестом). А сеть, между прочим, выросла совсем не маленькая — за две сотни машин. И это было что-то поистине магическое: сидя по своим квартирам, мы болтали в общем чате, качали что-то с файлопомойки и играли в CS. Зарубы порой заканчивались стычками за гаражами, но это уже совсем другая история. С тех пор любовь к сетям никуда не делась — она, по сути, и привела меня сюда.
Началось всё с прозаичной задачи. Связать распределенные сервера в разных точках земного шара в одну приватную сеть управления. Я перепробовал готовые решения и каждый раз упирался в одно и то же. В разных локациях стабильно работало разное: где-то жил один протокол, где-то другой, а где-то выручал только прокси с натянутым поверх TUN через сторонний костыль. В итоге вся сеть держалась на мешанине разных решений — стопке подпорок, которую совсем не хотелось тащить дальше, и это сильно удручало. И в какой-то момент сработал знакомый многим самообман: «да я сам напишу, там же ничего сложного» — так и было принято решение собрать продукт, который закрывал бы мои собственные потребности целиком — и даже больше.
Сложного там оказалось предостаточно. И началось всё скромно — с той самой задачи: чтобы серверы видели друг друга, и не более того. Как только это заработало, захотелось дотянуть туннель до телефона, потом до рабочего ноутбука, а в какой-то момент я поймал себя на том, что собираю уже не «связку для парка серверов», а полноценный VPN под все платформы. Так у проекта и появились нативные клиенты под Android, Windows и macOS. Теперь это рабочий кросс-платформенный туннель на Rust-ядре. Проект называется Qeli, исходники открыты. Это не маркетинг и не «смотрите какой я молодец», а честный технический разбор: архитектура, криптография, набор транспортных режимов и, главное, те места, где я набил шишки. Шишек будет много — без них статья была бы враньём.
Про имя — всё просто. Был свободный домен qeli.ru, короткий и удобный. Расшифровку придумал уже потом, под готовое название: Quick Easy Link 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, когда твой код не сходится с тестовым вектором, помогает в десять раз сильнее.
Одно поведение на всех платформах. Не зоопарк клиентов, а одно ядро с одинаковой логикой и одинаковым отпечатком.
Гибкий транспорт. Возможность по-разному упаковывать один и тот же зашифрованный туннель — от голого минимального оверхеда до полноценного 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;
симметрика — ChaCha20-Poly1305 (AEAD);
вывод ключей — HKDF;
пароли в панели — Argon2id.
Аутентификация построена вокруг channel binding, и порядок здесь жёсткий: сначала личность доказывает сервер. Его proof завязан на статический ключ, эфемерный ключ сессии и хэш транскрипта рукопожатия (ClientHello ‖ ServerHello ‖ Certificate ‖ Finished) — перехваченный в одной сессии, в другой он уже бесполезен. И только потом клиент отдаёт учётные данные — но уходят они уже внутри установленного канала (зашифрованного, на ключах из эфемерного обмена и статики сервера), а не отдельным открытым сообщением. Поэтому в трафике нет ничего, что можно подобрать офлайн по словарю: сам пароль сервер проверяет у себя через Argon2id. Представься сервер вторым — MITM мог бы выманить пароль, ничего не зная о настоящем сервере.
Отдельная история — гибридный пост-квантовый обмен. Внутренние ключи данных я вывожу не из чистого X25519, а из комбинации с ML-KEM-768 (это NIST FIPS 203, бывший Kyber):
IKM = x25519_shared ‖ mlkem768_shared // 64 байта keys = HKDF(salt = "qeli-key-derivation-v2-hybrid", IKM)
Смысл прозаичен — защита по модели 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.
Резонный вопрос: почему не взять готовый 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;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);свой клиент сервер опознаёт криптографически по токену в
session_id, а не эвристикой. Соединение без валидного токена прозрачно проксируется на тот самый upstream — и любой, кто постучится, увидит ровно его: настоящий сертификат, настоящий ServerHello;после рукопожатия сервер досылает пару 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!:
tokio::select! { res = read_tls_record(&mut stream) => { /* ... */ }, res = inbound_rx.recv() => { /* ... */ }, }
Беда в том, что 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, сервер не стартует;handshake_timeout = 0,max_clients = 0→ профиль «поднялся», но не пускает никого;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. Тонкость: у Androidtarget_os = "android", а не"linux", поэтому Linux-специфичные модули — серверный код и создание TUN черезioctl— отсекаются из сборки.soсами собой черезcfg. Сам TUN-дескриптор на Android никуда не девается: его выдаётVpnService, а ядро лишь читает и пишет в него.Windows —
x86_64-pc-windows-gnuплюсmingw-w64.macOS — самое неожиданное:
cargo-zigbuildсобирает universal-бинарник (arm64 + x86_64) прямо на Linux. Zig несёт стабы macOSlibSystem, так что 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 по умолчанию выключен, но это не оправдание.DNS spoofing — recv-цикл принимал любой ответ, не сверяя адрес источника и txid. Теперь валидируются оба, есть общий дедлайн против флуда.
OOB-panic на усечённом QUIC-заголовке — bound-check плюс fuzz-тест.
Анти-амплификация UDP — сервер не отвечает своим (бóльшим) рукопожатием, пока первая датаграмма клиента не дотянет до 1200 байт. Так нас нельзя превратить в усилитель для отражённой DDoS со спуфингом источника; легитимный клиент честно паддит свой первый пакет.
Панель — 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 с уже заведёнными пользователями и системами, а не вести ещё одну базу руками;
авторизация через RADIUS и LDAP / Active Directory;
авто-подбор транспорта под сеть — клиент сам прощупывает канал и выбирает рабочий режим маскировки, а если текущий начинает резаться, переключается на лету (plain ⇄ fake-tls ⇄ reality-tls ⇄ obfs ⇄ QUIC), без ручной возни с профилями;
API для управления сервером и пользователями + получение метрик (Prometheus) и вебхуки на connect/disconnect — чтобы заводить пользователей и профили из CI/IaC, а не кликами, и видеть VPN в общем мониторинге и алертинге;
Что-то про интеграцию с уже работающей инфраструктурой, что-то про охват платформ и устойчивость к сети — идей, чем заняться дальше, хватает с запасом.
Главное — проект открыт для участия. Если тема зацепила, присоединяйтесь: разверните у себя и протестируйте на своих каналах и устройствах, пришлите баг-репорт или pull request, поспорьте об архитектуре в issues, предложите сценарий, до которого у меня не дошли руки. Отдельно ищу человека с аккаунтом разработчика Apple, который помог бы выложить iOS-клиент в App Store. Ссылки на репозиторий и контакты — выше; буду рад каждому, кому это интересно. А если до пул-реквестов руки не дойдут — звезда на GitHub тоже помогает проекту найти своих.
P.S. И знаете, что грело сильнее зелёного гейта тестов и красивых цифр в бенчмарке? Те короткие мгновения, когда после очередной ночной отладки туннель вдруг оживал и впервые отвечал на ping, — и ко мне возвращалось то самое ощущение магии: будто я снова тяну кабель по чердаку…
Sap_ru
Конфиги в БД может быть и плохо. Лучше бы конфиг с зачатками скриптования и всякими include. Откнофигурировать сложную топологию в VPN это то ещё развлечение, а когда потом там нужно что-то изменить, то и вовсе больно.
Я себе под WireGuard/Amnezia сделал скриптовый язык поверх стандатного конфига, и это оказалось супер-удобно: текстовые ID клиентов и генерация их конфигов, разложенных по попаочкам и с осмысленными именами файлов, группы клиентов с шалобнными настройками, задание IP через инкремент от предыдущего и т.п. Я теперь даже не понимаю, как при сложной топологии это всё можно руками отконфигурировать и поддерживать.