Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a2b0a2c2b | ||
|
|
55d891f38c | ||
|
|
1375bbb7e3 | ||
|
|
723566db70 | ||
|
|
e8450c90da | ||
|
|
ae6bb3c1b6 | ||
|
|
0028ba8d80 | ||
|
|
78ed040495 | ||
|
|
20064ec9bd |
No files matched your search
@@ -1,32 +1,43 @@
|
||||
# Netbox Bind Provisioner
|
||||
A NetBox plugin that implements a lightweight DNS server to automate the
|
||||
provisioning and configuration of a BIND9 server based on DNS records
|
||||
maintained in NetBox. (netbox-plugin-dns).
|
||||
The Netbox Bind Provisioner plugin implements a lightweight DNS server inside
|
||||
Netbox and builds a bridge for BIND and other DNS Servers implementing RFC9432
|
||||
to retrieve DNS Zones directly from Netbox using DNS native mechanisms.
|
||||
|
||||
[](https://pypi.org/project/netbox-plugin-bind-provisioner/)
|
||||
[](https://github.com/suraxius/netbox-plugin-bind-provisioner/stargazers)
|
||||
[](https://github.com/suraxius/netbox-plugin-bind-provisioner/network/members)
|
||||
[](https://github.com/suraxius/netbox-plugin-bind-provisioner/issues)
|
||||
[](https://github.com/suraxius/netbox-plugin-bind-provisioner/pulls)
|
||||
[](https://github.com/suraxius/netbox-plugin-bind-provisioner/graphs/contributors)
|
||||
[](https://github.com/suraxius/netbox-plugin-bind-provisioner/blob/master/LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://pepy.tech/project/netbox-plugin-bind-provisioner)
|
||||
[](https://pepy.tech/project/netbox-plugin-bind-provisioner)
|
||||
[](https://pepy.tech/project/netbox-plugin-bind-provisioner)
|
||||
|
||||
## Plugin configuration
|
||||
The plugin has been reworked: instead of exporting zone files, it now provides
|
||||
a minimal DNS server that is fed directly from NetBox’s DNS data. This server
|
||||
also exposes specialized catalog zones that BIND uses to automatically discover
|
||||
newly created zones and remove deleted ones. The plugin supports BIND views and
|
||||
basic DNS security via TSIG.
|
||||
While providing Zone transfers via AXFR, the Server also exposes specialized
|
||||
catalog zones that BIND and other RFC9432 compliant DNS Servers use to
|
||||
automatically discover newly created zones and remove deleted ones. The plugin
|
||||
supports views and basic DNS security via TSIG.
|
||||
|
||||
The plugin exposes one catalog zone per view. Each catalog zone is available
|
||||
under the special zone name **"catz"** and can be queried through the built-in
|
||||
DNS server like any other zone.
|
||||
The plugin exposes one catalog zone per view. Each catalog zone is made available
|
||||
under the special zone name **"catz"** and addtionally under **"[viewname].catz"**
|
||||
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
|
||||
`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
|
||||
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
|
||||
```
|
||||
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
|
||||
Parameter | Description
|
||||
@@ -37,7 +48,6 @@ Parameter | Description
|
||||
### Plugin settings
|
||||
Setting | Description
|
||||
--------------------| ---------------------------------------------------------
|
||||
catalog_serial_file | !!!NO LONGER USED!!! Replaced by DB entry.
|
||||
tsig_keys | Maps a TSIG Key to be used for each view.
|
||||
|
||||
## Installation guide
|
||||
@@ -115,6 +125,7 @@ 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.
|
||||
A guide for setting up a systemd service on Ubuntu is provided by Matt
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
|
||||
__version__ = "1.0.3"
|
||||
__version__ = "1.0.5"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import socketserver
|
||||
import os
|
||||
#import re
|
||||
import threading
|
||||
import dns.query
|
||||
import dns.message
|
||||
@@ -14,7 +13,6 @@ import dns.rdtypes
|
||||
import dns.exception
|
||||
import dns.renderer
|
||||
|
||||
# from hashlib import sha256
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -107,13 +105,12 @@ class CatalogZone:
|
||||
ptr_base = dns.name.from_text("zones", origin)
|
||||
|
||||
for nb_zone in nb_zones:
|
||||
ttl = nb_zone.soa_ttl
|
||||
ttl = 0
|
||||
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
|
||||
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)
|
||||
rdata = dns.rdata.from_text(
|
||||
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
|
||||
@@ -141,16 +138,16 @@ class CatalogZone:
|
||||
# rdataset.add(rdata, ttl)
|
||||
|
||||
# SOA Record components
|
||||
ttl = 3600
|
||||
ttl = 0
|
||||
rclass = dns.rdataclass.IN
|
||||
rtype = dns.rdatatype.SOA
|
||||
mname = dns.name.root # dns.name.from_text("invalid.", origin)
|
||||
rname = dns.name.root # dns.name.from_text("contact.master.", origin)
|
||||
mname = dns.name.from_text("invalid", dns.name.root)
|
||||
rname = dns.name.from_text("invalid", dns.name.root)
|
||||
serial = cls._serial_obj.value
|
||||
refresh = 60
|
||||
retry = 10
|
||||
expire = 1209600
|
||||
minimum = 86400
|
||||
minimum = 0
|
||||
|
||||
# Create SOA rdata object
|
||||
soa_rdata = dns.rdata.from_text(
|
||||
@@ -173,7 +170,7 @@ class CatalogZone:
|
||||
dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name)
|
||||
)
|
||||
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)
|
||||
ns_node = zone.find_node(origin, create=True)
|
||||
@@ -186,7 +183,7 @@ class CatalogZone:
|
||||
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.add(txt_rdata, 86400)
|
||||
txt_rdataset.add(txt_rdata, 0)
|
||||
|
||||
# Add to node version.catz.
|
||||
txt_node = zone.find_node(version_name, create=True)
|
||||
@@ -194,23 +191,6 @@ class CatalogZone:
|
||||
|
||||
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):
|
||||
# getZoneFromNB rewritten
|
||||
@@ -252,13 +232,16 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
value = record.value
|
||||
if rdtype == dns.rdatatype.TXT:
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1].replace('" "', '').replace('"', '\"')
|
||||
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)]
|
||||
chunks = [
|
||||
'"{}"'.format(value[i : i + 255])
|
||||
for i in range(0, len(value), 255)
|
||||
]
|
||||
value = " ".join(chunks)
|
||||
else:
|
||||
value = f'"{value}"'
|
||||
@@ -300,7 +283,6 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
zone.replace_rdataset(name, rdataset)
|
||||
return zone
|
||||
|
||||
|
||||
def denyRequestBadTSIG(self, wire, tsig_error: dns.rcode):
|
||||
# Use empty keyring to parse TSIG without validating
|
||||
query = dns.message.from_wire(
|
||||
@@ -715,18 +697,15 @@ class Command(BaseCommand):
|
||||
port = options["port"]
|
||||
address = options["address"]
|
||||
CatalogZone.initSerial()
|
||||
|
||||
# Initialize settings
|
||||
self.loadSettings()
|
||||
# self.loadCatalogZoneSettings()
|
||||
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
|
||||
)
|
||||
|
||||
tcp_server = TCPDNSServer(
|
||||
(address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
|
||||
)
|
||||
@@ -738,6 +717,7 @@ class Command(BaseCommand):
|
||||
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}")
|
||||
|
||||
@@ -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}'"))
|
||||
|
||||
|
||||
+33
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -2,23 +2,13 @@ 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)
|
||||
class IntegerKeyValueSetting(models.Model):
|
||||
key = models.CharField(max_length=64)
|
||||
value = models.IntegerField()
|
||||
|
||||
def __str__(self):
|
||||
# return f'{self.view.name}: {self.serial}'
|
||||
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}")
|
||||
|
||||
Reference in new issue
Block a user