Глеб Гончаров

Системный администратор в ФанБоксе. Автоматизирую и сопровождаю инфраструктуру для проектов мобильных операторов.

Принудительно завершаем TLS-соединение в NGINX

На днях поставили задачу: есть веб-балансировщик, обслуживающий несколько виртуальных хостов с доступом по HTTP и HTTPS. Требуется разрывать соединение с клиентами, подключающихся по TLS к списку сервисов, доступных только по HTTP. При этом пользователю запретить видеть предупреждения о несоответствии получаемого сертификата и FQDN.

В этом поможет расширение Server Name Indication (SNI) TLS. SNI предоставляет механизм передачи клиентом серверу информации о запрашиваемом хосте. Так веб-сервер узнаёт, какой сертификат ему следует использовать для соединения с виртуальным хостом.

Начиная с версии NGINX 1.11.5 доступен модуль ngx_stream_ssl_preread_module. Модуль извлекает имя сервера, доступное через SNI на фазе ClientHello без терминирования SSL/TLS. Значение заголовка доступно в переменной $ssl_preread_server_name. Используя её, можно динамически задавать адрес проксируемого TCP-бэкэнда:

stream {
  proxy_protocol on;

  upstream teardown {
    server 127.0.0.1:444;
  }

  upstream backend {
    server 127.0.0.1:445;
  }

  map $ssl_preread_server_name $name {
    example.com    teardown;
    default        backend;
  }

  server {
    listen 444;
    return "";
  }

  server {
    listen 443;
    ssl_preread on;
    proxy_pass $name;
  }
}

Так при обращении по HTTPS, NGINX перенаправит трафик в 445-й порт, а для example.com — в 444-й. В первом случае объявляем виртуальный хост и сертификат, во втором — веб-сервер разорвёт соединение.

Есть нюанс: используйте протокол PROXY для отображения реальных IP-адресов пользователей в логах. Например, для виртуального хоста example.com:

server {
  listen 445 http2 proxy_protocol;
  server_name example.com;

  set_real_ip_from 127.0.0.1;
  real_ip_header proxy_protocol;

  [...]
}

К сожалению, не выйдет рвать соединение для тех виртуальных хостов, где не указан SSL-сертификат. Как вариант, вместе с map включать файл со списком разрешённых хостов. Такой список придётся создать вручную, а после отправить SIGHUP мастер-процессу NGINX для применения изменений.

Определение пользователя по ETag в NGINX

Расскажу о старом и простом трюке по слежке за пользователями с помощью HTTP-заголовка ETag. В отличие от Cookies, о нём редко вспоминают, когда пытаются стереть следы присутствия.

Принцип работы

Когда клиент загружает веб-страницу, веб-сервер может передать идентификатор загружаемого ресурса в заголовке ETag:

HTTP/1.1 200 OK
Server: nginx
[...]
ETag: 5888f5e0-34d

Браузер сохранит ответ, а когда ресурс будет нужен снова, то в следующий раз он добавит к запросу заголовок If-None-Match.

GET /style.css HTTP/1.1
If-None-Match: "5888f5e0-34d"
Host: gongled.me

Сервер сверит ETag и значение из If-None-Match: если они совпадают, то в ответ придёт код 304 (Not Modified). Так браузер понимает, что ресурс не изменился и загружать его повторно не нужно. Если идентификаторы разные — браузер повторно загрузит ресурс ещё раз.

HTTP/1.1 304 Not Modified
Server: nginx
[...]
ETag: 5888f5e0-34d

Способы применения

Веб-разработчики используют ETag как механизм кеширования без привязки ко времени. Например, в NGINX для этого есть опция etag. NGINX склеит время последнего изменения и размер файла в строку, преобразует каждую часть в шестнадцатеричное число, а затем запишет значение в заголовок.

$ printf "%x-%x\n" $(stat -c%Y style.css) $(stat --format="%s" style.css)
5888f5e0-34d

Проблема в том, что спецификация HTTP/1.1 не говорит как создавать ETag. На этом принципе и основана идентификация пользователя: вместо идентификатора файла можно отдавать любую строку. Например, переменную $request_id для трассировки запросов в NGINX.

Пользователь откроет страничку со стилями в CSS-файле, отправит запрос ресурсу и получит ответ с ETag и длительным временем кеширования. В следующий раз браузер отправит идентификатор обратно веб-серверу.

Пример конфигурации NGINX:

log_format ident_format '[$request_id] [$http_if_none_match] $remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_x_forwarded_for" "$http_referer" $host '
                        '"$http_referer" "$http_user_agent" '
                        '$request_time $upstream_response_time';

server {
  listen 80;

  server_name example.com;

  [...]

  location = /css/track.css {
    access_log /var/log/nginx/ident.log ident_format;

    if ($http_if_none_match) {
      return 304;
      break;
    }
    return 200;
    add_header ETag $request_id;
    default_type text/css;
    expires max;
  }
}

Переменную $http_if_none_match можно залогировать или передать в приложение для обработки с помощью proxy_set_header, fastcgi_param или uwsgi_param. Пока пользователь не сбросит кеш, за ним можно следить. Например, узнать когда и с каких IP-адресов тот заходит на сайт:

$ cat /var/log/nginx/ident.log | grep c834687fa74d6b616cedad8ae407d5b5
[c834687fa74d6b616cedad8ae407d5b5] [-] 89.250.169.240 - - [28/Jan/2017:13:27:59 +0300] "GET /css/style.css HTTP/1.1" 304 0 "-" "-" example.com "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.2994.0 Safari/537.36" 0.000 -
[57594da59a7c817aca6b70910a67e331] [c834687fa74d6b616cedad8ae407d5b5] 89.250.169.240 - - [28/Jan/2017:13:28:09 +0300] "GET /css/style.css HTTP/1.1" 304 0 "-" "-" example.com "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.2994.0 Safari/537.36" 0.000 -
[9fbd5604134a4f1dd2fee25d3098314c] [c834687fa74d6b616cedad8ae407d5b5] 95.104.194.197 - - [28/Jan/2017:13:28:20 +0300] "GET /css/style.css HTTP/1.1" 304 0 "-" "-" example.com "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.2994.0 Safari/537.36" 0.000 -

Σ

Если вы всерьёз озабочены своей приватностью, используйте режим «инкогнито» в браузере или отключайте кеш. Ознакомьтесь с продвинутыми методами идентификации пользователей в сети.

Я же советую не забивать голову: ETag не связывает ваш IP-адрес с личностью и ничем не грозит. Напротив, не нужно отключать кеширование и сводить на ноль усилия веб-разработчиков по оптимизации проектов.

За что я ненавижу панели управления

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

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

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

Сейчас я расскажу почему старательно избегаю сервера с панелями управления.

Веб-интерфейс

Начинающим нравится веб-интерфейс из-за лёгкости работы с ним: не нужно запоминать команды и разбираться в сложных настройках. Я предпочитаю командный интерфейс — CLI.

Не функционально. Linux изначально проектировали для работы в командной строке, благодаря чему любой параметр системы можно изменить из терминала. Веб-интерфейс не заменяет командную строку: сложные вещи по-прежнему делают в ней. Да и несложные тоже.

Не гибко. Дизайн CLI прост, а программы в нём делают что-то одно и делают это хорошо. По отдельности они делают простые вещи, но вместе позволяют создавать мощные выражения. Так достигается гибкость.

Например, чтобы показать десять IP-адресов, отправивших больше всего POST-запросов, достаточно выполнить команду:

grep 'POST' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -n | head

Как такое сделать через веб-интерфейс — не ясно.

Не универсально. CLI выглядит одинаково и не изменяется, в то время как все панели выглядят по-разному. Причина тому — отсутствие спецификации. В CLI программы стандартизированы POSIX, это гарантирует одинаковую работу на разных операционных системах.

Сложно автоматизировать и кастомизировать

Зоопарк редакций. cPanel, Plesk, ISPManager, Vesta, DirectAdmin, Virtualmin, CentOS Web Panel, Ajenti, InterWorx, ZPanel и BlueOnyx — всё это панели управления. Список неполный: есть другие, но о них слышно реже. Они несовместимы друг с другом: у них разная программная архитектура, функциональность, консольные утилиты, версии ПО и пути до файлов.

Например, Plesk работает в Ubuntu и Debian, а DirectAdmin – нет. Virtualmin поддерживает работу с группой серверов, а Ajenti — нет. В ISPManager NGINX поставляют по умолчанию, а в cPanel для этого устанавливают стороннее расширение Engintron. Учесть нюансы непросто.

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

Сервер — не произведение искусства с неповторимой историей. Хороший админ это знает и старается снизить издержки на поддержку инфраструктуры.

Установка на чистый сервер. Если вы попытаетесь поставить панель на уже настроенный сервер, у вас скорее всего ничего не выйдет. Это может стать проблемой, когда вам нужно что-то донастроить или поменять. У вас не получится заменить одну панель на другую — после того, как удалите первую, придётся переустановить всю систему.

Не автоматизировать. Удобством можно пренебречь, когда работу можно автоматизировать. Чтобы быстрее настраивать и реже ошибаться, я описываю работу программ в системах управления конфигурациями — таких как Ansible или SaltStack.

Панель вставляет палки в колёса: при следующем сохранении она перезапишет изменения, внесённые вручную. Вот подключаетесь вы к серверу с cPanel, открываете конфиг Apache и видите в шапке файла:

#
#   Direct modifications to the Apache configuration file WILL be lost upon subsequent
#   regeneration of this configuration file, or an Apache update.
#

Если что-то поменяете, то в следующий раз придётся настраивать заново.

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

#   To have your modifications retained, you should create/edit administrator-specific
#   include files:
#
#       /etc/apache2/conf.d/includes/pre_main_global.conf
#       /etc/apache2/conf.d/includes/pre_virtualhost_global.conf
#       /etc/apache2/conf.d/includes/post_virtualhost_global.conf

Для автоматизации действий в панелях есть API. Например, в cPanel две версии API: старая и новая. В новой версии ответы в XML или JSON, а в старой — HTML, строки и числа. Выходит, теперь нужно писать код с обратной совместимостью между версиями. Если панелей несколько — то и между ними.

Сложно поддерживать

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

Согласно FHS, правильно хранить файлы веб-сайтов в директории /srv. Приемлимыми вариантами считаю /var/www (/var/www/html), как это делают сопровождающие пакетов в Debian или RedHat-подобных дистрибутивах. Логи привычно располагают в /var/log, а конфигурации nginx и apache — в /etc/nginx и /etc/apache2 (/etc/httpd) соответственно.

Не безопасно. Заключительный аргумент против — безопасность. Панели управления переусложнены, а простые вещи безопаснее сложных. Кроме того, панели управления редко обновляют. Причина в том, что администраторы боятся перезаписать изменения и сломать, что уже настроено и работает. Пользователям же как правило всё равно. В итоге такие сервера годами работают с уязвимым ПО.

Σ

Работать с сервером через веб-интерфейс — всё равно, что программировать мышью. Возможности ни одной из панелей не покрывают мощь и гибкость команд в терминале.

Работу с панелями управления сложно автоматизировать: либо распылять усилия на поддержку нескольких редакций, либо сосредоточить усилия на одной. Обе крайности вредны: первая — тратой времени, вторая — вендор-локом, когда наработки по автоматизации будут привязаны к поставщику.

За безопасностью таких серверов никто не следит. По опыту, каждый второй сервер с панелью управления с уязвимостями в OpenSSL.

Панель управления не заменяет админа: случись беда — разбираться ему. Сервер — это как самолёт: салон должен быть удобен для пассажиров, а кабина пилота — для командира судна. Доверьте работу профессионалу.

Трассировка запросов в NGINX

Начиная с NGINX 1.11.0 в конфигурации доступна переменная $request_id — случайным образом сгенерированная 32-символьная HEX-строка, автоматически назначаемая каждому HTTP-запросу (например, 622f438a1f1b8020f092135383c77a69).

Нововведение помогает трассировать и отлаживать веб-приложения, помогая извлекать конкретный запрос от балансировщика до сервера приложения. Это также упрощает поиск в системе централизованного сбора и визуализации логов — такой как ELK.

До того, как появилась встроенная переменная, в ранних версиях NGINX такой идентификатор получали иначе. Например, вызовом кода на Perl. Модуль ngx_http_perl_module экспериментальный, а потому используйте в продакшене аккуратно и помните, блокирующие операции ставят на паузу процессы, обслуживающие запросы.

perl_set $request_id 'sub {
  return join "", map{(a..e,0..9)[rand 16]} 0..31;
}';

Ещё одна идея — объявить переменную, составленную из других переменных. В $request_id окажется строка вида 30791-1483643084.713-127.0.0.1-86-9563. К сожалению, переменную придётся указывать в каждом виртуальном хосте.

set $request_id "$pid-$msec-$remote_addr-$request_length-$connection";

Как это использовать

Расширьте действующий или добавьте новый формат лога. Журналирование идентификатора выручает, когда нужно найти ошибку, появившуюся какое-то время назад.

log_format extended '[$request_id] $remote_addr - $remote_user [$time_local] '
                    '$host $server_addr $request $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    '"$http_x_forwarded_for" $request_time '
                    '$upstream_response_time';

access_log /var/log/nginx/access.log extended;

Используйте HTTP-заголовок или FastCGI/uWSGI параметр для передачи значения приложению или веб-серверу. Обратите внимание, что для стоящего в цепочке проксирования NGINX, переменная будет называться $http_request_id.

proxy_set_header Request-ID $request_id;

# fastcgi_param Request-ID $request_id;
# uwsgi_param Request-ID $request_id;

Отдать идентификатор клиентскому приложению можно с HTTP-заголовком Request-ID. Имя подойдёт любое, но лучше без устаревшего префикса X-.

add_header Request-ID $request_id;

Так запрос к моему сайту:

$ curl -I https://gongled.ru/
HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html; charset=UTF-8
Request-ID: f5ae2621ffff2e7d5000bb8f04ef278a

Создаст запись в логе NGINX:

[f5ae2621ffff2e7d5000bb8f04ef278a] 89.250.169.207 - - [05/Jan/2017:21:12:16 +0300] gongled.ru 178.63.55.9 HEAD / HTTP/1.1 200 7695 "-" "curl/7.51.0" "-" 0.016 -

Попробуйте в своём проекте — это удобно.

Блокировка Tor в NGINX

Защитная стратегия защиты от DDoS атаки из TOR на уровне веб-приложения — блокировка HTTP-запросов из луковых подсетей. Сделать это несложно: проект Tor Project регулярно обновляет списки выходных узлов сети.

[...]
ExitNode FF0D1841086637CA0920E21AFA4C6A43905EA2BD
Published 2017-01-02 11:00:24
LastStatus 2017-01-02 12:03:41
ExitAddress 45.76.159.203 2017-01-02 12:12:29
ExitNode FFB8575D7C8E40AC6E48C1B7AA32AC7701E04AB9
Published 2017-01-01 16:10:32
LastStatus 2017-01-01 20:03:40
ExitAddress 80.15.98.127 2017-01-01 18:10:35
ExitNode FFB94702D023B6F824D8B3BC68F33EA02AFA70D8
Published 2017-01-02 08:37:56
LastStatus 2017-01-02 09:02:40
ExitAddress 51.15.39.2 2017-01-02 09:07:51

Обрабатываем реестр в формате TorDNSEL и сохраняем узлы в список:

curl -Ls https://check.torproject.org/exit-addresses | grep ExitAddress | awk '{print $2}' | sort | uniq > tor.list

Результирующий файл и включаем опцией include в секции geo (ngx_http_geo_module):

geo $is_tor {
  default 0;
  include /etc/nginx/conf.d/tor.list;
}

В секции server виртуального хоста указываем условие с кодом возрата:

Переменную ещё можно залогировать. Такие журналы интересно потом читать или разбирать.

if ($is_tor) {
  return 403;
}

Готово. Рекомендую также поэкспериментировать со ответами веб-сервера: иногда атакующий прекращает DDoS при получении статус-кодов 5XX. Как вариант, завершать запрос с внутренним кодом 444 для разрыва TCP-сессии.