Привет, Хабр!
Сегодня я хочу разобрать одну конкретную, но до безобразия полезную задачу, с которой мы столкнулись, когда наш сервис стал обрастать клиентами. Задача простая на словах, но с изюминкой: маршрутизировать входящие TLS-соединения в stream-модуле Nginx на разные бэкенд-пулы в зависимости от имени сервера SNI, которое клиент указывает в самом начале рукопожатия. Причем арендаторов могут добавлять каждую пятницу, а перезагружать Nginx каждый раз — это не наш метод. Конфиги должны быть статичными, а вот список арендаторов — динамическим, живущим где-то в Redis или etcd.
Почему именно stream? Потому что иногда нужно проксировать не HTTP, а что-то другое — например, raw TCP для своих кастомных протоколов, или тот же TLS поверх TCP, где внутри может быть всё что угодно. HTTP-модуль с его server_name
нам тут не помощник, он работает на уровне приложения, а нам нужно на уровне транспортного потока.
Итак, задача: выцепить SNI из ClientHello, пойти в ключ-значение хранилище, найти по этому имени арендатора и его бэкенд-пул, и направить трафик куда надо. И всё это — без перезагрузки, быстро и безопасно.
Как вытащить SNI из потока
В Nginx есть модуль ngx_stream_js_module
, он же njs. Это подмножество JavaScript, которое позволяет вставлять свою логику в разные фазы обработки соединения. Нас интересует фаза preread
— момент, когда данные от клиента уже пришли, но проксирование еще не началось. Мы можем заглянуть в эти данные и принять решение.
Вот как выглядит минимальный набросок конфига, который подключает njs и намекает, что мы будем делать что-то умное на фазе preread
.
load_module modules/ngx_stream_js_module.so;
events {}
stream {
js_include sni_router.js;
js_set $upstream_pool select_upstream;
server {
listen 443 ssl; # На самом деле ssl здесь необязательно, мы же в stream
preread_timeout 5s;
js_preread select_upstream;
proxy_pass $upstream_pool;
ssl_certificate /path/to/dummy.crt; # Заглушка, может потребоваться
ssl_certificate_key /path/to/dummy.key;
proxy_protocol on; # Если за бэкендом тоже nginx
}
}
Обрати внимание на строчку js_preread select_upstream;
. Именно здесь мы вызываем нашу функцию, которая будет разбирать ClientHello. Функция select_upstream
определена в подключаемом файле sni_router.js
и должна вернуть имя упастрима, на который нужно проксировать.
А теперь о том как вытащить SNI.
Парсим ClientHello
TLS Handshake — это не HTTP, здесь нет текстовых заголовков.
Структура TLS ClientHello начинается с заголовка записи. Первый байт указывает тип записи: для Handshake это значение 0x16. Следующие два байта определяют версию протокола TLS, например, 0x0303 соответствует TLS 1.2. Завершает заголовок двухбайтовое поле, указывающее общую длину последующих данных.
Далее следует непосредственно заголовок рукопожатия. Его первый байт определяет тип рукопожатия: для ClientHello это 0x01. Следующие три байта задают длину всего сообщения рукопожатия. После этого снова идут два байта версии TLS. Затем располагается 32 байта случайных данных. Следом указывается длина session ID, после которой следут сами данные session ID. Аналогично обрабатываются список шифров (два байта длины и сами шифры) и методы сжатия (один байт длины и сами методы).
Завершают структуру расширения. Сначала идет двухбайтовое поле общей длины всех расширений. Затем следует массив отдельных расширений, каждое из которых содержит тип (два байта, для SNI это 0x0000) и длину своих данных (два байта). Непосредственно данные расширения SNI начинаются с длины списка SNI (два байта), за которым следует массив записей. Каждая запись содержит тип имени (один байт, для host_name это 0x00), длину имени (два байта) и само строковое имя сервера.
Это нетривиально, но именно это нам и предстоит распарсить в njs. К счастью, njs предоставляет доступ к буферу preread-данных через специальный объект.
Вот как может выглядеть функция извлечения SNI:
function extractSNI(s) {
// s - это бинарный буфер, переданный njs
if (s.length < 5) return null;
if (s[0] != 0x16) return null; // Это не Handshake record
// Пропускаем Record Layer заголовок (5 байт) и смотрим на Handshake
let offset = 5;
if (offset + 4 > s.length) return null;
if (s[offset] != 0x01) return null; // Это не ClientHello
// Пропускаем тип (1 байт) и длину Handshake (3 байта)
offset += 4;
// Пропускаем версию (2 байта) и случайные данные (32 байта)
offset += 34;
// Session ID
if (offset + 1 > s.length) return null;
let sessionIdLen = s[offset];
offset += 1 + sessionIdLen;
// Cipher Suites
if (offset + 2 > s.length) return null;
let cipherSuitesLen = (s[offset] << 8) | s[offset + 1];
offset += 2 + cipherSuitesLen;
// Compression Methods
if (offset + 1 > s.length) return null;
let compressionMethodsLen = s[offset];
offset += 1 + compressionMethodsLen;
// Extensions
if (offset + 2 > s.length) return null;
let extensionsLen = (s[offset] << 8) | s[offset + 1];
offset += 2;
let extensionsEnd = offset + extensionsLen;
if (extensionsEnd > s.length) return null;
while (offset < extensionsEnd) {
if (offset + 4 > extensionsEnd) break;
let extType = (s[offset] << 8) | s[offset + 1];
let extLen = (s[offset + 2] << 8) | s[offset + 3];
offset += 4;
if (extType == 0x0000) { // Server Name Indication
if (offset + 2 > extensionsEnd) break;
let sniListLen = (s[offset] << 8) | s[offset + 1];
offset += 2;
let sniListEnd = offset + sniListLen;
while (offset < sniListEnd) {
if (offset + 3 > sniListEnd) break;
let nameType = s[offset];
let nameLen = (s[offset + 1] << 8) | s[offset + 2];
offset += 3;
if (nameType == 0x00) { // host_name
if (offset + nameLen > sniListEnd) break;
// Наконец-то! Преобразуем байты в строку.
let sni = String.fromCharCode.apply(null, s.slice(offset, offset + nameLen));
return sni;
}
offset += nameLen;
}
} else {
offset += extLen;
}
}
return null;
}
Эта функция — сердце маршрутизации. Она принимает буфер s
и возвращает строку с SNI, либо null
, если что-то пошло не так. Выглядит страшновато, но это буквально пошаговое следование спецификации TLS.
Динамический справочник арендаторов
SNI мы получили. Теперь нужно понять, куда слать трафик. Классический способ — это прописать upstream
блоки в конфиге Nginx и использовать map
для сопоставления. Но это статика. Нам же нужно динамическое обновление.
Здесь на помощь приходят внешние хранилища вроде Redis или etcd. Идея проста: мы храним там ключ-значение, где ключ — это SNI, а значение — имя апстрима (которое уже задекларировано в конфиге Nginx) или даже напрямую адрес бэкенда.
Рассмотрим вариант с Redis. Он быстрый, простой и отлично подходит для такой задачи.
В njs есть встроенный модуль ngx.fetch
, который позволяет делать HTTP-запросы. Redis говорит на своем собственном протоколе, но для простоты мы можем поднять рядом с Redis небольшой HTTP-интерфейс, например, redis-http-proxy
, или использовать REST-интерфейс к Redis, если такой есть. Но чтобы не усложнять, давай представим, что у нас есть простой HTTP-сервис, который по GET-запросу вида /lookup/
возвращает JSON с именем апстрима.
Но это не так интересно. Давай сделаем по-настоящему — прямо из njs подключимся к Redis по его родному протоколу, используя сокеты. Для этого нам потребуется написать немного больше кода, но зато мы избежим лишнего звена.
В njs нет нативного клиента для Redis, но мы можем общаться с ним через TCP-сокет, используя опять же ngx.stream
, но уже на стороне скрипта. Это уже более низкоуровнево.
Вот план:
Открываем TCP-соединение к Redis из njs-скрипта.
Формируем Redis-команду
GET
.Читаем ответ.
Парсим его.
Возвращаем результат.
Звучит сложно, но это выполнимо. Однако, есть нюанс: операции ввода-вывода в preread
фазе — это блокирующая операция. Она может затормозить прием новых соединений. Поэтому такой способ подойдет только если Redis живет очень близко, в одной сети, с минимальной задержкой.
Вот как это может выглядеть:
function redisCommand(cmd) {
try {
let s = new ngx.socket.tcp();
s.connect('127.0.0.1', 6379); // Подключаемся к Redis
s.send(cmd); // Отправляем команду в формате Redis Protocol
let reply = s.receive(); // Читаем ответ
s.close();
return reply;
} catch (e) {
return null;
}
}
// Форматируем команду по Redis Protocol (RESP)
function formatRedisGet(key) {
return `*2\r\n$3\r\nGET\r\n$${key.length}\r\n${key}\r\n`;
}
function lookupInRedis(sni) {
let cmd = formatRedisGet(sni);
let reply = redisCommand(cmd);
// Парсим ответ. Для простого строкового ответа это будет "$\r\n\r\n"
if (reply && reply.startsWith('$')) {
let lines = reply.split('\r\n');
if (lines.length >= 2) {
let value = lines[1];
return value; // Это у нас будет имя апстрима, например "tenant_a_backend"
}
}
return null; // Не нашли
}
Функция formatRedisGet
формирует команду по протоколу RESP. Команда GET sni_value
кодируется как *2\r\n$3\r\nGET\r\n$\r\n\r\n
. Это массив из двух элементов: строка "GET" и строка с самим SNI.
Это рабочий, но довольно хрупкий способ. Малейшая ошибка в парсинге — и всё сломается. Для продакшена я бы рекомендовал использовать более надежные методы, например, тот же HTTP-интерфейс к Redis через ngx.fetch
, если есть возможность его организовать. Код будет проще и стабильнее.
async function lookupInRedis(sni) { // ngx.fetch работает асинхронно
try {
// Предположим, у нас есть HTTP-сервис, который проксирует запросы к Redis
let reply = await ngx.fetch(`http://redis-proxy.internal/lookup/${sni}`);
if (reply.status != 200) return null;
let json = await reply.json();
return json.upstream; // { "upstream": "tenant_a_backend" }
} catch (e) {
return null;
}
}
Так и надежнее, и код читается легче.
Собираем всё вместе
Теперь напишем главную функцию select_upstream
, которую мы вызвали в js_preread
.
function select_upstream(s) {
let sni = extractSNI(s);
if (!sni) {
// Если не смогли извлечь SNI, может, это не TLS трафик?
// Отправляем в дефолтный апстрим или разрываем соединение.
return 'default_backend';
}
// Проверяем кэш? Можно завести простой in-memory кэш на несколько секунд,
// чтобы не дергать Redis на каждое соединение.
let upstream = lookupInRedisSync(sni); // Синхронная версия для примера
if (upstream) {
return upstream;
}
// Если не нашли в Redis, возможно, это попытка подключиться по "дикому" SNI.
// Логируем и отправляем в черную дыру или просто разрываем соединение.
ngx.log(ngx.WARN, `Unknown SNI: ${sni}`);
return 'blackhole_backend'; // Апстрим, который просто сбрасывает соединение.
}
Что делать, если пришел SNI, которого нет в нашем Redis? Это может быть сканер, бот или просто ошибка. Не стоит отправлять такой трафик на какой-то дефолтный бэкенд. Лучше иметь специальный апстрим blackhole_backend
, который просто обрывает соединение.
upstream blackhole_backend {
server 127.0.0.1:9; # port 9 - discard port, классика
}
upstream default_backend {
server 10.0.1.100:443;
}
upstream tenant_a_backend {
server 10.0.2.100:443;
server 10.0.2.101:443;
}
upstream tenant_b_backend {
server 10.0.3.100:443;
}
Горячее обновление без перезагрузки
Не трогаем конфиг Nginx. Когда добавляется новый арендатор, мы просто добавляем запись в Redis. При следующем подключении с новым SNI наша функция lookupInRedis
найдет его и направит трафик в правильный апстрим, который уже был заранее прописан в конфиге.
Это и есть многоарендность по SNI с динамической подгрузкой конфигурации. Новые арендаторы добавляются "на лету".
Бенчмарки и что с производительностью
Вставка JavaScript-кода в путь обработки каждого соединения — это дополнительная нагрузка. Парсинг TLS Handshake — операция не бесплатная. Плюс синхронный запрос к Redis (или HTTP-сервису) добавляет задержку.
На что стоит обратить внимание:
-
Кэширование. Реализуем простой TTL-кэш прямо в njs. При получении SNI сначала проверяем, нет ли его значения в памяти. Это резко снизит количество запросов к Redis.
let cache = {}; function getCached(sni, ttl = 10) { let item = cache[sni]; if (item && (Date.now() - item.timestamp < ttl * 1000)) { return item.value; } return null; } function setCached(sni, value) { cache[sni] = { value: value, timestamp: Date.now() }; }
У этого кэша есть недостаток — он глобальный для воркера. Но для наших целей сойдет.
Время парсинга. Парсинг ClientHello должен быть максимально эффективным. Наш код читает буфер последовательно, без лишних операций.
Расположение Redis. Он должен быть в той же датацентре, что и Nginx, чтобы задержка на запрос была минимальной (желательно <1ms).
В среднем, добавление njs-логики в preread
фазу увеличивает задержку установления соединения на доли миллисекунд (если кэш попадает) до нескольких миллисекунд (если нужен запрос к Redis). Для большинства задач это приемлемо.
Полный пример конфигурации
Соберем все кусочки в единый конфиг.
nginx.conf:
load_module modules/ngx_stream_js_module.so;
events {
worker_connections 1024;
}
stream {
js_include /etc/nginx/sni_router.js;
js_set $upstream_pool select_upstream;
upstream blackhole_backend {
server 127.0.0.1:9;
}
upstream default_backend {
server 10.0.1.100:443;
}
upstream tenant_a_backend {
server 10.0.2.100:443;
server 10.0.2.101:443;
}
upstream tenant_b_backend {
server 10.0.3.100:443;
}
server {
listen 443;
preread_timeout 5s;
js_preread select_upstream;
proxy_pass $upstream_pool;
proxy_protocol on; # Если нужно передавать оригинальный IP
}
}
/etc/nginx/sni_router.js:
// Кэш
let cache = {};
function getCached(key) {
let ttl = 30000; // 30 секунд
let item = cache[key];
if (item && (Date.now() - item.timestamp < ttl)) {
return item.value;
}
delete cache[key];
return null;
}
function setCached(key, value) {
cache[key] = { value: value, timestamp: Date.now() };
}
// Функция извлечения SNI (код см. выше)
function extractSNI(s) {
// ... весь код парсинга ...
}
// Синхронный lookup (упрощенный HTTP-вариант)
function lookupUpstream(sni) {
// Используем ngx.fetch, но в синхронном режиме через .text()
let reply = ngx.fetch(`http://redis-proxy.internal/lookup/${sni}`);
if (reply && reply.status == 200) {
let body = reply.body; // Предположим, что тело ответа - просто строка с именем апстрима
return body;
}
return null;
}
// Главная функция
function select_upstream(s) {
let sni = extractSNI(s);
if (!sni) {
ngx.log(ngx.INFO, "No SNI found, using default backend");
return 'default_backend';
}
// Проверяем кэш
let upstream = getCached(sni);
if (upstream) {
ngx.log(ngx.INFO, `SNI ${sni} found in cache, upstream: ${upstream}`);
return upstream;
}
// Идем в Redis
upstream = lookupUpstream(sni);
if (upstream) {
setCached(sni, upstream);
ngx.log(ngx.INFO, `SNI ${sni} routed to upstream: ${upstream}`);
return upstream;
}
// Неизвестный SNI
ngx.log(ngx.WARN, `Unknown SNI: ${sni}, routing to blackhole`);
return 'blackhole_backend';
}
Заключение
Мы разобрали, как с помощью njs в stream-модуле Nginx можно построить гибкую и динамическую систему маршрутизации на основе SNI. Это мощный инструмент, который позволяет адаптироваться к изменениям без постоянных релоадов конфигурации.
Если у тебя есть вопросы или предложения по улучшению кода — добро пожаловать в комментарии.
Если вам интересен подход, при котором конфигурация инфраструктуры становится динамичной, управляемой и легко масштабируемой без постоянных перезагрузок, то стоит обратить внимание на принципы Infrastructure as Code. В этом подходе все настройки хранятся в виде описаний, которые можно версионировать, тестировать и автоматически применять, что позволяет строить надежные и повторяемые среды.
На курсе Infrastructure as Code вы сможете глубже разобраться в этих практиках, изучить инструменты автоматизации и подходы к управлению инфраструктурой через код. А чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.

Рост в IT быстрее с Подпиской — дает доступ к 3-м курсам в месяц по цене одного. Подробнее