RE:NODE

Сеть13 мин чтения

Что делает reverse proxy: TLS, имена хостов и реальные IP

Как reverse proxy терминирует TLS, маршрутизирует по имени хоста и передаёт реальный IP клиента в X-Forwarded-For, и что ломается, если приложение это игнорирует.

Обновлено

0 прочтений

Ваше приложение слушает порт, который ему выделили, - 3000, 8000, что-нибудь из пяти цифр. Посетители набирают имя и ждут порт 443 с замочком. Между ними стоит reverse proxy, и понимание того, как он устроен, объясняет большую часть «магии» в слоте прокси в панели, большую часть того, что идёт не так при первом деплое, и то, почему каждый ваш посетитель выглядит в логах как один и тот же IP-адрес.

Reverse proxy - это сервер, который принимает соединения от имени других серверов. Слово «reverse» отличает его от прокси, который настраивают в браузере: прямой прокси действует от имени клиента и скрывает, кто спрашивает, обратный действует от имени сервера и скрывает, кто отвечает. Nginx, Caddy, Traefik, HAProxy и прокси-слой любой хостинговой панели делают одну и ту же работу.

Путь запроса по порядку#

TLS на 443app.example.comapi.example.comответ через проксиReverse proxyдержит сертификатNode-приложениеслушает :3000Python APIслушает :8000Посетительвводит имя хоста
Один адрес на порту 443 перед двумя приложениями

Вот что происходит с одним запросом шаг за шагом:

  1. Браузер посетителя резолвит ваш домен в адрес - адрес прокси, а не вашего приложения.
  2. Он открывает TCP-соединение на порту 443 и начинает TLS-рукопожатие. В первом незашифрованном сообщении он называет нужный ему хост - это поле SNI.
  3. Прокси выбирает подходящий сертификат, завершает рукопожатие и расшифровывает запрос. Ваше приложение сертификата никогда не видит.
  4. Прокси читает заголовок Host и решает, какому бэкенду принадлежит это имя.
  5. Он открывает обычное HTTP-соединение с вашим приложением на его внутреннем порту и повторяет запрос, добавляя заголовки, в которых записано, кто спрашивал изначально и как.
  6. Ваше приложение отвечает по этому внутреннему соединению. Прокси передаёт ответ обратно по зашифрованному.

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

Что это даёт#

  • Ваше приложение никогда не занимается сертификатами. Одной вещью меньше для продления, одним закрытым ключом меньше на машине, где работает ваш код, и никакого перезапуска приложения при смене сертификата.
  • Несколько имён на одном адресе и одном порту. Один публичный IP и один порт 443 могут обслуживать любое число имён хостов, потому что решение о маршрутизации принимается по SNI и заголовку Host, а не по адресу.
  • Порт вашего origin остаётся закрытым. Из интернета должен быть достижим только прокси. Приложение может слушать 127.0.0.1 и быть снаружи вообще недоступным.
  • Место для сквозных задач. Сжатие, лимиты частоты, ограничения размера запроса, блокировки по IP и журналы доступа живут в одной конфигурации, а не в каждом вашем приложении.
  • Приложение можно перенести, не трогая DNS. Публикуется адрес прокси. Смена бэкенда за ним - это перезагрузка конфигурации.

Цена - ещё один переход и ещё одно место, где можно ошибиться в настройке, и об этом остальная часть статьи.

Терминация TLS и сертификаты#

Терминировать TLS - значит, что закрытый ключ хранит прокси, расшифровывает и говорит с бэкендом по обычному HTTP. Этот последний участок не зашифрован, что звучит тревожно, пока не заметишь, что обычно он идёт по loopback или по частной сети внутри одной машины. Если он проходит через недоверенную сеть, TLS нужен и на втором участке - это повторное шифрование, а не терминация, - и решать об этом стоит осознанно, а не по умолчанию.

Сертификаты сейчас почти в любом прокси выпускаются автоматически, через ACME. Механику стоит знать, потому что все сбои связаны именно с ней:

  • Проверка HTTP-01 работает так: удостоверяющий центр запрашивает файл в /.well-known/acme-challenge/ на порту 80. Поэтому порт 80 должен оставаться открытым и доходить до прокси, даже на сайте, который всё перенаправляет на HTTPS. Полностью закрытый порт 80 - самая частая самостоятельно созданная причина сбоя продления.
  • DNS-запись должна уже указывать на прокси, прежде чем выпуск сможет пройти. Сертификат нельзя выпустить для имени, которое не резолвится в проверяемую машину.
  • Продление происходит задолго до истечения срока. Сертификаты обычно действуют 90 дней и продлеваются примерно на 30-й день до конца, что оставляет широкое окно, чтобы временный сбой был повторён так, что никто этого не заметит.
  • Лимиты действуют на зарегистрированный домен в неделю. Многократное удаление и пересоздание сайта при отладке исчерпает их, и блокировка длится дни.

На RE:NODE тарифы для приложений и сайтов включают слот прокси. Вы направляете запись A на адрес, показанный в панели, и сертификат выпускается и продлевается автоматически в пределах окна в 21 день, так что у имени, которое перестало резолвиться, есть три недели запаса до того, как что-то истечёт. Порядок действий разобран в статье ваш домен и его сертификат, а в статье HTTPS и Let's Encrypt простыми словами объяснено, что именно доказывает проверка.

Маршрутизация по имени хоста#

Reverse proxy перед несколькими приложениями должен знать, для какого из них запрос, а ответ приходит в каждом запросе дважды.

SNI - Server Name Indication - это расширение TLS. Клиент кладёт имя хоста в ClientHello открытым текстом, до появления какого-либо шифрования, именно чтобы сервер знал, какой сертификат предъявить. Без него один адрес мог бы обслуживать только один сертификат. Поэтому же имя хоста, на который вы заходите, видно всему, что наблюдает за соединением, даже когда остальное зашифровано.

Заголовок `Host` приходит внутри зашифрованного запроса после завершения рукопожатия. Прокси использует его, чтобы выбрать бэкенд. Обычно они совпадают, а несовпадение некоторые прокси попросту отклоняют.

Что это значит на практике: ваше приложение, как правило, не знает своего публичного имени. Оно отвечает на всё, что приходит на его порт. Отсюда два следствия.

Если ваше приложение строит абсолютные URL - редиректы, ссылки для сброса пароля, canonical-теги, карты сайта, - ему нужно сообщить его публичное имя хоста: либо через заголовки forwarded, описанные ниже, либо через значение в конфигурации. Приложение, которое угадывает по соединению, выдаст в письме http://localhost:3000/reset?token=..., и это из тех ошибок, которые появляются только в проде.

А поскольку бэкенд принимает любой Host, он охотно отдаст ваш сайт по запросу с чужим именем хоста, если такой запрос дойдёт до него напрямую. Привяжите приложение к loopback или ограничьте его адресом прокси, а не оставляйте порт origin открытым на публичном интерфейсе.

Заголовки, которые несут исходный запрос#

Поскольку соединение с вашим приложением устанавливает прокси, приложение видит в качестве клиента адрес прокси. Все посетители выглядят одним человеком. Если вы ограничиваете частоту, ведёте логи, определяете геолокацию или банете по IP, всё это вы делаете со своим же прокси.

Исходные данные приходят в заголовках:

ЗаголовокЧто несётТипичное значение
X-Forwarded-ForАдрес клиента, затем каждого прокси203.0.113.10, 10.0.0.2
X-Forwarded-ProtoСхема, которую использовал клиентhttps
X-Forwarded-HostИмя хоста, которое запрашивал клиентapp.example.com
X-Forwarded-PortПубличный порт443
X-Real-IPТолько адрес клиента203.0.113.10
ForwardedВсё перечисленное, в стандартизованном видеfor=203.0.113.10;proto=https

X-Forwarded-For - это список, в который дописывает каждый прокси в цепочке. Крайняя левая запись - исходный клиент, а каждая запись правее - прокси, обработавший запрос. Forwarded - стандартизованная замена из RFC 7239, правильная, но менее распространённая; заголовки с X- - стандарт де-факто, и именно их вы на деле и получите.

Вопрос безопасности важнее синтаксиса. Эти заголовки - просто заголовки. Клиент может отправить X-Forwarded-For: 1.2.3.4, и если ваше приложение верит ему безоговорочно, вы построили белый список IP, через который может пройти кто угодно, и ограничитель частоты, который любой обойдёт, меняя заголовок. Правило такое: доверяйте заголовку только от прокси, которые вы контролируете, и считайте справа. Если вы знаете, что перед вами ровно один доверенный прокси, то адрес клиента - это последняя запись, добавленная вашим прокси, а не первая в списке. Фреймворки выражают это как число доверенных прокси или список доверенных адресов, и об этом следующий раздел.

На RE:NODE реальный адрес клиента приходит в X-Forwarded-For, и его чтение отличает ограничение частоты по посетителям от ограничителя, который блокирует сразу всех. Как проектировать сам лимит, описано в статье лимиты частоты и защита от злоупотреблений.

Как сообщить фреймворку, что он за прокси#

У каждого серверного фреймворка для этого есть переключатель, по умолчанию выключенный, потому что безоговорочно доверять forwarded-заголовкам небезопасно. Его включение заставляет фреймворк читать заголовки и сообщать реального клиента, схему и хост.

Express
// 1 = trust exactly one proxy in front of usapp.set("trust proxy", 1);// req.ip and req.protocol now describe the real client
Flask
from werkzeug.middleware.proxy_fix import ProxyFixapp.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Django settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")USE_X_FORWARDED_HOST = TrueALLOWED_HOSTS = ["app.example.com"]
Uvicorn and Gunicorn
$ uvicorn main:app --host 0.0.0.0 --port 8000 --proxy-headers$ gunicorn app:wsgi --bind 0.0.0.0:8000 --forwarded-allow-ips="10.0.0.0/8"

У Laravel есть middleware TrustProxies со свойством $proxies; по возможности задайте в нём адрес прокси, а не «всё». Next.js читает X-Forwarded-Host и X-Forwarded-Proto при генерации URL и переключателя не требует, но всё, что вы пишете внутри него сами, - требует.

Число в trust proxy - это количество переходов, и ошибка в нём проявляется тонко, а не громко. Задайте слишком мало - увидите адрес прокси вместо клиента. Задайте слишком много - будете читать запись, которую подставил клиент, а это как раз подделываемый случай. Посчитайте прокси, которые действительно стоят на пути, - прокси панели это один, CDN перед ним - два, - и проверьте, выведя req.ip с машины, чей адрес вам известен.

Остальной чек-лист для первого случая - в статье деплой Express API в продакшен, а как вообще доставить код на сервер - в статье деплой Node-приложения из GitHub.

WebSocket, потоковые ответы и таймауты#

HTTP-запрос короткий. WebSocket - это запрос, который превращается в долгоживущее соединение, и прокси, которым об этом не сказали, будут мешать.

Апгрейд - это обычный HTTP-запрос с Connection: Upgrade и Upgrade: websocket. Прокси обязан пропустить оба заголовка и говорить на HTTP/1.1 на участке к upstream, потому что механизма апгрейда в HTTP/1.0 не существует. В nginx это четыре строки:

WebSocket upgrade, nginx
location /socket.io/ {    proxy_pass http://127.0.0.1:3000;    proxy_http_version 1.1;    proxy_set_header Upgrade $http_upgrade;    proxy_set_header Connection "upgrade";    proxy_read_timeout 3600s;}

Управляемые прокси обычно обрабатывают апгрейд автоматически. Автоматически они не обрабатывают таймаут: прокси с read timeout в 60 секунд будет каждую минуту обрывать простаивающий WebSocket, порождая цикл переподключений, который выглядит как сбой сети, а на деле - значение в конфигурации. Отправляйте ping на уровне приложения каждые 20-30 секунд, либо увеличьте таймаут, либо делайте и то и другое.

У server-sent events и потоковых ответов есть вторая проблема - буферизация ответа. Прокси, который буферизует, будет держать ваш поток, пока не накопит достаточно, и клиент получит долгое молчание, а затем всё сразу. В nginx для таких маршрутов нужен proxy_buffering off или заголовок ответа X-Accel-Buffering: no от приложения.

Здесь возникают и sticky-сессии. Если вы запускаете несколько экземпляров приложения за одним прокси, а ваша библиотека WebSocket откатывается на HTTP long-polling - как Socket.IO, - последовательные запросы от одного клиента должны попадать в один и тот же экземпляр, иначе сессия не будет найдена. Включите sticky-маршрутизацию либо используйте общий адаптер, чтобы экземпляры делили состояние. Каждый из этих вариантов подробно разобран в статье WebSocket за reverse proxy.

Чего reverse proxy не делает#

Именно здесь ожидания чаще всего неверны, и об этом стоит сказать прямо.

Он не переносит игровой трафик. Почти любой reverse proxy говорит на HTTP, а почти любая игра - на UDP со своим собственным протоколом. Valheim или Counter-Strike 2 за HTTP-прокси не поставить, а проксируемая DNS-запись, указывающая на игровой сервер, даёт адрес, который никогда не примет игровое соединение. То же относится к оранжевому облаку Cloudflare - в статье Cloudflare для сайтов и игровых серверов прямо сказано, что он проксирует, а что нет, а в статье TCP и UDP для игровых серверов объяснено, почему так.

Он не кэш, пока вы его не настроите. Большинство прокси по умолчанию пересылают каждый запрос. Кэширование - функция, которую включают, а потом приходится думать об инвалидации.

Он не защита от DDoS. Он - цель поменьше вашего приложения и может поглотить часть мусора, но объёмная атака забивает канал перед ним, и ничто на самой машине тут не поможет.

Он не исправляет медленное приложение. Прокси перед ответом в 4 секунды делает его ответом в 4 секунды плюс лишняя миллисекунда. Профилируйте приложение.

Он никого не аутентифицирует, пока вы не настроите и это. Basic auth и forward-auth есть в большинстве прокси и стоят того для админского интерфейса, но по умолчанию ничего не происходит.

Когда ломается: 502, 504, циклы и mixed content#

502 Bad Gateway. Прокси не получил от бэкенда корректного ответа. В девяти случаях из десяти приложение не запущено, либо привязано к 127.0.0.1, когда прокси находится в другом контейнере или пространстве имён, либо работает на другом порту, чем указано в конфигурации. Проверьте, что процесс жив, затем посмотрите, к чему он привязался, через ss -lntp. Если прокси и приложение в разных контейнерах, приложение должно слушать 0.0.0.0.

504 Gateway Timeout. Приложение приняло соединение и не ответило вовремя. Это медленный запрос, а не сбой прокси. Найдите медленный endpoint, прежде чем увеличивать таймаут, потому что увеличение обычно просто переносит сбой в браузер.

Цикл редиректов. Классика. Ваше приложение принудительно включает HTTPS, проверяя схему собственного соединения, а оно - обычный HTTP, потому что TLS терминировал прокси. Поэтому оно перенаправляет на HTTPS, прокси снова терминирует TLS, приложение снова видит HTTP, и браузер сдаётся после двадцати кругов. Исправление - доверять X-Forwarded-Proto - это переключатели фреймворка из раздела выше, - а не убирать редирект.

Предупреждения о mixed content. Та же причина, другой симптом: приложение генерирует абсолютные http:// URL для собственных ресурсов, потому что считает, что его отдают по HTTP. Исправьте определение схемы либо выдавайте относительные URL.

У всех посетителей один и тот же IP. Forwarded-заголовки не читаются. См. выше.

413 Request Entity Too Large. Это лимит тела запроса у прокси, а не у приложения. По умолчанию nginx ограничивает его 1 MB через client_max_body_size, а это меньше, чем ожидает большинство форм загрузки.

Работает на одном имени хоста и не работает на другом. Сертификат или правило маршрутизации покрывает одно имя и не покрывает другое. www.example.com и example.com - разные имена, и оба должны существовать и в DNS, и в прокси.

Если вы запускаете прокси сами, а не пользуетесь управляемым, в статье руководство по reverse proxy на nginx есть полный блок server со строками для TLS и заголовков.

FAQ#

Нужен ли reverse proxy, если у меня одно приложение?

Обычно да, потому что именно он даёт HTTPS без того, чтобы приложение занималось сертификатом, и позволяет держать порт приложения закрытым от интернета. Для одного внутреннего сервиса в частной сети его можно пропустить.

Почему моё приложение видит IP прокси, а не посетителя?

Потому что соединение установил прокси, а ваш фреймворк пока не читает forwarded-заголовки. Включите настройку доверенного прокси для вашего фреймворка, и вместо этого будет использоваться адрес клиента из X-Forwarded-For.

Зашифровано ли соединение между прокси и моим приложением?

По умолчанию нет. Это обычный HTTP, и это нормально, когда оба на одной машине или в одной частной сети. Если этот участок проходит через сеть, которую вы не контролируете, настройте TLS и на upstream-соединении.

Может ли reverse proxy стоять перед игровым сервером?

HTTP-прокси - нет. Игры используют собственные протоколы, в основном поверх UDP, и HTTP-прокси к ним отношения не имеет. Игровому трафику нужен прокси уровня L4 либо прямое подключение к игровому порту.

Почему мои WebSocket отключаются каждую минуту?

Read timeout прокси закрывает простаивающее соединение. Увеличьте таймаут для этого маршрута и отправляйте ping на уровне приложения каждые 20-30 секунд, чтобы соединение никогда не простаивало достаточно долго, чтобы его оборвали.

Чем reverse proxy отличается от балансировщика нагрузки?

Это скорее пересекающиеся задачи, чем разные вещи. Балансировщик нагрузки распределяет запросы между несколькими одинаковыми бэкендами; reverse proxy маршрутизирует и преобразует запросы для одного или нескольких разных бэкендов. Большинство программ делают и то и другое, и какое слово использовать, зависит от того, какая функция вам важнее.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000