Задача звучит просто: есть один публичный 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 — то, на чём всё держится
Самая важная строчка в этой конфигурации — 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.

