Compare commits

...
8 Commits
Author SHA1 Message Date
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
10 changed files with 343 additions and 88 deletions

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
src/netbox_plugin_bind_provisioner.egg-info
dist/
build/
__pycache__
+5 -2
View File
@@ -116,8 +116,11 @@ This guide assumes:
5. Start listener
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
activate the venv if you do decide to run this service in the background.
to write a service wrapper that runs this 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
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.conf import settings
__version__ = "0.9.8"
__version__ = "1.0.4"
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."
)
config = BindProvisionerConfig
default_app_config = ".apps.NetboxBindProvisionerConfig"
@@ -1,6 +1,7 @@
import logging
import socketserver
import os
#import re
import threading
import dns.query
import dns.message
@@ -22,9 +23,10 @@ from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
from datetime import datetime
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()
_serial_max = 0xFFFFFFFF
_serial_obj = None
@@ -35,12 +37,19 @@ class CatalogZone():
@classmethod
def initSerial(cls):
try:
cls._serial_obj = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
logger.debug(f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database")
cls._serial_obj = IntegerKeyValueSetting.objects.get(
key="catalog-zone-soa-serial"
)
logger.debug(
f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database"
)
except IntegerKeyValueSetting.DoesNotExist:
cls._serial_obj = IntegerKeyValueSetting.objects.create(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}")
cls._serial_obj = IntegerKeyValueSetting.objects.create(
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
def _incrementSerial(cls):
@@ -53,19 +62,24 @@ class CatalogZone():
)
cls._serial_obj = 1
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
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.
with cls.lock:
# cls = self.__class__
# datestamp = datetime.now().strftime("%y%m%d")
# 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
# If no zone was found previously then this will be false since (None != None) = False
@@ -77,12 +91,9 @@ class CatalogZone():
# Setting previous last zone update for next iteration:
cls._previous_last_zone_update = last_zone_update
cls._incrementSerial()
# If there was no update, do nothing.
#else:
# logger.debug(f"No need to update serial for catalog zone")
# 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
zone = dns.zone.Zone(origin)
@@ -113,19 +124,21 @@ class CatalogZone():
# Configure DNSSec Policy for member Zone if DNSSec is enabled
if nb_zone.dnssec_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(
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.add(rdata, ttl)
# Configure dnssec status for member zone
status = str(1 if nb_zone.dnssec_policy else 0)
rid = dns.name.from_text(f"enabled.dnssec", ptr_name)
rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
rdataset.add(rdata, ttl)
## Configure dnssec status for member zone
# status = str(1 if nb_zone.dnssec_policy else 0)
# rid = dns.name.from_text("enabled.dnssec.ext", ptr_name)
# rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
# rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
# rdataset.add(rdata, ttl)
# SOA Record components
ttl = 3600
@@ -154,7 +167,6 @@ class CatalogZone():
node = zone.find_node(origin, create=True)
node.rdatasets.append(soa_rdataset)
# NS record for catz.
ns_name = dns.name.from_text("invalid", dns.name.root)
ns_rdata = dns.rdata.from_text(
@@ -190,19 +202,25 @@ class CatalogZone():
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}")
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}")
logger.error(
f"Failed to import catalog zone serial from legacy file {file_path}"
)
class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# getZoneFromNB rewritten
def getZoneFromNB(self, zone_name, view_name, soa_only=False):
def getZoneFromNB(self, zone_name, view_name):
# Find the zone
try:
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:
return None
@@ -229,10 +247,26 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# If the record has no TTL, use the zone default
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(
dns.rdataclass.IN,
rdtype,
record.value,
value,
relativize=False,
origin=zone.origin,
)
@@ -241,7 +275,9 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
if name not in rdatasets_dict:
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
rdatasets_dict[name][rdtype].add(rdata, ttl)
@@ -251,7 +287,9 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
for rdtype, rdataset in rdtypes.items():
# Ensure rdataset has the same rdclass as the zone
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
if not rdataset:
@@ -263,17 +301,11 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
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):
# 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
response = dns.message.make_response(query)
@@ -284,7 +316,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
response.use_tsig(
keyring={}, # empty; we're not signing
keyname=query.keyname,
tsig_error=tsig_error
tsig_error=tsig_error,
)
self.denyRequest(query)
@@ -307,7 +339,7 @@ class UDPRequestHandler(DNSBaseRequestHandler):
data,
keyring=self.server.keyring,
continue_on_error=False,
ignore_trailing=True
ignore_trailing=True,
)
except Exception as e:
logger.error("Error parsing query: ", e)
@@ -328,7 +360,9 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# Only process SOA queries
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)
return
@@ -343,16 +377,18 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# Check if key matches a view
nb_view = self.server.tsig_view_map.get(key_name)
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)
return
# Check if catalog zone
if dname == "catz":
zone = CatalogZone.create(nb_view.name, soa_only=True)
if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(dname, nb_view.name)
# If generic zone, retreive from NB
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
if not zone:
logger.warning(f"Zone {nb_view.name}/{dname} not found in NB")
@@ -366,7 +402,6 @@ class UDPRequestHandler(DNSBaseRequestHandler):
soa_rdata = soa_rdataset[0] # Get the first SOA record
# 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)
# Append the rrset to the response's answer section
@@ -376,7 +411,9 @@ class UDPRequestHandler(DNSBaseRequestHandler):
if query.had_tsig:
# If key was found in DB
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:
# If key is unknown
response.set_rcode(dns.rcode.REFUSED)
@@ -384,7 +421,11 @@ class UDPRequestHandler(DNSBaseRequestHandler):
# keyname = query.keyname
# b64 = base64.b64encode(urandom(32)).decode("ascii")
# 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
response_wire = response.to_wire(max_size=512)
@@ -428,7 +469,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
query = dns.message.from_wire(wire, keyring=self.server.keyring)
# If TSIG signature doesnt match our key, refuse query:
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)
return
# If TSIG Key used is not in our list, refuse query:
@@ -451,7 +494,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Only process AXFR queries
if qtype != dns.rdatatype.AXFR:
# 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)
return
@@ -466,15 +511,17 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Check if the key matches a view
nb_view = self.server.tsig_view_map.get(key_name)
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)
return
# Check if catalog zone
if dname == "catz":
zone = CatalogZone.create(nb_view.name)
if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(dname, nb_view.name)
else:
zone = self.getZoneFromNB(dname, nb_view)
zone = self.getZoneFromNB(dname, nb_view.name)
# When zone was not found, let client know
if not zone:
@@ -503,7 +550,10 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# 2. Create a Renderer for the first message
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(
query.question[0].name,
@@ -538,7 +588,10 @@ class TCPRequestHandler(DNSBaseRequestHandler):
# Start new 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(
query.question[0].name,
@@ -570,8 +623,8 @@ class TCPRequestHandler(DNSBaseRequestHandler):
except Exception as e:
logger.error(f"Error handling request from {peer}: {e}")
import traceback
traceback.print_exc()
traceback.print_exc()
class TCPDNSServer(socketserver.TCPServer):
@@ -583,7 +636,6 @@ class TCPDNSServer(socketserver.TCPServer):
self.tsig_view_map = tsig_view_map
class UDPDNSServer(socketserver.UDPServer):
allow_reuse_address = True
@@ -593,12 +645,13 @@ class UDPDNSServer(socketserver.UDPServer):
self.tsig_view_map = tsig_view_map
class Command(BaseCommand):
help = "Run a minimal AXFR DNS server using NetBox DNS plugin data"
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:
raise RuntimeError(
"Command failed to initialize due to missing settings. Terminating Netbox."
@@ -606,9 +659,7 @@ class Command(BaseCommand):
self.tsig_keys = self.settings.get("tsig_keys", None)
if not self.tsig_keys:
raise RuntimeError(
"tsig_keys variable not set in plugin settings."
)
raise RuntimeError("tsig_keys variable not set in plugin settings.")
# Load TSIG keys and map them to views
def loadTSIGKeySettings(self):
@@ -652,13 +703,17 @@ class Command(BaseCommand):
raise RuntimeError(msg)
def add_arguments(self, parser):
parser.add_argument('--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')
parser.add_argument(
"--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):
# Load parameters
port = options['port']
address = options['address']
port = options["port"]
address = options["address"]
CatalogZone.initSerial()
# Initialize settings
self.loadSettings()
@@ -669,16 +724,21 @@ class Command(BaseCommand):
if serial_file:
CatalogZone.importLegacySerialFile(serial_file)
udp_server = UDPDNSServer((address, port), UDPRequestHandler, self.keyring, self.tsig_view_map)
tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map)
udp_server = UDPDNSServer(
(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):
logger.debug(f"SOA endpoint listening on {address} udp/{port}")
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()
logger.debug(f"AXFR endpoint listening on {address} tcp/{port}")
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
dependencies = [
('extras', '0133_make_cf_minmax_decimal'),
("extras", "0133_make_cf_minmax_decimal"),
]
operations = [
migrations.CreateModel(
name='IntegerKeyValueSetting',
name="IntegerKeyValueSetting",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('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')),
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("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={
'abstract': False,
"abstract": False,
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
+2 -1
View File
@@ -2,6 +2,7 @@ from django.db import models
import netbox.models
import netbox_dns.models
# class CatalogZoneSerial(models.Model):
class IntegerKeyValueSetting(netbox.models.NetBoxModel):
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
@@ -10,7 +11,7 @@ class IntegerKeyValueSetting(netbox.models.NetBoxModel):
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 TSIGKey(netbox.models.NetBoxModel):
@@ -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}")