Compare commits

..
18 Commits
Author SHA1 Message Date
Sven Luethi 9a2b0a2c2b modified: src/netbox_plugin_bind_provisioner/__init__.py
Bump version to v1.0.5
2026-01-09 14:46:09 +01:00
Sven Luethi 55d891f38c modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Slight changes to previous commit:

1. Stop converting the zone ID to string for catalog zone PTR record creation
   and simply use its int type.

2. Extend padding of ID in catalog zone PTR record from 6 digits to 9.
   Auto-incrementing of zone IDs may create larger IDs then expected if the
   turn-over of zones is high. Precautionary measure.

3. Fix catalog zone SOA record creation - A slight mistake was introduced in
   previous commit that would render the set the MNAME field to
   "invalid.zones.catz." rather than the intended "invalid." value.

4. Run code through black formater to stay compliant.
2026-01-09 13:36:08 +01:00
Sven Luethi 1375bbb7e3 Merge pull request #9 from jpmens/ttl_mname
Use TTL=0, alter MNAME,RNAME and rename PTR owners
2026-01-09 13:19:44 +01:00
Jan-Piet Mens 723566db70 Use TTL=0, alter MNAME,RNAME and rename PTR owners
I've opted for a slightly shorter name for PTR owner names
  The result looks like this:

  ```
  catz.			0	IN	SOA	invalid. invalid. 23 60 10 1209600 0
  zid-000002.zones.catz.	0	IN	PTR	2.0.192.in-addr.arpa.
  zid-000001.zones.catz.	0	IN	PTR	example.net.
  zid-000003.zones.catz.	0	IN	PTR	jpmens.net.
  zid-000004.zones.catz.	0	IN	PTR	tcp53.ch.
  catz.			0	IN	NS	invalid.
  version.catz.		0	IN	TXT	"2"
  ```
2026-01-09 08:53:59 +01:00
Sven Luethi e8450c90da modified: README.md
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Remove code and description that imported old catz serials from flat files that were used before switch to DB model.
Code and docs no longer applicable so removed.
2026-01-09 02:59:36 +01:00
Sven Luethi ae6bb3c1b6 modified: README.md 2026-01-09 02:50:58 +01:00
Sven Luthi 0028ba8d80 new file: src/netbox_plugin_bind_provisioner/migrations/0002_alter_integerkeyvaluesetting_options_and_more.py
modified:   src/netbox_plugin_bind_provisioner/models.py
Remove the settings model from Netbox's Permission system as these values are for internal use of the plugin only.
2025-12-18 15:53:38 +01:00
Sven Luthi 78ed040495 modified: README.md
Update readme
2025-12-18 15:03:10 +01:00
Sven Luthi 20064ec9bd modified: src/netbox_plugin_bind_provisioner/__init__.py
new file:   src/netbox_plugin_bind_provisioner/management/commands/export-all-dns-zones.py
new file:   src/netbox_plugin_bind_provisioner/management/commands/export-dns-zone.py
new file:   src/netbox_plugin_bind_provisioner/utils.py
Adds export commands for zones to bind zone files and bump version to v1.0.4
2025-12-11 15:59:21 +01:00
Sven Luethi 6ac1548eef modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Improve TXT record formating.
2025-12-01 19:22:11 +01:00
Sven Luethi d8f4b83c2a modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Allows for TXT records to be added without quotation. This will apply the correct quoting and
chunking where needed.
2025-11-29 13:36:18 +01:00
Sven Luethi fa032b1ea3 modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fixes bug: TXT records were truncated instead of chunked when larger then 255 characters and on semicolons.
2025-11-28 18:52:00 +01:00
Sven Luethi e6aaa3a54d modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
modified:   src/netbox_plugin_bind_provisioner/migrations/0001_initial.py
modified:   src/netbox_plugin_bind_provisioner/models.py
Run code through black formater and bump version string to v1.0.0
2025-11-27 11:26:35 +01:00
Sven Luethi eee5395ea6 modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fixes custom properties in catalog zone, adding dnssec policy as group property instead
(as spcified in RFC9432)
2025-11-27 11:25:30 +01:00
Sven Luethi a9c59f4c57 modified: .gitignore
Ingore build/ dir

modified:   src/netbox_plugin_bind_provisioner/__init__.py
Set version v0.9.9

modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fix bug where an exception occurse if a view has no zones.
Feature added - Fixes #5
2025-11-26 22:49:37 +01:00
Sven Luthi 787c3d0a20 modified: README.md
new file:   docs/install-systemd-service.md
Fixes #3
2025-11-25 10:00:31 +01:00
Sven Luethi 57195a822b modified: .github/workflows/python-publish.yml 2025-11-25 01:25:42 +01:00
Sven Luethi 0590caeebd modified: src/netbox_plugin_bind_provisioner/__init__.py
Update version string
2025-11-25 00:13:06 +01:00
12 changed files with 424 additions and 164 deletions

No files matched your search

+11 -20
View File
@@ -6,21 +6,21 @@
# separate terms of service, privacy policy, and support # separate terms of service, privacy policy, and support
# documentation. # documentation.
name: Upload Python Package name: Create and Upload Python Package
on: on:
release: release:
types: [published] types: [published]
# push:
# branches:
# - main
permissions: permissions:
contents: read contents: read
jobs: jobs:
build-release: build-and-publish:
name: Build name: Build and Publish
environment: pypi
permissions:
id-token: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -29,23 +29,14 @@ jobs:
with: with:
python-version: "3.x" python-version: "3.x"
- name: Build release distributions - name: Install build tools
run: | run: |
# NOTE: put your own distribution build steps here. python -m pip install --upgrade pip
python -m pip install build python -m pip install build
python -m build --wheel
- name: Upload distributions - name: Build release
uses: actions/upload-artifact@v4 run: |
with: python -m build --sdist --wheel
name: release-dists
path: dist/
pypi-publish:
name: Upload
environment: pypi
permissions:
id-token: write
steps:
- name: Publish package distributions to PyPI - name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
+1
View File
@@ -1,3 +1,4 @@
src/netbox_plugin_bind_provisioner.egg-info src/netbox_plugin_bind_provisioner.egg-info
dist/ dist/
build/
__pycache__ __pycache__
+32 -18
View File
@@ -1,32 +1,43 @@
# Netbox Bind Provisioner # Netbox Bind Provisioner
A NetBox plugin that implements a lightweight DNS server to automate the The Netbox Bind Provisioner plugin implements a lightweight DNS server inside
provisioning and configuration of a BIND9 server based on DNS records Netbox and builds a bridge for BIND and other DNS Servers implementing RFC9432
maintained in NetBox. (netbox-plugin-dns). to retrieve DNS Zones directly from Netbox using DNS native mechanisms.
[![PyPi](https://img.shields.io/pypi/v/netbox-plugin-bind-provisioner)](https://pypi.org/project/netbox-plugin-bind-provisioner/)
[![Stars Badge](https://img.shields.io/github/stars/suraxius/netbox-plugin-bind-provisioner?style=flat)](https://github.com/suraxius/netbox-plugin-bind-provisioner/stargazers)
[![Forks Badge](https://img.shields.io/github/forks/suraxius/netbox-plugin-bind-provisioner?style=flat)](https://github.com/suraxius/netbox-plugin-bind-provisioner/network/members)
[![Issues Badge](https://img.shields.io/github/issues/suraxius/netbox-plugin-bind-provisioner)](https://github.com/suraxius/netbox-plugin-bind-provisioner/issues)
[![Pull Requests Badge](https://img.shields.io/github/issues-pr/suraxius/netbox-plugin-bind-provisioner)](https://github.com/suraxius/netbox-plugin-bind-provisioner/pulls)
[![GitHub contributors](https://img.shields.io/github/contributors/suraxius/netbox-plugin-bind-provisioner?color=2b9348)](https://github.com/suraxius/netbox-plugin-bind-provisioner/graphs/contributors)
[![License Badge](https://img.shields.io/github/license/suraxius/netbox-plugin-bind-provisioner?color=2b9348)](https://github.com/suraxius/netbox-plugin-bind-provisioner/blob/master/LICENSE)
[![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Downloads](https://static.pepy.tech/personalized-badge/netbox-plugin-bind-provisioner?period=total&left_color=BLACK&right_color=BLUE&left_text=Downloads)](https://pepy.tech/project/netbox-plugin-bind-provisioner)
[![Downloads/Week](https://static.pepy.tech/personalized-badge/netbox-plugin-bind-provisioner?period=monthly&left_color=BLACK&right_color=BLUE&left_text=Downloads%2fMonth)](https://pepy.tech/project/netbox-plugin-bind-provisioner)
[![Downloads/Month](https://static.pepy.tech/personalized-badge/netbox-plugin-bind-provisioner?period=weekly&left_color=BLACK&right_color=BLUE&left_text=Downloads%2fWeek)](https://pepy.tech/project/netbox-plugin-bind-provisioner)
## Plugin configuration ## Plugin configuration
The plugin has been reworked: instead of exporting zone files, it now provides While providing Zone transfers via AXFR, the Server also exposes specialized
a minimal DNS server that is fed directly from NetBox’s DNS data. This server catalog zones that BIND and other RFC9432 compliant DNS Servers use to
also exposes specialized catalog zones that BIND uses to automatically discover automatically discover newly created zones and remove deleted ones. The plugin
newly created zones and remove deleted ones. The plugin supports BIND views and supports views and basic DNS security via TSIG.
basic DNS security via TSIG.
The plugin exposes one catalog zone per view. Each catalog zone is available The plugin exposes one catalog zone per view. Each catalog zone is made available
under the special zone name **"catz"** and can be queried through the built-in under the special zone name **"catz"** and addtionally under **"[viewname].catz"**
DNS server like any other zone. and may be queried through the built-in DNS server just like any other dns zone.
For proper operation, each view requires an installed TSIG key, and the For proper operation, each view requires an installed TSIG key, and the
`bind-transfer-endpoint` must be running as a separate background service using `bind-transfer-endpoint` must be running as a separate background service using
the `manage.py` command. Note that DNSSEC support will be added once BIND9 the `manage.py` command. Note that DNSSEC support will be added once BIND9
provides a mechanism to configure it through the Catalog Zones system. provides a mechanism to configure it through the Catalog Zones system.
To start the service: To start the service in the foreground:
``` ```
manage.py bind-transfer-endpoint --port 5354 manage.py bind-transfer-endpoint --port 5354
``` ```
This process needs to be scheduled as a background service for the built-in DNS
Server to work correctly. For Linux users with Systemd (Ubuntu, etc), Matt Kollross
provides a startup unit and instructions [here](docs/install-systemd-service.md).
When configuring the plugin, you need to provide a path on the filesystem for
this file where the plugin can create and manage it. Please do not update it by
hand or remove it unless you know what you are doing.
### Service parameters ### Service parameters
Parameter | Description Parameter | Description
@@ -37,7 +48,6 @@ Parameter | Description
### Plugin settings ### Plugin settings
Setting | Description Setting | Description
--------------------| --------------------------------------------------------- --------------------| ---------------------------------------------------------
catalog_serial_file | !!!NO LONGER USED!!! Replaced by DB entry.
tsig_keys | Maps a TSIG Key to be used for each view. tsig_keys | Maps a TSIG Key to be used for each view.
## Installation guide ## Installation guide
@@ -115,9 +125,13 @@ This guide assumes:
``` ```
5. Start listener 5. Start listener
This step runs the DNS endpoint used by bind to configure itself. You may want This step runs the DNS endpoint used by bind to configure itself. You may want
to write a service wrapper that runs this in the background. Dont forget to to write a service wrapper that runs this in the background.
activate the venv if you do decide to run this service in the background. A guide for setting up a systemd service on Ubuntu is provided by Matt
Kollross [here](docs/install-systemd-service.md). Dont forget to activate
the venv if you do decide to run this service in the background.
Note that `--port 5354` is optional. The listener will bind this port Note that `--port 5354` is optional. The listener will bind this port
by default. by default.
``` ```
+34
View File
@@ -0,0 +1,34 @@
1. Create System Unit file
```
cat <<EOF > /etc/systemd/system/netbox-bind-endpoint.service
[Unit]
Description=Netbox BIND Catalog Endpoing
Documentation=https://github.com/Suraxius/netbox-plugin-bind-provisioner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=netbox
Group=netbox
PIDFile=/var/tmp/netbox.pid
WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py bind-transfer-endpoint --port 5354
Restart=on-failure
RestartSec=30
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
```
2. Enable and start the service
```
systemctl enable netbox-bind-endpoint
systemctl start netbox-bind-endpoint
systemctl status netbox-bind-endpoint
```
@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
__version__ = "0.9.6" __version__ = "1.0.5"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,5 +32,6 @@ class BindProvisionerConfig(PluginConfig):
f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox." f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox."
) )
config = BindProvisionerConfig config = BindProvisionerConfig
default_app_config = ".apps.NetboxBindProvisionerConfig" default_app_config = ".apps.NetboxBindProvisionerConfig"
@@ -13,7 +13,6 @@ import dns.rdtypes
import dns.exception import dns.exception
import dns.renderer import dns.renderer
# from hashlib import sha256
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -22,9 +21,10 @@ from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
from datetime import datetime from datetime import datetime
from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting
logger = logging.getLogger('bind-transfer-endpoint') logger = logging.getLogger("bind-transfer-endpoint")
class CatalogZone():
class CatalogZone:
lock = threading.Lock() lock = threading.Lock()
_serial_max = 0xFFFFFFFF _serial_max = 0xFFFFFFFF
_serial_obj = None _serial_obj = None
@@ -35,12 +35,19 @@ class CatalogZone():
@classmethod @classmethod
def initSerial(cls): def initSerial(cls):
try: try:
cls._serial_obj = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial") cls._serial_obj = IntegerKeyValueSetting.objects.get(
logger.debug(f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database") key="catalog-zone-soa-serial"
)
logger.debug(
f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database"
)
except IntegerKeyValueSetting.DoesNotExist: except IntegerKeyValueSetting.DoesNotExist:
cls._serial_obj = IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1) cls._serial_obj = IntegerKeyValueSetting.objects.create(
logger.debug(f"Catalog zone SOA serial number was not set in the database. Set to {cls._serial_obj.value}") key="catalog-zone-soa-serial", value=1
)
logger.debug(
f"Catalog zone SOA serial number was not set in the database. Set to {cls._serial_obj.value}"
)
@classmethod @classmethod
def _incrementSerial(cls): def _incrementSerial(cls):
@@ -53,19 +60,24 @@ class CatalogZone():
) )
cls._serial_obj = 1 cls._serial_obj = 1
cls._serial_obj.save() cls._serial_obj.save()
logger.debug(f"Catalog zone SOA serial number incremented to {_serial_obj.value}") logger.debug(
f"Catalog zone SOA serial number incremented to {_serial_obj.value}"
)
@classmethod @classmethod
def create(cls, view_name, soa_only=False): def create(cls, name, view_name):
# Synchronize following across threads as TCP and UDP listener both use it. # Synchronize following across threads as TCP and UDP listener both use it.
with cls.lock: with cls.lock:
# cls = self.__class__ # cls = self.__class__
# datestamp = datetime.now().strftime("%y%m%d") # datestamp = datetime.now().strftime("%y%m%d")
# latest_zone = Zone.objects.filter().order_by("-last_updated").first() # latest_zone = Zone.objects.filter().order_by("-last_updated").first()
latest_zone = Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE).order_by('-last_updated').first() latest_zone = (
Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE)
.order_by("-last_updated")
.first()
)
last_zone_update = getattr(latest_zone, 'last_updated', None) last_zone_update = getattr(latest_zone, "last_updated", None)
# Check if there was a zone updated since last call # Check if there was a zone updated since last call
# If no zone was found previously then this will be false since (None != None) = False # If no zone was found previously then this will be false since (None != None) = False
@@ -77,12 +89,9 @@ class CatalogZone():
# Setting previous last zone update for next iteration: # Setting previous last zone update for next iteration:
cls._previous_last_zone_update = last_zone_update cls._previous_last_zone_update = last_zone_update
cls._incrementSerial() cls._incrementSerial()
# If there was no update, do nothing.
#else:
# logger.debug(f"No need to update serial for catalog zone")
# Zone origin # Zone origin
origin = dns.name.from_text("catz", dns.name.root) origin = dns.name.from_text(name, dns.name.root)
# Create a new empty zone # Create a new empty zone
zone = dns.zone.Zone(origin) zone = dns.zone.Zone(origin)
@@ -96,13 +105,12 @@ class CatalogZone():
ptr_base = dns.name.from_text("zones", origin) ptr_base = dns.name.from_text("zones", origin)
for nb_zone in nb_zones: for nb_zone in nb_zones:
ttl = nb_zone.soa_ttl ttl = 0
qname = dns.name.from_text(nb_zone.name, dns.name.root) qname = dns.name.from_text(nb_zone.name, dns.name.root)
# zone_id = sha256(qname.to_text().encode()).hexdigest()[0:63]
zone_id = str(nb_zone.id)
# Create PTR record # Create PTR record
ptr_name = dns.name.from_text(zone_id, ptr_base) p_name = f"zid-{nb_zone.id:09d}"
ptr_name = dns.name.from_text(p_name, ptr_base)
assert ptr_name.is_subdomain(origin) assert ptr_name.is_subdomain(origin)
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
@@ -113,31 +121,33 @@ class CatalogZone():
# Configure DNSSec Policy for member Zone if DNSSec is enabled # Configure DNSSec Policy for member Zone if DNSSec is enabled
if nb_zone.dnssec_policy: if nb_zone.dnssec_policy:
# Configure policy # Configure policy
rid = dns.name.from_text(f"policy.dnssec", ptr_name) rid = dns.name.from_text("group", ptr_name)
policy_name = nb_zone.dnssec_policy.name.rstrip(" ")
group_name = f"dnssec-policy-{policy_name}"
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.TXT, nb_zone.dnssec_policy.name dns.rdataclass.IN, dns.rdatatype.TXT, group_name
) )
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
rdataset.add(rdata, ttl) rdataset.add(rdata, ttl)
# Configure dnssec status for member zone ## Configure dnssec status for member zone
status = str(1 if nb_zone.dnssec_policy else 0) # status = str(1 if nb_zone.dnssec_policy else 0)
rid = dns.name.from_text(f"enabled.dnssec", ptr_name) # rid = dns.name.from_text("enabled.dnssec.ext", ptr_name)
rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status) # rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True) # rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
rdataset.add(rdata, ttl) # rdataset.add(rdata, ttl)
# SOA Record components # SOA Record components
ttl = 3600 ttl = 0
rclass = dns.rdataclass.IN rclass = dns.rdataclass.IN
rtype = dns.rdatatype.SOA rtype = dns.rdatatype.SOA
mname = dns.name.root # dns.name.from_text("invalid.", origin) mname = dns.name.from_text("invalid", dns.name.root)
rname = dns.name.root # dns.name.from_text("contact.master.", origin) rname = dns.name.from_text("invalid", dns.name.root)
serial = cls._serial_obj.value serial = cls._serial_obj.value
refresh = 60 refresh = 60
retry = 10 retry = 10
expire = 1209600 expire = 1209600
minimum = 86400 minimum = 0
# Create SOA rdata object # Create SOA rdata object
soa_rdata = dns.rdata.from_text( soa_rdata = dns.rdata.from_text(
@@ -154,14 +164,13 @@ class CatalogZone():
node = zone.find_node(origin, create=True) node = zone.find_node(origin, create=True)
node.rdatasets.append(soa_rdataset) node.rdatasets.append(soa_rdataset)
# NS record for catz. # NS record for catz.
ns_name = dns.name.from_text("invalid", dns.name.root) ns_name = dns.name.from_text("invalid", dns.name.root)
ns_rdata = dns.rdata.from_text( ns_rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name) dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name)
) )
ns_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS) ns_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS)
ns_rdataset.add(ns_rdata, 86400) ns_rdataset.add(ns_rdata, 0)
# Add to node (catz. is the origin) # Add to node (catz. is the origin)
ns_node = zone.find_node(origin, create=True) ns_node = zone.find_node(origin, create=True)
@@ -174,7 +183,7 @@ class CatalogZone():
txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"') txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"')
txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT) txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT)
txt_rdataset.add(txt_rdata, 86400) txt_rdataset.add(txt_rdata, 0)
# Add to node version.catz. # Add to node version.catz.
txt_node = zone.find_node(version_name, create=True) txt_node = zone.find_node(version_name, create=True)
@@ -182,27 +191,16 @@ class CatalogZone():
return zone return zone
@classmethod
def importLegacySerialFile(cls, file_path):
if os.path.exists(file_path):
try:
with open(file_path, "r") as f:
serial = int(f.read().strip())
cls._serial_obj.value = int(serial)
cls._serial_obj.save()
logger.info(f"Legacy catalog serial file {file_path} imported into database and file removed. Serial is now {serial}")
os.remove(file_path)
except OSError as e:
logger.error(f"Failed to import catalog zone serial from legacy file {file_path}")
class DNSBaseRequestHandler(socketserver.BaseRequestHandler): class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# getZoneFromNB rewritten # getZoneFromNB rewritten
def getZoneFromNB(self, zone_name, view_name, soa_only=False): def getZoneFromNB(self, zone_name, view_name):
# Find the zone # Find the zone
try: try:
nb_zone = Zone.objects.get( nb_zone = Zone.objects.get(
name=zone_name, view=view_name, status=ZoneStatusChoices.STATUS_ACTIVE name=zone_name,
view__name=view_name,
status=ZoneStatusChoices.STATUS_ACTIVE,
) )
except Zone.DoesNotExist: except Zone.DoesNotExist:
return None return None
@@ -229,10 +227,29 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# If the record has no TTL, use the zone default # If the record has no TTL, use the zone default
ttl = record.ttl or nb_zone.default_ttl ttl = record.ttl or nb_zone.default_ttl
# Apply quoting for TXT records to stop tokanizer
# from cutting it up:
value = record.value
if rdtype == dns.rdatatype.TXT:
if value.startswith('"') and value.endswith('"'):
value = value[1:-1].replace('" "', "").replace('"', '"')
if len(value) > 255:
# This is a bug fix for netbox. If netbox allowed for
# an unquoted value to be larger then 255 characters,
# it misunderstood everything behind a ; as a comment.
chunks = [
'"{}"'.format(value[i : i + 255])
for i in range(0, len(value), 255)
]
value = " ".join(chunks)
else:
value = f'"{value}"'
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdataclass.IN,
rdtype, rdtype,
record.value, value,
relativize=False, relativize=False,
origin=zone.origin, origin=zone.origin,
) )
@@ -241,7 +258,9 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
if name not in rdatasets_dict: if name not in rdatasets_dict:
rdatasets_dict[name] = {} rdatasets_dict[name] = {}
if rdtype not in rdatasets_dict[name]: if rdtype not in rdatasets_dict[name]:
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(dns.rdataclass.IN, rdtype) rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(
dns.rdataclass.IN, rdtype
)
# Add the rdata to the appropriate rdataset # Add the rdata to the appropriate rdataset
rdatasets_dict[name][rdtype].add(rdata, ttl) rdatasets_dict[name][rdtype].add(rdata, ttl)
@@ -251,7 +270,9 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
for rdtype, rdataset in rdtypes.items(): for rdtype, rdataset in rdtypes.items():
# Ensure rdataset has the same rdclass as the zone # Ensure rdataset has the same rdclass as the zone
if rdataset.rdclass != zone.rdclass: if rdataset.rdclass != zone.rdclass:
raise ValueError(f"rdataset rdclass {rdataset.rdclass} does not match zone rdclass {zone.rdclass}") raise ValueError(
f"rdataset rdclass {rdataset.rdclass} does not match zone rdclass {zone.rdclass}"
)
# Check if the rdataset has any rdata before creating an RRset # Check if the rdataset has any rdata before creating an RRset
if not rdataset: if not rdataset:
@@ -262,18 +283,11 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
zone.replace_rdataset(name, rdataset) zone.replace_rdataset(name, rdataset)
return zone return zone
# def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
# response = dns.message.make_response(query)
# response.set_rcode(rcode)
# wire = response.to_wire(multi=False)
# length = len(wire).to_bytes(2, byteorder="big")
# self.request.sendall(length + wire)
def denyRequestBadTSIG(self, wire, tsig_error: dns.rcode): def denyRequestBadTSIG(self, wire, tsig_error: dns.rcode):
# Use empty keyring to parse TSIG without validating # Use empty keyring to parse TSIG without validating
query = dns.message.from_wire(wire, keyring={}, ignore_trailing=True, continue_on_error=True) query = dns.message.from_wire(
wire, keyring={}, ignore_trailing=True, continue_on_error=True
)
# Make a response # Make a response
response = dns.message.make_response(query) response = dns.message.make_response(query)
@@ -284,7 +298,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
response.use_tsig( response.use_tsig(
keyring={}, # empty; we're not signing keyring={}, # empty; we're not signing
keyname=query.keyname, keyname=query.keyname,
tsig_error=tsig_error tsig_error=tsig_error,
) )
self.denyRequest(query) self.denyRequest(query)
@@ -307,7 +321,7 @@ class UDPRequestHandler(DNSBaseRequestHandler):
data, data,
keyring=self.server.keyring, keyring=self.server.keyring,
continue_on_error=False, continue_on_error=False,
ignore_trailing=True ignore_trailing=True,
) )
except Exception as e: except Exception as e:
logger.error("Error parsing query: ", e) logger.error("Error parsing query: ", e)
@@ -328,7 +342,9 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# Only process SOA queries # Only process SOA queries
if qtype != dns.rdatatype.SOA: if qtype != dns.rdatatype.SOA:
logger.warning(f"Request denied from {peer}: Request was not SOA (Type: {qtype})") logger.warning(
f"Request denied from {peer}: Request was not SOA (Type: {qtype})"
)
self.denyRequest(query) self.denyRequest(query)
return return
@@ -343,16 +359,18 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# Check if key matches a view # Check if key matches a view
nb_view = self.server.tsig_view_map.get(key_name) nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view: if not nb_view:
logger.warning(f"Request denied from {peer}: {key_name} does not match a view") logger.warning(
f"Request denied from {peer}: {key_name} does not match a view"
)
self.denyRequestBadTSIG(wire, dns.rcode.BADKEY) self.denyRequestBadTSIG(wire, dns.rcode.BADKEY)
return return
# Check if catalog zone # Check if catalog zone
if dname == "catz": if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(nb_view.name, soa_only=True) zone = CatalogZone.create(dname, nb_view.name)
# If generic zone, retreive from NB # If generic zone, retreive from NB
else: else:
zone = self.getZoneFromNB(dname, nb_view, soa_only=True) zone = self.getZoneFromNB(dname, nb_view.name)
# When zone was not found, let client know # When zone was not found, let client know
if not zone: if not zone:
logger.warning(f"Zone {nb_view.name}/{dname} not found in NB") logger.warning(f"Zone {nb_view.name}/{dname} not found in NB")
@@ -366,7 +384,6 @@ class UDPRequestHandler(DNSBaseRequestHandler):
soa_rdata = soa_rdataset[0] # Get the first SOA record soa_rdata = soa_rdataset[0] # Get the first SOA record
# Now, create the rrset from the soa_rdata # Now, create the rrset from the soa_rdata
#rrset = dns.rrset.from_rdata(dname, soa_rdataset.ttl, soa_rdata)
rrset = dns.rrset.from_rdata(zone.origin, soa_rdataset.ttl, soa_rdata) rrset = dns.rrset.from_rdata(zone.origin, soa_rdataset.ttl, soa_rdata)
# Append the rrset to the response's answer section # Append the rrset to the response's answer section
@@ -376,7 +393,9 @@ class UDPRequestHandler(DNSBaseRequestHandler):
if query.had_tsig: if query.had_tsig:
# If key was found in DB # If key was found in DB
if query.keyname in self.server.keyring: if query.keyname in self.server.keyring:
response.use_tsig(self.server.keyring, keyname=query.keyname, original_id=query.id) response.use_tsig(
self.server.keyring, keyname=query.keyname, original_id=query.id
)
else: else:
# If key is unknown # If key is unknown
response.set_rcode(dns.rcode.REFUSED) response.set_rcode(dns.rcode.REFUSED)
@@ -384,7 +403,11 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# keyname = query.keyname # keyname = query.keyname
# b64 = base64.b64encode(urandom(32)).decode("ascii") # b64 = base64.b64encode(urandom(32)).decode("ascii")
# newkey = dns.tsigkeyring.from_text({ keyname: b64 }) # newkey = dns.tsigkeyring.from_text({ keyname: b64 })
response.use_tsig(self.server.keyring, keyname=query.keyname, tsig_error=dns.rcode.BADKEY) response.use_tsig(
self.server.keyring,
keyname=query.keyname,
tsig_error=dns.rcode.BADKEY,
)
# Send back the response # Send back the response
response_wire = response.to_wire(max_size=512) response_wire = response.to_wire(max_size=512)
@@ -428,7 +451,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
query = dns.message.from_wire(wire, keyring=self.server.keyring) query = dns.message.from_wire(wire, keyring=self.server.keyring)
# If TSIG signature doesnt match our key, refuse query: # If TSIG signature doesnt match our key, refuse query:
except dns.tsig.BadSignature as e: except dns.tsig.BadSignature as e:
logger.warning(f"Request denied from {peer} failed TSIG verification: {e}") logger.warning(
f"Request denied from {peer} failed TSIG verification: {e}"
)
self.denyRequestBadTSIG(wire, dns.rcode.BADSIG) self.denyRequestBadTSIG(wire, dns.rcode.BADSIG)
return return
# If TSIG Key used is not in our list, refuse query: # If TSIG Key used is not in our list, refuse query:
@@ -451,7 +476,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Only process AXFR queries # Only process AXFR queries
if qtype != dns.rdatatype.AXFR: if qtype != dns.rdatatype.AXFR:
# if qtype != dns.rdatatype.AXFR or qtype != dns.rdatatype.SOA: # if qtype != dns.rdatatype.AXFR or qtype != dns.rdatatype.SOA:
logger.warning(f"Request denied from {peer}: Request was not AXFR (Type: {qtype})") logger.warning(
f"Request denied from {peer}: Request was not AXFR (Type: {qtype})"
)
self.denyRequest(query) self.denyRequest(query)
return return
@@ -466,15 +493,17 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Check if the key matches a view # Check if the key matches a view
nb_view = self.server.tsig_view_map.get(key_name) nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view: if not nb_view:
logger.warning(f"Request denied from {peer}: {key_name} does not match a view") logger.warning(
f"Request denied from {peer}: {key_name} does not match a view"
)
self.denyRequest(query) self.denyRequest(query)
return return
# Check if catalog zone # Check if catalog zone
if dname == "catz": if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(nb_view.name) zone = CatalogZone.create(dname, nb_view.name)
else: else:
zone = self.getZoneFromNB(dname, nb_view) zone = self.getZoneFromNB(dname, nb_view.name)
# When zone was not found, let client know # When zone was not found, let client know
if not zone: if not zone:
@@ -503,7 +532,10 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# 2. Create a Renderer for the first message # 2. Create a Renderer for the first message
r = dns.renderer.Renderer( r = dns.renderer.Renderer(
id=query.id, flags=(dns.flags.QR | dns.flags.AA), max_size=MAX_WIRE, origin=None id=query.id,
flags=(dns.flags.QR | dns.flags.AA),
max_size=MAX_WIRE,
origin=None,
) )
r.add_question( r.add_question(
query.question[0].name, query.question[0].name,
@@ -538,7 +570,10 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Start new renderer # Start new renderer
r = dns.renderer.Renderer( r = dns.renderer.Renderer(
id=query.id, flags=(dns.flags.QR | dns.flags.AA), max_size=MAX_WIRE, origin=None id=query.id,
flags=(dns.flags.QR | dns.flags.AA),
max_size=MAX_WIRE,
origin=None,
) )
r.add_question( r.add_question(
query.question[0].name, query.question[0].name,
@@ -570,8 +605,8 @@ class TCPRequestHandler(DNSBaseRequestHandler):
except Exception as e: except Exception as e:
logger.error(f"Error handling request from {peer}: {e}") logger.error(f"Error handling request from {peer}: {e}")
import traceback import traceback
traceback.print_exc()
traceback.print_exc()
class TCPDNSServer(socketserver.TCPServer): class TCPDNSServer(socketserver.TCPServer):
@@ -583,7 +618,6 @@ class TCPDNSServer(socketserver.TCPServer):
self.tsig_view_map = tsig_view_map self.tsig_view_map = tsig_view_map
class UDPDNSServer(socketserver.UDPServer): class UDPDNSServer(socketserver.UDPServer):
allow_reuse_address = True allow_reuse_address = True
@@ -593,12 +627,13 @@ class UDPDNSServer(socketserver.UDPServer):
self.tsig_view_map = tsig_view_map self.tsig_view_map = tsig_view_map
class Command(BaseCommand): class Command(BaseCommand):
help = "Run a minimal AXFR DNS server using NetBox DNS plugin data" help = "Run a minimal AXFR DNS server using NetBox DNS plugin data"
def loadSettings(self): def loadSettings(self):
self.settings = settings.PLUGINS_CONFIG.get("netbox_plugin_bind_provisioner", None) self.settings = settings.PLUGINS_CONFIG.get(
"netbox_plugin_bind_provisioner", None
)
if not self.settings: if not self.settings:
raise RuntimeError( raise RuntimeError(
"Command failed to initialize due to missing settings. Terminating Netbox." "Command failed to initialize due to missing settings. Terminating Netbox."
@@ -606,9 +641,7 @@ class Command(BaseCommand):
self.tsig_keys = self.settings.get("tsig_keys", None) self.tsig_keys = self.settings.get("tsig_keys", None)
if not self.tsig_keys: if not self.tsig_keys:
raise RuntimeError( raise RuntimeError("tsig_keys variable not set in plugin settings.")
"tsig_keys variable not set in plugin settings."
)
# Load TSIG keys and map them to views # Load TSIG keys and map them to views
def loadTSIGKeySettings(self): def loadTSIGKeySettings(self):
@@ -652,33 +685,40 @@ class Command(BaseCommand):
raise RuntimeError(msg) raise RuntimeError(msg)
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--port', type=int, default=5354, help='Port number to listen on') parser.add_argument(
parser.add_argument('--address', type=str, default="0.0.0.0", help='IP to bind to') "--port", type=int, default=5354, help="Port number to listen on"
)
parser.add_argument(
"--address", type=str, default="0.0.0.0", help="IP to bind to"
)
def handle(self, *args, **options): def handle(self, *args, **options):
# Load parameters # Load parameters
port = options['port'] port = options["port"]
address = options['address'] address = options["address"]
CatalogZone.initSerial() CatalogZone.initSerial()
# Initialize settings # Initialize settings
self.loadSettings() self.loadSettings()
#self.loadCatalogZoneSettings()
self.loadTSIGKeySettings() self.loadTSIGKeySettings()
# Following is temporary. Function and invocation can be deleted once v1.0 is reached.
serial_file = self.settings.get("catalog_serial_file", None)
if serial_file:
CatalogZone.importLegacySerialFile(serial_file)
udp_server = UDPDNSServer((address, port), UDPRequestHandler, self.keyring, self.tsig_view_map) udp_server = UDPDNSServer(
tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map) (address, port), UDPRequestHandler, self.keyring, self.tsig_view_map
)
tcp_server = TCPDNSServer(
(address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
)
def run_udp_server(server): def run_udp_server(server):
logger.debug(f"SOA endpoint listening on {address} udp/{port}") logger.debug(f"SOA endpoint listening on {address} udp/{port}")
server.serve_forever() server.serve_forever()
udp_thread = threading.Thread(target=run_udp_server, args=(udp_server,), daemon=True) udp_thread = threading.Thread(
target=run_udp_server, args=(udp_server,), daemon=True
)
udp_thread.start() udp_thread.start()
logger.debug(f"AXFR endpoint listening on {address} tcp/{port}") logger.debug(f"AXFR endpoint listening on {address} tcp/{port}")
tcp_server.serve_forever() tcp_server.serve_forever()
@@ -0,0 +1,47 @@
import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models
from netbox_plugin_bind_provisioner.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--path", type=str, help="Path to the directory where zone files are to be written"
)
def handle(self, *args, **options):
# Load parameters
base_path = options['path']
if not base_path:
raise CommandError("No --path parameter given")
# Ensure base_path exists
os.makedirs(base_path, exist_ok=True)
# Iterate through all zones
nb_zones = netbox_dns.models.Zone.objects.prefetch_related("view").all()
if not nb_zones.exists():
self.stdout.write(self.style.WARNING("No zones found in NetBox DNS."))
return
for zone in nb_zones:
# Determine view directory
view_name = zone.view.name
view_dir = os.path.join(base_path, view_name)
os.makedirs(view_dir, exist_ok=True)
# Zone file path
file_path = os.path.join(view_dir, zone.name)
try:
# Call existing export function
export_bind_zone_file(zone, file_path=file_path)
self.stdout.write(self.style.SUCCESS(f"Exported zone '{zone.name}' to '{file_path}'"))
except Exception as e:
self.stderr.write(f"Failed to export zone '{zone.name}': {e}")
@@ -0,0 +1,56 @@
import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models
from netbox_plugin_bind_provisioner.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--view", type=str, help="The name of the view the Zone to be exported is in"
)
parser.add_argument(
"--zone", type=str, help="The FQDN of the Zone to be exported"
)
parser.add_argument(
"--file", type=str, help="Path of the zone file to be written"
)
def handle(self, *args, **options):
#if len(options) < 2:
# print("export-zone <zone name> <file path>")
# sys.exit(1)
# Load parameters
view_name = options['view']
zone_name = options['zone']
file_path = options['file']
if not view_name:
raise CommandError("No --view parameter given")
elif not zone_name:
raise CommandError("No --zone parameter given")
elif not file_path:
raise CommandError("No --file parameter given")
try:
# Load the zone from NetBox DNS
nb_zone = netbox_dns.models.Zone.objects.get(view__name=view_name, name=zone_name)
if nb_zone:
export_bind_zone_file(nb_zone, file_path=file_path)
else:
print("Zone not found in Netbox. Aborting")
sys.exit(1)
except Exception as e:
raise CommandError(f"Failed to export zone: {e}")
self.stdout.write(self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'"))
@@ -11,23 +11,40 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('extras', '0133_make_cf_minmax_decimal'), ("extras", "0133_make_cf_minmax_decimal"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='IntegerKeyValueSetting', name="IntegerKeyValueSetting",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), (
('created', models.DateTimeField(auto_now_add=True, null=True)), "id",
('last_updated', models.DateTimeField(auto_now=True, null=True)), models.BigAutoField(
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), auto_created=True, primary_key=True, serialize=False
('key', models.CharField(max_length=64)), ),
('value', models.IntegerField()), ),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
(
"custom_field_data",
models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
("key", models.CharField(max_length=64)),
("value", models.IntegerField()),
(
"tags",
taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
bases=(netbox.models.deletion.DeleteMixin, models.Model), bases=(netbox.models.deletion.DeleteMixin, models.Model),
), ),
@@ -0,0 +1,33 @@
# Generated by Django 5.2.6 on 2025-12-18 14:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('netbox_plugin_bind_provisioner', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='integerkeyvaluesetting',
options={'default_permissions': ()},
),
migrations.RemoveField(
model_name='integerkeyvaluesetting',
name='created',
),
migrations.RemoveField(
model_name='integerkeyvaluesetting',
name='custom_field_data',
),
migrations.RemoveField(
model_name='integerkeyvaluesetting',
name='last_updated',
),
migrations.RemoveField(
model_name='integerkeyvaluesetting',
name='tags',
),
]
+4 -13
View File
@@ -2,22 +2,13 @@ from django.db import models
import netbox.models import netbox.models
import netbox_dns.models import netbox_dns.models
#class CatalogZoneSerial(models.Model): class IntegerKeyValueSetting(models.Model):
class IntegerKeyValueSetting(netbox.models.NetBoxModel):
#view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
key = models.CharField(max_length=64) key = models.CharField(max_length=64)
value = models.IntegerField() value = models.IntegerField()
def __str__(self): def __str__(self):
#return f'{self.view.name}: {self.serial}' return f"{self.key}: {str(self.value)}"
return f'{self.key}: {str(self.value)}'
class Meta:
default_permissions = ()
#class TSIGKey(netbox.models.NetBoxModel):
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
# name = models.CharField(max_length=256)
# algorithm = models.CharField(max_length=25)
# secret = models.CharField(max_length=50)
#
# def __str__(self):
# return f'key "{self.name}" {{ algorithm {self.algorithm}; secret "{self.secret}"; }};'
@@ -0,0 +1,35 @@
import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models
def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str):
# Create dnspython zone object
dp_zone = dns.zone.Zone(origin=dns.name.from_text(nb_zone.name))
for record in nb_zone.records.all():
rname = record.name or "@"
rdclass = dns.rdataclass.IN
rdatatype = dns.rdatatype.from_text(record.type)
# Create rdata object
rdata = dns.rdata.from_text(rdclass, rdatatype, record.value)
# Create rdataset
rdataset = dns.rdataset.Rdataset(rdclass, rdatatype, record.ttl)
rdataset.add(rdata)
# Add to zone
node = dp_zone.find_node(rname, create=True)
node.rdatasets.append(rdataset)
# Write zone to file in BIND format
try:
with open(file_path, "w") as f:
dp_zone.to_file(f, sorted=True)
except IOError as e:
raise IOError(f"Failed to write zone file to {file_path}: {e}")