Мы двигаемся к финалу нашей саги об интеграции Raspberry Pi 4 в выделенные серверы. В первом тексте я рассказал об отличиях процесса загрузки «малинок» от «классических» серверов. Во втором — собрал образ, способный после загрузки файлов по TFTP-протоколу запускаться и работать из оперативной памяти. При этом показал, как его кастомизировать, добавляя нужные пакеты и файлы.
Теперь нужно воспроизвести поведение, которое мы показали на примере iPXE-скрипта.
Опция 224
Напомню основную часть скрипта, оставив самое важное.
isset 224 || goto noparameter
chain --autofree ${224}
В данном случае скрипт проверяет, задана ли опция 224 (определяется в ответе от DHCP-сервера). Если да, скрипт идет дальше и выполняется chain, который загружает по URI (значение задается как раз опцией) следующий образ и запускает его.
Опция кастомная, поэтому объясню, для чего она служит и как формируется. Значение 224 выбрано как первое свободное для частного использования в стандарте DHCP.
Использование опции удобнее всего пояснить на схеме:
- DHCP-клиент делает запрос к DHCP-серверу. Здесь сервер определяет клиента по опции 82, так как «малинки» находятся в дата-центре. В более простой схеме клиентов можно идентифицировать по MAC-адресу.
- DHCP-сервер заворачивает опцию 82 в URL, по которому происходит обращение к внешнему серверу по HTTP.
- Сторонний сервер из запроса DHCP-сервера определяет клиента и формирует уникальный для него ответ.
- Ответ от внешнего сервера запаковывается в виде опции 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 <hooks/hooks.h>
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-образ. Заодно посмотрим, как решилась проблема с переключением режимов загрузки.
Автор:
burlunder