The Daily Newsstand · Free, Always
Tuesday, September 15, 2026

Два TLS-сервиса на одном 443-м порту: разбор по SNI

Translate

Задача звучит просто: есть один публичный IP и один 443-й порт, на нём уже работает сервис с живыми пользователями, и туда же нужно посадить второй. Менять что-либо на устройствах пользователей нельзя.

Ситуация встречается чаще, чем кажется. Один белый адрес, за ним и веб-сервис, и VPN-концентратор. Перенести второй на нестандартный порт не выходит: либо его режут по дороге корпоративные файрволы и гостевые сети, либо у клиентов физически нет возможности переключиться — конфиги розданы, устройства в поле, до половины из них вы не дотянетесь.

Ниже — как разделить порт между двумя TLS-сервисами, ничего не расшифровывая, чем за это платят и когда решение перестанет работать.

Разбирать буду на своём стенде: VPS, на котором стоит SoftEther (SSTP на 443, L2TP/IPsec и OpenVPN на своих портах), и рядом понадобилось поднять обычный HTTPS-сайт. Ограничения такие:

  • 443 занят VPN-сервисом, у которого уже есть подключённые клиенты;

  • настройки на устройствах пользователей трогать нельзя;

  • машина одноядерная, 973 МБ памяти — «поднять ещё одну такую же» некуда.

Почему очевидные варианты не подходят

Перенести один сервис на другой порт. Ломает существующих клиентов, а для VPN ещё и снижает проходимость: 443 — единственный порт, который открыт практически везде.

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

Разложить по путям на уровне HTTP. Не годится: чтобы увидеть URL, надо терминировать TLS. А во второй ветке у нас не HTTP вообще, там свой протокол поверх TLS.

Остаётся четвёртый уровень: научиться понимать, кому адресовано соединение, до того как оно расшифровано.

SNI виден до расшифровки

Когда клиент начинает TLS-рукопожатие, он отправляет ClientHello. В нём среди прочего лежит расширение SNI (Server Name Indication) — имя хоста, к которому клиент идёт. Расширение придумали ровно для того, чтобы за одним IP можно было держать несколько виртуальных хостов с разными сертификатами: сервер должен узнать имя раньше, чем выберет, какой сертификат предъявить.

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

На этом и строится маршрутизация. Модуль stream в nginx умеет подглядывать в ClientHello директивой ssl_preread, класть имя в переменную $ssl_preread_server_name и дальше проксировать соединение байт в байт — для TLS он остаётся полностью прозрачным, рукопожатие происходит между клиентом и настоящим бэкендом.

stream {
    map $ssl_preread_server_name $upstream_443 {
        www.example.com   127.0.0.1:8444;   # HTTPS-сайт
        default           127.0.0.1:4443;   # SoftEther
    }

    server {
        listen 443 reuseport;
        listen [::]:443 reuseport;

        ssl_preread   on;
        proxy_pass    $upstream_443;

        proxy_timeout         300s;
        proxy_connect_timeout 5s;
    }
}

Модуль ngx_stream_ssl_preread_module собирается в nginx по умолчанию начиная с 1.11.5, отдельно ставить обычно ничего не нужно — но проверить стоит:

nginx -V 2>&1 | tr ' ' '\n' | grep preread

Сам VPN-сервис при этом переезжает с 0.0.0.0:443 на 127.0.0.1:4443. Снаружи он больше не виден, весь вход идёт через nginx.

Ветка default — то, на чём всё держится

Самая важная строчка в этой конфигурации — default.

Старые клиенты подключаются по IP-адресу, а значит SNI не отправляют вообще. Расширение существует, чтобы различать хосты по имени; при обращении к голому IP отправлять в нём нечего, и клиент его просто не добавляет. Так же ведут себя многие VPN-клиенты, даже когда в настройках у них указано доменное имя.

Пустой SNI не совпадёт ни с одним именем в карте и уйдёт в ветку по умолчанию. Именно поэтому у пользователей ничего не меняется: они как ходили на IP:443, так и ходят, а разбор происходит до того, как их трафик кого-либо касается.

Проверяется это одной командой — с именем и без:

openssl s_client -connect 203.0.113.10:443 -servername www.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect 203.0.113.10:443 </dev/null 2>/dev/null | openssl x509 -noout -subject

Первая должна вернуть сертификат сайта, вторая — сертификат VPN-сервиса. Если обе возвращают одно и то же, ssl_preread не включился или карта не подхватилась.

Чем за это платим

Плата ровно одна, но о ней надо знать заранее: бэкенд перестаёт видеть реальные адреса клиентов. Для него все соединения приходят с 127.0.0.1.

Штатное лекарство — протокол PROXY: nginx добавляет перед потоком служебную строку с исходным адресом, бэкенд её разбирает. Проблема в том, что понимать её должен именно бэкенд, а SoftEther этого не умеет. И тут вылезает вторая деталь:

в блоке stream директива proxy_protocol задаётся на уровне server, а не upstream.

То есть включить её для одной ветки и не включить для другой в рамках одного server не получится — либо для обеих, либо ни для одной. Раз одна из сторон протокол не понимает, остаётся «ни для одной».

Обойти можно через iptables TPROXY с прозрачным проксированием, но это заметно сложнее в эксплуатации. Если по адресам клиентов вы никого не считаете и ограничений по IP не строите — проще принять как есть, а видимость вернуть логом самого роутера:

log_format sni_route '$remote_addr [$time_local] '
                     'sni="$ssl_preread_server_name" -> $upstream_addr '
                     'status=$status bytes=$bytes_sent/$bytes_received '
                     'dur=$session_time';

Он пишет настоящий адрес источника и то, в какую ветку ушло соединение. Для эксплуатации этого достаточно, а заодно сразу видно распределение трафика между сервисами. Логирование в stream доступно начиная с nginx 1.11.4.

Ёмкость: считайте соединения дважды

Одна вещь, которую легко упустить при переезде на L4-прокси: каждая сессия теперь занимает два слота соединений — со стороны клиента и со стороны бэкенда. Раньше сервис слушал порт сам и в бюджет nginx не входил вовсе.

В Debian значение по умолчанию — worker_connections 768. При одном рабочем процессе это потолок примерно в 384 одновременные сессии через роутер, и упереться в него можно неожиданно быстро — особенно если один из сервисов за этим портом работает короткими соединениями, а не длинными. Симптом в error.log характерный:

[alert] 768 worker_connections are not enough while connecting to upstream

Разумная база:

worker_processes      auto;
worker_rlimit_nofile  65535;

events {
    worker_connections 16384;
    multi_accept       on;
}

worker_rlimit_nofile поднимать обязательно вместе с worker_connections — иначе упрётесь в лимит файловых дескрипторов раньше, чем в лимит соединений, и получите совсем другую ошибку, про Too many open files.

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

Срок годности решения

Про это стоит сказать честно, потому что схема на SNI не вечная.

Разрабатывается ECH (Encrypted Client Hello) — механизм, который шифрует внутренний ClientHello целиком, включая SNI. Наружу при этом уходит «внешнее» имя, общее для всех сайтов за одним фронтендом. По мере того как ECH будет распространяться, маршрутизация по $ssl_preread_server_name начнёт видеть именно это внешнее имя, а не то, куда клиент на самом деле идёт.

Для схемы вроде описанной это не мгновенная смерть: ECH включается согласованно обеими сторонами, и ваш собственный бэкенд его просто не анонсирует. Но закладываться на SNI как на вечную опору не стоит — это решение на сегодняшний день, а не на десятилетие.

Альтернативы

nginx здесь не уникален:

  • HAProxy делает то же самое в режиме tcp через req.ssl_sni, и правила у него гибче — можно матчить по суффиксу и строить ACL посложнее;

  • sslh мультиплексирует по типу протокола, а не по имени: различает SSH, TLS, OpenVPN и другие на одном порту. Если задача не «два TLS», а «TLS и SSH» — брать надо его.

nginx удобен, если он у вас на машине уже есть: тогда это плюс двадцать строк конфигурации, а не новый компонент в эксплуатации.

Что забрать из этого текста

  • SNI читается до расшифровки — это открытый текст в ClientHello, и на этом строится маршрутизация без терминирования TLS.

  • Клиенты, ходящие по IP, SNI не отправляют вовсе и всегда попадают в ветку по умолчанию. На этом держится совместимость со старыми клиентами — и это же делает миграцию бесшовной.

  • За прозрачность платим потерей реальных адресов клиентов на бэкенде. proxy_protocol в stream задаётся на уровне server, поэтому «одной ветке включим, другой нет» не получится. Проще всего вернуть видимость логом самого роутера.

  • Считайте соединения дважды — через L4-прокси каждая сессия занимает два слота, и дефолтный worker_connections внезапно оказывается потолком.

  • У решения есть срок годности — ECH шифрует SNI, и рано или поздно это придётся учитывать.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.