Маленькие «малинки» в крупном дата-центре (часть 3 — Kea DHCP)

image-loader.svg


Мы двигаемся к финалу нашей саги об интеграции Raspberry Pi 4 в выделенные серверы. В первом тексте я рассказал об отличиях процесса загрузки «малинок» от «классических» серверов. Во втором — собрал образ, способный после загрузки файлов по TFTP-протоколу запускаться и работать из оперативной памяти. При этом показал, как его кастомизировать, добавляя нужные пакеты и файлы.

Теперь нужно воспроизвести поведение, которое мы показали на примере iPXE-скрипта.

Опция 224


Напомню основную часть скрипта, оставив самое важное.

isset 224 || goto noparameter
chain --autofree ${224}


В данном случае скрипт проверяет, задана ли опция 224 (определяется в ответе от DHCP-сервера). Если да, скрипт идет дальше и выполняется chain, который загружает по URI (значение задается как раз опцией) следующий образ и запускает его.

Опция кастомная, поэтому объясню, для чего она служит и как формируется. Значение 224 выбрано как первое свободное для частного использования в стандарте DHCP.

image-loader.svg


Использование опции удобнее всего пояснить на схеме:

  1. DHCP-клиент делает запрос к DHCP-серверу. Здесь сервер определяет клиента по опции 82, так как «малинки» находятся в дата-центре. В более простой схеме клиентов можно идентифицировать по MAC-адресу.
  2. DHCP-сервер заворачивает опцию 82 в URL, по которому происходит обращение к внешнему серверу по HTTP.
  3. Сторонний сервер из запроса DHCP-сервера определяет клиента и формирует уникальный для него ответ.
  4. Ответ от внешнего сервера запаковывается в виде опции 224 внутрь DHCP-ответа клиенту.


Чтобы проделать это на практике, обратимся к Kea DHCP-серверу и его системе hook-модулей.

Kea DHCP


Когда нужно установить DHCP-сервер, Linux-дистрибутивы по умолчанию предлагают ISC DHCP (ISC — Internet Systems Consortium). За почти 20 лет существования он продолжает поддерживаться консорциумом. Это мощный и гибкий продукт, который позволяет не только гибко управлять опциями в ответе, но даже задавать собственные. А через механизм dhcp-eval можно определять собственную логику. Например, через execute можно вызывать внешние скрипты с передать им аргументы.

Для схемы выше использование execute не подходит, так как эта команда не умеет возвращать данные обратно в сервер после выполнения.

Из-за этого и других подобных архитектурных ограничений основной фокус развития переключился на Kea. На странице его описания обозначены отличия от прежнего проекта. Для первого знакомства на практике пригодится эта статья, с поправкой, что текущая актуальная версия 2.0. Тем, кто задумался о переходе, для облегчения переноса конфигов рекомендуем воспользоваться ассистентом KeaMA.

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

По сути, hook — это динамическая библиотека, подгружаемая в процессе обработки запросов. При этом может использоваться несколько модулей, в таком случае их порядок обработки определяется порядком в конфигурационном файле. Модуль может обращаться ко всему API, доступному ядру Kea, и возможности на этом уровне не ограничены.

Правда, для нас это вызов. Ядро Kea написано на C++, и нужно разбираться в его архитектуре на этом уровне.

Собственный Kea hook


При написании собственного хука нам придется постоянно обращаться к руководству по их созданию. В его примерах также используется С++. Уже есть возможность использовать Python через kea_python, но здесь мы будем следовать руководству. Ориентироваться будем на уже готовый пример.

Рассмотрим файлы из директории src, где располагается основной код.

├── Makefile
├── pkt4_receive.cc
├── pkt4_send.cc
├── remopts_callouts.cc
├── remopts_callouts.h
├── remopts_common.cc
├── remopts_common.h
├── remopts_log.cc
├── remopts_log.h
├── remopts_messages.mes
└── version.cc


version.сс

#include 
extern "C" {
int version() { return (KEA_HOOKS_VERSION); }
}


Наш хук — это библиотека, подгружаемая ядром Kea. Перед загрузкой нужно убедиться, что файл собран под ту версию Kea, которая его вызывает. Код каждой версии Kea содержит собственную версию для хуков в символе KEA_HOOKS_VERSION. Как видно, функция version () использует ее, что гарантирует совпадение версий.

remopts_callouts.cc


Загрузка и выгрузка модуля ядром происходит через функции load () и unload ().

При вызове load () аргументом передается объект handle класса LibraryHandle от ядра. Он служит для регистрации собственных вызовов и получения параметров хука, заданных в конфигурационном файле Kea. В нашем примере мы используем только получение параметров.

int load(LibraryHandle &handle) {
  try {
    ConstElementPtr param_url = handle.getParameter("url");
    ConstElementPtr param_user_class = handle.getParameter("user_class");
    ConstElementPtr param_machine_guid = handle.getParameter("machine_guid");


Дальше по коду эти параметры проходят простую проверку на соответствие типу. Так как смысл нашего хука в том, чтобы обращаться к внешнему серверу, то параметр url является обязательным и его отсутствие вызывает исключение.

В конфигурационном файле /etc/kea/kea-dhcp4.conf это будет соответствовать отрывку:

"hooks-libraries": [
  {
    "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libremote_opts.so",
    "parameters": {
      "url": "http://nginx/data",
      "user_class": "test",
      "machine_id": "RPi4"
    }
  }
],


На основе этих параметров создаются переменные, которые используются в дальнейшем при обработке DHCP-пакетов.

std::string conf_url;
std::string conf_user_class;
std::string conf_machine_guid;


remopts_log.cc


При чтении файла выше remopts_callouts.cc можно заметить использование функций для вывода сообщений в лог — например, сообщение о том, что наш хук был успешно загружен.

LOG_INFO(remopts_logger, REMOPTS_LOAD);


Для работы функций нужно заранее создать общий логгер remopts_logger, используемый в остальных файлах.

isc::log::Logger remopts_logger("kea-hook-remote-opts");


Для удобства обращения к логгеру стоит использовать макросы (вроде LOG_INFO), заданные в файле macros.h из кода ядра Kea.

remopts_messages.mes


Для отправки сообщений в лог, помимо логгера, требуются еще сами сообщения, созданные определенным образом. Чтобы упростить работу с ними, нам предлагается файл особого формата, который при компиляции будет преобразован в код.

$NAMESPACE isc::log

% REMOPTS_MSG remopts message: %1
A common message logger


Формат файла хорошо описан в документации. Как можно заметить, сперва мы задаем пространство имен, в котором будут располагаться сообщения.

Строки, начинающиеся с символа »%», задают сами сообщения. Сперва идет идентификатор сообщения (здесь REMOPTS_MSG), который будет передаваться логгеру. Далее — текст, который попадает в вывод лога. При необходимости сообщению могут передаваться позиционные аргументы.

Для преобразования данного файла в код используется утилита kea-msg-compiler:

kea-msg-compiler remopts_messages.mes


После компиляции файла сообщений мы получаем готовый код С++, представленный в файле remopts_messages.cc, и связанный с ним заголовочный файл remopts_messages.h

На примере сообщения REMOPTS_MSG выше будет сгенерирован код:

namespace isc {
namespace log {
extern const isc::log::MessageID REMOPTS_MSG = "REMOPTS_MSG";
}
}

namespace {
const char* values[] = {
	"REMOTEOPTS_MSG", "remopts message: %1",
	NULL
};

const isc::log::MessageInitializer initializer(values);
}


remopts_common.cc


В этом файле определены вспомогательные функции, используемые при обработке пакетов. Помимо функций, связанных с преобразованием HEX-строк, здесь также определена функция make_curl_request (), через которую происходит вызов к внешнему серверу.

pkt4_receive.cc


Обработка входящих пакетов начинается с этого файла. В нем мы определяем функцию pkt4_receive (), вызываемую ядром Kea на приходящий DHCPv4-пакет. Аргументом передается объект handle типа CalloutHandle, который содержит контекст вызова на входящий пакет.

int pkt4_receive(CalloutHandle &handle) {
  try {
	Pkt4Ptr query4_ptr;
	handle.getArgument("query4", query4_ptr);


Здесь контекст входящего пакета становится доступным через Pkt4Ptr. В нашем примере он используется чтобы определить, пришел ли запрос от «нашего» клиента или нет. Протокол DHCP широковещательный, и обрабатывать все входящие пакеты получается накладно. Нас интересуют только запросы, связанные с PXE загрузкой. «Свои» пакеты мы определяем по опциям 77 (user-class) и 97 (uuid/guid).

OptionPtr user_class_ptr;
OptionPtr uuid_guid_ptr;
user_class_ptr = query4_ptr->getOption(77);
uuid_guid_ptr = query4_ptr->getOption(97);


Далее, при наличии нужной опции, мы задаем новый контекст объекту handle. Он является общим для базовых функций (как увидим далее). Так мы можем через создание контекста передать значение guid_id, чтобы использовать его при формировании DHCP-ответа.

   if (uuid_guid_ptr) {
      string guid_id;
      string guid_str = gethexOptionPtr(uuid_guid_ptr);
      handle.setContext("guid_id", guid_str);


Аналогично задается контекст user_class_id и для опции user_class_ptr.

pkt4_send.cc


По аналогии с файлом pkt4_receive.cc здесь определена функция pkt4_send (), которая отвечает за формирование исходящего DHCPv4-пакета. Передается тот же объект handle, на основе которого в этот раз мы получаем контекст пакета responce4_ptr, формируемого для отправки.

int pkt4_send(CalloutHandle &handle) {
  Pkt4Ptr response4_ptr;
  handle.getArgument("response4", response4_ptr);


Далее мы обращаемся к объекту handle, чтобы получить из него ранее созданный контекст guid_id. При этом дополнительно проверяем, что его значение совпадает с conf_machine_guid. Это параметр, который мы получали ранее из файла remopts_callouts.cc и который соответствует параметру из конфигурационного файла Kea.

  string guid_id;
  bool guid_match = false;
  try {
    handle.getContext("guid_id", guid_id);
    if (boost::algorithm::contains(guid_id, conf_machine_guid))
      guid_match = true;

  } catch (const std::exception &ex) {
    LOG_INFO(remopts_logger, REMOPTS_MSG).arg("guid_id is missing");
  }


Аналогичная схема используется для контекста user_class_id.

Далее мы получаем опцию 82 с ее субопциями. Напомню, что на ее основе мы однозначно идентифицируем клиента в PXE-сети. При этом проверяем как наличие самой опции 82, так и ранее созданные userclass_match и guid_match.

  OptionPtr option82;
  option82 = response4_ptr->getOption(82);

  string final_url;
  if (option82 and (userclass_match or guid_match)) {
	OptionPtr option82sub1_ptr = option82->getOption(1);
	OptionPtr option82sub2_ptr = option82->getOption(2);
	OptionPtr option82sub9_ptr = option82->getOption(9);


Субопции нужны для формирования адреса final_url, который затем будет использован для вызова через libcurl к внешнему Web-серверу. Формируется адрес на основе conf_url (параметр url в конфигурационном файле Kea), к которому добавляются значения субопций, преобразованных в HEX-строку. Последнее необходимо для передачи внешнему серверу через GET HTTP-запрос.

if (option82sub1_ptr) {
  final_url = final_url + "?sub82_1=" + gethexOptionPtr(option82sub1_ptr);
}
if (option82sub2_ptr) {
  final_url = final_url + "&sub82_2=" + gethexOptionPtr(option82sub2_ptr);
}
if (option82sub9_ptr) {
  final_url = final_url + "&sub82_9=" + gethexOptionPtr(option82sub9_ptr);
}
final_url = conf_url + final_url;


После всех подготовительных этапов происходит вызов к внешнему серверу. Ответ ожидается в формате JSON, который необходимо дополнительно распарсить. Для этого мы создаем объект root типа pt: ptree (property_tree из библиотеки boost), в который копируется полученный от сервера JSON-ответ.

   pt::ptree root;
    try {
      pt::read_json(ss, root);
    } catch (const exception &ex) {
      LOG_ERROR(remopts_logger, REMOPTS_MSG)
          .arg("curl ERR invalid json response: \"" + sstrim(ss) + "\"");
    }


При обработке мы ожидаем ответ в следующем виде. Здесь ключу соответствует номер опции, которую нужно включить в DHCP-ответ. Значение принимается пока только в виде строки.

{
  "224": "somestring",
  "66": "0.0.0.0"
}


Для подстановки опций мы проходимся в цикле по объекту root и получаем указанные по ключу опции:

   for (auto &node : root) {
      string first_ = node.first;  // json key
      string second_ = node.second.data();  // json value

      OptionPtr option = response4_ptr->getOption(stoi(first_));


После определяем, задана ли уже эта опция или нет. В первом случае достаточно подменить данные на основе значения элемента (здесь second). Во втором случае необходимо предварительно создать опцию, после чего добавить ее в ответный DHCP-пакет.

     if (option) {
        option->setData(second_.cbegin(), second_.cend());

      } else {
        OptionBuffer buffer;
        buffer.assign(second_.cbegin(), second_.cend());
        option.reset(new Option(Option::V4, stoi(first_), buffer));
        response4_ptr->addOption(option);
        response4_ptr->pack();
      }


На этом месте все действия выполнены и происходит выгрузка нашего хука через функцию unload (), описанную ранее. При появлении следующего DHCP-запроса хук загружается, и все повторяется заново.

Сборка и тестирование


Make


Перед сборкой необходимо убедиться, что удовлетворены все зависимости (на примере Ubuntu 20.04).

apt install g++ make kea-common kea-dev


При использовании репозитария от ISC последние два пакета можно заменить на isc-kea-common и isc-kea-dev.

apt install g++ make isc-kea-common isc-kea-dev


Так как код нашего хука опирается на библиотеки libcurl и boost, их также необходимо установить.

apt install libboost-dev libboost-system-dev libcurl4-openssl-dev


После достаточно перейти в директорию src и запустить команду make. После завершения в директории на уровень выше получим готовую библиотеку …/kea-hook-remote-opts.so

cd src && make


Использование Docker


Для демонстрации и упрощения сборки/тестирования подготовлен файл docker-compose.yml. В нем мы создаем отдельную сеть Kea, чтобы связать контейнеры kea-hook и dhtest и пускать через нее DHCP-трафик. Сами контейнеры при этом запускаются в привилегированном режиме. Это важно, так как сервер kea-dhcp4 и утилита dhtest требуют прямого доступа к сетевым интерфейсам для своей работы.

Запуск производится стандартно:

docker-compose up


После чего будет собрано два образа (kea-hook и dhtest), запустится nginx, выполняющий роль внешнего Web-сервера, и kea-hook, который запускает сервер kea-dhcp4 с модулем kea-hook-remote-opts.so.

Контейнер dhtest завершится после запуска, так как не является сервисом. Dhtest — это утилита, предназначенная для тестирования серверов DHCP. Она позволяет формировать запрос к серверу с различными значениями и наблюдать содержимое ответа.

В файле test/Dockerfile.dhtest приведены аргументы, которые формируют опции 12 и 82, используемые для тестов. В командной строке это будет соответствовать вызову:

dhtest --interface eth1 --verbose --timeout 30 -c "12,str,test" -c "82,hex,0103666f6f0203626172" --unicast


При использовании контейнера dhtest достаточно запустить его заново и изучить вывод:

docker container start --attach dhtest


Итоги и планы


Статья получилась не про сами «малинки». Ни опция 224, ни умение создавать хук-модули для Kea DHCP не требуются для загрузки Raspberry Pi 4 по сети. Более того, описанный здесь хук-модуль использовался в инфраструктуре Selectel еще до того, как появился вопрос интеграции «малинок» (хоть и не раз переделывался).

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

В следующей, финальной, статье мы соберем все знания цикла текстов про интеграцию «малинок» и покажем, что происходит после загрузки Raspberry Pi 4 в Buildroot-образ. Заодно посмотрим, как решилась проблема с переключением режимов загрузки.

image-loader.svg

© Habrahabr.ru