У меня появилась идея написать свой протокол VPN на Rust. Что бы подтянуть язык, подходы к программированию, начал я с проектирования, получилось 7 крейтов под разные слои и модули приложения.

И да, название протокола таксофон из матрицы, с помощью которых соединялись главные герои с виртуальным миром, остерегаясь агентов и стражей.

follow the white rabbit
follow the white rabbit

Разобрав с ChatGPT архитектуру будущего приложения, начал писать:

Протокол, который разговаривает как человек

Каждый кадр — 16 байт заголовка и payload. Типы такие: (да, решил использовать неформальное рукопожатие)

  • WhatsUpDude — клиент приходит с токеном

  • AllGoodDude — сервер выдаёт адрес и resume-секрет

  • BackAgainDude / StillGoodDude — переподключение без нового токена

  • AccessDeniedDude — токен протух, отозван, неизвестный ключ

  • Data — настоящий IPv4-пакет

  • Ping / Pong — keepalive с джиттером, не метроном на 10.000 с

Бинарный формат — нет: version, type, flags, length, sequence, сеть в big-endian, жёсткие лимиты длины.

impl TryFrom<u8> for FrameType {
    type Error = FrameError;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        match value {
            1 => Ok(FrameType::Data),

            2 => Ok(FrameType::WhatsUpDude),

            3 => Ok(FrameType::AllGoodDude),

            4 => Ok(FrameType::Ping),

            5 => Ok(FrameType::Pong),

            6 => Ok(FrameType::Rekey),

            7 => Ok(FrameType::Close),

            8 => Ok(FrameType::BackAgainDude),

            9 => Ok(FrameType::StillGoodDude),

            10 => Ok(FrameType::AccessDeniedDude),

            _ => Err(FrameError::UnknownFrameType(value)),
        }
    }
}

Токен — фиксированные 135 байт, подпись Ed25519. В claims есть план, лимит устройств и Mbps. Пока это честно записано и не энфорсится. Сессии живут в памяти процесса: рестарт сервера — все вылетели.

Клиент пинит self-signed сертификат файлом dev-certs/payphone-cert.der. Для прототипа ок, для продакшена — нет.

Зачем ещё один VPN

Обучение, практика и опыт! Возможно создам протокол, который не задетектит спрут ТСПУ… Понять стек целиком и сделать транспорт, который не выглядит как QUIC на проводе.

QUIC узнаваем даже на «нестандартном» порту: long header, version, характерный handshake. DPI не обязан расшифровать трафик — достаточно пассивно сматчить сигнатуру или самим постучаться на IP:port, завершить handshake и занести сервер в блоклист. Типичный ответ «просто повешу Quinn на 443» эту историю не закрывает: байты всё равно QUIC.

Поэтому PAYPHONE — это не «HTTP/3 прокси», а IP-пакеты из TUN, упакованные в QUIC datagrams, плюс тонкий XOR-слой с общим секретом, чтобы слепой probe не получал даже QUIC-ответа.

Далее в планах доработка под маскировку других сайтов (REALITY/Xray).

И тут уже в помощь Cursor с Grok High 4.6 так как компетенции стало не хватать и проблемы нужно было решать быстро.

Полный туннель: на сервере forwarding + MASQUERADE, на клиенте два маршрута /1 (0.0.0.0/1 и 128.0.0.0/1) через 10.77.0.1. Классика: default не трогаем, а более специфичные /1 перехватывают весь IPv4.

Бинарный формат — нет: version, type, flags, length, sequence, сеть в big-endian, жёсткие лимиты длины.

Токен — фиксированные 135 байт, подпись Ed25519. В claims есть план, лимит устройств и Mbps. Пока это честно записано и не энфорсится. Сессии живут в памяти процесса: рестарт сервера — все вылетели.

Клиент пинит self-signed сертификат файлом dev-certs/payphone-cert.der. Для прототипа ок, для продакшена — нет.

Почему datagrams, а не стримы

VPN возит IP. IP уже умеет терять пакеты. Если засунуть его в надёжный QUIC stream, получится TCP-over-TCP: внутренняя потеря плюс внешний ретрансмит, head-of-line, боль.

Поэтому payload едет unreliable datagram. Потерял — пусть внутренний TCP/QUIC сам решает. Quinn при переполнении буфера выкидывает старые datagrams; для туннеля это правильнее, чем ждать окно и стопорить TUN.

Первая версия ждала каждый send_datagram_wait. Под нагрузкой исходящий пакет блокировал входящий: select-цикл не крутился, пока Quinn не запишет. Лечится одной сменой на send_datagram и дропом при TooLarge.

Обфускация: не шифр, а «заткнись, probe (спрут)»

Поверх уже зашифрованного QUIC:

[ 8 байт salt ][ XOR( SHA256(psk || salt) по кругу,
                      [u16 длина][payload][случайный хвост 0–32] ) ]

Без секрета пакет слишком короткий или с враньём в length — сокет молчит, Quinn его не видит. Слепой handshake снаружи не начинается.

Это не криптография. Конфиденциальность уже у TLS 1.3. Слой только ломает сигнатуру и активный пробинг.

Что он не прячет: один долгоживущий UDP 4-tuple, кластеризацию размеров, факт «сервер отвечает не всем». Чтобы притвориться чужим сайтом на 443/tcp, нужен другой транспорт.

они, которые убили MTU

Первая версия паддинга округляла длину до корзины вроде [128, 296, 568, 1200, 1440]. Звучит умно против DPI.

Проблема: через этот слой проходят все UDP Quinn, включая пробы PMTU. Пакет у потолка 1200 прыгал сразу в 1440, проба не возвращалась, Quinn занижал оценку — и честный кадр приложения ловил SendDatagramError::TooLarge.

Сейчас хвост маленький и равномерный, 0–32 байта. Корзины выкинул.

Три раза, когда «протокол медленный», а виноват не протокол

1. MTU 1280 «потому что IPv6»

RFC говорит: 1280 безопасно. QUIC гарантирует другое: UDP payload 1200 до discovery. У Quinn накладные расходы, у PAYPHONE ещё 40 байт (16 кадр + 24 DATA). Итог: 1280 IP не влезает в безопасный datagram.

На реальном деплое клиент падал на первом же «толстом» браузерном пакете. Сейчас TUN 1100: 1100+40=1140 внутри пола ~1154. Браузеру хватает. Гигабита на этом не будет, пока не поднимем MTU после нормального path-MTU.

2. macOS убивает маршрут к серверу

Туннель вставал: handshake, 10.77.0.x, тысячи пакетов. Потом QUIC внезапно умирал. На 2ip снова домашний адрес.

networksetup (выключить IPv6 / прописать DNS) пересобирает таблицу маршрутов. Host-route к VPS исчезал, 128.0.0.0/1 заглатывал IP сервера, QUIC уходил в свой же utun — петля — сессия мертва.

Вторая проблема: маршрут -ifscope en0 виден только scoped lookup. Quinn смотрит unscoped и всё равно попадает в /1. Нужен обычный /32 через LAN-шлюз.

Третья: watchdog был sleep(2) внутри select. Каждый пакет с TUN пересоздавал таймер, под трафиком обход никогда не чинился.

На данный момент:

  • networksetup не трогаем: IPv6 режется route -blackhole, DNS — через scutil;

  • unscoped /32 на сервер + IP_BOUND_IF на UDP, чтобы пакеты физически уходили в Wi-Fi;

  • отдельный interval 400 мс, который чинит и /32, и оба /1.

3. Ctrl+C и дисконект от матрицы и всего мира

Tokio перехватывает SIGINT. Первый Ctrl+C выходил из цикла и зависал в endpoint.wait_idle(), пока /1 ещё смотрели в utun. Второй Ctrl+C уже никто не слушал. Интернет «отваливался», клиент «не выключался».

Маршруты нужно снимать до ожидания QUIC, а wait_idle ограничить таймаутом. После стопа должен остаться LAN default, не чёрная дыра.

Далее по плану:

  • MTU discovery — поднимать TUN MTU на лету по мере роста max_datagram_size() у Quinn, сейчас зафиксирован консервативный 1100

  • GSO/GRO — батчинг UDP-пакетов вместо одного syscall на пакет, самое техническое и рискованное (легко сломать обфускацию)

  • DNS — свой forwarder на сервере вместо публичного резолвера через scutil, убрать утечку и лишнюю сигнатуру

  • Сессии/лимиты/Rekey-Close — персистентность сессий на диск, реально проверять device/Mbps лимиты из токена, решить нужен ли Rekey/Close

  • Прод-обвязка — шифровать .payphone-session, нормальный TLS-сертификат (Let’s Encrypt) вместо ручного пиннинга, CLI/конфиг вместо голых env R- EALITY-мимикрия на 443 — маскировка под настоящий TLS-сайт, смена транспорта на TCP+TLS, отдельный большой заход с нуля

Проект таксофон vpn: GitHub