PAYPHONE: VPN на Rust, QUIC
У меня появилась идея написать свой протокол VPN на Rust. Что бы подтянуть язык, подходы к программированию, начал я с проектирования, получилось 7 крейтов под разные слои и модули приложения.
И да, название протокола таксофон из матрицы, с помощью которых соединялись главные герои с виртуальным миром, остерегаясь агентов и стражей.
Разобрав с 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;отдельный
interval400 мс, который чинит и/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