Новым пользователямБесплатные тарифы DNS и CDN для каждого нового аккаунта.Создать аккаунт

Возобновление сеансов TLS и HTTP/3 на пограничных узлах

Возобновление сеансов позволяет повторным посетителям пропустить полное рукопожатие TLS. Почему ключи билетов должны сохраняться при перезапусках, как мы безопасно меняем их и как HTTP/3 использует общий конвейер обработки запросов.

Для CDN основные затраты часто связаны не с передачей байтов, а с установлением соединений. Полное рукопожатие TLS требует асимметричной криптографии и дополнительных сетевых обменов, а для удалённых посетителей эти десятки или сотни миллисекунд напрямую увеличивают время до первого байта. В статье рассматриваются два направления работы над рукопожатием в нашей пограничной реализации на Rust: возобновление сеансов TLS и HTTP/3.

Что такое возобновление сеансов

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

  • TLS 1.2 предлагает два механизма: идентификаторы сеансов (сервер хранит состояние сеанса в памяти) и билеты сеансов (сервер шифрует состояние сеанса и передаёт его клиенту, который возвращает его при следующем подключении).
  • TLS 1.3 объединяет их в возобновление на основе PSK (предварительно согласованного ключа): после рукопожатия сервер отправляет NewSessionTicket, а клиент предъявляет его при следующем подключении.

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

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

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

Без явной настройки BoringSSL и OpenSSL генерируют случайный ключ билетов при запуске процесса. Для одного долгоживущего процесса это подходит, но процессы пограничных узлов перезапускаются при развёртываниях и обновлениях. При каждом перезапуске новый процесс получал новый ключ, все клиентские билеты разом становились бесполезными, и клиентам приходилось выполнять полное рукопожатие. В кодовой базе уже была реализация связки ключей, но её так и не подключили к обработчику входящих TLS-соединений.

Решение: сохранение, ротация, ограничения

Теперь мы рассматриваем ключи билетов как состояние, которым нужно тщательно управлять, а не как одноразовый побочный результат работы процесса:

  1. Сохранение на диск. При запуске узел загружает связку ключей из своего каталога сертификатов; если файла нет, генерирует новые ключи с помощью CSPRNG. Сначала данные записываются во временный файл, затем он атомарно переименовывается. Файл сразу создаётся с правами 0600, поэтому промежутка, когда он доступен для постороннего чтения, не возникает.
  2. Подключение функции обратного вызова. Мы добавили функцию обратного вызова для ключей билетов в поставляемые с проектом привязки TLS (в BoringSSL это SSL_CTX_set_tlsext_ticket_key_cb). Новые билеты шифруются текущим ключом; при расшифровке ключ ищется в связке по имени, встроенному в билет.
  3. Ротация по расписанию. По умолчанию ключи меняются каждые 6 часов. В связке хранятся текущий ключ и несколько предыдущих, которые используются только для расшифровки, но не для выпуска билетов. Недавно выпущенные билеты продолжают работать, а срок использования каждого ключа ограничен, что уменьшает влияние возможной утечки на прямую секретность.
  4. Продолжение работы при сбое. Если файл ключей не удаётся прочитать, узел генерирует временные ключи и продолжает обслуживать запросы; билеты этого запуска просто не переживут следующий перезапуск. Возобновление — это оптимизация, и оно никогда не должно становиться причиной остановки обслуживания.

Отдельно серверный кэш сеансов (для идентификаторов сеансов TLS 1.2) реализован как ограниченный LRU с лимитом по умолчанию 20,480 записей. При заполнении самые давние сеансы вытесняются, поэтому потребление памяти не может расти бесконечно.

Стоит рассказать, как мы это проверили. В тестах мы имитируем перезапуск, создавая совершенно новый SSL_CTX из того же файла ключей, затем подключаемся с билетом, выданным старым контекстом, и проверяем session_reused == true. На реальном узле мы получаем билет, перезапускаем пограничный сервис, повторно подключаемся со старым билетом и убеждаемся, что сеанс возобновляется. Важный нюанс: отправьте настоящий запрос и немного подождите. В TLS 1.3 сообщение NewSessionTicket приходит после рукопожатия, поэтому при слишком раннем отключении билет не будет получен и каждое соединение будет выглядеть новым.

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

Попутное исправление: корректные перезапуски

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

HTTP/3: тот же конвейер, другой транспорт

HTTP/3 работает поверх QUIC, который использует UDP и включает TLS 1.3. Преимущества особенно заметны в ненадёжных сетях: нет блокировки начала очереди на уровне TCP, требуется меньше сетевых обменов для установления соединения, а соединения могут мигрировать при смене сети.

Собственная поддержка HTTP/3 в Pingora ещё не включена в основной проект. Мы не хотели привязывать развитие плоскости данных к циклу выпусков внешнего проекта, поэтому реализовали терминацию QUIC и HTTP/3 на основе библиотеки Cloudflare tokio-quiche, построенной на quiche.

Ключевое архитектурное решение: HTTP/3 отвечает только за транспорт, а вся логика запросов использует существующий конвейер обработки. А именно:

  • tokio-quiche принимает QUIC и HTTP/3 на UDP 443 и выбирает сертификат по SNI во время рукопожатия. Сертификаты берутся из того же хранилища, что и для TCP 443; отдельной системы сертификатов нет.
  • Декодированные запросы HTTP/3 передаются Pingora по локальному loopback-соединению в том же процессе, поэтому маршрутизация, кэширование, WAF, правила и обращения к серверу-источнику используются повторно. Ни один функциональный модуль не пришлось переписывать для HTTP/3.
  • Loopback-запросы содержат случайный секрет, существующий только в памяти процесса, и считаются доверенными лишь тогда, когда действительно поступают через loopback. Так восстанавливаются реальный IP клиента и протокол. Одноимённые заголовки, поступающие извне, всегда удаляются.
  • H1/H2 и H3 используют одну BoringSSL в одном процессе, что также позволяет вычислять отпечатки клиентов во время рукопожатия QUIC.

Объявлять только доступное: условия отправки Alt-Svc

Браузеры узнают о поддержке HTTP/3 сайтом из заголовка ответа Alt-Svc: h3=":443"; ma=86400. Если сервер отправляет этот заголовок, но не принимает UDP, браузеры тратят время на неудачную попытку, прежде чем перейти на HTTP/2. Поэтому отправка Alt-Svc контролируется и на уровне узла, и на уровне домена: заголовок передаётся, только когда узел действительно принимает QUIC-соединения, а для домена включён HTTP/3. Мы также удаляем любой Alt-Svc, возвращённый сервером-источником, чтобы его собственное объявление не вводило клиентов в заблуждение.

Сейчас мы не включаем 0-RTT. Он экономит ещё один сетевой обмен, но ранние данные могут быть воспроизведены повторно, а для безопасного включения нужно определить, какие запросы идемпотентны. Мы вернёмся к этому, когда будем уверены в решении.

Итоги

Ни возобновление сеансов, ни HTTP/3 нельзя просто включить одним переключателем. Возобновление требует управления жизненным циклом ключей билетов; HTTP/3 нужен отдельный транспортный стек без разделения логики запросов. Наш подход: сохранять и ротировать ключи как состояние, использовать HTTP/3 только как транспортный входной слой и реализовывать каждую функцию ровно один раз.

Создавайте проекты на Yeap Cloud

Бесплатные тарифы DNS и CDN для каждого нового аккаунта.

Зарегистрироваться бесплатно

Читать дальше

Подключите сайт к Yeap CDN с бесплатным HTTPS за 10 минут

Пошаговое руководство по подключению сайта к Yeap CDN: добавление домена, настройка сервера-источника, подключение через CNAME или NS, бесплатный сертификат Let's Encrypt, выбор режима SSL, принудительный HTTPS и проверка через curl.

Начало работы с Yeap LLM API через OpenAI SDK

Yeap LLM API совместим с OpenAI: используйте привычный SDK, указав https://llm.yeap.cloud/v1 в base_url. Создание ключа, примеры на Python и Node.js, потоковые ответы, эмбеддинги, квоты и ограничения моделей и IP для ключей.

PHP2Shell (CVE-2026-109618/109619): как мы закрыли уязвимость виртуальным патчем на границе сети

Два критических бюллетеня PHP позволяют выполнить код удалённо без аутентификации через аварийный выход при нехватке памяти и устаревшее глобальное состояние. Рассказываем, как работают уязвимости, что Yeap WAF блокирует на границе с первого дня и что вам всё ещё нужно исправить на своём источнике.