main

Немного про классы в dhcpd

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

Дано: конфиг dhcpd с несколькими сотнями записей хостов, около трети из которых составляют ip-телефоны.

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

host 10.10.10.13 { hardware-ethernet 00:11:22:34:56:78; fixed-address 10.10.10.13; option tftp-server-name "10.10.10.5"; }
host 10.10.10.14 { hardware-ethernet 00:11:22:45:67:89; fixed-address 10.10.10.15; option tftp-server-name "10.10.10.5"; }
host 10.10.10.15 { hardware-ethernet 00:11:22:56:78:9A; fixed-address 10.10.10.16; option tftp-server-name "10.10.10.5"; }
# и так далее, ещё две сотни похожих строк

Необходимое и достаточное решение

Первое, и самое простое что можно сделать - избавиться от option tftp-server-name для каждого хоста, через объявление группы хостов и вынесение опции на уровень группы.

Можно ещё уменьшить объём писанины, убрав также fixed-address из определения хоста, но таким образом у вас может начаться чехарда с адресами. На практике я столкнулся с тем, что смена адреса телефона без его перезагрузки нежелательна, поскольку из-за этого возникают проблемы со звонками. Не все прошивки могут корректно раздуплить, что у телефона сменился адрес, в SIP/SDP указывается прежний ip, и поток голосовых данных радостно улетает "вникуда".

С этим можно бороться, поставив default-lease-time побольше, в пару суток, но адреса у вас по-прежнему выделяются из общего пула, и в данном случае вы с этим ничего сделать не сможете, разве что выделив отдельный vlan чисто для телефонов (это хорошо и правильно, но не всегда возможно).

Если же вы оставляете fixed-address, крайне желательно, чтобы записи самих хостов были расположены в сети компактной группой, иначе, собрав в группу кучу отдельностоящих ip, вы очень быстро получите головняк при поиске свободных адресов в сети.

Примерно так это будет выглядеть после всех изменений:

group {
  option default-lease-time 86400;
  option tftp-server-name "10.10.10.5";
  host 10.10.10.13 { hardware-ethernet 00:11:22:34:56:78; }
  host 10.10.10.14 { hardware-ethernet 00:11:22:45:67:89; }
  host 10.10.10.15 { hardware-ethernet 00:11:22:56:78:9A; }
  ...
}

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

Красивое, но ограниченное решение

Можно пойти дальше, и попытаться задействовать механизм классов dhcpd. Штука с одной стороны весьма мощная, но в то же время с рядом серьёзных ограничений.

У классов dhcpd есть два варианта определения "попадания" в него очередного request-запроса. В классе может использоваться только один из них и только один раз. Первый - выражение "match if", которое предполагает выполнение неких операций над данными в request-запросе, в результате которых получается уже готовый булевый результат true/false "попал/не попал". Второй вариант - выражение "match", которое так же выполняет некие операции над данными, но потом сравнивает их с идентификаторами subclass'ов, определенными для данного класса.

Соответственно, возможны так же два варианта переписывания нашего примера выше, первый с использованием "match", второй - "match if".

Первый будет выглядеть так:

class "phones-match" {
  option default-lease-time 86400;
  option tftp-server-name "10.10.10.5";
  match hardware;
}
subclass "phones" "1:00:11:22:34:56:78";
subclass "phones" "1:00:11:22:45:67:89";
subclass "phones" "1:00:11:22:56:78:9A";

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

class "phones-match-if" {
  option default-lease-time 86400;
  option tftp-server-name "10.10.10.5";
  match if (hardware = "1:00:11:22:34:56:78")
        or (hardware = "1:00:11:22:45:67:89")
        or (hardware = "1:00:11:22:56:78:9A");
}

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

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

class "phones-match" {
  option default-lease-time 86400;
  option tftp-server-name "10.10.10.5";
  match substring(hardware, 0, 4);
}
subclass "phones" "1:00:11:22";

Относительно предыдущего варианта с "match" ушло два subclass'а, и теперь любые хосты с mac-адресом, начинающимся с "00:11:22" (т.е. его "vendor-specific" часть) будут попадать в этот класс, сколько бы однотипных устройств у нас не было в сети. Это примерно то, что нам было надо - уйти от ведения перечня всех устройств в сети.

Первый недостаток - префикс выделяется для компании производителя, а та может производить сетевые устройства разных типов. Соответственно, таким образом не получится однозначно отделить телефоны от свитчей, например. Второй недостаток - выражение после "match" обязано выдавать какие-то данные, которые dhcpd потом будет пытаться найти в subclass'ах. Поэтому нельзя написать что-то вроде substring(hardware, 0, 4) and exists option ..., потому что это будет (цитируя ошибку парсера конфига) "illegal expression relating different types".

Вариант с "match if" будет выглядеть схожим образом:

class "phones-match-if" {
  option default-lease-time 86400;
  option tftp-server-name "10.10.10.5";
  match if substring(hardware, 0, 4) = "1:00:11:22";
}

Так же уходит перечисление mac-адресов всех устройств, и выражение сворачивается до проверки "vendor-specific" части адреса. Однако это выражение немедленно разрастается обратно, если у вас в сети зоопарк устройств разных производителей, или же одного производителя, но разных поколений устройств. И в том и в другом случае они будут иметь другой префикс mac-адреса, со всеми вытекающими. Зато в плане "развесистости" условия в этом варианте ограничений нет, например оно вполне может выглядеть так:

match if exists tftp-server-name and
         (substring(hardware, 0, 4) = "1:00:11:22" or
          substring(hardware, 0, 4) = "1:00:22:33");

И если в первом случае вы получаете удобную выборку по vendor-prefix'у mac-адреса, то во втором случае вы получаете неудобную, но точную выборку, поскольку из всех устройств данного производителя дополнительно отбираются лишь те, которые запрашивают опцию с кодом 66 (tftp-server-name). В этот момент ты осознаёшь себя буридановым ослом, и тихим, незлым матерным словом поминаешь кодеров из ISC.

Также можно использовать и другие опции из dhcp-запроса, такие как vendor-class (code 60) и hostname, но идентификация по ним работает ещё хуже: в отличие от mac-адреса, она даже не всегда вообще есть в запросе, а о какой-то стандартизированной схеме именования вообще говорить не приходится. Ниже приведён фрагмент конфига, с попыткой сделать выделение класса ip-телефонов по опции vendor-class.

class "phones" {
  match option vendor-class-identifier;
}
subclass "phones" "yealink";
subclass "phones" "Yealink";
subclass "phones" "CISCO SPA112";
subclass "phones" "Cisco SPA303";
subclass "phones" "Cisco SPA502G";
subclass "phones" "Cisco SPA504G";
subclass "phones" "Grandstream GXP1625 dslforum.org";
subclass "phones" "Grandstream GXP2124 dslforum.org";
subclass "phones" "Grandstream GXP2160 dslforum.org";

Да, в какой-то мере это работает, но в этой же сети присутствуют куча D-Link DPH-150, которые vendor-class вообще не передают, и соответственно в эту красивую схему не попадают.

Выходом может являться подход "по классу на вендора", но и это вызывает свои побочные эффекты, например необходимость перечисления всех этих классов в "allow" пула адресов. И упаси тебя ктулху про какой-то из них забыть...

За кулисами конфига

Отдельно хотел сказать про то, как технически реализован механизм классифицирования на уровне кода. Если конструкция типа "match hardware" ещё может обойтись обычными статическими структурами при разборе конфига, то "match if" уже требует хранить синтактическое дерево выражения match в памяти, а также реализации поддержки его выполнения в рантайме. Даже если не брать на порядок большую сложность реализации, нужно учитывать, что вся эта сложная машинерия работает (1) непосредственно с данными, полученными по сети, (2) по логике которая пишется конечным пользователем и (3) выполняется с недостаточными проверками на валидность условия, как будет показано ниже.

В "приложениях здорового человека" такое обычно реализуется через интеграцию более высокоуровнего языка, допускающего встраивание, и через который уже относительно безопасно рулят сишными примитивами[1]. Или же идут по другому пути, тщательно контролируя на уровне синтаксиса ту логику, которую юзер может реализовать[2]. Делается это как раз для того, чтобы отобрать у end-user'а возможность отстрелить себе ногу.

Но поскольку isc dhcpd - это не просто древнее, а очень древнее приложение, там используется свой специфичный dsl, реализованный "с нуля". В свою очередь, это влияет на парсер конфига, и на то как конфиг тот в памяти. Можете сразу забыть про BNF-описание синтаксиса, и про внятную документацию, какие директивы где допустимы. Всё это делается руками, без применения генераторов типа ragel и даже flex/yacc/bison.

Это - прямое следствие забивания болта на технический долг и изобретение велосипедов, которые всё равно будут ущербны по сравнению с использованием специально предназначенных для решения этой задачи инструментов. Т.е. в состав исходников dhcpd фактически включена неполная, небезопасная и медленная реализация flex/yacc. "Зато своё!", да.

То что выполнение происходит в рантайме и не самым оптимальным образом, можно убедиться, указав в "match" несуществующую функцию (например substr() вместо корректного substring()). После рестарта, в лог повалят ошибки следующего вида: dhcpd: substr: no such function. Это говорит о том, что dhcpd не пытается на этапе разбора конфига проверить правильность выражения, и задается вопросом "а есть ли у меня вообще такая функция?" только на стадии "петух в жопу клюнул", что соответствующим образом влияет на надежность, предсказуемость и производительность кода.

О качестве этого кода, и насколько полно его возможности описаны в документации, можно судить по следующему:

Только логика парсера конфига, без учёта определения необходимых структур и вспомогательных функций занимает порядка 300Кб кода: 162кб в server/confpars.c и ещё 140кб - в common/parse.c. В последнем реализован свой собственный токенайзер, который разбирает конфиг на лексемы (аналог flex), и свой собственный аналог yacc, которые используют не внешнюю и формализованную схему описания конфига, а логику, встроеннную прямо в код самого парсера. Сама логика переполнена блоками вроде такого:

if (type == CLASS_DECL) {
  parse_warn (cfile, "class declarations not allowed here.");
  skip_to_semi (cfile);
  break;
}

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

Например, в официальной документации, отличие "match" от "match if" описывается следующим образом:

This separation can be done either with a conditional statement, or with a match statement within the class declaration.

В качестве разминки - попробуйте из этого предложения понять, должен ли "conditional statement" также быть "within the class declaration", а также является ли он вариацией "match statement" или совершенно другим синтаксисом. Может ли "match statement" быть одновременно и "conditional"?

Примеров, чем и как они отличаются в терминах конфига - нет, зато из кода это очевидно: для первого используется parse_data_expression() и поле структуры class->submatches, для второго - parse_boolean_expression() и class->expr. Правда из самой логики классификации (server/class.c) вы этого не узнаете, и придётся опять залезть в парсер конфига. Заодно становится понятна теснейшая связь между match, spawn и subclass: одно без другого невозможно, и в случае использование просто "match" - spawn subclass'а выполняется всегда и для каждого клиента, а не только когда это указано явно, как можно подумать из документации.

Документация назидательно поучает, что второй вариант "match" изначально появился из-за проблем с производительностью у "match if". Это совершенно неудивительно, с таким-то подходом к его реализации.

A subclass is a class with the same name as a regular class, but with a specific submatch expression which is hashed for quick matching. This is essentially a speed hack - the main difference between five classes with match expressions and one class with five subclasses is that it will be quicker to find the subclasses.

Также документация ни слова не говорит, что будет, если просто определить класс, но не указывать ни одной формы "match", хотя это допустимый синтаксис, и в конце раздела "client classing" даже приведён пример с ним, сопровождаемый поразительным по глубине мысли комментарием:

Note that whether you use matching expressions or add statements (or both) to classify clients, you must always write a class declaration for any class that you use

Это можно узнать только из комментариев в коде:

/* The old vendor-class and user-class declarations had an implicit
   match.   We don't do the implicit match anymore.   Instead, for
   backward compatibility, we have an implicit-vendor-class and an
   implicit-user-class.   vendor-class and user-class declarations
   are turned into subclasses of the implicit classes, and the
   submatch expression of the implicit classes extracts the contents of
   the vendor class or user class. */

По комментариям также хорошо заметен возраст проекта как таковой. Вот например прекрасное из includes/dhctoken.h:

/*
 * The following tokens have been deprecated and aren't in use anymore.
 * They have been left in place to avoid disturbing the code.
 */

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

Неудивительно, что ISC решила переписать dhcpd с нуля, однако плохо, что в отличие ужасного кодом, но относительно нетребовательного к ресурсам и компактного по зависимостям unix-style демона, на замену предлагается откровенное overengineered bloatware.

Современных альтернатив dhcpd в его нише "продвинутого dhcp-сервера" фактически нет: простой как лом dnsmasq, возможностей которого начинает не хватать уже при попытке использования кастомных опций для отдельного хоста таковым считаться не может, а использовать dhcp из freeradius'а - очевидный overkill.

[1] Сейчас как правило это lua (см. например rspamd, игровые движки), а в более древних приложениях - perl (freeradius, exim).

[2] См например как это сделано в pcap/BPF