Один публичный IP, один 443-й порт, на нём уже работает сервис с живыми пользователями — и туда же нужно посадить второй. Менять настройки на устройствах пользователей нельзя.Разбираю, как разделить порт между двумя TLS-сервисами, ничего не расшифровывая: SNI уходит открытым текстом в ClientHello, и nginx умеет читать его директивой ssl_preread, оставаясь для TLS прозрачным. Отдельно — почему всё держится на ветке default, чем за прозрачность платят (бэкенд перестаёт видеть реальные адреса клиентов) и когда у этого решения закончится срок годности. Читать далее
Уровень сложностиСредний
Время на прочтение5 мин
Охват и читатели9.5K
Кейс
Задача звучит просто: есть один публичный IP и один 443-й порт, на нём уже работает сервис с живыми пользователями, и туда же нужно посадить второй. Менять что-либо на устройствах пользователей нельзя.
Один белый адрес, за ним и веб-сервис, и VPN-концентратор. Перенести второй на нестандартный порт не выходит: либо его режут по дороге корпоративные файрволы и гостевые сети, либо у клиентов физически нет возможности переключиться: конфиги розданы, устройства в поле, до половины из них вы не дотянетесь.
Разбирать буду на своём стенде: 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.
Старые клиенты подключаются по 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.
Разрабатывается 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 читается до расшифровки, маршрутизация строится без терминирования TLS.
Клиенты по IP не отправляют SNI и всегда попадают в default — на этом держится бесшовность миграции.
Бэкенд перестаёт видеть адреса клиентов, и это не лечится точечно: proxy_protocol в stream включается на весь server.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Мой бесплатный сервис чуть не стал сканером портов для всего интернета | 0 | 7.03 | 28-09-2026 |
| 2 | Nginx Proxy Manager на облачных серверах — установка, настройка, порядок в URL’ах | 0 | 10.4 | 07-10-2026 |
| 3 | Где HTTP/2 и HTTP/3 реально быстрее, а где не дают ничего | 0 | 15.15 | 14-09-2026 |
| 4 | TLS‑сертификатам урезали жизнь до 47 дней. Готовьте автоматизацию, время пошло | 0 | 11.35 | 22-09-2026 |
| 5 | Мониторинг SSL: как не пропустить истекающий сертификат | 0 | 13.85 | 08-10-2026 |
| 6 | Мониторинг SSL сертификатов в Zabbix | 0 | 7.39 | 28-09-2026 |
| 7 | Джуну дали ИИ, а работы прибавилось у сеньора | 0 | 9.77 | 28-09-2026 |
| 8 | У вас 64 ядра и 256 ГБ памяти, но Nginx ... | 0 | 14.42 | 29-09-2026 |
| 9 | Address already in use: как найти, кто занял порт, и почему он не всегда освобождается сразу | -1 | 5.36 | 02-10-2026 |
| 10 | Консоль починки сети по аудиокабелю: когда SSH уже бесполезен | 0 | 8.26 | 01-10-2026 |