1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 1 из 5

Forward proxy и модуль tunnel

Коротко

Обратный прокси стоит перед твоими серверами, forward proxy - перед твоими клиентами: приложение из закрытого контура ходит наружу через него. Клиент шлёт CONNECT host:port, прокси открывает TCP и перекладывает байты, не расшифровывая TLS. С 1.31.0 это умеет ngx_http_tunnel_module: директива tunnel_pass без аргументов плюс обязательный resolver. Вокруг работает обычная http-обвязка - allow, auth_basic, map, лимиты, логи, - но отвечает она клиенту прокси по-своему: не 401, а 407.

Нужен только рабочий конфиг - листай до «Правило». Разборы про коды ответа прокси и про белый список назначений.

Конфиг, который кочует по статьям про новый модуль. Что скажет nginx -t?

/etc/nginx/conf.d/forward-proxy.confhttp
server {
    listen 3128;
    tunnel_pass $host:$port;
}
↳  выводnginx -t
nginx: [emerg] unknown "port" variable

Переменной $port в nginx нет. Дописать её неоткуда, а tunnel_pass $host; загрузится и упадёт уже в работе: no port in upstream "api.pay.example" и 500 клиенту. В CONNECT порт есть, но $host его не содержит.

На что это похоже

CONNECT - это просьба к телефонистке: «соедините меня с таким-то номером». Телефонистка проверяет, положено ли тебе звонить наружу, набирает номер, говорит «соединила» и втыкает провод в гнездо. Дальше она разговор не слышит: внутри TLS, и ключей у неё нет.

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

Механизм: что происходит при CONNECT

CONNECT api.pay.example:443 allow, auth_basic, map resolver: имя в адрес 200 Connection established байты в обе стороны, TLS внутри обычный http не понимает
До ответа 200 работает вся http-обвязка, после - только перекладывание байтов

Правило

tunnel_pass пишется без аргументов: адрес назначения берётся из самого запроса. Имя оттуда надо разрешать в работе, поэтому resolver обязателен.

/etc/nginx/conf.d/forward-proxy.confhttp
server {
    listen 10.0.0.5:3128;
    resolver 10.0.0.2 ipv6=off;

    tunnel_pass;
    tunnel_connect_timeout 5s;
    tunnel_read_timeout    60s;

    satisfy any;
    allow 10.0.0.0/8;
    deny  all;

    auth_basic           "Corporate proxy";
    auth_basic_user_file /etc/nginx/.htpasswd;
    auth_delay           2s;

    access_log /var/log/nginx/proxy.log;
}

Явная форма tunnel_pass $host:$request_port; даёт ровно тот же результат - $request_port в запросе CONNECT равен порту назначения. Она нужна, только если адрес хочется подменить; в обычном случае пиши директиву без аргументов. Рядом есть tunnel_buffer_size, tunnel_send_timeout, tunnel_next_upstream и tunnel_bind (с какого адреса выходить наружу).

Разбор: четыре ответа прокси

Стенд с конфигом выше, curl -x. Коды сняты с провода, не из документации:

что сделал клиент код заголовок в ответе
CONNECT без пароля 407 Proxy-Authenticate: Basic realm="Corporate proxy"
CONNECT с неверным паролем 407 тот же, задержка auth_delay 2 с
обычный GET через прокси 405 -
CONNECT на закрытый порт 502 -

407 вместо привычного 401 - это и есть то, что добавили в 1.31.0 вместе с модулем: auth_basic научился отвечать клиенту прокси на его языке. Браузер и curl понимают только этот код, на 401 они пароль не подставят.

405 объясняет частое недоумение «прокси не работает с http-сайтами». Обычный запрос с абсолютным адресом (GET http://site/ HTTP/1.1) туннелем не обслуживается: модуль отвечает на CONNECT и только на него. Для http-целей нужен отдельный location с обычным proxy_pass.

Причина 502 видна только в error_log, и там она названа точно:

↳  вывод/var/log/nginx/error.log
connect() failed (111: Connection refused) while connecting to upstream,
  request: "CONNECT ngx:9999 HTTP/1.1", upstream: "172.25.0.3:9999"
nosuchhost.invalid could not be resolved (3: Host not found),
  request: "CONNECT nosuchhost.invalid:443 HTTP/1.1"

Разбор: белый список назначений

tunnel_allow_upstream есть только в коммерческой сборке - в открытой на неё unknown directive. Белый список собирается тем же map, а if здесь уместен, потому что внутри стоит return (тринадцатый раздел):

/etc/nginx/conf.d/forward-proxy.confhttp
map $host $allowed_target {
    hostnames;                       # без него .домен - просто строка, а не маска
    default                0;
    api.pay.example        1;
    .githubusercontent.com 1;
}

hostnames здесь обязателен, и это ровно то, о чём говорила глава про map: маски .имя работают ТОЛЬКО с этим параметром. Замер на 1.31.6 показывает, что без него запись .githubusercontent.com не ловит вообще ничего - даже сам домен:

↳  выводзначение $allowed_target при разных целях CONNECT
цель                           без hostnames   с hostnames
api.pay.example                      1              1
githubusercontent.com                0              1
raw.githubusercontent.com            0              1
evil.example                         0              0

Ошибка тихая и «безопасная»: список не открывает лишнего, он закрывает нужное - прокси начинает отдавать 403 на разрешённую цель, и искать причину приходится в map, а не в туннеле.

/etc/nginx/conf.d/forward-proxy.confhttp › server
if ($allowed_target = 0) {
    return 403;
}

На стенде разрешённая цель даёт 200, запрещённая - 403 прямо на CONNECT, то есть клиент не успевает открыть соединение. Проверять $host здесь можно именно потому, что в CONNECT он равен имени назначения - в отличие от обычного запроса, где это имя твоего же сайта.

Что ломается без этого

Забытый resolver. nginx -t доволен, прокси поднимается, и каждый CONNECT возвращает 502. В error_log при этом написано прямым текстом:

↳  вывод/var/log/nginx/error.log
no resolver defined to resolve api.pay.example,
  request: "CONNECT api.pay.example:443 HTTP/1.1"

Это тот же класс дефекта, что server_name без ssl_preread из прошлой главы: конфиг проверку проходит, ломается в работе.

Прокси, открытый наружу. Сканеры находят такой за часы, и дальше через твой адрес идёт чужой трафик - от перебора паролей до рассылок. Минимум: слушать внутренний адрес (listen 10.0.0.5:3128, а не listen 3128), список сетей, пароль и белый список назначений. Три из четырёх пунктов в конфиге выше стоят не для красоты.

Ожидание, что цель увидит клиента. Не увидит: соединение к назначению открывает nginx, и адрес источника - его. Замер это подтверждает - сервис за туннелем видит remote=172.25.0.3, адрес прокси. Туннель не подставляет ни X-Forwarded-For (заголовков внутри TLS он не видит), ни PROXY protocol.

Зачем это в работе

Три случая, ради которых это заводят. Выпустить приложение из закрытого контура строго к нескольким внешним API - и доказать аудиту, что больше никуда. Собрать журнал того, куда ходят твои сервисы: в access.log прокси остаётся "CONNECT api.pay.example:443 HTTP/1.1" 200, адрес назначения в $upstream_addr и объём трафика в $bytes_sent. Терминировать исходящие соединения на одном адресе, который проще внести в чужой белый список.

Ничего из этого не нужно - модуль не нужен тоже: forward proxy это ещё один сервис, который придётся охранять и чинить.

Теперь сам

Конфиг подняли, nginx -t зелёный, из контейнера приложения curl -x http://10.0.0.5:3128 https://api.pay.example/ возвращает 407. Пароль в приложении прописан верный. Где смотреть?

/etc/nginx/conf.d/forward-proxy.confhttp
server {
    listen 10.0.0.5:3128;
    resolver 10.0.0.2 ipv6=off;
    tunnel_pass;
    auth_basic           "Corporate proxy";
    auth_basic_user_file /etc/nginx/.htpasswd;
}

Ответ: 407 означает, что пароль до проверки не дошёл или не подошёл, и error_log скажет что именно - user "app": password mismatch против no user в файле. Частая причина - приложение подставляет пароль только на 401: библиотеки различают обычную и прокси-авторизацию, и учётные данные надо класть в настройку прокси, а не в настройку запроса. Проверяется одной командой: curl -x http://app:пароль@10.0.0.5:3128 ... должен дать 200.

Главное

Перед запуском forward proxy ограничь клиентов и сетевой выход. allow/deny проверяют адрес клиента, а не цель CONNECT; допустимые назначения в показанной сборке ограничивай сетевыми правилами. Проверь разрешённую и запрещённую цель, DNS и авторизацию. Успешный nginx -t подтверждает разбор конфига, но не доступность внешней цели и не правильность правил выхода.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.