Началось всё не с идеи «напишу‑ка я библиотеку», а с гораздо более скучной задачи — разобраться в BIND9. Причём разобраться по‑настоящему: не «погуглил директиву, вставил в конфиг, заработало, забыл», а понять, как эта штука вообще устроена, потому что конфигурации нужно было генерировать не для одного сервера, а для нескольких, и делать это регулярно.
Управлять этим вручную, руками редактировать named.conf на каждом сервере и следить, чтобы зоны, ACL и view не разъехались, показалось мне откровенно плохой идеей с самого начала. Не потому что кто‑то запрещает так делать, а потому что как инженерная задача это просто неприятно: слишком много состояния, которое нужно держать в голове синхронизированным вручную, причём зачастую ещё и вспоминая, где что лежит.
Сразу про границу задачи, чтобы не было недопонимания
У управления DNS есть два довольно разных слоя, и их регулярно путают:
конфигурация сервера — named.conf: options, views, ACL, ключи, controls, dnssec‑policy, объявления зон. Меняется редко, но это самая громоздкая и самая неоднородная по грамматике часть;
данные зон — собственно ресурсные записи, которые живут внутри зон и меняются постоянно.
Второй слой закрыт хорошо: есть RFC 2136 и nsupdate, есть octoDNS, есть куча провайдеров и готовых пайплайнов. Мне же нужен был именно первый — и именно с ним, как ни странно, обычно работают либо руками, либо Jinja2-шаблоном.
Поэтому дальше речь только про генерацию named.conf и про начальное состояние зон — то, что кладётся на чистый сервер при развёртывании. Всё, что происходит с зоной дальше (nsupdate, IXFR с primary, переподписывание по dnssec‑policy), — не моя территория: BIND ведёт .jnl‑журнал и переписывает zone‑файл сам, ничего не зная про мои модели.
Отдельно, чтобы не создавать ложных ожиданий: библиотека по итогу генерирует named.conf и zone‑файлы, и на этом всё. Ключи TSIG и DNSSEC, права на каталоги, AppArmor/SELinux, systemd, доставка на хост и перезагрузка сервиса — вне её зоны ответственности. Это делает то, чем вы вообще раскатываете сервер.
Почему не шаблонизатор
Первая мысль, понятное дело, — Jinja2. Генерируем текст, накатываем. Но чем больше я читал документацию по BIND, тем меньше мне нравилась идея генерировать текст из текста. Шаблон не знает, что serial у SOA‑записи — это число, что allow-recursion — это список, а не строка, что зона типа redirect обязана называться .. Он с радостью подставит куда угодно что угодно и отдаст результат, который выглядит нормально ровно до того момента, как named откажется его читать.
Мне хотелось, чтобы как можно больше ошибок отваливалось на этапе конструирования объекта, а не на этапе named-checkconf — и чтобы каждая пойманная ошибка превращалась в тест и в правило валидации, а не в очередную правку шаблона.
Отсюда и вырос план: описать весь named.conf как дерево Python‑моделей, навалить на них валидацию и генерить синтаксис из уже провалидированных данных
И тут я, честно говоря, немного поддался своему pydantic головного мозга. Для чистого хранения данных с валидацией вокруг него вполне хватило бы dataclass с ручными __post_init__. Но мне откровенно нравится, как это выглядит в Pydantic: валидатор живёт прямо рядом с полем, а не в отдельной функции, которую надо не забыть вызвать; неправильный тип отваливается с человекочитаемой ошибкой на конкретном поле, а не где‑то в середине рендеринга строки; JSON‑схема достаётся бесплатно. В общем, выбор был скорее мне так приятнее, и я в этом честно признаюсь.
А дальше я просто пошёл по документации BIND — и начал спотыкаться
Вот тут начинается самое интересное. Я читал доку по конкретной директиве, пытался понять, как её правильно смоделировать классом с типизированными полями, — и упирался в то, что грамматика named.conf устроена гораздо менее последовательно, чем кажется, пока ты просто копируешь чужие конфиги.
key <имя> — это не значение, а слово‑ссылка, и легальность этой ссылки зависит от места, а не от синтаксиса
В named.conf есть общий мини‑язык для правил доступа — address match list. Туда можно положить IP, подсеть, имя ACL, отрицание через !, вложенный список в фигурных скобках или ссылку на TSIG‑ключ через key <имя>. Синтаксически key tsig-key в любом таком списке выглядит одинаково. Семантически — нет.
В allow-transfer / allow-update / match-clients ключ реально участвует в проверке: соединение авторизуется, если оно подписано именно этим TSIG‑ключом. А у controls (канал управления для rndc) есть свой список allow { ... } точно такого же вида — но авторизация по ключу там задаётся отдельным полем keys { ... }, и если написать key rndc-key прямо внутри allow, BIND его молча проигнорирует.
# ключ реально участвует в проверке - соединение авторизуется, # только если оно подписано этим TSIG-ключом allow-transfer { key tsig-key; }; # а тут - нет: "allow" внутри controls отвечает только за IP, # BIND молча проигнорирует "key rndc-key" controls { inet 127.0.0.1 allow { key rndc-key; }; }; # авторизация по ключу для rndc задаётся отдельным полем controls { inet 127.0.0.1 allow { localhost; } keys { rndc-key; }; };
named-checkconf это пропустит без единого предупреждения, конфиг применится, rndc просто не потребует ключ — а вы будете думать, что канал управления защищён, хотя защищает его разве что то, что порт не торчит наружу. Один и тот же кусок текста: реальная проверка в одном блоке и безобидный шум в другом, внешне неотличимые.
algorithm у ключа — это не закрытый список
Кажется, что это перечисление из шести значений (hmac-md5, hmac-sha1,..., hmac-sha512). На деле список открытый: TSIG по RFC 4635 разрешает усекать MAC до нужной длины в битах, и это усечение дописывается прямо в имя алгоритма через дефис — hmac-sha256-128, hmac-sha1-80 и так далее.
То есть корректное значение поля — это не «один из шести токенов», а «один из шести префиксов плюс, возможно, -NN после него». Пока я не открыл спецификацию самого TSIG, а не документацию BIND, было совершенно непонятно, откуда в реальных конфигах вообще берутся такие значения.
key "tsig-key" { algorithm hmac-sha256-128; secret "base64-encoded-secret=="; };
«Доменное имя» в named.conf — это минимум три разных типа, которые в тексте выглядят одинаково
У resource record поле name — это его собственное имя‑владелец внутри зоны, и оно может быть относительным (ns1 внутри example.com значит ns1.example.com). А поля вроде nsdname, mname, exchange — это ссылки на другие доменные имена, и по стандарту они обязаны быть полностью квалифицированы, с точкой на конце.
$ORIGIN example.com. ns1 IN A 192.168.1.1 @ IN NS ns1.example.com.
Выглядят оба поля одинаково — строка с точками. Ведут себя противоположно: для поля‑ссылки дописать точку в конце — нормализация, а для собственного имени записи это превращение ns1 в абсолютное имя на уровне корня DNS, а вовсе не в ns1.example.com. BIND в ответ говорит ignoring out-of-zone data и отказывается грузить зону целиком.
Второе отличие внутри того же понятия «доменное имя» — допустимый алфавит. У DNS‑имён внутри зоны (в отличие от чистых доменных имён вроде имени самой зоны или имени ACL) разрешены подчёркивание и звёздочка. Это не произвол, а отражение реальных конвенций поверх DNS, которых нет в тексте RFC 1035, но которые использует весь интернет:
_dmarc.example.com. TXT "v=DMARC1; p=none" _acme-challenge.example.com. TXT "token..." _sip._tcp.example.com. SRV 10 60 5060 sipserver.example.com.
А вот имя самой зоны, объявленное в named.conf, — обычное RFC 1035-имя, подчёркивание там как раз недопустимо. Строгость валидации у одного и того же на вид понятия «доменное имя» зависит от того, декларируете вы зону или описываете запись внутри неё.
И третье: @ и * в этих полях вообще не имена в смысле набор меток, разделённых точками. @ — это ссылка на текущий $ORIGIN зоны, * — позиционный wildcard‑токен. Оба обрабатываются отдельной веткой валидации ещё до того, как строка вообще пытается разбиться на метки по точке, потому что применять к ним обычные правила именования (минимальная длина метки, запрет дефиса в начале и в конце) попросту бессмысленно.
Один тип <duration> — три несовместимых диалекта, и буква M в них означает разное
Почти любое поле с длительностью в BIND (TTL, таймеры dnssec-policy вроде signatures-refresh или publish-safety) принимает три формы:
dnskey-ttl 3600; # голое число секунд dnskey-ttl 1M; # TTL-стиль: буква M = минуты dnskey-ttl P1M; # ISO 8601: буква M перед T = месяцы dnskey-ttl PT1M; # ISO 8601 после T: снова минуты
Оба строковых варианта регистронезависимы. И вот в чём подвох: в TTL‑стиле буква M значит минуты, а в ISO 8601 та же буква перед T значит месяцы, и только после T снова означает минуты. Написать dnskey-ttl P1M, имея в виду одну минуту по инерции от TTL‑привычки, — совершенно реалистичная ошибка, которую ни один парсер не поймает, потому что P1M синтаксически полностью валиден. Просто это не то число, которое вы имели в виду.
Одни и те же токены — валидное имя для ссылки и заведомо мёртвая конфигурация для декларации
tls ephemeral, tls none, dnssec-policy default / insecure / none — имена, зарезервированные BIND исключительно для ссылки из другого места (поле tls у сервера, поле dnssec-policy у зоны).
tls my-tls-profile { ... }; # свободное имя - можно объявлять tls none { }; # named-checkconf: "reserved for internal use", # даже с пустым телом
Попытка реально объявить блок с одним из зарезервированных имён отклоняется безусловно. Разница между легальная ссылка и заведомо невалидная декларация тут чисто семантическая: буквы совпадают один в один, а на уровне синтаксиса грамматика их вообще никак не различает.
Как это устроено внутри
Раз уж грамматика оказалась настолько неоднородной, хотелось не растаскивать эти нюансы по каждому классу отдельно, а вынести в один слой и переиспользовать. В итоге в bindantic (bind + pydantic, ну вы поняли) вся штука держится на двух вещах.
Первая — файл base_types.py, где для каждого примитива грамматики BIND (<string>, <domain_name>, <owner_name>, <duration>, <boolean>, <server_key> и так далее) заведён отдельный тип‑алиас с приклеенной к нему функцией валидации через Annotated:
domain_name_BIND: TypeAlias = Annotated[ str, BindTypeCoreSchema(Validator.validate_domain_name), ] owner_name_BIND: TypeAlias = Annotated[ str, BindTypeCoreSchema(Validator.validate_owner_name), ] duration_BIND: TypeAlias = Annotated[ int | str, BindTypeCoreSchema(Validator.validate_duration), ]
BindTypeCoreSchema — маленький класс, который просто регистрирует произвольную функцию как pydantic core schema через __get_pydantic_core_schema__, то есть превращает обычную функцию‑валидатор в тип, который можно один раз объявить и потом использовать как аннотацию поля где угодно:
@dataclass(frozen=True) class BindTypeCoreSchema: func: Callable[[Any], Any] def __get_pydantic_core_schema__(self, source_type, handler): return core_schema.no_info_after_validator_function( self.func, handler(source_type) )
За счёт этого разница между именем‑ссылкой и собственным именем записи из раздела про домены выше не размазана по каждому классу, а живёт в двух разных типах: domain_name_BIND и owner_name_BIND. Поле mname у SOARecord объявлено как domain_name_BIND, поле name у любого resource record — как owner_name_BIND. Дальше это просто аннотация типа, а не отдельная функция, которую надо не забыть вызвать.
Вторая вещь — BindBaseModel в base_model.py, общий предок всех блоков. У него есть:
comparison_attr— по нему сортируются блоки там, где порядок для BIND не важен. Именно его отсутствие вViewBlockв своё время путало порядок view, о чём ниже;абстрактный
model_bind_syntax(), который каждый блок обязан реализовать;auto_format_fields()— метод, который проходит по полям модели, переводитsnake_caseвkebab-caseи в зависимости от типа значения решает, как его печатать: вложенную модель — рекурсивно через её собственныйmodel_bind_syntax(), список — оборачивает в{ ... };, enum — берёт.value, всё остальное — какname value;.
Если для конкретного поля нужна нестандартная печать (как раз тот самый fetch-quota-params, который нельзя сортировать, потому что порядок чисел там значащий), в классе просто объявляется метод _format_fetch_quota_params, и auto_format_fields сам его подхватит вместо общей логики. Это тот самый механизм, которым в итоге разруливается почти всё из списка особых случаев выше: не веткой if внутри одного гигантского форматтера, а точечным переопределением для одного конкретного поля.
В общем как это собирается в минимальный конфиг
from bindantic import ( ARecord, NamedConfig, NSRecord, OptionsBlock, SOARecord, ZoneBlock, ZoneTypeEnum, ) config = NamedConfig( options_block=OptionsBlock( directory="/etc/bind", recursion=True, allow_recursion=["localhost", "localnets"], ), zone_blocks=[ ZoneBlock( name="example.com", zone_type=ZoneTypeEnum.PRIMARY, file="zones/example.com.zone", resource_records=[ SOARecord( mname="ns1.example.com", rname="admin.example.com", serial=2026010101, refresh=10800, retry=3600, expire=604800, minimum=3600, ), NSRecord(nsdname="ns1.example.com"), ARecord(name="@", address="192.168.1.1"), ], ) ], ) config.model_bind_syntax()
Тут mname / rname в SOA — это domain_name_BIND (ссылки, дописываются точкой), name="@" в A‑записи — owner_name_BIND (спецтокен $ORIGIN, точка не трогается), serial / refresh /... — integer_BIND, directory — quoted_string_BIND.
NamedConfig.model_bind_syntax() рекурсивно спускается по дереву моделей и на каждом уровне вызывает auto_format_fields() или собственный model_bind_syntax(), если блок печатает себя нестандартно, как та же зона. Никакой отдельной генерации текста в смысле шаблона: вывод строится из уже провалидированных объектов.
Не доверять строкам, доверять реальному BIND
По мере того как список смоделированных блоков рос, я довольно быстро понял, что юнит‑тесты вида assert "type primary;" in syntax проверяют только то, что я сам придумал проверять, — то есть мои собственные (иногда неверные) представления о грамматике BIND, а не саму грамматику. Поэтому в CI появился отдельный шаг, который реально прогоняет сгенерированный named.conf через named-checkconf на настоящем BIND.
Результат был отрезвляющим. Несколько примеров того, что нашёл настоящий BIND и не нашли мои строковые ассерты:
viewзаворачивал вложенныеkeyиserverв несуществующие обёрткиkey-blocks { ... };иserver-blocks { ... };. Таких statements в грамматике BIND нет вообще — внутри view они пишутся ровно так же, как на верхнем уровне. Диагностика:unknown option 'key-blocks';поля назывались
max-transfers-in/max-transfers-out, а реальные ключевые слова BIND —transfers-in/transfers-out;зона типа
in-viewполучалаtype in-view;, хотя у неё вообще нет statementtype— толькоin-view <viewname>;на своей строке. Диагностика:'in-view' unexpected;print-timeмолча исчезал из вывода при булевом значении: валидаторboolean_BINDзаранее нормализовалTrueв строку"yes", а форматтер сравнивал черезis True, и условие никогда не срабатывало. Та же ошибка убивала проверку «нельзяallow-recursionвместе сrecursion no»: сравнениеself.recursion is Falseне срабатывало, потому что там уже лежала строка"no", которая в Python истинна;disable-empty-zoneпечатался как список в фигурных скобках, хотя это одиночный statement, повторяющийся по разу на зону.
Ни одну из них строковый тест поймать не мог, потому что тест проверял ровно то, что я в него заложил. Мораль довольно банальная, но выстраданная: эталоном должен быть сам инструмент, а не ваше представление о нём.
Отдельная ирония в ту же тему: сам named-checkconf тоже может врать, если он устаревший. CI одно время ставил bind9-utils из Ubuntu apt — на несколько минорных версий позади актуального BIND, без поддержки key-store, remote-servers и части dnssec-policy. Он честно отклонял конфигурацию, которая для актуальной версии BIND абсолютно корректна. В итоге пришлось валидировать против официального образа internetsystemsconsortium/bind9 конкретной версии, а не против того, что попадётся в системном пакетном менеджере.
Чего библиотека не делает
Чтобы не создавать ложных ожиданий, короткий список честных ограничений:
не читает существующий
named.conf— парсер грамматики BIND я не писал, генерация односторонняя;не считает дифф с текущим состоянием сервера и ничего не применяет: на выходе текст и файлы, доставка — ваша;
не управляет ключами, правами, сервисом и файрволом;
не работает с живой динамической зоной — только с нулевым состоянием и полной пересборкой при деплое;
таргетит BIND 9.20.x;
named-checkconfиз вашего дистрибутива может оказаться старше и ругаться на директивы, которых он ещё не знает.
Где это живёт в чужом стеке — и вопрос к вам
Мне не так интересно рассказать «вот что я построил», как понять, ломается ли идея, что конфиг собирается из типизированных моделей на сценариях, которых я не предусмотрел. Поэтому сначала честно про соседей по нише, потом вопросы.
dnspython
Это соседний слой, а не конкурент. Тулкит уровня протокола и данных: резолвер, транспорты вплоть до DoH/DoQ, объектная модель зоны с довольно строгим парсером zone‑файлов, динамические обновления по RFC 2136 с TSIG, DNSSEC. named.conf он не знает и знать не должен.
Взаимодополнение тут очевидное: dns.zone.from_file() — отличная независимая проверка того, что сгенерированный zone‑файл вообще грузится (парсер строгий, ошибётся примерно там же, где named-checkzone), а dns.update — способ довезти дельту до живой зоны, не трогая файлы. Ровно то, чего у меня нет.
Ansible‑роли для bind9
Прямой конкурент, и с честными преимуществами. bertvv.bind, systemli.bind9 и десятки менее известных ролей: идемпотентность из коробки, секреты через vault, на сервере ничего не нужно кроме SSH. И — главное приём, который я долго считал недооценённым:
- name: named.conf.local ansible.builtin.template: src: named.conf.local.j2 dest: /etc/bind/named.conf.local validate: "named-checkconf %s" # файл не подменится, если конфиг битый notify: reload bind
Плюс роль настраивает не только BIND, но и firewall, apparmor, логи, то есть закрывает задачу целиком, а не её кусок.
Мой аргумент против ровно один, и он про масштаб сложности: как только появляются views со split‑horizon, catalog zones и dnssec‑policy, Jinja‑шаблон превращается в нечитаемое месиво из {% if %}, а ошибка в нём всё равно ловится только на этапе named-checkconf — то есть уже после того, как вы её написали, и без указания, в каком поле. Мне хотелось поймать её на этапе конструирования объекта. Но я вполне допускаю, что для большинства инсталляций это лечение болезни, которой у людей нет.
octoDNS
Самый зрелый инструмент в области, и я специально смотрел, не делаю ли я его хуже. Не делаю — он про другой слой. Его модель: YAML как источник истины для записей, провайдеры как таргеты, octodns-sync строит человекочитаемый план и ничего не применяет без --doit. Для BIND есть octodns-bind: AxfrSource вычитывает текущее состояние трансфером, ZoneFileSource/ZoneFileProvider читают и пишут zone‑файлы, Rfc2136Provider применяет изменения через nsupdate. named.conf он не трогает — views, ACL, ключи, dnssec‑policy остаются вашей проблемой.
И вот его черта, которой у меня нет и которой мне честно не хватает: чтение текущего состояния и дифф перед применением. У меня всё построено на «собрать с нуля и положить целиком». Для named.conf, который меняется раз в месяц, это приемлемо; для живых зон — нет.
Если разложить по слоям
Сервер: пакет, сервис, права, firewall, доставка — Ansible или любой другой config managementnamed.conf и начальное состояние зон — Jinja2-шаблон, самописный генератор — или вот это
Записи в живых зонах, дифф и применение — octoDNS, nsupdate, dnspython
Собственно, вопросы
1. Как у вас устроена доставка именно named.conf сейчас — шаблонизатор, руками, самописный генератор, или конфиг статический и не меняется годами?
2. Те, у кого views, split‑horizon или catalog zones: в какой момент шаблон перестал быть читаемым и что вы с этим сделали?
3. Если бы такой слой встраивался в ваш пайплайн — в каком виде? Ansible‑модуль или фильтр, генерация конфига в CI с артефактом, Terraform‑провайдер, просто скрипт?

