Установка DNS сервера Bind9 на UbuntuServer 24.04 с WebMin в Proxmox
Развёртывание DNS-сервера BIND9 для OT-сети Самара
Статус документа: рабочая пошаговая инструкция, основанная на фактически выполненной и проверенной установке.
Редакция: 14, состояние проекта на 01.08.2026.
Текущее состояние: BIND9 работает как выделенный авторитетный DNS для одной прямой и восьми обратных OT-зон; рекурсия и forwarder отключены. DDNS включён во всех девяти рабочих зонах и ограничен сетями источников через
allow-update. Регистрация A/PTR подтверждена с реального Windows-клиента во всех пяти OT-сетях, включая вторые половины сетей/23—172.16.69.0/24и172.16.71.0/24. Для reverse-зон применены однозначные локальные SOA MNAME/NS-имена. Webmin проверен для безопасного редактирования динамических зон, синхронизации журналов.jnlи запуска процедур контроля старых PTR. Добавлен эксплуатационный регламент для подключения, переноса, переименования и удаления узлов.
1. Концепция проекта: что строим и как это работает
1.1. Задача проекта
В OT-сетях много устройств со статическими IP-адресами. Запоминать и сопровождать только адреса неудобно, поэтому создаётся собственная DNS-зона ot-samara.baltika.ru и набор обратных зон. В результате инженер может обращаться к оборудованию по понятному имени, а по IP-адресу можно определить зарегистрированное имя.
Имя оборудования → A-запись → IP-адрес
IP-адрес → PTR-запись → имя оборудования
Один экземпляр BIND9 обслуживает несколько OT-подсетей. У виртуальной машины есть отдельный локальный DNS-адрес в каждой сети, поэтому клиент обращается к адресу своего сегмента и не зависит от межвлановой маршрутизации.
1.2. Инструменты, лицензии и назначение
| Компонент | Для чего используется | Стоимость и лицензирование |
|---|---|---|
| Proxmox VE | Запускает отдельную виртуальную машину DNS и подключает её виртуальные сетевые карты к нужным bridge/VLAN. | Свободное ПО AGPLv3; использовать можно без платы. Платная подписка нужна только для enterprise-репозитория и коммерческой поддержки. Официальное описание. |
| Ubuntu Server 24.04 LTS | Гостевая операционная система, в которой работают BIND9, UFW, Webmin и диагностические утилиты. | Свободно скачивается и используется; стандартная поддержка LTS предоставляется бесплатно, Ubuntu Pro является дополнительной услугой. Официальная страница. |
| BIND9 | Авторитетный DNS-сервер: хранит зоны и отвечает на запросы A, CNAME, PTR, NS и SOA; принимает DDNS от разрешённых статических Windows-клиентов. | Свободное ПО под MPL 2.0, отдельная лицензия не требуется. Официальная страница ISC. |
| Webmin | Веб-интерфейс для редкого ручного добавления и изменения DNS-записей, просмотра служб и базового администрирования. | Свободное ПО под BSD-3-Clause, отдельная лицензия не требуется. Официальное описание. |
| UFW | Host firewall: разрешает DNS только из нужных OT-сетей, а SSH и Webmin — только из management-сетей. | Входит в репозиторий Ubuntu; отдельной платы нет. |
| systemd, rndc, dig, named-checkconf, named-checkzone, tcpdump | Управление службами, безопасное перечитывание BIND, проверка синтаксиса, DNS-тесты и анализ трафика. | Штатные свободные компоненты Ubuntu/BIND, отдельной платы нет. |
| QEMU Guest Agent | Передаёт Proxmox сведения о гостевой системе и обеспечивает корректное взаимодействие с ВМ. | Свободный пакет из Ubuntu, отдельной платы нет. |
| BookStack | Хранит эту инструкцию, схему адресации, правила эксплуатации и историю решений. В DNS-службу не входит. | Свободное ПО под MIT License. Официальное описание. |
| Windows-клиенты | Проверяют прямое и обратное разрешение; компьютеры со статическими IP выполняют DDNS. | Используются уже имеющиеся лицензированные ОС; в стоимость серверного решения не входят. |
Итог по стоимости: весь серверный программный стек можно развернуть без лицензионных платежей. Платными остаются только необязательная коммерческая поддержка/подписки и существующие лицензии Windows.
1.3. Как проходит обычный DNS-запрос
OT-клиент
↓ UDP/53 или TCP/53 на локальный DNS-адрес своей подсети
UFW
↓ проверяет входной интерфейс, сеть источника, адрес назначения и порт
BIND9
↓ читает авторитетную прямую или обратную зону
Ответ клиенту: IP по имени или имя по IP
Для имён из ot-samara.baltika.ru сервер отвечает авторитетно. Для внешних доменов он возвращает REFUSED, потому что рекурсия и пересылка запросов намеренно отключены.
1.4. Как выполняется администрирование
Компьютер администратора
↓ HTTPS/TCP 10000 через management-сеть
Webmin
↓ для динамической зоны выполняет Freeze → Edit → Thaw
BIND9
↓ сохраняет live-изменения DDNS в журнале .jnl
Пользовательская команда «Синхронизировать DDNS-зоны»
↓ выполняет rndc sync для всех рабочих зон
Файлы зон и Webmin
↓ показывают актуальное синхронизированное состояние
Webmin не заменяет BIND9 и не хранит отдельную базу DNS-записей. Работающий named обслуживает динамические зоны из основного файла вместе с журналом .jnl. Поэтому просмотр файла зоны или страницы Webmin без предварительного rndc sync может не показать только что полученную DDNS-запись, хотя запрос dig уже возвращает её.
После ручного изменения динамической зоны проверяются named-checkzone, named-checkconf -z, состояние rndc zonestatus и контрольные запросы dig. Целевое состояние каждой рабочей зоны: dynamic: yes, frozen: no.
1.5. Текущий и планируемый режим наполнения зон
| Тип узла | Как появляются записи |
|---|---|
| Статические Windows-компьютеры | DNS Client регистрирует A и PTR через RFC 2136. На OT-интерфейсе задаются статический адрес, локальный DNS своей сети и суффикс ot-samara.baltika.ru. |
| ПЛК, HMI, счётчики и устройства без DDNS | Пары A/PTR добавляются вручную через Webmin по процедуре безопасного редактирования динамической зоны. |
| Старые PTR после переноса Windows-хоста | Проверяются и удаляются отдельной утилитой /usr/local/sbin/bind-ptr-mismatch; Windows не гарантирует удаление PTR из прежней reverse-зоны. |
DDNS разрешён только из проектных OT-сетей. Это сетевое ограничение, а не криптографическая аутентификация: узел с разрешённым адресом потенциально может отправлять UPDATE. Для усиления защиты позднее следует рассмотреть TSIG, GSS-TSIG либо централизованное обновление через доверенный DHCP.
Сервер не выполняет функции DHCP, маршрутизатора или публичного recursive DNS. Он является специализированным авторитетным DNS-сервисом для OT.
1.6. Как устроены исполняемые примеры в этой инструкции
Команды создания конфигураций приведены в исполняемом виде: с точными путями, именами интерфейсов, IP-адресами, зонами и комментариями для каждой сети. Для записи файлов используется команда tee с heredoc: содержимое между строками EOF записывается в указанный файл без подстановки переменных оболочки.
Важно: блоки «ожидаемый результат» не вводятся в терминал. Конфигурация Proxmox-хоста не заменяется готовым
/etc/network/interfaces, потому что этот файл содержит посторонние для DNS-сервера bridge, адреса и физические интерфейсы конкретного узла. Для Proxmox приведена точная карта уже используемых bridge, VLAN-подинтерфейсов, VM netX и MAC-адресов.
2. Принятая архитектура
DNS-сервер разворачивается на отдельной виртуальной машине в Proxmox VE. BIND9 устанавливается нативно из репозитория Ubuntu, без Docker.
| Параметр | Значение |
|---|---|
| Имя виртуальной машины | SMR-SRV-OT-DNS |
| Hostname Ubuntu | smr-srv-ot-dns |
| Операционная система | Ubuntu Server 24.04.4 LTS |
| DNS-сервер | BIND9, нативная установка |
| Режим DNS | Авторитетный сервер только для собственных OT-зон |
| Рекурсия | Отключена |
| Forwarder | Не используется |
| Контейнеризация | Не используется |
| Назначение ВМ | DNS, диагностика и системное администрирование |
| Веб-интерфейс администрирования | Webmin 2.653, HTTPS/TCP 10000, доступ только из management-сетей |
Нативная установка выбрана намеренно: виртуальная машина уже обеспечивает изоляцию, а работа с несколькими IP-адресами, файлами зон, systemd, rndc и журналами DDNS .jnl остаётся прозрачной.
2.1. Авторитетный режим без рекурсии
Сервер отвечает только за зоны, перечисленные в /etc/bind/named.conf.local. Для внешних доменов, например ubuntu.com, сервер возвращает REFUSED.
OT-клиент → запрос имени из ot-samara.baltika.ru → локальный авторитетный ответ
OT-клиент → запрос внешнего имени → REFUSED
Это исключает зависимость от внешнего DNS и не превращает OT-сервер в рекурсивный resolver.
3. План DNS-зон
3.1. Прямая зона
ot-samara.baltika.ru
Примеры имён оборудования:
plc-brew-001.ot-samara.baltika.ru
hmi-brew-001.ot-samara.baltika.ru
plc-fill-001.ot-samara.baltika.ru
hmi-util-001.ot-samara.baltika.ru
3.2. Обратные зоны
| IP-сеть | Назначение | Обратная DNS-зона |
|---|---|---|
10.255.0.0/16 |
Legacy OT | 255.10.in-addr.arpa |
172.16.66.0/24 |
Пивопроизводство, первая половина /23 | 66.16.172.in-addr.arpa |
172.16.67.0/24 |
Пивопроизводство, вторая половина /23 | 67.16.172.in-addr.arpa |
172.16.68.0/24 |
Розлив, первая половина /23 | 68.16.172.in-addr.arpa |
172.16.69.0/24 |
Розлив, вторая половина /23 | 69.16.172.in-addr.arpa |
172.16.70.0/24 |
Утилиты, первая половина /23 | 70.16.172.in-addr.arpa |
172.16.71.0/24 |
Утилиты, вторая половина /23 | 71.16.172.in-addr.arpa |
172.17.14.0/24 |
АСТУЭ, VLAN 2006 | 14.17.172.in-addr.arpa |
В домене
in-addr.arpaоктеты записываются в обратном порядке. Поэтому для сети10.255.0.0/16используется зона255.10.in-addr.arpa, а не10.255.in-addr.arpa.
Каждая сеть /23 состоит из двух соседних сетей /24, поэтому для неё создаются две стандартные обратные зоны.
4. Сетевая схема
4.1. Текущее состояние OT-сети
| Сеть | VLAN | Состояние |
|---|---|---|
10.255.0.0/16 |
VLAN 1 | Действующая legacy-сеть |
172.16.66.0/23 |
VLAN 1 | Действующая производственная сеть |
Обе подсети пока находятся в одном L2-домене и назначены одному интерфейсу Ubuntu.
4.2. Состояние после модернизации
| Сеть | VLAN | Назначение | Локальный DNS-адрес |
|---|---|---|---|
172.16.66.0/23 |
2030 | Пивопроизводство | 172.16.66.53 |
172.16.68.0/23 |
2002 | Розлив | 172.16.68.53 |
172.16.70.0/23 |
2040 | Утилиты | 172.16.70.53 |
172.17.14.0/24 |
2006 | АСТУЭ | 172.17.14.53 |
10.255.0.0/16 |
1 | Legacy OT на переходный период | 10.255.0.53 |
4.3. Адреса управления
| Этап | Адрес | Шлюз | Назначение |
|---|---|---|---|
| Развёртывание | 192.168.1.53/24 |
192.168.1.1 |
Временное управление, обновления и доступ в Интернет |
| Production | 10.44.100.228/24 |
10.44.100.1 |
Постоянное управление сервером; default route пока не активирован |
На сервере должен быть только один маршрут по умолчанию. Сейчас активен маршрут через
ens18 → 192.168.1.1. Будущий маршрут черезens23 → 10.44.100.1оставлен в Netplan комментарием.
4.4. Важное ограничение маршрутизации
ВМ многосетевая, но не является маршрутизатором:
net.ipv4.ip_forward = 0
net.ipv6.conf.all.forwarding = 0
На OT-интерфейсах шлюзы не назначаются. Каждый OT-клиент обращается к DNS-адресу своей локальной подсети.
5. Proxmox и интерфейсы виртуальной машины
5.1. Рабочая схема bridge
| Ubuntu | MAC | Proxmox | Назначение | Адрес |
|---|---|---|---|---|
ens18 |
BC:24:11:AE:75:CD |
net0 / vmbr0 |
Временное управление | 192.168.1.53/24 |
ens19 |
BC:24:11:DB:4F:A2 |
net1 / vmbr7 / nic1.2006 |
VLAN 2006, АСТУЭ | 172.17.14.53/24 |
ens20 |
BC:24:11:6B:32:0E |
net2 / vmbr3 / nic1.2002 |
VLAN 2002, Розлив | 172.16.68.53/23 |
ens21 |
BC:24:11:E4:E6:A9 |
net3 / vmbr4 / nic1.2030 |
VLAN 2030, Пивопроизводство | Пока без IPv4; позднее 172.16.66.53/23 |
ens22 |
BC:24:11:32:D6:01 |
net4 / vmbr5 / nic1.2040 |
VLAN 2040, Утилиты | 172.16.70.53/23 |
ens23 |
BC:24:11:D1:4C:AE |
net5 / vmbr1 / nic1.2100 |
Production management | 10.44.100.228/24 |
enp2s1 |
BC:24:11:7E:0F:4D |
net6 / vmbr6 / nic2 |
Untagged access VLAN 1 | 10.255.0.53/16, 172.16.66.53/23 |
5.2. Почему VLAN 1 подключён через отдельный физический access-порт
Рабочий путь VLAN 1:
Коммутатор: access VLAN 1 / PVID 1
↓ untagged Ethernet
Proxmox nic2
↓
vmbr6
↓
VM net6
↓
Ubuntu enp2s1
Предыдущая схема nic1.1 → vmbr2 была удалена. Linux-подинтерфейс nic1.1 ожидает кадры с тегом VLAN 1, тогда как access/native VLAN 1 приходит без тега.
5.3. Подключение VLAN 2006 для АСТУЭ
Рабочий путь VLAN 2006:
Коммутатор: tagged VLAN 2006
↓
Proxmox nic1.2006
↓ тег снимается на Linux VLAN-интерфейсе
vmbr7
↓
VM net1, MAC BC:24:11:DB:4F:A2
↓ обычный нетегированный Ethernet внутри ВМ
Ubuntu ens19 → 172.17.14.53/24
VLAN-интерфейс внутри Ubuntu создавать не требуется. Тег 2006 обрабатывается Proxmox на nic1.2006, а гостевая система получает обычный виртуальный Ethernet-интерфейс ens19.
5.4. Состояние физического линка и виртуального интерфейса
Внутри ВМ интерфейс может показывать UP, LOWER_UP даже при отключённом кабеле nic2. Гостевая ОС видит виртуальный VirtIO/tap-порт, а не физический carrier хоста.
Физический линк проверяется на Proxmox:
ip -br link show nic2
cat /sys/class/net/nic2/carrier
ethtool nic2 | grep -E 'Speed:|Duplex:|Link detected:'
bridge link show master vmbr6
carrier = 0 означает отключённый физический линк, carrier = 1 — подключённый.
5.5. Правило миграции VLAN 2030
Адрес
172.16.66.53/23нельзя одновременно назначать интерфейсамenp2s1иens21. Во время миграции он удаляется с VLAN 1 и в том же изменении назначается интерфейсу VLAN 2030.
6. Параметры виртуальной машины и установка Ubuntu
| Ресурс | Значение |
|---|---|
| CPU | 2 vCPU |
| RAM | 4 ГБ |
| Диск | 32 ГБ |
| Дисковый контроллер | VirtIO SCSI |
| Сетевые карты | VirtIO |
| QEMU Guest Agent | Включён в настройках ВМ |
Параметры установщика:
- hostname:
smr-srv-ot-dns; - установить OpenSSH Server;
- не устанавливать Docker и дополнительные snaps;
- во время установки активировать только management-интерфейс;
- на остальных интерфейсах отключить DHCP.
6.1. Установка hostname после первого входа
Даже если hostname уже задан установщиком, команда ниже приводит его к проектному значению и сразу показывает результат.
# hostnamectl меняет статическое имя узла через systemd-hostnamed.
sudo hostnamectl set-hostname smr-srv-ot-dns
# Проверяем короткое имя и полное состояние hostname.
hostname
hostnamectl status
7. Базовая подготовка Ubuntu
7.1. Обновление системы
sudo apt update
sudo apt full-upgrade -y
apt update обновляет индекс пакетов. apt full-upgrade устанавливает обновления и при необходимости корректно изменяет зависимости.
Перезагрузка выполняется только тогда, когда система её запросила. Проверка:
# -f проверяет существование обычного файла. if [ -f /var/run/reboot-required ]; then cat /var/run/reboot-required.pkgs systemctl reboot fiПосле загрузки повторно проверяются сеть,
named,webmin, конфигурация зон и контрольные запросыdig. Конкретный пакет, который потребовал reboot на отдельной установке, для универсальной инструкции несущественен.
7.2. Базовые пакеты
sudo apt install -y \
ca-certificates \
curl \
nano \
openssl \
iputils-ping \
tcpdump \
arping \
bash-completion \
lsof \
qemu-guest-agent \
systemd-timesyncd \
ufw
| Пакет | Назначение |
|---|---|
ca-certificates |
Проверка TLS-сертификатов |
curl |
Проверка HTTP/HTTPS и получение файлов |
nano |
Редактирование конфигурационных файлов |
openssl |
Криптографические операции и диагностика TLS |
iputils-ping |
Проверка IP-доступности |
tcpdump |
Анализ DNS, ARP, ICMP и NTP-трафика |
arping |
Проверка адресных конфликтов и L2-доступности |
bash-completion |
Автодополнение команд |
lsof |
Поиск процессов, использующих файлы и порты |
qemu-guest-agent |
Интеграция Ubuntu с Proxmox |
systemd-timesyncd |
Синхронизация системного времени |
ufw |
Host firewall |
8. QEMU Guest Agent
systemctl status qemu-guest-agent --no-pager
ls -l /dev/virtio-ports/
Ожидается:
Active: active (running)
/dev/virtio-ports/org.qemu.guest_agent.0
Служба может иметь тип static. Это нормально: она запускается при наличии virtio-канала, созданного Proxmox.
9. Часовой пояс и NTP
9.1. Часовой пояс
sudo timedatectl set-timezone Europe/Samara
sudo timedatectl set-local-rtc 0
sudo timedatectl set-ntp true
9.2. Временный NTP
sudo install -d -m 0755 /etc/systemd/timesyncd.conf.d
sudo tee /etc/systemd/timesyncd.conf.d/10-internal-ntp.conf >/dev/null <<'EOF'
[Time]
NTP=ntp.ubuntu.com
FallbackNTP=
EOF
sudo systemctl restart systemd-timesyncd
9.3. Проверка
timedatectl status
timedatectl timesync-status
systemctl status systemd-timesyncd --no-pager
journalctl -u systemd-timesyncd --since "5 minutes ago" --no-pager
Подтверждённое состояние: часы синхронизированы, источник ntp.ubuntu.com, stratum 2.
9.4. Production NTP
После окончательного перевода default route в production management планируются серверы:
10.44.14.34
10.44.14.33
Потребуется маршрут от 10.44.100.228 и разрешение UDP/123. Пока default route остаётся через ens18, эта замена не выполняется.
10. Netplan
10.1. Отключение управления сетью со стороны cloud-init
sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg >/dev/null <<'EOF'
network: {config: disabled}
EOF
sudo mv /etc/netplan/50-cloud-init.yaml /etc/netplan/50-cloud-init.yaml.disabled
10.2. Актуальный файл /etc/netplan/01-ot-dns.yaml
Создаём файл целиком одной командой:
# tee получает текст из heredoc и полностью записывает файл Netplan.
# Кавычки в <<'EOF' запрещают shell подставлять переменные внутри YAML.
sudo tee /etc/netplan/01-ot-dns.yaml >/dev/null <<'EOF'
network:
version: 2
renderer: networkd
ethernets:
# Временная management-сеть и единственный действующий default route.
ens18:
dhcp4: false
dhcp6: false
addresses:
- 192.168.1.53/24
nameservers:
addresses:
- 192.168.1.1
routes:
- to: default
via: 192.168.1.1
# АСТУЭ, VLAN 2006 через Proxmox vmbr7 / nic1.2006.
ens19:
dhcp4: false
dhcp6: false
accept-ra: false
link-local: []
optional: true
addresses:
- 172.17.14.53/24
# Legacy OT и временное Пивопроизводство в untagged VLAN 1.
enp2s1:
dhcp4: false
dhcp6: false
accept-ra: false
link-local: []
optional: true
addresses:
- 10.255.0.53/16
- 172.16.66.53/23
# Розлив, VLAN 2002 через Proxmox vmbr3 / nic1.2002.
ens20:
dhcp4: false
dhcp6: false
accept-ra: false
link-local: []
optional: true
addresses:
- 172.16.68.53/23
# Будущее Пивопроизводство, VLAN 2030 через vmbr4 / nic1.2030.
# Адрес нельзя включать, пока 172.16.66.53/23 назначен enp2s1.
ens21:
dhcp4: false
dhcp6: false
accept-ra: false
link-local: []
optional: true
# addresses:
# - 172.16.66.53/23
# Утилиты, VLAN 2040 через Proxmox vmbr5 / nic1.2040.
ens22:
dhcp4: false
dhcp6: false
accept-ra: false
link-local: []
optional: true
addresses:
- 172.16.70.53/23
# Production management, VLAN 2100 через vmbr1 / nic1.2100.
ens23:
dhcp4: false
dhcp6: false
accept-ra: false
link-local: []
optional: true
addresses:
- 10.44.100.228/24
# Будущий production default route.
# Включается одновременно с удалением default route у ens18.
# routes:
# - to: default
# via: 10.44.100.1
EOF
10.3. Безопасное применение
sudo chmod 600 /etc/netplan/01-ot-dns.yaml
sudo netplan generate
sudo netplan try --timeout 120
netplan try временно применяет конфигурацию и автоматически откатывает её, если администратор не подтвердит изменения.
10.4. Подтверждённое состояние
ens18 192.168.1.53/24
ens19 172.17.14.53/24
enp2s1 10.255.0.53/16 172.16.66.53/23
ens20 172.16.68.53/23
ens21 без IPv4
ens22 172.16.70.53/23
ens23 10.44.100.228/24
Активный маршрут по умолчанию:
default via 192.168.1.1 dev ens18
Проверена доступность production management: 10.44.100.225, 10.44.100.1 и 10.44.100.210 отвечают через ens23. Для АСТУЭ подтверждён связанный маршрут 172.17.14.0/24 dev ens19 src 172.17.14.53; единственный default route остался через ens18.
11. Запрет маршрутизации и UFW
11.1. IP forwarding
sudo tee /etc/sysctl.d/90-ot-dns-no-forwarding.conf >/dev/null <<'EOF'
net.ipv4.ip_forward=0
net.ipv6.conf.all.forwarding=0
EOF
sudo sysctl --system
Проверка:
sysctl net.ipv4.ip_forward
sysctl net.ipv6.conf.all.forwarding
11.2. Текущие правила UFW
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw default deny routed
sudo ufw allow in on ens18 proto tcp \
from 192.168.1.0/24 to 192.168.1.53 port 22 \
comment 'SSH temporary management network'
sudo ufw allow in on ens23 proto tcp \
from 10.44.100.0/24 to 10.44.100.228 port 22 \
comment 'SSH production management network'
sudo ufw enable
sudo ufw status verbose
Подтверждённое состояние:
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
11.3. DNS-правила UFW — применены
Для каждого DNS-адреса разрешены оба штатных транспортных протокола DNS: UDP/53 и TCP/53. Правила ограничены одновременно входным интерфейсом, сетью источника, адресом назначения и портом.
| Сеть клиентов | Входной интерфейс | DNS-адрес | Состояние |
|---|---|---|---|
10.255.0.0/16 |
enp2s1 |
10.255.0.53 |
Действующее правило VLAN 1 |
172.16.66.0/23 |
enp2s1 |
172.16.66.53 |
Действующее временное правило VLAN 1 |
172.16.68.0/23 |
ens20 |
172.16.68.53 |
Действующее правило VLAN 2002 |
172.16.70.0/23 |
ens22 |
172.16.70.53 |
Действующее правило VLAN 2040 |
172.16.66.0/23 |
ens21 |
172.16.66.53 |
Подготовлено для будущего VLAN 2030 |
172.17.14.0/24 |
ens19 |
172.17.14.53 |
Действующее правило VLAN 2006, АСТУЭ |
# Legacy OT, VLAN 1
sudo ufw allow in on enp2s1 proto udp \
from 10.255.0.0/16 to 10.255.0.53 port 53 \
comment 'DNS legacy OT UDP'
sudo ufw allow in on enp2s1 proto tcp \
from 10.255.0.0/16 to 10.255.0.53 port 53 \
comment 'DNS legacy OT TCP'
# Пивопроизводство, временно VLAN 1
sudo ufw allow in on enp2s1 proto udp \
from 172.16.66.0/23 to 172.16.66.53 port 53 \
comment 'DNS beer production current VLAN1 UDP'
sudo ufw allow in on enp2s1 proto tcp \
from 172.16.66.0/23 to 172.16.66.53 port 53 \
comment 'DNS beer production current VLAN1 TCP'
# Розлив, VLAN 2002
sudo ufw allow in on ens20 proto udp \
from 172.16.68.0/23 to 172.16.68.53 port 53 \
comment 'DNS bottling VLAN2002 UDP'
sudo ufw allow in on ens20 proto tcp \
from 172.16.68.0/23 to 172.16.68.53 port 53 \
comment 'DNS bottling VLAN2002 TCP'
# Утилиты, VLAN 2040
sudo ufw allow in on ens22 proto udp \
from 172.16.70.0/23 to 172.16.70.53 port 53 \
comment 'DNS utilities VLAN2040 UDP'
sudo ufw allow in on ens22 proto tcp \
from 172.16.70.0/23 to 172.16.70.53 port 53 \
comment 'DNS utilities VLAN2040 TCP'
# Пивопроизводство, будущее подключение VLAN 2030
sudo ufw allow in on ens21 proto udp \
from 172.16.66.0/23 to 172.16.66.53 port 53 \
comment 'DNS beer production future VLAN2030 UDP'
sudo ufw allow in on ens21 proto tcp \
from 172.16.66.0/23 to 172.16.66.53 port 53 \
comment 'DNS beer production future VLAN2030 TCP'
# АСТУЭ, VLAN 2006
sudo ufw allow in on ens19 proto udp \
from 172.17.14.0/24 to 172.17.14.53 port 53 \
comment 'DNS ASTUE VLAN2006 UDP'
sudo ufw allow in on ens19 proto tcp \
from 172.17.14.0/24 to 172.17.14.53 port 53 \
comment 'DNS ASTUE VLAN2006 TCP'
sudo ufw reload
sudo ufw status numbered
sudo ufw show added
Правила на ens21 не переносят IP-адрес и не создают прослушивающий сокет. Пока 172.16.66.53/23 находится на enp2s1, они фактически бездействуют. После миграции адреса в VLAN 2030 они начнут применяться автоматически.
После окончательной миграции два старых правила для 172.16.66.53 на enp2s1 необходимо удалить. Порт 53 на management-интерфейсах ens18 и ens23 не открывается.
11.4. Предварительная проверка UFW до установки BIND9
На этом этапе BIND9 ещё не установлен, поэтому проверяются только правила firewall и запрет маршрутизации. Отсутствие процесса named, слушающего порта 53 и ответов dig здесь является ожидаемым состоянием.
# Проверяем активные правила и команды, которыми они были добавлены.
sudo ufw status numbered
sudo ufw show added
# Повторно убеждаемся, что ВМ не маршрутизирует трафик между OT-сетями.
sysctl net.ipv4.ip_forward
sysctl net.ipv6.conf.all.forwarding
Команды systemctl is-active named, ss ... | grep named, journalctl -u named и запросы dig выполняются только после установки BIND9, создания зон и запуска службы — в разделе 18.
12. Установка BIND9
sudo apt update
sudo apt install -y bind9 bind9-utils dnsutils
| Пакет | Назначение |
|---|---|
bind9 |
Служба named |
bind9-utils |
named-checkconf, named-checkzone, rndc |
dnsutils |
dig, host, nslookup |
Подтверждённая версия на момент установки:
BIND 9.18.39-0ubuntu0.24.04.5-Ubuntu
bind9.service является alias для основной службы named.service. Поэтому журнал удобнее смотреть через journalctl -u named.
12.1. Резервная копия исходной конфигурации
backup_dir="/root/bind9-before-configuration-$(date +%F-%H%M%S)"
sudo mkdir -m 0700 "$backup_dir"
sudo cp -a /etc/bind "$backup_dir/"
sudo tar -C "$backup_dir" -czf "${backup_dir}.tar.gz" bind
sudo sha256sum "${backup_dir}.tar.gz"
13. Глобальная конфигурация BIND
13.1. Главный файл /etc/bind/named.conf
Главный файл не содержит зон напрямую: он подключает три отдельных файла. Это штатная структура пакета BIND9 в Ubuntu.
# Полностью записываем главный конфигурационный файл BIND.
sudo tee /etc/bind/named.conf >/dev/null <<'EOF'
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
include "/etc/bind/named.conf.default-zones";
EOF
13.2. Файл /etc/bind/named.conf.options
# Создаём ACL клиентов и полный блок options.
sudo tee /etc/bind/named.conf.options >/dev/null <<'EOF'
// Сети, которым разрешено обращаться к авторитетным OT-зонам.
acl "ot-clients" {
localhost;
10.255.0.0/16; // Legacy OT, VLAN 1.
172.16.66.0/23; // Пивопроизводство: VLAN 1, позднее VLAN 2030.
172.16.68.0/23; // Розлив, VLAN 2002.
172.16.70.0/23; // Утилиты, VLAN 2040.
172.17.14.0/24; // АСТУЭ, VLAN 2006.
};
options {
directory "/var/cache/bind";
// BIND слушает localhost и только сервисные OT-адреса.
// Management-адреса 192.168.1.53 и 10.44.100.228 сюда не входят.
listen-on {
127.0.0.1;
10.255.0.53; // Legacy OT, VLAN 1.
172.16.66.53; // Пивопроизводство.
172.16.68.53; // Розлив, VLAN 2002.
172.16.70.53; // Утилиты, VLAN 2040.
172.17.14.53; // АСТУЭ, VLAN 2006.
};
listen-on-v6 { none; };
allow-query { ot-clients; };
// Сервер является только авторитетным и не разрешает внешние имена.
recursion no;
allow-query-cache { none; };
dnssec-validation no;
// Secondary DNS пока отсутствует.
allow-transfer { none; };
version "not disclosed";
minimal-responses yes;
auth-nxdomain no;
};
EOF
13.3. Смысл ключевых параметров
| Параметр | Назначение |
|---|---|
acl "ot-clients" |
Перечень сетей, которым разрешены DNS-запросы |
listen-on |
Точные локальные адреса, на которых BIND открывает TCP/UDP 53 |
listen-on-v6 { none; } |
Запрет DNS-прослушивания по IPv6 |
allow-query |
Кто может спрашивать записи авторитетных зон |
recursion no |
Не разрешать внешние имена через другие DNS-серверы |
allow-query-cache { none; } |
Не предоставлять клиентам рекурсивный кэш |
dnssec-validation no |
Валидация рекурсивных ответов не нужна, потому что рекурсия отключена |
allow-transfer { none; } |
Запрет AXFR/IXFR до появления secondary DNS |
version "not disclosed" |
Не публиковать точную версию в version.bind |
В конфигурации намеренно отсутствуют:
# В действующей конфигурации эти директивы должны отсутствовать.
# Пустой вывод означает, что forwarder не настроен.
grep -RniE '^\s*(forwarders|forward only|forward first)\b' /etc/bind
13.4. Проверка и перечитывание
sudo named-checkconf
sudo rndc reconfig
sudo rndc flush
systemctl is-active named
Внешний запрос должен быстро получить отказ:
dig @127.0.0.1 ubuntu.com A +norecurse
Ожидается status: REFUSED.
14. Файлы зон
Назначение раздела: команды
teeниже предназначены только для первичного развёртывания, когда проектные зоны ещё не объявлены вnamed.conf.local, не загружены работающимnamedи для них ещё не существуют журналы.jnl. В этом режиме выполнятьrndc freezeне требуется: замораживать ещё нечего.На уже работающем сервере: не выполнять эти блоки повторно и не перезаписывать файлы зон через
tee. Этот раздел нужно пропустить и перейти к регламенту обслуживания динамических зон в разделе 21; все необходимые средства управления к этому моменту уже устанавливаются в предыдущих разделах.
Перед созданием файлов проверяем, что проектные файлы и журналы ещё отсутствуют. Пустой вывод означает, что можно продолжать первичное развёртывание:
for file in \
/var/lib/bind/zones/db.ot-samara.baltika.ru \
/var/lib/bind/zones/db.255.10 \
/var/lib/bind/zones/db.66.16.172 \
/var/lib/bind/zones/db.67.16.172 \
/var/lib/bind/zones/db.68.16.172 \
/var/lib/bind/zones/db.69.16.172 \
/var/lib/bind/zones/db.70.16.172 \
/var/lib/bind/zones/db.71.16.172 \
/var/lib/bind/zones/db.14.17.172
do
if [ -e "$file" ] || [ -e "$file.jnl" ]; then
echo "STOP: уже существует $file или $file.jnl"
fi
done
Если команда вывела хотя бы одну строку
STOP, данный раздел не является процедурой изменения существующей системы. Переходите к разделу 21 и не копируйте стартовые serial из примеров поверх live-зон.
14.1. Каталог зон
sudo install -d -o bind -g bind -m 0750 /var/lib/bind/zones
Каталог размещён в /var/lib/bind, потому что BIND создаёт здесь журналы динамических обновлений .jnl.
14.2. Прямая зона
На этом шаге создаётся стартовый файл зоны. Динамической она станет позднее, после объявления allow-update в named.conf.local и загрузки зоны командой rndc reconfig либо при запуске named.
Стартовый serial
2026073100во всех примерах этого раздела относится только к первичному созданию файлов. На существующей системе его нельзя копировать поверх текущего serial.
Ниже приведён воспроизводимый базовый файл без временных клиентских записей. После запуска DDNS фактический serial и клиентские A-записи могут увеличиваться и находиться в .jnl до выполнения rndc sync.
# Полностью создаём базовый файл прямой зоны.
sudo tee /var/lib/bind/zones/db.ot-samara.baltika.ru >/dev/null <<'EOF'
$ORIGIN ot-samara.baltika.ru.
$TTL 3600
@ IN SOA smr-srv-ot-dns.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS smr-srv-ot-dns.ot-samara.baltika.ru.
; Фактическое имя многосетевого DNS-сервера.
smr-srv-ot-dns IN A 10.255.0.53
smr-srv-ot-dns IN A 172.16.66.53
smr-srv-ot-dns IN A 172.16.68.53
smr-srv-ot-dns IN A 172.16.70.53
smr-srv-ot-dns IN A 172.17.14.53
; Однозначные имена primary для reverse-зон.
ns-10-255 1200 IN A 10.255.0.53
ns-172-16-66 1200 IN A 172.16.66.53
ns-172-16-68 1200 IN A 172.16.68.53
ns-172-16-70 1200 IN A 172.16.70.53
ns-172-17-14 1200 IN A 172.17.14.53
dns IN CNAME smr-srv-ot-dns
EOF
sudo chown bind:bind /var/lib/bind/zones/db.ot-samara.baltika.ru
sudo chmod 0640 /var/lib/bind/zones/db.ot-samara.baltika.ru
Имя smr-srv-ot-dns сохраняет пять сервисных адресов и остаётся SOA/NS прямой зоны. Имена ns-* предназначены для reverse-зон и каждое имеет строго одну A-запись, доступную клиентам соответствующей сети.
14.3. Контакт администратора в SOA
Во всех девяти зонах используется одно фактическое значение RNAME:
hostmaster.ot-samara.baltika.ru.
В DNS-формате первая точка заменяет символ @, поэтому значение соответствует адресу hostmaster@ot-samara.baltika.ru. BIND не отправляет на него почту автоматически. Изменение контакта является изменением данных зоны и требует увеличения SOA serial, проверки named-checkzone и перечитывания зоны.
14.4. Обратные зоны
На этом шаге создаются стартовые файлы восьми обратных зон. Динамическими они станут после объявления allow-update и загрузки конфигурации BIND. Для каждой сети заранее задаётся локальное имя primary с единственной A-записью; это устраняет неоднозначность выбора адреса сервером DNS Client Windows.
| Reverse-зона | SOA MNAME и NS | A primary |
|---|---|---|
255.10.in-addr.arpa |
ns-10-255.ot-samara.baltika.ru. |
10.255.0.53 |
66.16.172.in-addr.arpa, 67.16.172.in-addr.arpa |
ns-172-16-66.ot-samara.baltika.ru. |
172.16.66.53 |
68.16.172.in-addr.arpa, 69.16.172.in-addr.arpa |
ns-172-16-68.ot-samara.baltika.ru. |
172.16.68.53 |
70.16.172.in-addr.arpa, 71.16.172.in-addr.arpa |
ns-172-16-70.ot-samara.baltika.ru. |
172.16.70.53 |
14.17.172.in-addr.arpa |
ns-172-17-14.ot-samara.baltika.ru. |
172.17.14.53 |
14.4.1. /var/lib/bind/zones/db.255.10
# Legacy OT: 10.255.0.0/16, VLAN 1.
sudo tee /var/lib/bind/zones/db.255.10 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-10-255.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-10-255.ot-samara.baltika.ru.
53.0 IN PTR smr-srv-ot-dns.ot-samara.baltika.ru.
EOF
14.4.2. /var/lib/bind/zones/db.66.16.172
# Пивопроизводство: 172.16.66.0/24.
sudo tee /var/lib/bind/zones/db.66.16.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-16-66.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-16-66.ot-samara.baltika.ru.
53 IN PTR smr-srv-ot-dns.ot-samara.baltika.ru.
EOF
14.4.3. /var/lib/bind/zones/db.67.16.172
# Пивопроизводство: 172.16.67.0/24, вторая половина /23.
sudo tee /var/lib/bind/zones/db.67.16.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-16-66.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-16-66.ot-samara.baltika.ru.
; Клиентские PTR создаются динамически или вручную через Webmin.
EOF
14.4.4. /var/lib/bind/zones/db.68.16.172
# Розлив: 172.16.68.0/24, VLAN 2002.
sudo tee /var/lib/bind/zones/db.68.16.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-16-68.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-16-68.ot-samara.baltika.ru.
53 IN PTR smr-srv-ot-dns.ot-samara.baltika.ru.
EOF
14.4.5. /var/lib/bind/zones/db.69.16.172
# Розлив: 172.16.69.0/24, вторая половина /23.
sudo tee /var/lib/bind/zones/db.69.16.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-16-68.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-16-68.ot-samara.baltika.ru.
; Клиентские PTR создаются динамически или вручную через Webmin.
EOF
14.4.6. /var/lib/bind/zones/db.70.16.172
# Утилиты: 172.16.70.0/24, VLAN 2040.
sudo tee /var/lib/bind/zones/db.70.16.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-16-70.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-16-70.ot-samara.baltika.ru.
53 IN PTR smr-srv-ot-dns.ot-samara.baltika.ru.
EOF
14.4.7. /var/lib/bind/zones/db.71.16.172
# Утилиты: 172.16.71.0/24, вторая половина /23.
sudo tee /var/lib/bind/zones/db.71.16.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-16-70.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-16-70.ot-samara.baltika.ru.
; Клиентские PTR создаются динамически или вручную через Webmin.
EOF
14.4.8. /var/lib/bind/zones/db.14.17.172
# АСТУЭ: 172.17.14.0/24, VLAN 2006.
sudo tee /var/lib/bind/zones/db.14.17.172 >/dev/null <<'EOF'
$TTL 3600
@ IN SOA ns-172-17-14.ot-samara.baltika.ru. hostmaster.ot-samara.baltika.ru. (
2026073100
3600
900
1209600
300
)
@ IN NS ns-172-17-14.ot-samara.baltika.ru.
53 IN PTR smr-srv-ot-dns.ot-samara.baltika.ru.
EOF
14.4.9. Владелец и права всех файлов зон
sudo chown bind:bind /var/lib/bind/zones/db.*
sudo chmod 0640 /var/lib/bind/zones/db.*
После получения DDNS в текстовых файлах могут появиться A/PTR только после rndc sync. Live-ответ dig является первичной проверкой; Webmin и grep по файлу — проверкой после синхронизации.
14.5. Файл /etc/bind/named.conf.local
# Полностью объявляем одну прямую и восемь обратных динамических зон.
sudo tee /etc/bind/named.conf.local >/dev/null <<'EOF'
zone "ot-samara.baltika.ru" {
type master;
file "/var/lib/bind/zones/db.ot-samara.baltika.ru";
allow-update {
10.255.0.0/16;
172.16.66.0/23;
172.16.68.0/23;
172.16.70.0/23;
172.17.14.0/24;
};
notify no;
};
zone "255.10.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.255.10";
allow-update { 10.255.0.0/16; };
notify no;
};
zone "66.16.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.66.16.172";
allow-update { 172.16.66.0/23; };
notify no;
};
zone "67.16.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.67.16.172";
allow-update { 172.16.66.0/23; };
notify no;
};
zone "68.16.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.68.16.172";
allow-update { 172.16.68.0/23; };
notify no;
};
zone "69.16.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.69.16.172";
allow-update { 172.16.68.0/23; };
notify no;
};
zone "70.16.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.70.16.172";
allow-update { 172.16.70.0/23; };
notify no;
};
zone "71.16.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.71.16.172";
allow-update { 172.16.70.0/23; };
notify no;
};
zone "14.17.172.in-addr.arpa" {
type master;
file "/var/lib/bind/zones/db.14.17.172";
allow-update { 172.17.14.0/24; };
notify no;
};
EOF
allow-update в прямой зоне допускает регистрацию A из всех проектных OT-сетей. В каждой reverse-зоне разрешена только соответствующая сеть; для пары зон одной сети /23 используется общий ACL /23.
Модель доверия: ACL по IP не удостоверяет личность клиента. В текущем изолированном OT-контуре это принято как рабочий этап, но не заменяет TSIG/GSS-TSIG.
15. Основные DNS-записи — расшифровка
Учебный раздел: короткие записи ниже объясняют типы RR и не заменяют проектные файлы. Полные фактические зоны для копирования приведены в разделе 14.
15.1. Общий формат
ИМЯ TTL КЛАСС ТИП ЗНАЧЕНИЕ
| Элемент | Смысл |
|---|---|
$ORIGIN |
Базовый суффикс, который BIND добавляет к коротким именам |
$TTL |
Время хранения ответа в кэше, в секундах |
@ |
Вершина текущей зоны, то есть значение $ORIGIN |
IN |
Класс Internet, используемый для обычных DNS-записей |
| Точка в конце имени | Имя полное; $ORIGIN дописывать не нужно |
15.2. SOA — паспорт зоны
SOA означает Start of Authority. Запись описывает конкретную зону:
- MNAME — primary DNS-сервер зоны;
- RNAME — контакт ответственного администратора;
- serial — версия зоны;
- refresh — как часто secondary проверяет serial;
- retry — через сколько повторить неудачную проверку;
- expire — когда secondary перестаёт считать старую копию действительной;
- negative cache TTL — сколько кэшировать ответ об отсутствии имени.
15.3. NS — серверы зоны
NS означает Name Server. Запись перечисляет авторитетные DNS-серверы, которые обслуживают зону.
@ IN NS smr-srv-ot-dns.ot-samara.baltika.ru.
SOA у зоны одна, а NS-записей может быть несколько.
15.4. A и AAAA
A связывает имя с IPv4-адресом:
plc-001 IN A 172.16.66.100
AAAA является IPv6-аналогом A. В проекте IPv6 для DNS-службы не используется.
15.5. CNAME
CNAME создаёт псевдоним другого DNS-имени:
dns IN CNAME smr-srv-ot-dns
Имя, являющееся CNAME, не должно одновременно иметь A, PTR, MX и другие обычные записи.
15.6. PTR
PTR связывает IP-адрес с DNS-именем. Команда dig -x автоматически формирует обратное имя в in-addr.arpa.
172.16.66.53
→ 53.66.16.172.in-addr.arpa
→ smr-srv-ot-dns.ot-samara.baltika.ru.
15.7. MX
MX означает Mail Exchanger — сервер, принимающий электронную почту для домена.
@ IN MX 10 mail1.ot-samara.baltika.ru.
@ IN MX 20 mail2.ot-samara.baltika.ru.
mail1 IN A 172.16.66.25
mail2 IN A 172.16.66.26
Чем меньше число, тем выше приоритет. MX указывает на DNS-имя, а не на IP-адрес. Для текущей OT-зоны MX не нужен, поскольку она не предназначена для приёма почты.
16. Проверка конфигурации и зон
16.1. Проверка отдельной зоны
Каждое имя зоны проверяется вместе с конкретным файлом. Успешный результат для каждой команды заканчивается строкой OK.
# Прямая зона.
sudo named-checkzone ot-samara.baltika.ru /var/lib/bind/zones/db.ot-samara.baltika.ru
# Legacy OT.
sudo named-checkzone 255.10.in-addr.arpa /var/lib/bind/zones/db.255.10
# Пивопроизводство, две /24 зоны.
sudo named-checkzone 66.16.172.in-addr.arpa /var/lib/bind/zones/db.66.16.172
sudo named-checkzone 67.16.172.in-addr.arpa /var/lib/bind/zones/db.67.16.172
# Розлив, две /24 зоны.
sudo named-checkzone 68.16.172.in-addr.arpa /var/lib/bind/zones/db.68.16.172
sudo named-checkzone 69.16.172.in-addr.arpa /var/lib/bind/zones/db.69.16.172
# Утилиты, две /24 зоны.
sudo named-checkzone 70.16.172.in-addr.arpa /var/lib/bind/zones/db.70.16.172
sudo named-checkzone 71.16.172.in-addr.arpa /var/lib/bind/zones/db.71.16.172
# АСТУЭ.
sudo named-checkzone 14.17.172.in-addr.arpa /var/lib/bind/zones/db.14.17.172
16.2. Проверка всей конфигурации и загрузки зон
sudo named-checkconf
sudo named-checkconf -z
named-checkconf проверяет синтаксис конфигурации. Ключ -z дополнительно пытается загрузить все primary-зоны.
16.3. Запуск и состояние
sudo systemctl start named
systemctl is-active named
sudo rndc status
sudo journalctl -u named --since "3 minutes ago" --no-pager
Подтверждённое состояние:
number of zones: 14
server is up and running
query logging is OFF
recursive clients: 0
Число 14 складывается из девяти созданных зон — одной прямой и восьми обратных, — четырёх стандартных localhost-зон Ubuntu и одной служебной managed-keys zone.
17. Проверка через dig
17.1. SOA
dig @127.0.0.1 ot-samara.baltika.ru SOA +norecurse
17.2. NS
dig @127.0.0.1 ot-samara.baltika.ru NS +norecurse
17.3. A и CNAME
dig @127.0.0.1 smr-srv-ot-dns.ot-samara.baltika.ru A +norecurse
dig @127.0.0.1 dns.ot-samara.baltika.ru A +norecurse
17.4. PTR
dig @127.0.0.1 -x 10.255.0.53 +norecurse
dig @127.0.0.1 -x 172.16.66.53 +norecurse
dig @127.0.0.1 -x 172.16.68.53 +norecurse
dig @127.0.0.1 -x 172.16.70.53 +norecurse
dig @127.0.0.1 -x 172.17.14.53 +norecurse
17.5. Проверка через сервисный адрес АСТУЭ
dig @172.17.14.53 smr-srv-ot-dns.ot-samara.baltika.ru A +norecurse
dig @172.17.14.53 dns.ot-samara.baltika.ru A +norecurse
dig @172.17.14.53 -x 172.17.14.53 +norecurse
dig @172.17.14.53 14.17.172.in-addr.arpa SOA +tcp +norecurse
Фактически получено: пять A-записей; одна CNAME и пять A-записей; корректный PTR; актуальный SOA reverse-зоны. Все ответы имеют NOERROR и флаг aa.2026072702. Все ответы имеют NOERROR и флаг aa.
17.6. Внешнее имя
dig @127.0.0.1 ubuntu.com A +norecurse
Ожидаемая логика:
собственные зоны → status: NOERROR, флаг aa
внешние зоны → status: REFUSED
17.7. Основные элементы вывода dig
| Поле или флаг | Смысл |
|---|---|
NOERROR |
Запрос обработан без ошибки |
NXDOMAIN |
Такого имени не существует |
REFUSED |
Политика сервера запрещает запрос |
SERVFAIL |
Сервер не смог обработать запрос |
qr |
Пакет является ответом |
aa |
Авторитетный ответ из собственной зоны |
rd |
Клиент запросил рекурсию |
ra |
Сервер предоставляет рекурсию; в данном проекте отсутствует |
QUESTION |
Что было запрошено |
ANSWER |
Непосредственный ответ |
AUTHORITY |
Информация об авторитетной зоне |
ADDITIONAL |
Дополнительные полезные записи и EDNS OPT |
18. Проверка firewall, локальных DNS-сокетов и end-to-end
Порядок выполнения: этот раздел выполняется только после завершения разделов 12–17: BIND9 установлен, глобальная конфигурация и файлы зон созданы,
named-checkconf -zзавершился успешно, а службаnamedзапущена.
18.1. Проверка firewall и локальных DNS-сокетов после запуска BIND9
sudo ufw status numbered
sudo ufw show added
systemctl is-active named
sudo ss -lntup | grep named
sudo journalctl -u named --since "10 minutes ago" --no-pager
Подтверждено: UFW активен, правила применены, служба named активна и слушает OT-адреса по UDP и TCP.
Локально успешно выполнены следующие проверки:
# UDP через сервисный адрес VLAN 1
dig @10.255.0.53 ot-samara.baltika.ru SOA +norecurse
# TCP через сервисный адрес VLAN 1
dig @10.255.0.53 ot-samara.baltika.ru SOA +tcp +norecurse
# UDP через сервисный адрес VLAN 2002
dig @172.16.68.53 ot-samara.baltika.ru SOA +norecurse
# UDP через сервисный адрес АСТУЭ, VLAN 2006
dig @172.17.14.53 ot-samara.baltika.ru SOA +norecurse
# TCP через сервисный адрес АСТУЭ, VLAN 2006
dig @172.17.14.53 14.17.172.in-addr.arpa SOA +tcp +norecurse
Ожидаемые и фактически полученные признаки:
status: NOERROR
flags: qr aa
Ограничение локальной проверки: запрос к собственному IP-адресу сервера обрабатывается ядром как локальный маршрут и не доказывает прохождение пакета через физический OT-интерфейс, VLAN и интерфейсное правило UFW. Полная end-to-end-проверка выполняется только с отдельного хоста соответствующей OT-сети.
Проверка BIND через каждый конкретный сервисный адрес. Здесь запрос выполняется локально на сервере; он проверяет сокет BIND и зону, но не заменяет end-to-end-тест с удалённого клиента.
# Legacy OT, VLAN 1.
dig @10.255.0.53 ot-samara.baltika.ru SOA +norecurse
dig @10.255.0.53 ot-samara.baltika.ru SOA +tcp +norecurse
# Пивопроизводство, сейчас VLAN 1.
dig @172.16.66.53 ot-samara.baltika.ru SOA +norecurse
dig @172.16.66.53 ot-samara.baltika.ru SOA +tcp +norecurse
# Розлив, VLAN 2002.
dig @172.16.68.53 ot-samara.baltika.ru SOA +norecurse
dig @172.16.68.53 ot-samara.baltika.ru SOA +tcp +norecurse
# Утилиты, VLAN 2040.
dig @172.16.70.53 ot-samara.baltika.ru SOA +norecurse
dig @172.16.70.53 ot-samara.baltika.ru SOA +tcp +norecurse
# АСТУЭ, VLAN 2006.
dig @172.17.14.53 ot-samara.baltika.ru SOA +norecurse
dig @172.17.14.53 ot-samara.baltika.ru SOA +tcp +norecurse
18.2. End-to-end-проверка с OT-клиентов — выполнена
Каждый блок выполняется в PowerShell на Windows-клиенте, физически подключённом к указанной сети. Проверяются авторитетный A-ответ, PTR локального DNS-адреса, DNS по TCP и доступность TCP/53. Параметр -DnsOnly исключает LLMNR/NetBIOS, а -TcpOnly принудительно использует DNS over TCP.
| Сеть | Назначение | DNS-адрес | Результат |
|---|---|---|---|
10.255.0.0/16 |
Legacy OT, VLAN 1 | 10.255.0.53 |
A/PTR/UDP/TCP успешно |
172.16.66.0/23 |
Пивопроизводство, временно VLAN 1 | 172.16.66.53 |
A/PTR/UDP/TCP успешно |
172.16.68.0/23 |
Розлив, VLAN 2002 | 172.16.68.53 |
A/PTR/UDP/TCP успешно |
172.16.70.0/23 |
Утилиты, VLAN 2040 | 172.16.70.53 |
A/PTR/UDP/TCP успешно |
172.17.14.0/24 |
АСТУЭ, VLAN 2006 | 172.17.14.53 |
A/PTR/UDP/TCP успешно |
18.2.1. Legacy OT — 10.255.0.0/16, VLAN 1
# Прямая запись: имя DNS-сервера должно вернуть RRset из пяти A-адресов.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 10.255.0.53 `
-DnsOnly
# Обратная запись: локальный DNS-адрес должен вернуть имя сервера.
Resolve-DnsName `
-Name 10.255.0.53 `
-Type PTR `
-Server 10.255.0.53 `
-DnsOnly
# Содержательная проверка DNS по TCP.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 10.255.0.53 `
-DnsOnly `
-TcpOnly
# Проверка установки TCP-соединения на порт 53.
Test-NetConnection `
-ComputerName 10.255.0.53 `
-Port 53 `
-InformationLevel Detailed
18.2.2. Пивопроизводство — 172.16.66.0/23, временно VLAN 1
# Прямая запись: имя DNS-сервера должно вернуть RRset из пяти A-адресов.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.16.66.53 `
-DnsOnly
# Обратная запись: локальный DNS-адрес должен вернуть имя сервера.
Resolve-DnsName `
-Name 172.16.66.53 `
-Type PTR `
-Server 172.16.66.53 `
-DnsOnly
# Содержательная проверка DNS по TCP.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.16.66.53 `
-DnsOnly `
-TcpOnly
# Проверка установки TCP-соединения на порт 53.
Test-NetConnection `
-ComputerName 172.16.66.53 `
-Port 53 `
-InformationLevel Detailed
18.2.3. Розлив — 172.16.68.0/23, VLAN 2002
# Прямая запись: имя DNS-сервера должно вернуть RRset из пяти A-адресов.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.16.68.53 `
-DnsOnly
# Обратная запись: локальный DNS-адрес должен вернуть имя сервера.
Resolve-DnsName `
-Name 172.16.68.53 `
-Type PTR `
-Server 172.16.68.53 `
-DnsOnly
# Содержательная проверка DNS по TCP.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.16.68.53 `
-DnsOnly `
-TcpOnly
# Проверка установки TCP-соединения на порт 53.
Test-NetConnection `
-ComputerName 172.16.68.53 `
-Port 53 `
-InformationLevel Detailed
18.2.4. Утилиты — 172.16.70.0/23, VLAN 2040
# Прямая запись: имя DNS-сервера должно вернуть RRset из пяти A-адресов.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.16.70.53 `
-DnsOnly
# Обратная запись: локальный DNS-адрес должен вернуть имя сервера.
Resolve-DnsName `
-Name 172.16.70.53 `
-Type PTR `
-Server 172.16.70.53 `
-DnsOnly
# Содержательная проверка DNS по TCP.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.16.70.53 `
-DnsOnly `
-TcpOnly
# Проверка установки TCP-соединения на порт 53.
Test-NetConnection `
-ComputerName 172.16.70.53 `
-Port 53 `
-InformationLevel Detailed
18.2.5. АСТУЭ — 172.17.14.0/24, VLAN 2006
# Прямая запись: имя DNS-сервера должно вернуть RRset из пяти A-адресов.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.17.14.53 `
-DnsOnly
# Обратная запись: локальный DNS-адрес должен вернуть имя сервера.
Resolve-DnsName `
-Name 172.17.14.53 `
-Type PTR `
-Server 172.17.14.53 `
-DnsOnly
# Содержательная проверка DNS по TCP.
Resolve-DnsName `
-Name smr-srv-ot-dns.ot-samara.baltika.ru `
-Type A `
-Server 172.17.14.53 `
-DnsOnly `
-TcpOnly
# Проверка установки TCP-соединения на порт 53.
Test-NetConnection `
-ComputerName 172.17.14.53 `
-Port 53 `
-InformationLevel Detailed
В A-ответе порядок адресов может изменяться — DNS RRset не задаёт приоритет. Корректный набор: 10.255.0.53, 172.16.66.53, 172.16.68.53, 172.16.70.53, 172.17.14.53.
18.3. Диагностика AmneziaVPN и локального DNS
Первоначально клиент 10.255.255.250 успешно выполнял ping 10.255.0.53, но запросы Resolve-DnsName, nslookup и TCP/53 завершались тайм-аутом.
Маршрут Windows был правильным:
10.255.0.53
→ 10.255.0.0/16
→ Ethernet
→ source 10.255.255.250
→ NextHop 0.0.0.0
Первый захват на Ubuntu показывал ICMP, но не показывал ни UDP/53, ни TCP/53:
sudo tcpdump -ni any -vv 'host 10.255.255.250 and (icmp or udp port 53 or tcp port 53)'
Следовательно, DNS-пакеты блокировались на Windows до выхода через Ethernet. Причиной оказался AmneziaVPN и его защита локального DNS/DNS leak через Windows Filtering Platform.
После отключения VPN в tcpdump появились:
- UDP-запросы и ответы для A и PTR;
- TCP SYN, SYN-ACK и ACK;
- полноценный DNS-запрос и авторитетный ответ по TCP.
Для постоянной совместной работы VPN и OT-DNS в клиенте VPN требуется исключить сеть 10.255.0.0/16 и разрешить обращения к 10.255.0.53 по UDP/53 и TCP/53 либо включить доступ к локальной сети и локальному DNS.
Не смешивать две разные неисправности: в раннем тесте AmneziaVPN действительно блокировал локальный DNS до выхода пакетов с Windows. В отдельном тесте reverse-DDNS для
172.16.71.250VPN уже был отключён; причиной отсутствия PTR был многозначный SOA MNAME reverse-зоны. После назначения локального MNAME с единственной A-записью Windows отправила UPDATE.
18.4. Ожидаемый REFUSED для внешних имён
После назначения OT-DNS системным DNS Windows отправляет на него фоновые запросы приложений и операционной системы, например client.wns.windows.com, yandex.ru, chatgpt.com и корпоративные зоны.
BIND отвечает на них REFUSED, поскольку работает в авторитетном режиме:
recursion no;
allow-query-cache { none; };
Это штатное поведение. Если OT-клиентам требуется разрешение корпоративных или внешних зон, нужно отдельно проектировать условную пересылку или доверенный forwarder. Второй DNS-сервер в настройках Windows не гарантирует разделение запросов по зонам.
18.5. Сообщения bad udp cksum в tcpdump
Строки bad udp cksum или cksum incorrect для исходящих пакетов сервера обычно являются следствием checksum offloading: tcpdump видит пакет до вычисления окончательной контрольной суммы виртуальным или физическим адаптером. При успешном ответе клиента и завершённом TCP-обмене это не является сетевой ошибкой.
19. Важный нюанс нескольких A-записей DNS-сервера
Имя smr-srv-ot-dns.ot-samara.baltika.ru имеет пять A-записей — по одному сервисному адресу в каждой OT-сети. Для обычных запросов это допустимо, но для поля MNAME reverse-зоны оказалось неприемлемо: Windows получает primary из SOA, разрешает его имя и может выбрать недоступный адрес другой сети.
Подтверждённая неисправность на адресе 172.16.71.250/23 выглядела так:
UPDATE прямой зоны → успешно
SOA? 250.71.16.172.in-addr.arpa → reverse-зона найдена
A? smr-srv-ot-dns.ot-samara.baltika.ru → получено пять IP
UPDATE зоны 71.16.172.in-addr.arpa → на локальный DNS не отправлен
Решение реализовано для всех reverse-зон: SOA MNAME и NS указывают на локальное имя ns-*, имеющее ровно одну A-запись.
| Сети | Имя primary | Адрес |
|---|---|---|
10.255.0.0/16 |
ns-10-255.ot-samara.baltika.ru. |
10.255.0.53 |
172.16.66.0/23 |
ns-172-16-66.ot-samara.baltika.ru. |
172.16.66.53 |
172.16.68.0/23 |
ns-172-16-68.ot-samara.baltika.ru. |
172.16.68.53 |
172.16.70.0/23 |
ns-172-16-70.ot-samara.baltika.ru. |
172.16.70.53 |
172.17.14.0/24 |
ns-172-17-14.ot-samara.baltika.ru. |
172.17.14.53 |
После изменения Windows выполнила полную цепочку SOA → A primary → UPDATE PTR, а BIND добавил 250.71.16.172.in-addr.arpa PTR SMROTNBK01.ot-samara.baltika.ru.. Аналогично подтверждена зона 69.16.172.in-addr.arpa для адреса 172.16.69.250.
# Каждое служебное имя должно вернуть один адрес.
for host in ns-10-255 ns-172-16-66 ns-172-16-68 ns-172-16-70 ns-172-17-14
do
echo "===== $host ====="
dig @127.0.0.1 "$host.ot-samara.baltika.ru" A +norecurse +short
done
Прямая зона по-прежнему использует многозначное имя smr-srv-ot-dns. В пилотах A-обновления проходили успешно из всех сетей, поэтому её MNAME не изменялся. Это остаётся отдельным архитектурным ограничением и должно учитываться при дальнейшем усилении DDNS.
20. Установка и проверка Webmin
20.1. Назначение и границы использования
Webmin установлен как веб-интерфейс для администрирования работающего BIND9. Он читает существующие /etc/bind/named.conf* и файлы зон в /var/lib/bind/zones.
Все рабочие зоны сейчас динамические. Webmin используется для ручных A/PTR/CNAME/TXT-записей по подтверждённой схеме Freeze → Edit → Thaw. Для отображения свежих DDNS-записей из .jnl перед просмотром выполняется пользовательская команда Синхронизировать DDNS-зоны.
20.2. Контрольная копия перед установкой
# Формируем имя каталога с датой и временем.
backup_dir="/root/bind9-before-webmin-$(date +%F-%H%M%S)"
# install -d создаёт каталог; -m 0700 оставляет доступ только root.
install -d -m 0700 "$backup_dir"
# cp -a копирует рекурсивно и сохраняет владельцев, права и временные метки.
cp -a \
/etc/bind \
/var/lib/bind/zones \
/etc/netplan \
/etc/ufw \
"$backup_dir/"
# -C /root меняет рабочий каталог tar перед упаковкой.
# -c создаёт архив, -z включает gzip, -f задаёт имя файла.
tar -C /root -czf "${backup_dir}.tar.gz" "$(basename "$backup_dir")"
# Создаём и сразу проверяем контрольную сумму архива.
sha256sum "${backup_dir}.tar.gz" | tee "${backup_dir}.tar.gz.sha256"
sha256sum -c "${backup_dir}.tar.gz.sha256"
Фактическая резервная точка:
/root/bind9-before-webmin-2026-07-28-152931.tar.gz
SHA-256: 5e0db79047d44a7fca97c910c6d6656e250a90a6d0711065712acbf1c64897bd
Дополнительно были сохранены хеши 21 файла BIND. Пустой вывод последующего diff подтвердил, что сама установка Webmin не изменила конфигурацию DNS.
# find выбирает обычные файлы в двух каталогах.
# -exec sha256sum {} + передаёт их sha256sum группами.
# sort -k2 сортирует по второму полю — пути к файлу.
find /etc/bind /var/lib/bind/zones \
-type f \
-exec sha256sum {} + | \
sort -k2 > /root/bind-before-webmin.sha256
20.3. Доступ через UFW
Webmin доступен только через временную и production management-сети. На OT-интерфейсах TCP/10000 не разрешён.
# Доступ через временный management-интерфейс ens18.
ufw allow in on ens18 \
proto tcp \
from 192.168.1.0/24 \
to 192.168.1.53 \
port 10000 \
comment 'Webmin temporary management HTTPS'
# Доступ через production management-интерфейс ens23.
ufw allow in on ens23 \
proto tcp \
from 10.44.100.0/24 \
to 10.44.100.228 \
port 10000 \
comment 'Webmin production management HTTPS'
ufw reload
ufw status numbered
Смысл ограничений: правило совпадает только при одновременном выполнении условий по входному интерфейсу, сети источника, локальному адресу назначения, протоколу TCP и порту 10000.
20.4. Подключение официального stable-репозитория
cd /root
# --fail завершает curl ошибкой при HTTP 4xx/5xx.
# --location разрешает HTTPS-перенаправления.
# --proto '=https' запрещает загрузку по HTTP.
# --tlsv1.2 требует TLS 1.2 или новее.
curl \
--fail \
--location \
--proto '=https' \
--tlsv1.2 \
--output /root/webmin-setup-repo.sh \
https://raw.githubusercontent.com/webmin/webmin/master/webmin-setup-repo.sh
file /root/webmin-setup-repo.sh
head -n 20 /root/webmin-setup-repo.sh
sha256sum /root/webmin-setup-repo.sh
chmod 0700 /root/webmin-setup-repo.sh
# Скрипт добавляет stable-репозиторий и ключ подписи Webmin.
sh /root/webmin-setup-repo.sh
apt-get update
apt-cache policy webmin
Перед установкой был обнаружен пакет webmin 2.653 из репозитория https://download.webmin.com/download/newkey/repository.
20.5. Установка и проверка службы
# --install-recommends устанавливает рекомендуемые зависимости Webmin.
apt-get install webmin --install-recommends
# is-active проверяет текущее состояние процесса.
systemctl is-active webmin
# is-enabled проверяет запуск при загрузке ОС.
systemctl is-enabled webmin
# Показываем слушающий сокет TCP/10000 и процесс-владелец.
ss -lntp | grep ':10000'
# --insecure разрешён только для первичной проверки локального self-signed сертификата.
curl --insecure --head https://127.0.0.1:10000/
Подтверждённое состояние:
Webmin: 2.653
webmin.service: active, enabled
TCP/10000: LISTEN на 0.0.0.0 и [::]
HTTPS: HTTP/1.0 200 Document follows
Хотя процесс слушает все локальные адреса, входящий IPv4-доступ ограничен правилами UFW только management-сетями.
20.6. Проверка неизменности BIND после установки
find /etc/bind /var/lib/bind/zones -type f -exec sha256sum {} + |
sort -k2 > /root/bind-after-webmin.sha256
diff -u /root/bind-before-webmin.sha256 /root/bind-after-webmin.sha256
named-checkconf -z
systemctl is-active named
На момент установки Webmin проверка хешей подтвердила, что веб-интерфейс не изменил исходную статическую конфигурацию. Затем отдельным контролируемым этапом DDNS был включён и allow-update заменён на ACL сетей, приведённые в разделе 14.5.
# Текущая эффективная конфигурация зон и ACL.
named-checkconf -p |
grep -A 12 -E '^zone "(ot-samara|255\.10|6[6-9]\.16\.172|7[01]\.16\.172|14\.17\.172)'
# Все зоны должны загрузиться.
named-checkconf -z
20.7. Первое открытие модуля BIND
Адреса интерфейса:
https://192.168.1.53:10000
https://10.44.100.228:10000
Вход выполняется под Linux-пользователем baltikadns. В меню Серверы → Сервер DNS BIND Webmin автоматически определил BIND 9.18.39, прямую зону, восемь обратных зон и стандартные локальные зоны. Создавать новую конфигурацию BIND не потребовалось.
Когда существующая конфигурация не обнаружена, нельзя нажимать Create Configuration: сначала проверяются пути модуля. В этой установке существующие зоны обнаружены корректно.
Если модуль не обнаружил существующие зоны, не нажимать Create Configuration. В Module Config должны использоваться проектные пути:
Main configuration file: /etc/bind/named.conf
Options file: /etc/bind/named.conf.options
Local zones file: /etc/bind/named.conf.local
Zone files directory: /var/lib/bind/zones
named-checkconf: /usr/sbin/named-checkconf
named-checkzone: /usr/sbin/named-checkzone
rndc: /usr/sbin/rndc
systemd service: named.service
Chroot: не используется
20.8. Создание скрипта синхронизации DDNS-зон
До создания кнопки Webmin сначала устанавливается исполняемый скрипт, который она будет запускать. Скрипт выполняет rndc sync для прямой и всех восьми обратных зон: актуальное состояние из журналов .jnl записывается в основные файлы зон без остановки DDNS.
sudo tee /usr/local/sbin/bind-sync-ddns-zones >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
zones=(
ot-samara.baltika.ru
255.10.in-addr.arpa
66.16.172.in-addr.arpa
67.16.172.in-addr.arpa
68.16.172.in-addr.arpa
69.16.172.in-addr.arpa
70.16.172.in-addr.arpa
71.16.172.in-addr.arpa
14.17.172.in-addr.arpa
)
for zone in "${zones[@]}"; do
printf 'sync: %s\n' "$zone"
rndc sync "$zone"
done
EOF
sudo chown root:root /usr/local/sbin/bind-sync-ddns-zones
sudo chmod 0750 /usr/local/sbin/bind-sync-ddns-zones
# Проверяем синтаксис и выполняем скрипт до добавления его в Webmin.
sudo bash -n /usr/local/sbin/bind-sync-ddns-zones
sudo /usr/local/sbin/bind-sync-ddns-zones
Успешный запуск выводит по одной строке sync: для каждой из девяти зон и завершается без ошибки. Только после этой проверки создаётся пользовательская команда Webmin.
20.9. Создание команды «Синхронизировать DDNS-зоны» в Webmin
Открыть Прочие → Пользовательские команды и создать новую команду со следующими параметрами:
| Поле | Значение |
|---|---|
| Название/описание | Синхронизировать DDNS-зоны |
| Команда | /usr/local/sbin/bind-sync-ddns-zones |
| Пользователь выполнения | root |
Сохранить команду, затем запустить её один раз из Webmin. В результате должны быть показаны девять строк sync:. Если Webmin сообщает, что файл не найден или выполнение запрещено, не переходить к тесту редактирования: сначала проверить путь, владельца и режим файла.
ls -l /usr/local/sbin/bind-sync-ddns-zones
sudo /usr/local/sbin/bind-sync-ddns-zones
Кнопки контроля старых PTR на этом этапе ещё не создаются. Они относятся к отдельной утилите
bind-ptr-mismatchи добавляются только после её установки и проверки в разделе 24.
20.10. Функциональный тест редактирования динамической зоны
Теперь необходимые компоненты уже существуют: Webmin установлен, скрипт синхронизации проверен, а команда Синхронизировать DDNS-зоны создана в интерфейсе. После этого выполняется безопасный тест изменения динамической зоны.
- запустить в Webmin команду Синхронизировать DDNS-зоны;
- открыть прямую зону
ot-samara.baltika.ru; - создать временную TXT-запись
webmin-dynamic-testсо значениемwebmin-dynamic-write-test; - сохранить изменение через Webmin;
- проверить, что зона осталась динамической и не заморожена;
- проверить TXT-запись и существующую DDNS A-запись через
dig; - удалить временную TXT-запись через Webmin и повторить проверку.
named-checkconf -z
rndc zonestatus ot-samara.baltika.ru |
grep -E '^(name|serial|dynamic|frozen):'
dig @127.0.0.1 \
webmin-dynamic-test.ot-samara.baltika.ru \
TXT \
+norecurse \
+noall \
+comments \
+answer
dig @127.0.0.1 \
SMROTNBK01.ot-samara.baltika.ru \
A \
+norecurse \
+short
После создания TXT ожидаются NOERROR, флаг aa, dynamic: yes и frozen: no. После удаления тестовой записи запрос должен вернуть авторитетный NXDOMAIN, а существующие DDNS-записи клиента должны сохраниться.
На фактическом сервере полный цикл проверен успешно: Webmin выполнил защитный цикл редактирования, увеличил serial, вернул зону в состояние
frozen: noи не потерял клиентские A/PTR-записи.
20.11. Официальные справочные материалы
- Установка Webmin и настройка репозитория;
- Модуль Webmin BIND DNS Server;
- Исходный код скрипта настройки репозитория.
21. Правила редактирования динамических зон
Этот раздел применяется только после завершения первичного создания файлов, загрузки зон и установки Webmin в разделе 20.
Все девять рабочих зон динамические. Их нельзя редактировать напрямую во время работы named, потому что актуальные изменения могут находиться в журнале .jnl и быть перезаписаны при следующей синхронизации.
21.1. Предпочтительный способ через Webmin
- открыть нужную зону в Серверы → Сервер DNS BIND;
- создать, изменить или удалить запись;
- сохранить изменение;
- убедиться, что зона снова имеет
frozen: no; - выполнить пользовательскую команду Синхронизировать DDNS-зоны;
- проверить запись через
dig.
На сервере подтверждено, что Webmin выполняет защитный цикл freeze → изменение файла и serial → thaw, а существующие DDNS-записи при этом сохраняются.
21.2. Ручной CLI-цикл
# Замораживаем и синхронизируем live-журнал с файлом.
sudo rndc freeze ot-samara.baltika.ru
# Редактируем файл и увеличиваем serial.
sudo nano /var/lib/bind/zones/db.ot-samara.baltika.ru
# Проверяем файл до возврата DDNS.
sudo named-checkzone ot-samara.baltika.ru /var/lib/bind/zones/db.ot-samara.baltika.ru
# Перечитываем файл и возвращаем динамические обновления.
sudo rndc thaw ot-samara.baltika.ru
# Контроль состояния.
sudo rndc zonestatus ot-samara.baltika.ru |
grep -E '^(name|serial|dynamic|frozen):'
Целевой результат:
dynamic: yes
frozen: no
21.3. Синхронизация без остановки DDNS
sudo rndc sync ot-samara.baltika.ru
sudo rndc sync 69.16.172.in-addr.arpa
rndc sync переносит текущее состояние из .jnl в основной файл зоны, но не замораживает зону и не отключает DDNS.
21.4. Резервное копирование динамических зон
backup="/root/bind9-dynamic-backup-$(date +%F-%H%M%S)"
sudo install -d -m 0700 "$backup"
# Сначала синхронизируем зоны.
sudo /usr/local/sbin/bind-sync-ddns-zones
# Копируем конфигурацию, основные файлы и сохранившиеся .jnl.
sudo cp -a /etc/bind "$backup/"
sudo cp -a /var/lib/bind/zones "$backup/"
sudo tar -C /root -czf "${backup}.tar.gz" "$(basename "$backup")"
sudo sha256sum "${backup}.tar.gz" |
sudo tee "${backup}.tar.gz.sha256"
sudo sha256sum -c "${backup}.tar.gz.sha256"
22. DDNS для статических Windows-клиентов
22.1. Параметры OT-интерфейса
| Сеть клиента | DNS | Маска | Шлюз на OT-интерфейсе |
|---|---|---|---|
10.255.0.0/16 |
10.255.0.53 |
255.255.0.0 |
не задаётся |
172.16.66.0/23 |
172.16.66.53 |
255.255.254.0 |
не задаётся |
172.16.68.0/23 |
172.16.68.53 |
255.255.254.0 |
не задаётся |
172.16.70.0/23 |
172.16.70.53 |
255.255.254.0 |
не задаётся |
172.17.14.0/24 |
172.17.14.53 |
255.255.255.0 |
не задаётся |
Для OT-интерфейса задаётся DNS-суффикс ot-samara.baltika.ru и включается регистрация адреса этого подключения в DNS. На Wi-Fi, VPN и других не-OT интерфейсах автоматическая регистрация отключается.
# Просмотр состояния регистрации интерфейсов.
Get-DnsClient |
Format-Table InterfaceAlias,InterfaceIndex,ConnectionSpecificSuffix,RegisterThisConnectionsAddress
# Пример отключения регистрации на конкретном VPN-интерфейсе.
Set-DnsClient `
-InterfaceIndex <индекс_VPN> `
-RegisterThisConnectionsAddress $false
22.2. Ручной запуск регистрации
ipconfig /flushdns
ipconfig /registerdns
ipconfig /registerdns запускает асинхронную регистрацию всех подходящих адаптеров. Сообщение «Начата регистрация» означает, что задание передано службе DNS Client, а не что A и PTR уже созданы.
A и PTR являются отдельными обновлениями:
FQDN → SOA прямой зоны → UPDATE A
IP → SOA reverse-зоны → A primary из MNAME → UPDATE PTR
22.3. Наблюдение на сервере
# Журнал BIND.
journalctl -u named -f |
grep --line-buffered -E \
'updating zone|update denied|ot-samara\.baltika\.ru|in-addr\.arpa'
# Трафик конкретного клиента; интерфейс меняется по сети.
tcpdump -ni ens22 \
'host 172.16.71.250 and port 53'
Успешная reverse-регистрация включает запрос SOA обратного имени, запрос A локального ns-* и пакет с opcode UPDATE.
22.4. Проверка A и PTR
dig @127.0.0.1 \
SMROTNBK01.ot-samara.baltika.ru \
A \
+norecurse \
+short
dig @127.0.0.1 \
-x 172.16.69.250 \
+norecurse \
+short
Целевой результат — взаимно согласованная пара:
SMROTNBK01.ot-samara.baltika.ru → 172.16.69.250
172.16.69.250 → SMROTNBK01.ot-samara.baltika.ru.
22.5. Подтверждённые пилоты
| Сеть | Фактический тестовый адрес | Результат |
|---|---|---|
172.17.14.0/24 |
172.17.14.200 |
A и PTR зарегистрированы |
10.255.0.0/16 |
10.255.255.250 |
A и PTR зарегистрированы |
172.16.66.0/23 |
172.16.66.89 |
A и PTR зарегистрированы; адрес .250 был занят |
172.16.68.0/23 |
172.16.69.250 |
A и PTR зарегистрированы в зоне 69.16.172.in-addr.arpa |
172.16.70.0/23 |
172.16.71.250 |
A и PTR зарегистрированы в зоне 71.16.172.in-addr.arpa |
23. Синхронизация DDNS-зон и отображение в Webmin
Работающий BIND может уже отвечать новой записью из .jnl, а Webmin ещё показывать старое содержимое основного файла. Поэтому перед просмотром динамических зон выполняется синхронизация.
23.1. Предварительные условия
Скрипт /usr/local/sbin/bind-sync-ddns-zones создаётся и проверяется в разделе 20.8, а одноимённая пользовательская команда Webmin — в разделе 20.9. На этапе эксплуатации их повторно создавать не требуется.
test -x /usr/local/sbin/bind-sync-ddns-zones && \
echo 'Скрипт синхронизации установлен'
sudo /usr/local/sbin/bind-sync-ddns-zones
23.2. Порядок просмотра зоны в Webmin
- запустить Прочие → Пользовательские команды → Синхронизировать DDNS-зоны;
- закрыть и снова открыть страницу нужной зоны;
- при необходимости обновить страницу через
Ctrl+F5; - сверить отображаемую запись с live-ответом
dig.
named-checkconf -z
find /var/lib/bind/zones \
-maxdepth 1 \
-type f \
-name '*.jnl' \
-ls
Наличие .jnl является нормальным признаком динамической зоны. rndc sync переносит текущее состояние журнала в основной файл зоны, но не замораживает зону, не удаляет .jnl и не отключает DDNS.
24. Контроль и очистка старых PTR
При переносе статического Windows-хоста в другую подсеть клиент регистрирует новый A и новый PTR, но старый PTR в прежней reverse-зоне может сохраниться. Периодическая повторная регистрация Windows не является scavenging и не гарантирует удаление записи из другой зоны.
24.1. Назначение и условия применения
Утилита /usr/local/sbin/bind-ptr-mismatch выполняет три операции:
| Команда | Назначение | Изменяет DNS |
|---|---|---|
discover |
Автоматически обнаруживает динамические IPv4 reverse-зоны, их файлы и локальный исходный адрес для nsupdate. |
Нет |
report |
Синхронизирует зоны, сравнивает каждый PTR с текущими A-записями и создаёт свежий снимок строгих несовпадений. | Нет |
clean |
Повторно проверяет каждую запись из свежего снимка и удаляет только всё ещё подтверждённый старый PTR. | Да, только PTR |
Место этого этапа в инструкции: установка выполняется только после установки и настройки BIND9, включения DDNS, проверки всех reverse-зон и установки Webmin. До выполнения подразделов 24.2–24.5 кнопки контроля PTR в Webmin не создаются и не используются.
Защитные свойства версии 2.0.0:
- reverse-зоны и разрешённые локальные адреса определяются из действующей конфигурации BIND, а не из жёстко заданного списка;
- автоматически удаляется только строгий mismatch: PTR указывает на имя, у имени есть A-запись, но IP данного PTR отсутствует среди текущих A;
- PTR для имени без A-записи попадает в отчёт, но автоматически не удаляется;
- перед удалением каждой записи заново проверяются текущие PTR и A;
- снимок отчёта действует 15 минут;
- за один обычный запуск разрешено не более 20 удалений;
- зоны с
update-policy, неоднозначными views или неподдерживаемой ACL доступны только для аудита; - все удаления журналируются в
/var/log/bind-ptr-cleanup.log.
24.2. Установка зависимостей и полного скрипта
Проверяем наличие программ, которые использует утилита:
sudo apt update
sudo apt install -y \
python3 \
bind9-utils \
dnsutils \
iproute2
command -v python3
command -v rndc
command -v dig
command -v nsupdate
command -v named-checkzone
command -v named-checkconf
command -v ip
Перед заменой уже существующего файла сохраняем резервную копию:
if [ -e /usr/local/sbin/bind-ptr-mismatch ]; then
sudo cp -a \
/usr/local/sbin/bind-ptr-mismatch \
"/usr/local/sbin/bind-ptr-mismatch.backup-$(date +%F-%H%M%S)"
fi
Создаём полный скрипт версии 2.0.0:
sudo tee /usr/local/sbin/bind-ptr-mismatch >/dev/null <<'PYTHON'
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import dataclass
import datetime as dt
import ipaddress
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
from typing import Iterable
SCRIPT_VERSION = "2.0.0"
COMMAND_TIMEOUT_SECONDS = 60
BIND_CONFIG = Path("/etc/bind/named.conf")
STATE_DIR = Path("/var/lib/bind/ptr-mismatch")
LATEST_REPORT = STATE_DIR / "latest.tsv"
IGNORE_FILE = Path("/etc/bind/ptr-audit-ignore.txt")
CLEANUP_LOG = Path("/var/log/bind-ptr-cleanup.log")
MAX_REPORT_AGE_SECONDS = 15 * 60
MAX_DELETIONS_PER_RUN = 20
REPORT_FORMAT_VERSION = 2
class CommandError(RuntimeError):
pass
class ConfigParseError(RuntimeError):
pass
@dataclass(frozen=True)
class Programs:
rndc: str
dig: str
nsupdate: str
named_checkzone: str
named_checkconf: str
ip: str
@dataclass(frozen=True)
class Statement:
head: tuple[str, ...]
children: tuple["Statement", ...] | None = None
@dataclass(frozen=True)
class LocalAddress:
address: ipaddress.IPv4Address
network: ipaddress.IPv4Network
interface: str
scope: str
@dataclass(frozen=True)
class ZoneInfo:
name: str
zone_file: Path
source_address: str | None
cleanable: bool
reason: str
view: str | None = None
@dataclass(frozen=True)
class DiscoveryResult:
zones: tuple[ZoneInfo, ...]
skipped: tuple[str, ...]
def find_program(name: str) -> str:
candidates = (
Path("/usr/sbin") / name,
Path("/usr/bin") / name,
Path("/sbin") / name,
Path("/bin") / name,
)
for candidate in candidates:
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
discovered = shutil.which(name)
if discovered:
return discovered
raise RuntimeError(f"Не найдена обязательная команда: {name}")
def load_programs() -> Programs:
return Programs(
rndc=find_program("rndc"),
dig=find_program("dig"),
nsupdate=find_program("nsupdate"),
named_checkzone=find_program("named-checkzone"),
named_checkconf=find_program("named-checkconf"),
ip=find_program("ip"),
)
def run(command: list[str], *, stdin: str | None = None) -> str:
try:
completed = subprocess.run(
command,
input=stdin,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=COMMAND_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as error:
raise CommandError(
f"Команда не завершилась за {COMMAND_TIMEOUT_SECONDS} секунд: "
f"{' '.join(command)}"
) from error
if completed.returncode != 0:
details = completed.stderr.strip() or completed.stdout.strip() or "нет диагностического вывода"
raise CommandError(
f"Команда завершилась с кодом {completed.returncode}: "
f"{' '.join(command)}\n{details}"
)
return completed.stdout
def fqdn(name: str) -> str:
return name.strip().rstrip(".").lower() + "."
def unquote_bind_token(token: str) -> str:
return token
def tokenize_named_conf(text: str) -> list[str]:
"""Лексический разбор named.conf с удалением комментариев.
Возвращает строки без кавычек и отдельные токены { } ; !.
Полный синтаксис BIND здесь не реализуется: нам достаточно безопасно
разобрать options, acl, view и zone из вывода named-checkconf -p.
"""
tokens: list[str] = []
index = 0
length = len(text)
while index < length:
char = text[index]
if char.isspace():
index += 1
continue
if text.startswith("//", index):
newline = text.find("\n", index + 2)
index = length if newline == -1 else newline + 1
continue
if char == "#":
newline = text.find("\n", index + 1)
index = length if newline == -1 else newline + 1
continue
if text.startswith("/*", index):
end = text.find("*/", index + 2)
if end == -1:
raise ConfigParseError("Незакрытый комментарий /* ... */ в конфигурации BIND")
index = end + 2
continue
if char in "{};!":
tokens.append(char)
index += 1
continue
if char == '"':
index += 1
value: list[str] = []
while index < length:
char = text[index]
if char == '"':
index += 1
break
if char == "\\":
index += 1
if index >= length:
raise ConfigParseError("Незавершённая escape-последовательность в строке")
value.append(text[index])
index += 1
continue
value.append(char)
index += 1
else:
raise ConfigParseError("Незакрытая строка в конфигурации BIND")
tokens.append("".join(value))
continue
start = index
while index < length:
if text[index].isspace() or text[index] in "{};!\"":
break
if text.startswith("//", index) or text.startswith("/*", index) or text[index] == "#":
break
index += 1
if start == index:
raise ConfigParseError(f"Неожиданный символ в конфигурации: {text[index]!r}")
tokens.append(text[start:index])
return tokens
def parse_statement_block(
tokens: list[str],
start_index: int = 0,
*,
expect_closing_brace: bool = False,
) -> tuple[tuple[Statement, ...], int]:
statements: list[Statement] = []
head: list[str] = []
index = start_index
while index < len(tokens):
token = tokens[index]
if token == ";":
if head:
statements.append(Statement(tuple(head), None))
head = []
index += 1
continue
if token == "{":
if not head:
raise ConfigParseError("Блок { ... } без заголовка")
children, index = parse_statement_block(
tokens,
index + 1,
expect_closing_brace=True,
)
statements.append(Statement(tuple(head), children))
head = []
if index < len(tokens) and tokens[index] == ";":
index += 1
continue
if token == "}":
if not expect_closing_brace:
raise ConfigParseError("Лишняя закрывающая фигурная скобка")
if head:
raise ConfigParseError("Перед } обнаружено выражение без завершающей ;")
return tuple(statements), index + 1
head.append(unquote_bind_token(token))
index += 1
if expect_closing_brace:
raise ConfigParseError("Не найдена закрывающая фигурная скобка")
if head:
raise ConfigParseError("Последнее выражение конфигурации не завершено символом ;")
return tuple(statements), index
def parse_named_conf(text: str) -> tuple[Statement, ...]:
tokens = tokenize_named_conf(text)
statements, index = parse_statement_block(tokens)
if index != len(tokens):
raise ConfigParseError("Не удалось разобрать конфигурацию BIND полностью")
return statements
def statement_keyword(statement: Statement) -> str:
return statement.head[0].lower() if statement.head else ""
def direct_children(statement: Statement, keyword: str) -> list[Statement]:
wanted = keyword.lower()
if statement.children is None:
return []
return [item for item in statement.children if statement_keyword(item) == wanted]
def first_direct_child(statement: Statement, keyword: str) -> Statement | None:
matches = direct_children(statement, keyword)
return matches[0] if matches else None
def walk_statements(
statements: Iterable[Statement],
*,
current_view: str | None = None,
) -> Iterable[tuple[Statement, str | None]]:
for statement in statements:
keyword = statement_keyword(statement)
next_view = current_view
if keyword == "view" and len(statement.head) >= 2:
next_view = statement.head[1]
yield statement, current_view
if statement.children is not None:
yield from walk_statements(statement.children, current_view=next_view)
def get_global_directory(statements: tuple[Statement, ...]) -> Path:
for statement in statements:
if statement_keyword(statement) != "options" or statement.children is None:
continue
directory_statement = first_direct_child(statement, "directory")
if directory_statement and len(directory_statement.head) >= 2:
return Path(directory_statement.head[1])
return Path(".")
def get_acl_definitions(statements: tuple[Statement, ...]) -> dict[str, tuple[Statement, ...]]:
definitions: dict[str, tuple[Statement, ...]] = {}
for statement, _ in walk_statements(statements):
if statement_keyword(statement) != "acl":
continue
if len(statement.head) < 2 or statement.children is None:
continue
definitions[statement.head[1].lower()] = statement.children
return definitions
def get_local_ipv4_addresses(programs: Programs) -> list[LocalAddress]:
output = run([programs.ip, "-j", "-4", "address", "show"])
try:
interfaces = json.loads(output)
except json.JSONDecodeError as error:
raise CommandError(f"Команда ip вернула некорректный JSON: {error}") from error
addresses: list[LocalAddress] = []
for interface in interfaces:
interface_name = str(interface.get("ifname", "?"))
for item in interface.get("addr_info", []):
if item.get("family") != "inet":
continue
raw_address = item.get("local")
raw_prefix = item.get("prefixlen")
if raw_address is None or raw_prefix is None:
continue
try:
address = ipaddress.IPv4Address(raw_address)
network = ipaddress.IPv4Network(f"{address}/{int(raw_prefix)}", strict=False)
except (ipaddress.AddressValueError, ValueError):
continue
addresses.append(
LocalAddress(
address=address,
network=network,
interface=interface_name,
scope=str(item.get("scope", "")),
)
)
if not addresses:
raise CommandError("На сервере не найдено ни одного локального IPv4-адреса")
return addresses
def parse_acl_items(statements: tuple[Statement, ...]) -> tuple[list[tuple[bool, str]], str | None]:
items: list[tuple[bool, str]] = []
for statement in statements:
if statement.children is not None:
return [], "в ACL обнаружен вложенный блок, который скрипт намеренно не интерпретирует"
tokens = list(statement.head)
if not tokens:
continue
negated = False
if tokens[0] == "!":
negated = True
tokens = tokens[1:]
if len(tokens) != 1:
return [], f"неподдерживаемый элемент ACL: {' '.join(statement.head)}"
items.append((negated, tokens[0].lower()))
return items, None
def acl_has_unsupported_constructs(
items: list[tuple[bool, str]],
acl_definitions: dict[str, tuple[Statement, ...]],
*,
stack: tuple[str, ...] = (),
) -> str | None:
for negated, value in items:
if value in {"any", "none", "localhost", "localnets"}:
continue
try:
ipaddress.IPv4Network(value, strict=False)
continue
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError):
pass
if value not in acl_definitions:
return f"ACL содержит неподдерживаемый элемент или неизвестное имя: {value}"
if value in stack:
return f"обнаружена рекурсивная ссылка ACL: {' -> '.join((*stack, value))}"
nested_items, error = parse_acl_items(acl_definitions[value])
if error:
return f"ACL {value}: {error}"
if negated and any(nested_negated for nested_negated, _ in nested_items):
return f"ACL {value}: вложенная комбинация отрицаний не поддерживается безопасно"
nested_error = acl_has_unsupported_constructs(
nested_items,
acl_definitions,
stack=(*stack, value),
)
if nested_error:
return nested_error
return None
def acl_value_matches(
value: str,
candidate: LocalAddress,
acl_definitions: dict[str, tuple[Statement, ...]],
local_addresses: list[LocalAddress],
*,
stack: tuple[str, ...] = (),
) -> bool:
if value == "any":
return True
if value == "none":
return False
if value == "localhost":
return candidate.address.is_loopback
if value == "localnets":
return any(candidate.address in local.network for local in local_addresses)
try:
network = ipaddress.IPv4Network(value, strict=False)
return candidate.address in network
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError):
pass
if value not in acl_definitions or value in stack:
return False
nested_items, error = parse_acl_items(acl_definitions[value])
if error:
return False
return evaluate_acl(
nested_items,
candidate,
acl_definitions,
local_addresses,
stack=(*stack, value),
)
def evaluate_acl(
items: list[tuple[bool, str]],
candidate: LocalAddress,
acl_definitions: dict[str, tuple[Statement, ...]],
local_addresses: list[LocalAddress],
*,
stack: tuple[str, ...] = (),
) -> bool:
for negated, value in items:
if acl_value_matches(
value,
candidate,
acl_definitions,
local_addresses,
stack=stack,
):
return not negated
return False
def reverse_zone_network(zone_name: str) -> ipaddress.IPv4Network | None:
normalized = fqdn(zone_name)
suffix = ".in-addr.arpa."
if not normalized.endswith(suffix):
return None
labels = normalized[: -len(suffix)].rstrip(".").split(".")
if not 1 <= len(labels) <= 4:
return None
octets: list[int] = []
for label in reversed(labels):
try:
value = int(label)
except ValueError:
return None
if not 0 <= value <= 255:
return None
octets.append(value)
octets.extend([0] * (4 - len(octets)))
prefix = len(labels) * 8
return ipaddress.IPv4Network((".".join(str(value) for value in octets), prefix), strict=False)
def choose_source_address(
allow_update: Statement,
acl_definitions: dict[str, tuple[Statement, ...]],
local_addresses: list[LocalAddress],
zone_name: str,
) -> tuple[str | None, str]:
if allow_update.children is None:
return None, "allow-update не содержит адресного списка"
items, error = parse_acl_items(allow_update.children)
if error:
return None, error
if not items:
return None, "allow-update пуст"
unsupported = acl_has_unsupported_constructs(items, acl_definitions)
if unsupported:
return None, unsupported
candidates = [
item
for item in local_addresses
if evaluate_acl(items, item, acl_definitions, local_addresses)
]
if not candidates:
return None, "не найден локальный IPv4-адрес, разрешённый в allow-update"
zone_network = reverse_zone_network(zone_name)
def score(item: LocalAddress) -> tuple[int, int, int]:
# Для /23 reverse-зона второго октета (например 67.16.172)
# может обслуживаться адресом из первого /24 (172.16.66.53/23).
# Поэтому сравниваем не только сам адрес, а сеть интерфейса.
related_interface = (
0
if zone_network and item.network.overlaps(zone_network)
else 1
)
loopback = 1 if item.address.is_loopback else 0
return related_interface, loopback, int(item.address)
selected = sorted(candidates, key=score)[0]
return str(selected.address), (
f"локальный адрес {selected.address} на {selected.interface} разрешён allow-update"
)
def resolve_zone_file(raw_path: str, working_directory: Path) -> Path:
path = Path(raw_path)
if path.is_absolute():
return path
return working_directory / path
def discover_reverse_zones(programs: Programs) -> DiscoveryResult:
config_text = run([
programs.named_checkconf,
"-p",
str(BIND_CONFIG),
])
statements = parse_named_conf(config_text)
working_directory = get_global_directory(statements)
acl_definitions = get_acl_definitions(statements)
local_addresses = get_local_ipv4_addresses(programs)
found: list[ZoneInfo] = []
skipped: list[str] = []
names_seen: dict[str, int] = {}
zone_statements: list[tuple[Statement, str | None]] = []
for statement, current_view in walk_statements(statements):
if statement_keyword(statement) == "zone":
zone_statements.append((statement, current_view))
for statement, current_view in zone_statements:
if len(statement.head) < 2 or statement.children is None:
continue
zone_name = statement.head[1].lower().rstrip(".")
if not zone_name.endswith(".in-addr.arpa"):
continue
type_statement = first_direct_child(statement, "type")
zone_type = (
type_statement.head[1].lower()
if type_statement and len(type_statement.head) >= 2
else ""
)
if zone_type not in {"master", "primary"}:
continue
allow_update = first_direct_child(statement, "allow-update")
update_policy = first_direct_child(statement, "update-policy")
# Нас интересуют только динамические reverse-зоны. Это исключает
# стандартные 127/0/255.in-addr.arpa из named.conf.default-zones.
if allow_update is None and update_policy is None:
continue
file_statement = first_direct_child(statement, "file")
if file_statement is None or len(file_statement.head) < 2:
skipped.append(f"{zone_name}: не найдена директива file")
continue
zone_file = resolve_zone_file(file_statement.head[1], working_directory)
if update_policy is not None:
source_address = None
cleanable = False
reason = "используется update-policy; зона доступна только для аудита"
elif allow_update is not None:
source_address, reason = choose_source_address(
allow_update,
acl_definitions,
local_addresses,
zone_name,
)
cleanable = source_address is not None
else:
source_address = None
cleanable = False
reason = "не удалось определить политику динамических обновлений"
found.append(
ZoneInfo(
name=zone_name,
zone_file=zone_file,
source_address=source_address,
cleanable=cleanable,
reason=reason,
view=current_view,
)
)
names_seen[zone_name] = names_seen.get(zone_name, 0) + 1
duplicates = {name for name, count in names_seen.items() if count > 1}
if duplicates:
adjusted: list[ZoneInfo] = []
for zone in found:
if zone.name in duplicates:
adjusted.append(
ZoneInfo(
name=zone.name,
zone_file=zone.zone_file,
source_address=None,
cleanable=False,
reason="одноимённая зона обнаружена в нескольких views; очистка отключена",
view=zone.view,
)
)
else:
adjusted.append(zone)
found = adjusted
found.sort(key=lambda item: (item.name, item.view or ""))
return DiscoveryResult(tuple(found), tuple(skipped))
def print_discovery(discovery: DiscoveryResult) -> None:
print(f"bind-ptr-mismatch {SCRIPT_VERSION}")
print(f"Обнаружено динамических IPv4 reverse-зон: {len(discovery.zones)}")
if not discovery.zones:
print(" нет")
for zone in discovery.zones:
mode = "аудит+очистка" if zone.cleanable else "только аудит"
view_suffix = f", view={zone.view}" if zone.view else ""
source = zone.source_address or "—"
print(f" {zone.name}")
print(f" режим: {mode}{view_suffix}")
print(f" файл: {zone.zone_file}")
print(f" источник: {source}")
print(f" причина: {zone.reason}")
if discovery.skipped:
print()
print("Пропущенные зоны:")
for message in discovery.skipped:
print(f" {message}")
def ptr_owner_to_ip(owner: str) -> str:
normalized = fqdn(owner)
suffix = ".in-addr.arpa."
if not normalized.endswith(suffix):
raise ValueError(f"Не PTR-владелец: {owner}")
labels = normalized[: -len(suffix)].rstrip(".").split(".")
if len(labels) != 4:
raise ValueError(f"Ожидалось четыре IPv4-октета в {owner}")
address = ".".join(reversed(labels))
return str(ipaddress.IPv4Address(address))
def load_ignore_set() -> set[str]:
ignored: set[str] = set()
if not IGNORE_FILE.exists():
return ignored
for raw_line in IGNORE_FILE.read_text(encoding="utf-8").splitlines():
item = raw_line.split("#", 1)[0].strip().lower()
if item:
ignored.add(item.rstrip("."))
return ignored
def is_ignored(ip: str, owner: str, target: str, ignored: set[str]) -> bool:
candidates = {
ip.lower().rstrip("."),
owner.lower().rstrip("."),
target.lower().rstrip("."),
}
return bool(candidates & ignored)
def parse_rr_answers(output: str, rrtype: str) -> list[str]:
values: list[str] = []
wanted = rrtype.upper()
for raw_line in output.splitlines():
fields = raw_line.split()
for index, field in enumerate(fields):
if field.upper() == wanted and index + 1 < len(fields):
values.append(fields[index + 1])
break
return values
def query_a(programs: Programs, name: str) -> list[str]:
output = run([
programs.dig,
"@127.0.0.1",
fqdn(name),
"A",
"+norecurse",
"+noall",
"+answer",
])
addresses: list[str] = []
for value in parse_rr_answers(output, "A"):
try:
addresses.append(str(ipaddress.IPv4Address(value)))
except ipaddress.AddressValueError:
continue
return sorted(set(addresses), key=ipaddress.IPv4Address)
def query_ptr(programs: Programs, owner: str) -> list[str]:
output = run([
programs.dig,
"@127.0.0.1",
fqdn(owner),
"PTR",
"+norecurse",
"+noall",
"+answer",
])
return sorted({fqdn(value) for value in parse_rr_answers(output, "PTR")})
def read_ptr_records(
programs: Programs,
zone: str,
zone_file: Path,
) -> list[tuple[str, str, str]]:
output = run([
programs.named_checkzone,
"-D",
"-q",
"-o",
"-",
zone,
str(zone_file),
])
records: list[tuple[str, str, str]] = []
for raw_line in output.splitlines():
fields = raw_line.split()
ptr_index = next(
(index for index, value in enumerate(fields) if value.upper() == "PTR"),
None,
)
if ptr_index is None or ptr_index + 1 >= len(fields):
continue
owner = fqdn(fields[0])
target = fqdn(fields[ptr_index + 1])
try:
address = ptr_owner_to_ip(owner)
except ValueError:
continue
records.append((owner, address, target))
return records
def atomic_write(path: Path, text: str, mode: int = 0o600) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
delete=False,
) as temporary:
temporary.write(text)
temporary.flush()
os.fsync(temporary.fileno())
temp_name = temporary.name
os.chmod(temp_name, mode)
os.replace(temp_name, path)
def collect(
programs: Programs,
discovery: DiscoveryResult,
) -> tuple[
list[dict[str, str]],
list[dict[str, str]],
list[dict[str, str]],
int,
list[str],
]:
run([programs.rndc, "sync"])
ignored = load_ignore_set()
cleanable_mismatches: list[dict[str, str]] = []
audit_only_mismatches: list[dict[str, str]] = []
no_a: list[dict[str, str]] = []
ignored_count = 0
zone_errors: list[str] = []
for zone in discovery.zones:
if not zone.zone_file.is_file():
zone_errors.append(f"{zone.name}: не найден файл зоны {zone.zone_file}")
continue
try:
ptr_records = read_ptr_records(programs, zone.name, zone.zone_file)
except CommandError as error:
zone_errors.append(f"{zone.name}: {error}")
continue
for owner, address, target in ptr_records:
if is_ignored(address, owner, target, ignored):
ignored_count += 1
continue
addresses = query_a(programs, target)
record = {
"zone": zone.name,
"owner": owner,
"ip": address,
"target": target,
"a": ",".join(addresses),
"reason": zone.reason,
}
if not addresses:
no_a.append(record)
elif address not in addresses:
if zone.cleanable:
cleanable_mismatches.append(record)
else:
audit_only_mismatches.append(record)
sort_key = lambda item: (ipaddress.IPv4Address(item["ip"]), item["target"])
cleanable_mismatches.sort(key=sort_key)
audit_only_mismatches.sort(key=sort_key)
no_a.sort(key=sort_key)
return (
cleanable_mismatches,
audit_only_mismatches,
no_a,
ignored_count,
zone_errors,
)
def save_latest_report(mismatches: list[dict[str, str]]) -> None:
generated_epoch = int(time.time())
lines = [
f"# format_version={REPORT_FORMAT_VERSION}",
f"# generated_epoch={generated_epoch}",
"# zone\towner\tip\ttarget\ta_records",
]
for item in mismatches:
lines.append("\t".join([
item["zone"],
item["owner"],
item["ip"],
item["target"],
item["a"],
]))
atomic_write(LATEST_REPORT, "\n".join(lines) + "\n")
def print_report(
discovery: DiscoveryResult,
cleanable_mismatches: list[dict[str, str]],
audit_only_mismatches: list[dict[str, str]],
no_a: list[dict[str, str]],
ignored_count: int,
zone_errors: list[str],
) -> None:
now = dt.datetime.now().astimezone().isoformat(timespec="seconds")
cleanable_zones = sum(1 for zone in discovery.zones if zone.cleanable)
print(f"bind-ptr-mismatch {SCRIPT_VERSION}")
print(f"Проверка PTR/A: {now}")
print(
f"Автоматически обнаружено reverse-зон: {len(discovery.zones)}; "
f"очистка доступна для {cleanable_zones}"
)
print()
print(
"НЕСОВПАДЕНИЯ, ДОПУЩЕННЫЕ К РУЧНОЙ ОЧИСТКЕ: "
f"{len(cleanable_mismatches)}"
)
if cleanable_mismatches:
for item in cleanable_mismatches:
print(
f" {item['ip']:<15} PTR {item['target']:<45} "
f"A {item['a']} [{item['zone']}]"
)
else:
print(" нет")
print()
print(
"НЕСОВПАДЕНИЯ ТОЛЬКО ДЛЯ АУДИТА: "
f"{len(audit_only_mismatches)}"
)
if audit_only_mismatches:
for item in audit_only_mismatches:
print(
f" {item['ip']:<15} PTR {item['target']:<45} "
f"A {item['a']} [{item['zone']}]"
)
print(f" причина: {item['reason']}")
else:
print(" нет")
print()
print(f"PTR БЕЗ A-ЗАПИСИ, АВТОМАТИЧЕСКИ НЕ УДАЛЯЮТСЯ: {len(no_a)}")
if no_a:
for item in no_a:
print(f" {item['ip']:<15} PTR {item['target']} [{item['zone']}]")
else:
print(" нет")
if ignored_count:
print()
print(f"Пропущено по списку исключений: {ignored_count}")
if zone_errors:
print()
print("ОШИБКИ ЧТЕНИЯ ЗОН, НЕ ПРЕРВАВШИЕ ОТЧЁТ:")
for message in zone_errors:
print(f" {message}")
if discovery.skipped:
print()
print("ПРОПУЩЕНО ПРИ ОБНАРУЖЕНИИ:")
for message in discovery.skipped:
print(f" {message}")
print()
print(f"Снимок для кнопки очистки: {LATEST_REPORT}")
print(f"Срок действия снимка: {MAX_REPORT_AGE_SECONDS // 60} минут")
print(f"Лимит одного запуска очистки: {MAX_DELETIONS_PER_RUN} PTR")
def report(programs: Programs) -> int:
discovery = discover_reverse_zones(programs)
(
cleanable_mismatches,
audit_only_mismatches,
no_a,
ignored_count,
zone_errors,
) = collect(programs, discovery)
save_latest_report(cleanable_mismatches)
print_report(
discovery,
cleanable_mismatches,
audit_only_mismatches,
no_a,
ignored_count,
zone_errors,
)
return 0
def load_latest_report() -> tuple[int, list[dict[str, str]]]:
if not LATEST_REPORT.is_file():
raise CommandError(
"Нет свежего отчёта. Сначала запустите «Текущие несовпадения»."
)
generated_epoch: int | None = None
format_version: int | None = None
records: list[dict[str, str]] = []
for raw_line in LATEST_REPORT.read_text(encoding="utf-8").splitlines():
if raw_line.startswith("# format_version="):
format_version = int(raw_line.split("=", 1)[1])
continue
if raw_line.startswith("# generated_epoch="):
generated_epoch = int(raw_line.split("=", 1)[1])
continue
if not raw_line or raw_line.startswith("#"):
continue
fields = raw_line.split("\t")
if len(fields) != 5:
raise CommandError(f"Повреждён снимок отчёта: {LATEST_REPORT}")
zone, owner, address, target, a_records = fields
records.append({
"zone": zone,
"owner": owner,
"ip": address,
"target": target,
"a": a_records,
})
if format_version != REPORT_FORMAT_VERSION:
raise CommandError(
"Формат снимка не соответствует текущей версии скрипта. "
"Снова запустите «Текущие несовпадения»."
)
if generated_epoch is None:
raise CommandError(f"В снимке нет времени создания: {LATEST_REPORT}")
return generated_epoch, records
def delete_ptr(programs: Programs, item: dict[str, str], zone: ZoneInfo) -> None:
if zone.source_address is None:
raise CommandError(f"Для зоны {zone.name} не определён исходный адрес nsupdate")
payload = "\n".join([
f"server {zone.source_address}",
f"local {zone.source_address}",
f"zone {zone.name}.",
f"prereq yxrrset {item['owner']} PTR {item['target']}",
f"update delete {item['owner']} PTR {item['target']}",
"send",
"",
])
run([programs.nsupdate], stdin=payload)
def append_cleanup_log(message: str) -> None:
timestamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
descriptor = os.open(
CLEANUP_LOG,
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
0o600,
)
with os.fdopen(descriptor, "a", encoding="utf-8") as log_file:
log_file.write(f"{timestamp}\t{message}\n")
def clean(programs: Programs, *, force_many: bool = False) -> int:
generated_epoch, records = load_latest_report()
age = int(time.time()) - generated_epoch
if age < 0 or age > MAX_REPORT_AGE_SECONDS:
raise CommandError(
f"Отчёт устарел: возраст {age} секунд. "
"Сначала снова запустите «Текущие несовпадения»."
)
if not records:
print("Удалять нечего: в свежем отчёте нет строгих PTR/A-несовпадений.")
LATEST_REPORT.unlink(missing_ok=True)
return 0
if len(records) > MAX_DELETIONS_PER_RUN and not force_many:
raise CommandError(
f"В отчёте {len(records)} записей, что превышает защитный лимит "
f"{MAX_DELETIONS_PER_RUN}. Проверьте причину массового несовпадения. "
"Для осознанного консольного запуска используйте clean --force-many."
)
discovery = discover_reverse_zones(programs)
zones_by_name: dict[str, ZoneInfo] = {}
duplicate_names: set[str] = set()
for zone in discovery.zones:
if zone.name in zones_by_name:
duplicate_names.add(zone.name)
zones_by_name[zone.name] = zone
ignored = load_ignore_set()
deleted = 0
skipped = 0
touched_zones: set[str] = set()
for item in records:
zone = zones_by_name.get(item["zone"])
if zone is None:
print(f"ПРОПУСК: зона {item['zone']} больше не обнаруживается")
skipped += 1
continue
if item["zone"] in duplicate_names or not zone.cleanable:
print(
f"ПРОПУСК: зона {item['zone']} сейчас недоступна для очистки: "
f"{zone.reason}"
)
skipped += 1
continue
if not fqdn(item["owner"]).endswith(fqdn(zone.name)):
print(
f"ПРОПУСК: владелец {item['owner']} не принадлежит зоне {zone.name}"
)
skipped += 1
continue
if is_ignored(item["ip"], item["owner"], item["target"], ignored):
print(f"ПРОПУСК: {item['ip']} добавлен в список исключений")
skipped += 1
continue
current_ptr = query_ptr(programs, item["owner"])
current_a = query_a(programs, item["target"])
expected_target = fqdn(item["target"])
if expected_target not in current_ptr:
print(f"ПРОПУСК: {item['owner']} уже не указывает на {item['target']}")
skipped += 1
continue
if len(current_ptr) != 1:
print(
f"ПРОПУСК: у {item['owner']} несколько PTR-значений: "
f"{','.join(current_ptr)}"
)
skipped += 1
continue
if not current_a:
print(f"ПРОПУСК: у {item['target']} сейчас нет A-записей")
skipped += 1
continue
if item["ip"] in current_a:
print(
f"ПРОПУСК: {item['ip']} снова присутствует в A-записях "
f"{item['target']}"
)
skipped += 1
continue
delete_ptr(programs, item, zone)
remaining_ptr = query_ptr(programs, item["owner"])
if expected_target in remaining_ptr:
raise CommandError(
f"BIND не удалил PTR {item['owner']} -> {item['target']}"
)
deleted += 1
touched_zones.add(zone.name)
message = (
f"deleted\tzone={zone.name}\tip={item['ip']}\t"
f"ptr={item['target']}\tcurrent_a={','.join(current_a)}"
)
append_cleanup_log(message)
print(
f"УДАЛЕНО: {item['ip']} -> {item['target']} "
f"(A: {','.join(current_a)})"
)
for zone_name in sorted(touched_zones):
run([programs.rndc, "sync", zone_name])
STATE_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(STATE_DIR, 0o700)
archive = STATE_DIR / f"used-{dt.datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.tsv"
LATEST_REPORT.replace(archive)
print()
print(f"Удалено: {deleted}")
print(f"Пропущено после повторной проверки: {skipped}")
print(f"Использованный снимок сохранён: {archive}")
print(f"Журнал удаления: {CLEANUP_LOG}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Автоматическое обнаружение динамических reverse-зон BIND, "
"аудит и ручная очистка несовпадающих PTR/A-записей"
)
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {SCRIPT_VERSION}",
)
parser.add_argument("action", choices=("discover", "report", "clean"))
parser.add_argument(
"--force-many",
action="store_true",
help=(
"разрешить clean удалить больше защитного лимита; "
"предназначено только для осознанного консольного запуска"
),
)
args = parser.parse_args()
try:
programs = load_programs()
if args.action == "discover":
print_discovery(discover_reverse_zones(programs))
return 0
if args.action == "report":
return report(programs)
return clean(programs, force_many=args.force_many)
except (
CommandError,
ConfigParseError,
OSError,
RuntimeError,
ValueError,
) as error:
print(f"ОШИБКА: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
PYTHON
sudo chown root:root /usr/local/sbin/bind-ptr-mismatch
sudo chmod 0750 /usr/local/sbin/bind-ptr-mismatch
Контрольная сумма приведённого листинга:
sha256sum /usr/local/sbin/bind-ptr-mismatch
Ожидается:
3d481fc557ef15b6b303949e9a0ff8fcd4e4504b950faf1e0d1d9dca11ffea1b /usr/local/sbin/bind-ptr-mismatch
Несовпадение SHA-256 означает, что листинг скопирован не полностью либо изменён. До устранения расхождения команды
reportи особенноcleanне запускать.
24.3. Список исключений
Список исключений необязателен. Он позволяет исключить из аудита конкретный IP, полное имя владельца PTR или целевое имя PTR. Создаём пустой файл без перезаписи существующего содержимого:
sudo touch /etc/bind/ptr-audit-ignore.txt
sudo chown root:root /etc/bind/ptr-audit-ignore.txt
sudo chmod 0600 /etc/bind/ptr-audit-ignore.txt
Формат: одно значение на строку; пустые строки и текст после # игнорируются.
# Пример IP
172.16.69.200
# Пример полного владельца PTR
200.69.16.172.in-addr.arpa.
# Пример целевого имени PTR
legacy-host.ot-samara.baltika.ru.
Не добавлять исключение только ради скрытия непонятного отчёта. Сначала определить, почему A и PTR расходятся.
24.4. Проверка установки и обнаружения зон
Сначала проверяем синтаксис Python, версию и конфигурацию BIND:
sudo python3 -m py_compile /usr/local/sbin/bind-ptr-mismatch
sudo /usr/local/sbin/bind-ptr-mismatch --version
sudo named-checkconf -z
systemctl is-active named
Ожидаемая версия:
bind-ptr-mismatch 2.0.0
Затем запускаем только обнаружение:
sudo /usr/local/sbin/bind-ptr-mismatch discover
Для текущего проекта должны обнаружиться восемь динамических IPv4 reverse-зон. Для каждой зоны ожидается режим аудит+очистка и локальный адрес своей сети:
| Reverse-зона | Ожидаемый исходный адрес |
|---|---|
255.10.in-addr.arpa |
10.255.0.53 |
66.16.172.in-addr.arpa |
172.16.66.53 |
67.16.172.in-addr.arpa |
172.16.66.53 |
68.16.172.in-addr.arpa |
172.16.68.53 |
69.16.172.in-addr.arpa |
172.16.68.53 |
70.16.172.in-addr.arpa |
172.16.70.53 |
71.16.172.in-addr.arpa |
172.16.70.53 |
14.17.172.in-addr.arpa |
172.17.14.53 |
Если зона показана как
только аудит, отсутствует в выводе или выбран неправильный исходный адрес, очистку не запускать. Сначала проверитьallow-update, локальные IP, файл зоны и выводnamed-checkconf -p.
24.5. Первый отчёт и проверка файлов состояния
Отчёт безопасен: он не удаляет DNS-записи. Перед чтением файлов зон скрипт выполняет rndc sync.
sudo /usr/local/sbin/bind-ptr-mismatch report
Снимок строгих несовпадений сохраняется в:
/var/lib/bind/ptr-mismatch/latest.tsv
Проверка:
sudo ls -ld /var/lib/bind/ptr-mismatch
sudo ls -l /var/lib/bind/ptr-mismatch/latest.tsv
sudo sed -n '1,80p' \
/var/lib/bind/ptr-mismatch/latest.tsv
В интерактивном отчёте отдельно показываются:
- строгие несовпадения, допускаемые к удалению;
- несовпадения из зон, доступных только для аудита;
- PTR без A-записи, которые не удаляются автоматически;
- исключённые записи и ошибки чтения отдельных зон.
24.6. Безопасная очистка
Очистка запускается только после просмотра отчёта, пока снимку не исполнилось 15 минут:
sudo /usr/local/sbin/bind-ptr-mismatch clean
Перед фактическим удалением утилита ещё раз проверяет live-состояние:
- PTR всё ещё существует и имеет ожидаемое единственное значение;
- у целевого имени всё ещё есть A-запись;
- старый IP не вернулся в текущий A RRset;
- зона всё ещё обнаруживается и допускает очистку;
- запись не добавлена в список исключений.
После успешного удаления:
- затронутая зона синхронизируется через
rndc sync; - использованный снимок переименовывается в
used-YYYYMMDD-HHMMSS-ffffff.tsv; - операция добавляется в
/var/log/bind-ptr-cleanup.log.
sudo tail -n 50 /var/log/bind-ptr-cleanup.log
sudo find /var/lib/bind/ptr-mismatch \
-maxdepth 1 \
-type f \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %f
' | \
sort
Параметр:
sudo /usr/local/sbin/bind-ptr-mismatch clean --force-many
разрешает превышение лимита 20 удалений. Он предназначен только для осознанного консольного запуска после расследования массового несовпадения и не добавляется в Webmin.
24.7. Создание двух пользовательских команд Webmin
Кнопки создаются только после успешного выполнения --version, discover и report.
Открыть:
Webmin
→ Прочие
→ Пользовательские команды
→ Создать новую пользовательскую команду
Первая команда:
| Описание | 1. Текущие несовпадения PTR/A |
|---|---|
| Команда | /usr/local/sbin/bind-ptr-mismatch report |
| Запуск от пользователя | root |
Вторая команда:
| Описание | 2. Убрать текущие несовпадения PTR/A |
|---|---|
| Команда | /usr/local/sbin/bind-ptr-mismatch clean |
| Запуск от пользователя | root |
Правильный рабочий порядок:
- запустить 1. Текущие несовпадения PTR/A;
- просмотреть каждую запись, допускаемую к удалению;
- не позднее чем через 15 минут запустить 2. Убрать текущие несовпадения PTR/A;
- повторно запустить отчёт;
- проверить удалённый PTR через
digи журнал очистки.
Команда очистки не должна запускаться первой. При отсутствии свежего снимка, устаревшем снимке, превышении лимита или изменившемся DNS-состоянии утилита завершится ошибкой либо пропустит запись без удаления.
25. Эксплуатация: как правильно пользоваться DNS, DDNS и Webmin
Этот раздел является рабочей инструкцией для повседневной эксплуатации. Основной принцип: Windows-компьютеры регистрируют A/PTR через DDNS, оборудование без DDNS сопровождается вручную через Webmin, а фактическое состояние всегда подтверждается запросом к работающему BIND.
Источник истины: ответ
digилиResolve-DnsNameот локального сервисного адреса DNS показывает состояние работающегоnamed. Webmin показывает содержимое основного файла зоны и после DDNS может требовать предварительной синхронизации.jnl.
25.1. Что для чего использовать
| Задача | Правильный инструмент | Комментарий |
|---|---|---|
| Регистрация статического Windows-компьютера | Настройки сетевого интерфейса Windows и ipconfig /registerdns |
Клиент самостоятельно создаёт или обновляет A и PTR. |
| Добавление ПЛК, HMI, счётчика, коммутатора или другого устройства без DDNS | Webmin → BIND DNS Server | A и PTR создаются вручную как согласованная пара. |
| Проверка фактического ответа DNS | dig или Resolve-DnsName |
Проверяется работающий BIND, а не только отображение Webmin. |
| Показать в Webmin последние DDNS-записи | Пользовательская команда Синхронизировать DDNS-зоны | Выгружает изменения из journal в основной файл зоны. |
| Найти старые PTR после смены адреса | 1. Текущие несовпадения PTR/A | Сначала только отчёт, без удаления. |
| Удалить подтверждённые старые PTR | 2. Убрать текущие несовпадения PTR/A | Запускается только после просмотра отчёта. |
| Массовое или аварийное изменение файлов зон | CLI с rndc freeze/thaw |
Не выполнять обычному оператору без резервной копии и проверки. |
25.2. Какой DNS-адрес назначать клиенту
| Сеть клиента | Маска | DNS-сервер | Обратная зона |
|---|---|---|---|
10.255.0.0/16 |
255.255.0.0 |
10.255.0.53 |
255.10.in-addr.arpa |
172.16.66.0/23 |
255.255.254.0 |
172.16.66.53 |
66 или 67.16.172.in-addr.arpa |
172.16.68.0/23 |
255.255.254.0 |
172.16.68.53 |
68 или 69.16.172.in-addr.arpa |
172.16.70.0/23 |
255.255.254.0 |
172.16.70.53 |
70 или 71.16.172.in-addr.arpa |
172.17.14.0/24 |
255.255.255.0 |
172.17.14.53 |
14.17.172.in-addr.arpa |
- На OT-интерфейсе задаётся только локальный DNS своей сети.
- DNS-адрес другой OT-подсети не используется: между сегментами нет гарантированной маршрутизации.
- На OT-интерфейсе шлюз не задаётся, если он не требуется отдельным утверждённым проектным решением.
- Management-адреса
192.168.1.53и10.44.100.228не являются клиентскими DNS-адресами. - Второй DNS-сервер из другой подсети не обеспечивает корректное разделение запросов и не заменяет локальный адрес.
25.3. Подключение нового Windows-компьютера
- Утвердить имя компьютера и свободный статический IP по действующему плану адресации.
- На OT-интерфейсе задать IP, правильную маску и локальный DNS из таблицы выше; шлюз оставить пустым.
- В дополнительных параметрах IPv4 задать DNS-суффикс
ot-samara.baltika.ru. - Включить параметры «Зарегистрировать адреса этого подключения в DNS» и «Использовать DNS-суффикс этого подключения при регистрации».
- Отключить регистрацию адресов на Wi-Fi, VPN и других интерфейсах, которые не должны попадать в OT-зону.
- Запустить регистрацию и подождать 30–60 секунд.
- Проверить A и PTR. Только после этого считать подключение завершённым.
# PowerShell от имени администратора: контроль интерфейсов.
Get-DnsClient |
Format-Table InterfaceAlias,InterfaceIndex,ConnectionSpecificSuffix,RegisterThisConnectionsAddress -AutoSize
# Ручной запуск регистрации.
ipconfig /flushdns
ipconfig /registerdns
Сообщение Начата регистрация означает только постановку асинхронного задания службе DNS Client. Оно не подтверждает создание обеих записей.
Проверка на DNS-сервере:
# Заменить имя и IP фактическими значениями нового клиента.
dig @127.0.0.1 \
HOSTNAME.ot-samara.baltika.ru \
A \
+norecurse \
+short
dig @127.0.0.1 \
-x 172.16.69.250 \
+norecurse \
+short
Успешный результат — взаимно согласованная пара:
HOSTNAME.ot-samara.baltika.ru → фактический IP
фактический IP → HOSTNAME.ot-samara.baltika.ru.
25.4. Добавление устройства без DDNS через Webmin
Для ПЛК, HMI, счётчиков, коммутаторов и другого оборудования создаются две записи: A в прямой зоне и PTR в соответствующей обратной зоне.
- Проверить, что имя и IP не используются другим объектом.
- Открыть Серверы → BIND DNS Server → ot-samara.baltika.ru → Address Records.
- Создать A-запись с коротким именем без суффикса, например
plc-fill-001, и фактическим IP. - Открыть reverse-зону, соответствующую адресу, и создать PTR на полное имя с точкой в конце:
plc-fill-001.ot-samara.baltika.ru. - Запустить Check BIND Configuration, затем применить изменения.
- Проверить обе стороны через
dig.
Выбор reverse-зоны:
172.16.66.x → 66.16.172.in-addr.arpa
172.16.67.x → 67.16.172.in-addr.arpa
172.16.68.x → 68.16.172.in-addr.arpa
172.16.69.x → 69.16.172.in-addr.arpa
172.16.70.x → 70.16.172.in-addr.arpa
172.16.71.x → 71.16.172.in-addr.arpa
172.17.14.x → 14.17.172.in-addr.arpa
В зоне 255.10.in-addr.arpa используется /16-логика. Например, для 10.255.10.25 владельцем PTR внутри зоны 255.10.in-addr.arpa будет 25.10.
Не создавать только A или только PTR. Рабочей записью считается согласованная пара, если для объекта не утверждено специальное исключение.
25.5. Перенос Windows-компьютера в другую сеть или смена IP
- Назначить новый статический IP, маску и локальный DNS новой сети.
- Выполнить
ipconfig /flushdnsиipconfig /registerdns. - Проверить, что A указывает на новый IP и новый PTR указывает на текущее имя.
- В Webmin запустить 1. Текущие несовпадения PTR/A.
- Просмотреть отчёт и убедиться, что старый PTR действительно относится к перенесённому хосту.
- Запустить 2. Убрать текущие несовпадения PTR/A.
- Повторно проверить новый A/PTR и отсутствие старого PTR.
Windows обычно обновляет текущий A и создаёт PTR в новой reverse-зоне, но не обязана удалить PTR из старой зоны. Поэтому отчёт после переноса является обязательным шагом.
25.6. Переименование компьютера
После переименования Windows зарегистрирует новое имя, но старые записи могут сохраниться. Порядок действий:
- перезагрузить компьютер после штатного переименования;
- выполнить
ipconfig /registerdns; - проверить A/PTR нового имени;
- через Webmin вручную удалить старую A-запись и связанные CNAME, если они больше не нужны;
- запустить отчёт PTR/A и удалить подтверждённый старый PTR;
- убедиться, что старое имя больше не разрешается.
Утилита bind-ptr-mismatch контролирует PTR, но не удаляет автоматически старые A-записи или CNAME.
25.7. Вывод узла из эксплуатации
- Убедиться, что IP и имя действительно освобождаются и не будут немедленно использованы другим устройством.
- Удалить A-запись из прямой зоны.
- Удалить PTR из соответствующей reverse-зоны.
- Удалить связанные CNAME и другие записи, указывающие на выводимый узел.
- Проверить через
dig, что старое имя и старый PTR отсутствуют. - Зафиксировать освобождение имени и IP в плане адресации.
25.8. Как правильно смотреть записи в Webmin
- Открыть Прочие → Пользовательские команды.
- Запустить Синхронизировать DDNS-зоны.
- Повторно открыть нужную зону в модуле BIND.
- При необходимости выполнить
Ctrl+F5. - Сверить запись с
dig.
Если dig уже возвращает новую запись, а Webmin её не показывает, это обычно означает, что изменение находится в .jnl, а не ошибку DDNS.
25.9. Безопасное ручное изменение динамической зоны
Обычные точечные изменения выполняются через Webmin. Перед массовой правкой или изменением SOA/NS:
- синхронизировать DDNS-зоны;
- создать резервную копию
/etc/bindи/var/lib/bind/zones; - убедиться, что зона не осталась замороженной после предыдущей операции;
- выполнить изменение через Webmin либо контролируемый цикл
freeze → edit → check → thaw; - проверить
named-checkconf -zи live-ответы; - проверить
dynamic: yesиfrozen: no.
rndc zonestatus ot-samara.baltika.ru |
grep -E '^(name|serial|dynamic|frozen):'
named-checkconf -z
systemctl is-active named
Запрещено: редактировать основной файл работающей динамической зоны без
freeze, удалять.jnlдля «обновления Webmin», уменьшать serial или оставлять зону в состоянииfrozen: yes.
25.10. Регулярный эксплуатационный контроль
| Периодичность | Действие | Нормальный результат |
|---|---|---|
| После каждого добавления или изменения | Проверить A и PTR через dig |
Записи взаимно согласованы |
| После переноса/переименования | Запустить отчёт PTR/A | Нет необъяснимых mismatch |
| Перед просмотром DDNS-зон в Webmin | Синхронизировать зоны | Интерфейс показывает актуальный файл |
| Еженедельно | Проверить named, зоны и отчёт PTR/A |
active, dynamic: yes, frozen: no |
| После массовых изменений | Создать и проверить резервную копию | Архив читается, SHA-256 сохранён |
| После обновления ОС или BIND | Проверить UDP/TCP 53 и ключевые зоны | NOERROR, флаг aa |
# Короткая проверка службы и зон.
systemctl is-active named
named-checkconf -z
for zone in \
ot-samara.baltika.ru \
255.10.in-addr.arpa \
66.16.172.in-addr.arpa \
67.16.172.in-addr.arpa \
68.16.172.in-addr.arpa \
69.16.172.in-addr.arpa \
70.16.172.in-addr.arpa \
71.16.172.in-addr.arpa \
14.17.172.in-addr.arpa
do
rndc zonestatus "$zone" |
grep -E '^(name|serial|dynamic|frozen):'
done
25.11. Быстрая диагностика типовых ситуаций
| Симптом | Что это обычно означает | Следующее действие |
|---|---|---|
A и PTR есть в dig, но не видны в Webmin |
Изменения находятся в .jnl |
Запустить синхронизацию и повторно открыть страницу зоны |
| A обновился, PTR отсутствует | Не завершилась отдельная reverse-регистрация | Проверить SOA reverse-зоны, локальный ns-*, журнал и tcpdump |
В журнале update denied |
IP источника не входит в allow-update нужной зоны |
Проверить фактический source IP и ACL; не расширять ACL без анализа |
| Пакета UPDATE нет на сервере | Windows не сформировала его либо трафик блокируется на клиенте | Проверить DNS Client, регистрацию интерфейса, VPN/WFP и маршрут |
Внешние имена получают REFUSED |
Авторитетный режим без рекурсии | Считать штатным поведением; не включать рекурсию без отдельного проекта |
| Остался PTR старого адреса | Windows не выполнила scavenging в прежней зоне | Отчёт PTR/A, затем контролируемая очистка |
frozen: yes |
Зона осталась замороженной после редактирования | Проверить файл и выполнить rndc thaw <zone> |
bad udp cksum в исходящем пакете tcpdump |
Checksum offloading | Не считать ошибкой при успешном обмене |
25.12. Действия, которые нельзя выполнять
- не назначать клиенту DNS-сервер из другой OT-подсети;
- не добавлять management-адрес DNS-сервера в настройки OT-клиента;
- не включать рекурсию или forwarder только для устранения
REFUSED; - не расширять
allow-updateдоanyили крупной несогласованной сети; - не удалять
.jnlвручную; - не редактировать динамический файл зоны без синхронизации и
freeze; - не считать вывод
ipconfig /registerdnsподтверждением успешного A/PTR; - не запускать очистку PTR без предварительного отчёта;
- не создавать новую конфигурацию BIND в Webmin поверх существующей;
- не назначать один и тот же сервисный IP одновременно двум интерфейсам при миграции VLAN.
25.13. Минимальные рабочие чек-листы
| Операция | Обязательные шаги |
|---|---|
| Новый Windows-хост | IP/маска/DNS/суффикс → регистрация → A/PTR → синхронизация Webmin |
| Новое устройство без DDNS | Проверка уникальности → A → PTR → Check Configuration → dig |
| Смена IP или сети | Новая регистрация → новый A/PTR → отчёт mismatch → очистка старого PTR |
| Переименование | Регистрация нового имени → удалить старые A/CNAME → очистить старый PTR |
| Удаление узла | Удалить A/PTR/CNAME → проверить NXDOMAIN/отсутствие PTR → освободить IP в учёте |
| Ручная правка зоны | Синхронизация → backup → freeze/edit/check/thaw → live-проверка |
26. Контроль полноты конфигурационных материалов
В этой редакции собраны фактическая архитектура, полные основные конфигурационные файлы и подтверждённые эксплуатационные процедуры. Конфигурация Proxmox-хоста по-прежнему документируется картой bridge/VLAN/MAC, а не универсальной заменой /etc/network/interfaces.
| Файл или объект | Материал |
|---|---|
/etc/netplan/01-ot-dns.yaml |
Семь интерфейсов, адреса, единственный default route и комментарии VLAN |
| UFW | SSH/Webmin management и UDP/TCP 53 для каждой OT-сети |
/etc/bind/named.conf.options |
ACL клиентов, listen-on, авторитетный режим без рекурсии |
/etc/bind/named.conf.local |
Одна прямая и восемь reverse-зон с действующими DDNS ACL |
| Файлы зон | Базовые SOA/NS/A/PTR и локальные ns-* для reverse-DDNS |
| Windows DDNS | Настройки интерфейса, ручная регистрация, журнал, tcpdump и A/PTR-проверки |
| Webmin | Установка, доступ, работа с динамическими зонами и пользовательские команды |
bind-sync-ddns-zones |
Полный воспроизводимый скрипт синхронизации девяти зон |
bind-ptr-mismatch |
Полный листинг версии 2.0.0, установка, SHA-256, обнаружение зон, отчёт, безопасная очистка, исключения и две команды Webmin |
| Пилоты | Подтверждены все пять сетей, включая reverse-зоны 69 и 71 |
| Эксплуатационный регламент | Подключение Windows и оборудования без DDNS, перенос, переименование, удаление, диагностика и запрещённые действия |
27. Текущее фактическое состояние
| Компонент или проверка | Подтверждённое состояние |
|---|---|
| Ubuntu Server | 24.04.4 LTS, статическая многосетевая конфигурация Netplan применена |
| Proxmox/QEMU | VirtIO-интерфейсы подключены к нужным bridge/VLAN; qemu-guest-agent активен |
| Маршрутизация | Один default route через временный management; IP forwarding IPv4/IPv6 отключён |
| UFW | DNS UDP/53 и TCP/53 разрешён только из соответствующих OT-сетей; SSH/Webmin — из management-сетей |
| BIND9 | 9.18.39, named активен; одна прямая и восемь reverse-зон динамические и не заморожены |
| Режим DNS | Авторитетный сервер без рекурсии и forwarder; внешние имена получают REFUSED |
| DDNS | Включён во всех девяти рабочих зонах; ACL ограничены проектными сетями |
| Reverse SOA/NS | Все reverse-зоны используют локальные однозначные имена ns-* с единственной A-записью |
| Windows-пилоты | A/PTR подтверждены во всех пяти сетях; отдельно проверены адреса 172.16.69.250 и 172.16.71.250 |
| Webmin | 2.653; редактирование динамических зон, синхронизация и пользовательские команды проверены |
| Старые PTR | Отчёт и безопасная очистка через bind-ptr-mismatch 2.0.0 проверены |
| Известные особенности | VPN/DNS-leak-фильтры могут блокировать локальный DNS; отдельно устранена неоднозначность MNAME reverse-зон |
28. Следующие этапы
- создать и проверить контрольную резервную копию после внедрения локальных reverse MNAME/NS и всех DDNS-пилотов;
- утвердить промышленную схему именования и начать регистрацию рабочих Windows-хостов и ручных пар A/PTR для оборудования без DDNS;
- регулярно запускать отчёт PTR/A и выполнять очистку только под контролем изменений;
- усилить аутентификацию UPDATE: оценить TSIG, GSS-TSIG или централизованный DHCP-DDNS вместо доверия только по IP;
- ограничить Webmin встроенным IP Access Control и при необходимости установить доверенный внутренний TLS-сертификат;
- для клиентов, которым нужны корпоративные или внешние имена, отдельно спроектировать условную пересылку либо доверенный forwarder;
- настроить исключения AmneziaVPN для локальных OT-сетей и DNS либо отключать VPN при диагностике;
- выполнить атомарную миграцию
172.16.66.53/23сenp2s1наens21при вводе VLAN 2030 и удалить старые UFW-правила; - перевести default route на production management после проверки SSH, Webmin и NTP;
- спроектировать secondary DNS и контролируемые zone transfer.
Все изменения Netplan выполнять через
netplan try. Перед изменением BIND создавать резервную копию, синхронизировать динамические зоны, выполнятьnamed-checkconf -zи проверять результат черезdig.
Контрольная точка редакции 14: авторитетный BIND9, Webmin и DDNS настроены и проверены. Раздел контроля старых PTR теперь является полностью воспроизводимым: приведены зависимости, полный листинг bind-ptr-mismatch 2.0.0, команда установки, контрольная сумма, список исключений, проверка обнаружения зон, порядок report → clean, журналирование и создание двух пользовательских команд Webmin только после проверки утилиты.