Saturday, November 26, 2016

Adventures with FortiGate LB

Столкнулся на днях с довольно загадочной ситуацией.

Преамбула
Дано: есть сайт об осьми веб-серверах. Есть перед ними load balancer в виде файерволла FortiGate v5.4.1 - распределяет между ними нагрузку, а заодно переводит HTTPS-трафик в HTTP и инспектирует его. А перед ним находится сеть CDN, которая в свою очередь также шерстит трафик на предмет всяких там SQL-иньекций, блокирует DDoS-атаки, кэширует картинки и прочую статику - в общем, полезная вещь.

Когда юзер открывает сайт, DNS направляет его браузер на прокси CDN, браузер устанавливает с прокси SSL-соединение и посылает ему HTTP-запрос. Если запрос прокси нравится - пересылает его по другому SSL-соединению на LB. Тот прогоняет свои проверки - IPS, антивирус, - и перекидывает запрос на один из восьми серверов. Ответ сервера пересылается обратным путём.

Как видим, всё несложно, но есть дополнительное требование: sessions persistence, так же известное как sticky sessions. Сиречь, начав работать с каким-то сервером, юзер так и должен продолжать с ним работать - все запросы от браузера должны приходить на тот же сервер, разве что тот загнётся.
За это у нас отвечает FortiGate - он этого может добиваться одним из двух способов:

  • HTTP cookie - когда приходит запрос от нового клиента, FortiGate добавляет в ответ новый cookie с названием FGTServer и длинным псевдорандомальным значением. Браузер запоминает этот cookie и добавляет в каждый последующий запрос. По его значению Фортигейт знает, на какой из серверов эти запросы перекидывать.
  • SSL Session ID - когда клиент с сервером устанавливает SSL-соединение, сервер сочиняет некий session identifier и посылает его клиенту. Если TCP-connection, на котором SSL-соединение основано, сломалось, клиент может быстро восстановить общение с сервером, открыв новую TCP-трубу и выслав этот identifier - избежав таким образом необходимости в полном обмене ключами. Это ускоряет SSL и расцепляет его с TCP - одно SSL-соединение может использовать множество TCP-соединений. Подробности можно узнать здесь.
    В данном случае "сервер" - это не веб-сервер, а сам FortiGate. Он запоминает, на какой настоящий сервер он перебросил запрос, пришедший по соединению с новым session ID, и последующие запросы направляет соответственно.
Мы используем первый способ.

А теперь амбула
Выплыла проблема: юзерская сессия ломается. Вот залогинился он на сайт, и всё вроде отлично, но вдруг сайт ему и говорит: а ты кто такой, мил человек? Знать тебя не знаю.

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

Казалось бы, дело ясное - FortiGate виновен, на помойку. Но вот в чём закавыка: происходит это только при обращении через CDN - если браузер общается с Фортигейтом напрямую, то всё прекрасно, cookie при всех запросах сохраняется тот же, сервер - тот же, работает наш persistence!

Что за лабуда, думаю - может, CDN наши cookies срезает каким-то образом? Запускаю сниффер на Фортигейте, смотрю - ничего подобного! Вот он наш FGTServer в запросе, и значение правильное - да Фортигейт запрос на другой сервак перекидывает!

От отсутвия идей перешёл на второй способ, на SSL Session ID - не помогло ровно ничем. Напрямую - работает чудесно, через CDN - ломается. Что за притча?

Может, сервер health check проваливает и LB его помечает как дохлый? Может, мы максимум соединений на сервер перешли? Нет, вроде ничем не подтверждается - да и как объяснить тогда, что напрямую всё летает?


Развязка
А ларчик открылся вот как: оказывается, чёртов CDN запросы от множества разных клиентов мультиплексирует на уровне TCP. То есть приходят к ихнему прокси запросы от сотни юзеров - а тот поддерживает с нашим LB какой-то пул TCP-соединений - скажем, пять - и в каждое пихает запросы от разных юзеров.

Это performance, конечно, улучшает - не нужно для каждого нового юзера новый TCP-handshake устанавливать и отдельный сокет держать: бери да пихай его запросы в свободную TCP-трубу. Да только наш load balancer это с ума сводит - видать, не подумали программисты Fortinet, что такая ситуация возможна. При прямом-то обращении такого не бывает - по каждому TCP-коннекшену запросы только от одного клиента идут, с тем же самым FGTServer cookie - а тут он вдруг раз и меняется.

И уж понятно, почему SSL Session ID не помогает - соответствует-то он не клиенту, а CDN-прокси. Решил прокси пихнуть один запрос от клиента в один сокет, а следующий - в другой, и привет: session IDs разные, LB их отсылает на разные сервера.

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

P.S.
Была, впрочем, и альтернатива - почётную функцию load balancing на сам CDN возложить. Но его жадные хозяева хотели за это дополнительную денежку, а нас жаба задавила. Нам грубияны не нужны, мы сами грубияны.

Sunday, June 26, 2016

Публикация CRL на внешнем хостинге

(Унылый апдейт от апреля 2017: ну всё, увы. SmartFile вслед за Дропбоксом ввели обязательный редирект на HTTPS, который нам противопоказан - см. внизу, почему. Придётся искать что-то другое.)

Частенько сталкиваюсь с типичной сисадминской задачкой: надо настроить CA и опубликовать в Интернете подписанный им список отозванных сертификатов (CRL). Это просто небольшой файлик, который надо сделать доступным по HTTP-линку, чтобы затем прописать этот линк в выдаваемых этим CA сертификатах. Любая сторона, желающая проверить, действителен ли ещё сертификат, найдёт в нём поле CDP, содержащее линк, скачает по нему файл, проверит на нём цифровую подпись и затем глянет, содержит ли CRL серийный номер сертификата in question, или нет.

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

Взамен хочется такого:
  • Чтобы можно было выкладывать CRL на каком-то внешнем хостинге, автоматически заливать туда изменения - желательно через какой-нибудь простенький REST-сервис.
  • Чтобы был постоянный линк на опубликованный файл, чтобы он не требовал никакой авторизации или сложных редиректов.
  • Чтобы этот линк содержал наш собственный домен - и для понтов, и для гибкости: чтобы не переделывать массу сертификатов только потому, что сменился хостинг.
  • Чтобы линк был доступен по plain HTTP - не по HTTPS! Что сегодня уже, кстати, нетривиально. Необходимо это потому, что иначе может возникнуть логическая петля: некто хочет проверить сертификат, поэтому идёт скачивать CRL, но для этого нужно проверить сертификат HTTPS-сервера, но для этого нужно скачать CRL, и так далее. Чтобы не допускать этой дурной бесконечности, стандарт требует публиковать CRL по обычному HTTP - что безопасно, поскольку сам CRL защищён цифровой подписью и имеет ограниченное время жизни. Поэтому подсунуть фальшивый не выйдет.
  • Ну и наконец, чтобы всё это удовольствие было на халяву.
Я малость поныкался и нашёл хостинг, всем пунктам удолетворяющий - SmartFile.
Они предоставляют бесплатный девелоперовский аккаунт, позволяющий держать какие-то бешенные гиги - для нашей задачки это более чем излишне, нам-то всего несколько килобайтиков залить. В отличие от платного, этот аккаунт не даёт использовать удобный веб-интерфейс - но зато есть прекрасный REST API. Им и воспользуемся.

Для этого API есть имплементации клиентской части на разных языках, но поскольку я адски ленив, то решил пользоваться обычным cURL - в моём случае, сборкой под Windows, поддерживающей SSL.

Процесс таков:

1. Регистрируемся и заходим в качестве девелопера.

2. Жмём на кнопку "Get API Key" и получаем две длинные строки, которые будут использоваться для аутентификации наших REST-запросов:
ACCESS_KEY = SeKyFDgmdA1ikWdnAuvrWxLX1do4q4
PASSWORD   = eSDBw3WcZskDSzs2Wu3wy4pixe8uIt

SmartFile использует Basic Authentication - схему, которая сама по себе не предоставляет шифрования пароля, но поскольку сами запросы идут по HTTPS, то проблемы в этом нет.

3. Итак, первый запрос - создаём папочку на хостинге. Назовём её, к примеру, PKI:
curl --user %ACCESS_KEY%:%PASSWORD% -F "path=PKI" -X PUT https://app.smartfile.com/api/2/path/oper/mkdir

4. Заливаем в неё наш CRL-файл:
curl --user %ACCESS_KEY%:%PASSWORD% -X POST -F path="@MyOwnCoolCA.crl" https://app.smartfile.com/api/2/path/data/PKI/

5. Заодно там же разместим и сертификат самого CA - его можно прописать в поле AIA выпекаемых им сертификатах, чтобы проверяющие могли подгрузить всю цепочку:
curl --user %ACCESS_KEY%:%PASSWORD% -X POST -F path="@MyOwnCoolCA.crt" https://app.smartfile.com/api/2/path/data/PKI/

6. Файлы залиты, но просто так их ещё не скачаешь - потребуется авторизация. Чтобы сделать их доступными urbi et orbi, надобно создать линк на содержащую их папку PKI:
curl --user %ACCESS_KEY%:%PASSWORD% -X POST -d "name=PKI&path=/PKI&usage_limit=1000000000&read=on&list=on" https://app.smartfile.com/api/2/link/

Параметр "usage_limit" задаёт, сколько раз файл может быть скачан по линку. К сожалению, мне не удалось выяснить, можно ли этот параметр выставить в бесконечность. Но для нашего маленького скромного CRL миллиарда закачек вполне хватит.

В ответ мы получаем линк вида http://file.ac/g743sRnJfgS. На этом работа с API закончена.

7. Осталось лишь прописать в DNS хостнейм, который мы хотим видеть в наших линках. Пускай это будет crl.mydomain.com. Можно создать его как CNAME-алиас для file.ac, или как A-запись для IP-адреса 209.43.40.101 - это не особо существенно.

Всё. На этом этапе у нас есть два живых линка:
  • http://crl.mydomain.com/g743sRnJfgS/MyOwnCoolCA.crl - собственно CRL,
  • http://crl.mydomain.com/g743sRnJfgS/MyOwnCoolCA.crt - сертификат подписавшего его CA.
Их уже можно взять и прописать в конфигурации CA, чтобы они фигурировали в полях CDP и AIA всех сертификатов, этим CA выданных.


Осталось лишь наладить обновления. Можно, конечно, просто регулярно заливать CRL каждые полчаса-час, но это путь какой-то тупой. Хочется, чтобы он заливался лишь тогда, когда CA подписывает новый выпуск - допустим, раз в неделю.

Дальше идёт уже конфигурация под определённую платформу - поскольку я юзаю Microsoft ADCS, то и дальнейшие штучки заточены под Windows. На Линуксе можно придумать что-то своё.

Итак, я хочу, чтобы всякий раз, как файл MyOwnCoolCA.crl меняется, автоматом исполнялся REST-запрос, отправляющий его содержание на хостинг. К счастью, нашёлся скрипт PowerShell, позволяющий добиться этого минимальным расходом ресурсов. Скрипт подписывается на события, происходящие с выбранными файлами в определённой папке, и запускает внешнюю програмку, когда они происходят:

UploadToSmartFile.ps1:
$ACCESS_KEY = "SeKyFDgmdA1ikWdnAuvrWxLX1do4q4"
$PASSWORD   = "eSDBw3WcZskDSzs2Wu3wy4pixe8uIt"

### SET FOLDER TO WATCH + FILES TO WATCH + SUBFOLDERS YES/NO
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = "C:\crl.mydomain.com"
$watcher.Filter = "MyOwnCoolCA.crl"
$watcher.EnableRaisingEvents = $true  

### DEFINE ACTIONS AFTER A EVENT IS DETECTED
$action = 

curl.exe --user ${ACCESS_KEY}:${PASSWORD} -X POST -F path="@$Event.SourceEventArgs.FullPath" https://app.smartfile.com/api/2/path/data/PKI/
}
  
### DECIDE WHICH EVENTS SHOULD BE WATCHED + SET CHECK FREQUENCY  
$created = Register-ObjectEvent $watcher "Created" -Action $action
$changed = Register-ObjectEvent $watcher "Changed" -Action $action
$renamed = Register-ObjectEvent $watcher "Renamed" -Action $action
while ($true) {sleep 5}

(Update: "curl.exe" - это важно; как неожиданно выяснилось, добрые люди из Микрософта определили в новых версиях Пауэршелла команду "curl" как alias на Invoke-WebRequest, поломав мне нафиг скрипт.)

Итак, то что делает скрипт: висит и ждёт, пока в директории C:\crl.mydomain.com будет либо создан файл MyOwnCoolCA.crl, либо изменён, либо какой-нибудь другой файл получит это название. В этом случае скрипт отправляет его содержимое на хостинг.

(N.B.: сама директория, как и скрипт, совершенно не обязана находиться непосредственно на CA - это может быть любой комп. Я обычно предпочитаю располагать её в папке SYSVOL - это гарантирует очень быструю репликацию на все контроллеры в домене. Дело вкуса).

Осталось отконфигурачить автоматическое исполнение скрипта при загрузке компьютера. Можно запускать его как сервис при помощи srvany.exe, но я воспользовался Task Scheduler.

Итак, создаём новый task со следующими параметрами:
  • Triggers: at startup
  • User account: SYSTEM
  • Action: powershell -ExecutionPolicy Bypass -File C:\CA\Script\UploadToSmartFile.ps1
и отменяем остановку скрипта через определённый период времени.
(Bypass требуется, поскольку по дефолту исполнение скриптов PowerShell запрещено).

Теперь после перезагрузки скрипт запустится автоматом и будет ждать, пока CA не подпишет новый CRL - после чего тот разом окажется доступен для клиентов по линку, прописанному в сертификатах.

Всё. Mischief managed.

Tuesday, May 10, 2016

Немного о трёхглавых собаках

Нашёл недавно довольно интересную штуку:

Положим, у нас есть сайт, использующий "Windows Integrated Authentication" - то есть Kerberos или NTLM. И допустим, что юзер, пытающийся на него зайти - относится к другому, недоверяемому домену, или же он локальный юзер на доменном компе, а то и вовсе на стэнд-элоне. Так или иначе, попытка автоматического, прозрачного для юзера логина проваливается. Что произойдёт?

Ответ: зависит от браузера. Firefox просто выдаст сообщение об ошибке. А вот Chrome или IE поступят по другому - выкинут стандартное окошко username & password. А самое интересное, что если username указать в полной форме - к примеру, me@mydomain.com, - то они пошлют запросы DNS-серверу на две записи SRV, стремясь обнаружить керберосовский центр раздачи ключей (KDC) для указанного домена:
_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs.mydomain.com
_kerberos._tcp.dc._msdcs.mydomain.com

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

Кстати, и клиент Remote Desktop в Win7 тоже так умеет.

Почему это так интересно? А потому, что означает, вопреки популярному убеждению, что Kerberos можно использовать в Интернете - а не только в корпоративных сеточках.
(Апдейт: не так всё здорово, см. внизу *).

Допустим, у нас есть сайт где-то в облаке, без всякой связи с сетью домена. Допустим, он вовсе не на Windows - например, это бегущий под линуксом Tomcat. Тогда всё, что нам надо сделать:
1. Создать какой-нибудь аккаунт в домене и сгенерить для него файл keytab - аналог пароля.
2. Связать URL сайта с этим аккаунтом, определив SPN.
3. Залить файл keytab на сервер и прописать его в конфигурации - сервер будет им юзерские билетики расшифровывать.
4. Открыть 88-й порт (TCP и UDP) на доменном контроллере в Интернет - наиболее спорный пункт. На мой взгляд, достаточно безопасный вариант - построить read-only domain controller, разместить его в DMZ, запретить ему держать у себя пароли и разрешить разговаривать с обычным DC. Тогда он превращается в прокси, обвал которого домену никак не вредит.
Если же это никак неприемлемо, остаётся вариант с VPN - что, конечно, странно, но может и кому-то и подойти, учитывая, что вся система предназначена всё же для людей из определённой организации.
5. Прописать публичный адрес этого RODC в DNS - под SRV-record _kerberos._tcp.dc._msdcs.mydomain.com.

Всё. Любой юзер домена, вооружённый IE или Хромом, может логиниться на сайт. Возможно, также и юзеры других доменов, у которых есть отношения доверия с доменом сайта - не проверял.

Достоинства:

  • Пароли не проходят через сайт ни на каком этапе.
  • Взаимная аутентификация - без нужды в SSL. Ответ сервера на предъявленный билетик доказывает, что он был способен его расшифровать - что сам сервер легитимен.
  • Сайт работает полностью автономно от доменной сети.
  • URL сайта может быть любым, никак с доменом не связаным.
  • В случае взлома сайта выписка любых новых билетов для него прекращается моментально, отключением ассоциированного с ним аккаунта, а уже выписанные билеты протухают по дефолту за 10 часов - но этот срок можно уменьшить и до 10-ти минут. 
  • В случае взлома юзера - тем более, баним его аккаунт и конец делу. Время жизни выданного ему TGT с дефолтовых 10-ти часов также можно урезать. Сравните с процессом отмены сертификата!
  • Проще в настройке, чем SAML, оAuth, OpenID и прочее с того же куста, и намного прозрачнее для серверной аппликации.
  • При этом позволяет передавать не только username, но и информацию о группах - микрософтовская имплементация Кербероса присобачивает к билетику поле под названием PAC - оно содержит список групп в Active Directory, к которым относится юзер (причём, что особенно прелестно, не обязательно напрямую - nesting groups там сидят также). Неприятное ограничение в том, что имена групп в PAC не значатся - только их длинные номера SID. Но построив на сервере mapping из SID в имена, можно справиться и с этим, после чего разным группам пораздавать разные права.
    Существует библиотека на Java, умеющая извлекать SID-ы групп из PAC - может быть полезна для всевозможных серверов JavaEE.


Недостатки:

  • Необходимость доступности контроллера домена в Интернете, пусть даже и read-only и лишь парой керберосовских портов. Я большой проблемы с этим не вижу, но возможно, что люди поумнее таки да. (Апдейт: таки есть проблема, см. внизу *).
  • Сами билетики шифрованные, но некоторые поля в пакете Kerberos передаются открытым текстом - например, юзернейм. С трафиком между сидящим вне доменной сети юзером и KDC особо делать нечего (разве что VPN), между юзером и сайтом - включить HTTPS. Лишний слой шифрования не помешает.
  • Отсутствует logout - нет способа сказать сайту, что юзерская сессия закончилась и дальнейшие запросы с тем же билетом приниматься не должны. Неидеальный способ для юзера состоит в том, чтобы закрыть полностью браузер - лишь в заново запущенном браузере процесс логина повторится сначала. Впрочем, с клиентскими сертификатами та же проблема.
  • Как уже сказано, не самый удобный способ передавать информацию о группах - тут ADFS куда круче, конечно, к тому же позволяет передавать массу других атрибутов. Тут же, если дополнительная информация из AD необходима - воленс-ноленс приходится либо запрашивать по LDAP, либо переходить на ADFS или аналог.
  • Какой-либо provisioning юзеров исключается - создавать или убивать их можно лишь через AD. Не через сайт.
  • Использовать таким образом иные методы аутентификации, кроме пароля - проблемно, несмотря на том, что в Windows в целом поддерживается интеграция сертификатов и Кербероса (см. PKINIT).
  • Привязка к конкретным браузерам.


Вот такие пироги с собачками. Для полноценного сайта, предназначенного для общей публики, это, конечно, не решение - но если задача в том, чтобы корпоративный сайтик из серверной комнаты куда-нибудь на Амазон перенести, то Kerberos тут вполне в тему, на мой взгляд.


* Апдейт: не так всё просто, оказывается. Между получением адресов контроллеров от DNS и запросом билетиков Kerberos есть ещё один этап - "LDAP ping". Это запрос по Connectionless LDAP (CLDAP) к домейн-контроллеру, адрес которого получен от DNS, по UDP-порту 389. Его цель - обнаружить ближайший контроллер для дальнейших запросов. Контроллер, получивший "ping", смотрит, с какого адреса он пришёл, и смотрит к какому сабнету можно его отнести (а сабнеты в AD относятся к тому или иному "сайту", в каждом "сайте" как минимум один свой контроллер). Соответственно, он сообщает клиенту, от какого контроллера тому требовать билетики.
Ответ кэшируется, так что при перезапуске браузера "LDAP ping" будет послан необязательно.

Это означает, что в Интернет придётся открывать ещё и этот порт - что уже проблемнее, чем открывать Kerberos. Все последствия для безопасности мне понять знаний не хватает, так что предложение пока предлагаю считать снятым.