Началось всё не с идеи «напишу‑ка я библиотеку», а с гораздо более скучной задачи — разобраться в 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;, хотя у неё вообще нет statement type — только 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 management
named.conf и начальное состояние зон — Jinja2-шаблон, самописный генератор — или вот это
Записи в живых зонах, дифф и применение — octoDNS, nsupdate, dnspython

Собственно, вопросы

1. Как у вас устроена доставка именно named.conf сейчас — шаблонизатор, руками, самописный генератор, или конфиг статический и не меняется годами?

2. Те, у кого views, split‑horizon или catalog zones: в какой момент шаблон перестал быть читаемым и что вы с этим сделали?

3. Если бы такой слой встраивался в ваш пайплайн — в каком виде? Ansible‑модуль или фильтр, генерация конфига в CI с артефактом, Terraform‑провайдер, просто скрипт?

Код: https://github.com/DVSAWR/bindantic