Следуй за белым кроликом.

В «Матрице» герои возвращались в реальный мир через телефон. Мне эта идея понравилась настолько, что собственный VPN я назвал PAYPHONE, а в консольном клиенте сделал зелёный дождь из символов. Правда, прежде чем через него удалось выйти в интернет, пришлось разобраться, почему туннель отправляет пакеты самому себе, таймер не срабатывает, а нажатие Ctrl+C не возвращает сеть. Об этом и будет статья — с устройством протокола, фрагментами кода и ошибками, которые обнаружились уже после первого успешного подключения.

Follow the white rabbit
Follow the white rabbit

Зачем свой VPN, когда есть AmneziaWG и Xray

Когда речь заходит о VPN в России, совета «поставьте WireGuard» давно недостаточно. Обычный WireGuard распознаётся по характерным признакам трафика и попадает под блокировки. В документации Amnezia отдельно разобрана эта проблема: шифрование содержимого не мешает системе фильтрации определить, какой протокол используется.

Поэтому здесь уместнее говорить об AmneziaWG и связках VLESS/Xray, в том числе с REALITY. AmneziaWG развивает WireGuard, добавляя средства маскировки трафика. У Xray другой подход: это платформа для проксирования, в которой выбираются протокол и способ передачи данных. AmneziaWG и VLESS в Xray решают близкую пользовательскую задачу, но устроены по-разному.

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

Так появился PAYPHONE. Сейчас это экспериментальный IPv4 VPN версии 0.2.0: сервер и консольный клиент на Rust, основной транспорт на QUIC и дополнительный — на TLS поверх TCP. Есть и мобильные клиенты. Оснований утверждать, что проект устойчивее к блокировкам, чем AmneziaWG или Xray, у меня нет: для этого нужны отдельные испытания.

Что происходит с пакетом

Приложения на компьютере ничего не знают о PAYPHONE. Браузер открывает соединение как обычно, а операционная система по таблице маршрутов направляет его пакеты в виртуальный интерфейс TUN. Программа читает их оттуда, добавляет собственный заголовок и отправляет серверу.

На сервере всё происходит в обратном порядке: программа снимает заголовок и записывает исходный IP-пакет в серверный TUN. Дальше работает обычная маршрутизация Linux. NAT подменяет внутренний адрес клиента внешним адресом сервера, и пакет уходит в интернет. Ответ возвращается через тот же туннель.

Проект разделён на семь Rust-крейтов, чтобы формат сообщений не зависел от настройки маршрутов, а проверка подписки — от выбранного транспорта.

Крейт

Что в нём находится

payphone-core

Формат сообщений и их кодирование

payphone-auth

Проверка подписок и отозванных токенов

payphone-token

Утилита выпуска ключей и токенов

payphone-transport

QUIC, TLS, обфускация и экспериментальный REALITY

payphone-tun

Работа с TUN и маршрутами в разных ОС

payphone-server

Сессии, адреса, ограничения скорости и пересылка пакетов

payphone-client

Подключение, восстановление сессии и работа туннеля

Внутри используется сеть 10.77.0.0/24. Серверу принадлежит 10.77.0.1, клиентские адреса начинаются с 10.77.0.2. На сервере также работает небольшой DNS-посредник: принимает запросы на 10.77.0.1:53 и пересылает их внешнему DNS-серверу.

Почему для передачи выбран QUIC

Внутри VPN могут находиться десятки TCP-соединений. Если собрать их пакеты в один надёжный упорядоченный поток, потеря данных задержит и пакеты остальных соединений: они будут ждать восстановления пропуска.

Поэтому PAYPHONE использует датаграммы QUIC — отдельные сообщения без гарантии доставки и порядка. Шифрование и контроль перегрузки сохраняются, но потерянную датаграмму QUIC повторно не отправляет. Для внутреннего TCP потерю обработает TCP приложения. Такая передача IP-пакетов предусмотрена в RFC 9221.

Для работы с QUIC я выбрал Quinn и сначала допустил ошибку уже в использовании библиотеки. Вызов send_datagram_wait ждёт места в очереди. Ожидание внутри общего цикла туннеля при перегрузке останавливало обработку остальных событий.

Теперь в send_vpn_datagram из payphone-transport/src/lib.rs используется обычная отправка:

match connection.send_datagram(bytes) {
    Ok(()) => Ok(true),
    Err(quinn::SendDatagramError::TooLarge)
    | Err(quinn::SendDatagramError::UnsupportedByPeer)
    | Err(quinn::SendDatagramError::Disabled) => Ok(false),
    Err(quinn::SendDatagramError::ConnectionLost(error)) => Err(error),
}

Размер проверяется заранее, слишком большой кадр отбрасывается. При переполнении очереди Quinn может удалять старые неотправленные датаграммы, поэтому весь цикл не ждёт одного пакета.

Та же ошибка встретилась на сервере при передаче по TLS: очередь медленного клиента задерживала чтение общего TUN. Я заменил tx.send(...).await на tx.try_send(bytes). При полной очереди новый кадр теряется; пока это касается и управляющих сообщений.

Собственный протокол: 16 байт заголовка

Через оба транспорта передаются одинаковые сообщения PAYPHONE. У каждого есть 16-байтовый заголовок:

Смещение

Размер, байт

Содержимое

0

1

Версия протокола

1

1

Тип сообщения

2

2

Флаги

4

4

Длина данных после заголовка

8

8

Порядковый номер

Многобайтовые числа записываются старшим байтом вперёд. Версия этого формата — 1; она не совпадает с версией приложения 0.2.0.

В payphone-core/src/lib.rs за сборку кадра отвечает Frame::encode. Если убрать длинные комментарии, остаётся:

pub fn encode(&self) -> Bytes {
    let payload_len = self.payload.len();
    let mut buffer = BytesMut::with_capacity(HEADER_SIZE + payload_len);
    buffer.put_u8(self.version);
    buffer.put_u8(self.frame_type as u8);
    buffer.put_u16(self.flags);
    buffer.put_u32(payload_len as u32);
    buffer.put_u64(self.sequence);
    buffer.extend_from_slice(&self.payload);
    buffer.freeze()
}

На приёме проверяются версия, тип и длина. В QUIC целый кадр помещается в одну датаграмму, поэтому указанная длина должна точно совпасть с оставшимся количеством байтов. При передаче по TLS сначала читаются 16 байт заголовка, затем — ровно столько данных, сколько в нём указано. Так получатель восстанавливает границы сообщений в непрерывном потоке.

Кадр Data содержит идентификатор сессии на 16 байт, номер пакета на 8 байт и сам IP-пакет. Вместе с общим заголовком это даёт 40 дополнительных байт на пакет. Заголовки QUIC, UDP и внешнего IP считаются отдельно. Позже именно эта арифметика объяснила, почему туннель работал только на маленьких пакетах.

Для управляющих сообщений я оставил фразы из фильмов моего детсва. Клиент начинает с WhatsUpDude, сервер отвечает AllGoodDude, а запрос на восстановление сессии называется BackAgainDude. На строгость проверки байтов это не влияет.

Как сервер решает, кого пускать

Сертификат позволяет клиенту проверить сервер. Для допуска пользователя PAYPHONE использует отдельный токен с подписью Ed25519.

Токен занимает 135 байт: 71 байт полей и 64 байта подписи. В полях указаны формат PAYT, версия, идентификаторы ключа, токена и клиента, сроки и ограничения подписки. Закрытый ключ остаётся у того, кто выпускает токены. Сервер получает только открытый ключ для проверки.

Сервер учитывает срок действия, отзыв токена, число устройств и скорость. При превышении числа устройств отключается самая старая сессия клиента. Скорость ограничивается алгоритмом token bucket: доступный объём передачи постепенно пополняется, а отправка пакетов его расходует.

Проверки только при входе недостаточно: подписку могут отозвать во время работы. Поэтому verify_session_at в payphone-auth/src/verifier.rs повторно проверяет срок и отзыв для уже созданной сессии — при восстановлении, передаче трафика и фоновой очистке.

Отозванные идентификаторы хранятся в файле, который перечитывается при изменении. Если уже прочитанный файл исчезнет, известные отзывы сохранятся в памяти. При ошибке чтения доступ запрещается до успешной проверки.

Проверяется и внутренний IPv4-пакет: версия, длина заголовка и полная длина. Адрес отправителя должен совпадать с выданным сессии. Подписка не даёт права отправлять пакеты от имени другого клиента.

Дежавю при восстановлении соединения

При входе клиент получает адрес, идентификатор сессии и 32-байтовый секрет для её восстановления. Состояние сохраняется с обеих сторон. После обрыва клиент устанавливает новое защищённое соединение и просит вернуть прежнюю сессию PAYPHONE; с возобновлением TLS это не связано.

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

Сообщение Rekey меняет секрет восстановления, а не ключи шифрования транспорта. Сервер предлагает новое значение, клиент сохраняет его и подтверждает получение. Здесь важно отвечать лишь на первый переход к новому значению. Иначе подтверждения могут вызвать бесконечный обмен ответами. В SessionManager::rekey_confirm повторное подтверждение уже ничего не меняет.

С порядковыми номерами есть другое ограничение: сервер допускает переупорядочивание в пределах 1024 позиций, но не хранит каждый принятый номер. Дубликаты внутри окна проходят, поэтому полноценной защитой от повторной передачи это не является.

Почему MTU 1280 не подошёл

Соединение устанавливается, маленькие пакеты проходят, а при загрузке страницы появляется TooLarge. Я искал причину в передаче данных, хотя нужно было посчитать байты.

Для внутреннего интерфейса был выбран MTU 1280 — знакомое число из IPv6. Но каждый такой пакет получал ещё 40 байт PAYPHONE, а затем заголовки и криптографическую защиту QUIC.

Требование QUIC к поддержке UDP-датаграмм на 1200 байт относится ко всей полезной нагрузке UDP, а не к данным приложения. Это различие описано в RFC 9000.

В стартовой конфигурации Quinn приложению оставалось примерно 1154 байта. Получалось: 1280 + 40 = 1320, что слишком много. А 1100 + 40 = 1140 уже помещалось.

Теперь начальный MTU равен 1100. После определения доступного размера на пути до сервера клиент пересчитывает MTU функцией из payphone-tun/src/lib.rs:

pub fn mtu_from_datagram_budget(max_datagram: usize) -> u16 {
    let usable = max_datagram.saturating_sub(PAYPHONE_FRAME_OVERHEAD as usize);
    usable.clamp(PAYPHONE_MTU as usize, PAYPHONE_MTU_MAX as usize) as u16
}

Она вычитает 40 байт и ограничивает результат диапазоном 1100–1450. Перед отправкой размер всё равно проверяется. Значение 1154 относится к используемой конфигурации; в коде нужно опираться на результат max_datagram_size().

Как маскировка размеров помешала самой себе

Перед отправкой в UDP-сокет PAYPHONE изменяет представление уже защищённых QUIC-пакетов. Обфускация в payphone-transport/src/obfuscation.rs устроена так:

key    = SHA256(psk)
mask   = SHA256(key || salt)
plain  = u16_be(payload_len) || quic_packet || padding
wire   = salt[8] || XOR(plain, циклически повторяемая mask)
padding: от 0 до 32 байт

Маской обрабатываются длина, данные и случайное дополнение. При обратном преобразовании поле длины позволяет отделить исходный пакет. Некорректные размеры отбрасываются до Quinn.

Сначала я ещё и округлял длины до значений 128, 296, 568, 1200, 1440, чтобы скрыть исходные размеры. После этого вернулись ошибки MTU.

Через обфускацию проходят и служебные пакеты Quinn для определения MTU. Проба около 1200 байт после добавления обёртки округлялась до 1440. QUIC оценивал возможности пути по пакету, который мы сами существенно увеличили.

Я убрал округление и оставил дополнение от 0 до 32 байт. Вместе с солью и полем длины это 10–42 дополнительных байта: расходы остаются, но скачка на сотни байт больше нет.

У обёртки нет собственного кода аутентификации сообщения. Соль и дополнение генерируются xorshift с начальным значением от генератора ОС; целостность и конфиденциальность обеспечивает QUIC. Изменение видимых байтов не даёт гарантии, что система фильтрации не распознает соединение по другим признакам.

Как Mac отправил туннель внутрь самого себя

Для перенаправления IPv4-трафика клиент добавляет два маршрута: 0.0.0.0/1 и 128.0.0.0/1. Вместе они покрывают всё адресное пространство и имеют приоритет над маршрутом по умолчанию. Сам маршрут по умолчанию при этом сохраняется.

Но до внешнего IP сервера нужно добираться обычным путём, через физический интерфейс. Поэтому для него добавляется более точный маршрут /32 через прежний шлюз. Без этого исключения пакет QUIC попадёт в TUN, будет снова упакован в QUIC и опять отправлен в TUN.

Именно это и случилось на macOS. Сначала всё работало, затем соединение обрывалось. Причиной оказались вызовы networksetup, которыми я менял DNS и настройки IPv6. Они перестраивали сетевую конфигурацию, и маршрут к серверу исчезал.

Попытка привязать маршрут к интерфейсу через -ifscope en0 тоже не решила задачу. Такой маршрут участвует в поиске с учётом конкретного интерфейса, а обычный поиск маршрута мог по-прежнему выбирать один из наших /1. Запись в таблице была, но нужный сокет ей не пользовался.

В итоге на macOS я оставил обычный маршрут /32 через физический шлюз, а UDP-сокет дополнительно привязал к интерфейсу через IP_BOUND_IF. DNS настраивается через scutil. Вместо изменения настроек IPv6 добавляются маршруты, отбрасывающие IPv6-трафик: внутри PAYPHONE IPv6 пока не поддерживается.

Клиент проверяет маршруты каждые 400 мс и при необходимости восстанавливает их. Это помогает при изменениях сетевой конфигурации, но не заменяет отдельную блокировку трафика при обрыве VPN. Такая возможность есть в настройках и по умолчанию выключена.

Таймер, который никогда не дожидался своего времени

Проверку маршрутов тоже можно было сломать: достаточно создавать новый sleep при каждой итерации select!. Та же ошибка обнаружилась у отправки PING. Сокращённая схема:

loop {
    tokio::select! {
        _ = time::sleep(random_ping_interval()) => send_ping().await,
        _ = rain_tick.tick() => redraw(),
        packet = read_packet() => forward(packet).await,
    }
}

После срабатывания другой ветки цикл создавал новый таймер. Анимация обновлялась каждые 50 мс, маршруты проверялись каждые 400 мс, а PING должен был отправляться через 7–14 секунд. Отсчёт постоянно начинался заново.

Это легко пропустить: ветка с таймером есть, код компилируется, но объект ожидания не живёт достаточно долго. Подробности отмены ожиданий есть в документации Tokio.

Теперь в payphone-client/src/tunnel.rs таймер создаётся до цикла. Схема исправления:

let ping_timer = time::sleep(crate::random_ping_interval());
tokio::pin!(ping_timer);
loop {
    tokio::select! {
        _ = &mut ping_timer => {
            ping_timer.as_mut().reset(
                time::Instant::now() + crate::random_ping_interval()
            );
            // Формирование и отправка Ping.
        }
        // TUN, входящие кадры, маршруты, анимация, Ctrl+C.
    }
}

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

Что должен делать Ctrl+C

Другая неприятность проявлялась при завершении работы. Клиент уже переставал передавать пакеты и переходил к ожиданию endpoint.wait_idle(), но маршруты всё ещё направляли интернет-трафик в туннель. Для пользователя это выглядело так: VPN не выключается, интернет не работает, повторное нажатие Ctrl+C не помогает.

После того как Tokio установил обработчик SIGINT, нельзя рассчитывать, что следующее нажатие обязательно завершит процесс само по себе. Нужно закончить собственную процедуру остановки.

В текущем QUIC-варианте сначала уничтожается объект, отвечающий за маршруты, затем закрывается соединение:

drop(full_tunnel.take());
close_quic(quic).await;
return Ok(TunnelExit::Stopped);

Функция close_quic вызывает connection.close(...) без ожидания wait_idle. Если такое ожидание снова понадобится, ему потребуется ограничение по времени, а маршруты к этому моменту уже должны быть восстановлены.

У TLS здесь ещё есть недоработка: перед снятием маршрутов клиент ждёт отправки сообщения Close. Зависшая запись может задержать остановку. Исправление QUIC-пути не означает, что это свойство автоматически появилось у второго транспорта.

Где здесь REALITY и при чём VLESS

VLESS задаёт протокол обмена с прокси-сервером, а REALITY относится к установлению и защите внешнего соединения. В PAYPHONE есть экспериментальный REALITY, выключенный по умолчанию. Внутри него идут кадры PAYPHONE, поэтому обычный VLESS-клиент с таким сервером работать не сможет.

В payphone-transport/src/reality/ реализована авторизация через session_id сообщения ClientHello: X25519, HKDF-SHA256 с меткой REALITY и AES-256-GCM. Неавторизованное подключение передаётся внешнему сайту. Свой клиент проверяет временный Ed25519-сертификат с помощью HMAC-SHA512 на основе AuthKey.

ClientHello построен по образцу Chrome 131: GREASE, перемешивание расширений, GREASE ECH, публичный ключ ML-KEM-768 и X25519. Сам VPN использует X25519, поэтому объявлять его постквантовым нельзя.

Сервер может использовать ServerHello внешнего сайта с заменой X25519 key share и подбирать размеры зашифрованных записей по пробным подключениям. Но расшифрованное содержимое чужого EncryptedExtensions ему недоступно.

Это наиболее экспериментальная часть проекта. Сходство с браузерным соединением не доказывает неразличимость трафика и не гарантирует совместимость со всеми реализациями Xray.

Сервер в Docker и Coolify

Сервер развёрнут в контейнере и подключён к управлению через Coolify. Помимо портов ему нужны доступ к TUN, право менять сетевые настройки и включённая пересылка IPv4.

Основные настройки из docker-compose.server.yml:

ports:
  - "${PAYPHONE_HOST_PORT:-443}:40404/udp"
  - "${PAYPHONE_HOST_PORT:-443}:40443/tcp"
cap_add:
  - NET_ADMIN
devices:
  - /dev/net/tun
sysctls:
  - net.ipv4.ip_forward=1
volumes:
  - payphone-certs:/app/dev-certs
  - payphone-state:/app/state

TCP и UDP публикуются отдельно. Если порт 443 уже занят прокси, нужно выбрать другой внешний порт и указать его клиенту.

Сертификаты и сессии сохраняются в постоянных томах Docker. Иначе после пересоздания контейнера появится новый самоподписанный сертификат, и клиенты перестанут узнавать сервер. Без сохранённых сессий не получится восстановить прежнее подключение после перезапуска.

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

Что удалось проверить

Одного успешного подключения мало. Поэтому проверки появились прежде всего вокруг тех мест, где проект уже ломался: передача максимального стартового пакета, заполнение очереди TLS, повторное подтверждение смены секрета, отзыв действующей подписки и восстановление состояния после перезапуска.

В payphone-transport/examples/probe_vpn.rs находится отдельная проверка сервера. Она не меняет маршруты компьютера, с которого запускается: сама формирует внутренние IP-пакеты и передаёт их через PAYPHONE. Так проверяются авторизация, PING/PONG, смена секрета, восстановление сессии, DNS через сервер и отправка пакета на 1100 байт.

По сохранённому отчёту прошли 106 Rust-тестов, 23 теста PayphoneKit и 5 Android-тестов. Проверены оба транспорта, DNS через TUN и NAT, восстановление сессии после перезапуска сервера и отзыв доступа.

Отладочная Android-сборка установлена на телефон по USB. Но установка и запуск приложения ещё не заменяют длительную проверку самого VPN на устройстве. Испытания при потерях пакетов и смене сети, измерения скорости и независимый разбор безопасности остаются впереди.

Можно возвращаться

В консольном клиенте по-прежнему идёт зелёный дождь. Даже с ним пришлось повозиться: полноширинная катакана занимала две колонки и ломала перерисовку, а частое обновление экрана помогло обнаружить ошибку таймера.

Больше всего времени в этом проекте ушло на вещи, которых не видно на схеме «клиент — сервер». Добавленные байты меняли результат определения MTU. Настройка DNS уничтожала нужный маршрут. Ожидание одной отправки задерживало остальные соединения. Из таких деталей и складывается работающий туннель.

Так что мой выход из «Матрицы» пока выглядит прозаично: пакеты доходят, подписки проверяются, а после остановки клиента возвращается обычная сеть. Для телефонной будки, которую собираешь сам, это уже неплохое начало.

Проект github: https://github.com/digkill/PAYPHONE

TG канал: https://t.me/CodeAndPropellers