Compare commits

...
23 Commits
Author SHA1 Message Date
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
Sven Luethi 962be7a048 modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fixes broken dns responses to UDP endpoint (denyRequest function replaced)
2025-11-25 00:03:36 +01:00
Sven Luethi 204a946a84 modified: README.md
Removes the warning for step 4 from the installation guide. The warning said not to run migrations
as the plugin did not have its own models. Since v0.9.6 it does so this was forgotten to be removed.
2025-11-24 13:51:42 +01:00
Sven Luethi d72dc5a115 modified: .github/workflows/python-publish.yml 2025-11-23 16:45:46 +01:00
Sven Luethi b659268117 modified: .github/workflows/python-publish.yml
Update flow
2025-11-23 16:43:27 +01:00
Sven Luethi 8c4bc91f6e modified: .gitignore 2025-11-23 15:56:23 +01:00
Sven Luethi c8e28d2397 modified: README.md
modified:   src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
new file:   src/netbox_plugin_bind_provisioner/migrations/0001_initial.py
new file:   src/netbox_plugin_bind_provisioner/migrations/__init__.py
new file:   src/netbox_plugin_bind_provisioner/models.py

Adds a database model to track the catalog zone serial instead of tracking it inside a flat file.
2025-11-23 15:56:06 +01:00
Sven Luethi 0349204aff modified: README.md
In the previous commit Matt Kollross pointed out a naming error where the plugin settings were referenced as 'netbox_bind_provisioner' instead of the correct name 'netbox_plugin_bind_provisioner'.
His commit missed the second instance of the same mistake further down in the README however. This fixes that one too.
2025-11-18 20:51:37 +01:00
SL e4dbd7b014 Merge pull request #2 from kollross/main
Updated REAME.md with corrected info
2025-11-18 20:35:41 +01:00
Matt Kollross 07f8387963 Merge pull request #1 from kollross/kollross-patch-1
Change plugin key from netbox_bind_provisioner to netbox_plugin_bind_…
2025-11-18 10:26:30 -06:00
Matt Kollross d8acd3f525 Change plugin key from netbox_bind_provisioner to netbox_plugin_bind_provisioner
The PLUGIN_CONFIG needs to reference the correct name of the plugin "netbox_plugin_bind_provisioner" not "netbox_bind_provisioner"
2025-11-18 10:25:55 -06:00
Sven Luethi 8c1e9502f4 modified: README.md 2025-11-17 22:44:06 +01:00
Sven Luthi 86457cc858 modified: README.md
Add catalog zone serial counter info.
2025-11-12 13:52:13 +01:00
SL 0bad0e31df Update requirements.txt
Adds dnspython as dependency instead of relying indirectly on netbox-plugin-dns to require it.
2025-10-30 11:30:52 +01:00
Sven Luethi 7b6556cf01 modified: pyproject.toml
renamed:    src/netbox_bind_provisioner/__init__.py -> src/netbox_plugin_bind_provisioner/__init__.py
renamed:    src/netbox_bind_provisioner/management/commands/bind-transfer-endpoint.py -> src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
2025-10-05 03:54:42 +02:00
Sven Luethi 61c83a368c modified: pyproject.toml
renamed:    src/netbox_plugin_bind_provisioner/__init__.py -> src/netbox_bind_provisioner/__init__.py
renamed:    src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py -> src/netbox_bind_provisioner/management/commands/bind-transfer-endpoint.py
2025-10-05 03:39:19 +02:00
10 changed files with 505 additions and 297 deletions

No files matched your search

+12 -40
View File
@@ -6,7 +6,7 @@
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package
name: Create and Upload Python Package
on:
release:
@@ -16,9 +16,12 @@ permissions:
contents: read
jobs:
release-build:
build-and-publish:
name: Build and Publish
environment: pypi
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -26,45 +29,14 @@ jobs:
with:
python-version: "3.x"
- name: Build release distributions
- name: Install build tools
run: |
# NOTE: put your own distribution build steps here.
python -m pip install --upgrade pip
python -m pip install build
python -m build
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
path: dist/
- name: Build release
run: |
python -m build --sdist --wheel
pypi-publish:
runs-on: ubuntu-latest
needs:
- release-build
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Dedicated environments with protections for publishing are strongly recommended.
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
environment:
name: pypi
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
# url: https://pypi.org/p/YOURPROJECT
#
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
# ALTERNATIVE: exactly, uncomment the following line instead:
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to PyPI
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
+2
View File
@@ -1,2 +1,4 @@
src/netbox_plugin_bind_provisioner.egg-info
dist/
build/
__pycache__
+180 -93
View File
@@ -1,124 +1,211 @@
# Netbox Bind Provisioner
A Netbox Plugin that provides a minimal DNS Server for the automatic
provisioning of a Bind9 Server from Netbox DNS data (netbox-plugin-dns).
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).
## Plugin configuration
The plugin was re-worked; Instead of exporting zone files, it provides an
entire (though minimal) DNS Server that is fed directly from Netbox's DNS data.
The Server also provides specialized "catalog" zones that bind uses to
automatically discover new zones and remove deleted ones. The plugin supports
view as well as basic dns security using TSIG.
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.
To work correctly, each view needs a tsig key installed and the
bind-transfer-endpoint needs to be running as its own service in the background
via the `manage.py` command. Note that dnssec support will be implemented as
soon as bind9 has a mechanism to allow configuration of such via the Catalog
Zones mechanism.
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.
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:
```
venv/bin/python3 netbox/netbox/manage.py bind-transfer-endpoint --port 5354
manage.py bind-transfer-endpoint --port 5354
```
### Parameters
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
--------- | --------------------------------------------------------
--------- | -------------------------------------------------------------------
--port | Port to listen on for requests (defaults to 5354)
--address | IP of interface to bind to (defaults to 0.0.0.0)
```
PLUGINS_CONFIG = {
"netbox_bind_provisioner": {
"tsig_keys": {
"key1name": {
"view": "public",
"keyname": "view1key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
},
"key2name": {
"view": "private",
"keyname": "view2key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
### 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
This setup provisions a BIND9 server directly with DNS data from NetBox.
BIND9 can optionally run on a separate server. If so, any reference to
127.0.0.1 in step 6 must be replaced with the IP address of the NetBox host.
TCP and UDP traffic from the BIND9 server to the NetBox host must be allowed on
port 5354 (or the port you have configured).
This guide assumes:
- Netbox has been installed under /opt/netbox
- Bind9 is installed on the same host as Netbox
- The Netbox DNS Plugin netbox-plugin-dns is installed
- The following dns views exist in Netbox DNS:
- `public` (the default)
- `private`
1. Preliminaries
- Install Bind9 on the same host that netbox is on.
- Generate a TSIG Key for the `public` and `private` dns views respectively.
2. Adding required package
```
cd netbox
echo netbox-plugin-bind-provisioner >> local_requirements.txt
. venv/bin/activate
pip install -r local_requirements.txt
```
3. Updating netbox plugin configuration (configuration.py)
Change following line from
```
PLUGINS = ['netbox_dns']
```
to
```
PLUGINS = ['netbox_dns', 'netbox_plugin_bind_provisioner']
```
Configure the Bind Exporter Plugin using the PLUGINS_CONFIG dictionary.
Change
```
PLUGINS_CONFIG = {}
```
to
```
PLUGINS_CONFIG = {
"netbox_plugin_bind_provisioner": {
"tsig_keys": {
"public": {
"keyname": "public_view_key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
},
"private": {
"keyname": "private_view_key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
}
}
},
}
}
}
```
```
Note that the tsig-key attributes keyname, algorithm and secret form a
dictionary in following python structure path:
```
PLUGINS_CONFIG.netbox_plugin_bind_provisioner.tsig_keys.<dns_view_name>
```
This allows the plugin to map requests to the right dns view using the tsig
signature from each request.
## Bind configuration
```
options {
...
...
allow-update { none; };
allow-query { any; };
allow-recursion { none; };
notify yes;
...
...
};
4. Run migrations
```
python3 netbox/manage.py migrate
```
# ACLs
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
Kollross [here](docs/install-systemd-service.md). Dont forget to activate
the venv if you do decide to run this service in the background.
acl public {
!10.0.0.0/8;
!172.16.0.0/12;
!192.168.0.0/16;
any;
};
Note that `--port 5354` is optional. The listener will bind this port
by default.
```
python3 netbox/manage.py bind-transfer-endpoint --port 5354
```
acl private {
10.0.0.0/8;
172.16.0.0/12;
192.168.0.0/16;
};
6. Configuring a Bind9 to interact with Netbox via the bind-transfer-endpoint
endpoint. Note that its not possible to give all the correct details of the
`options` block as it is heavily dependent on the Operating System used.
Please dont forget to adjust as required.
# Zone definitions
```
########## OPTIONS ##########
view "public" {
key "key1name" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
options {
allow-update { none; };
allow-query { any; };
allow-recursion { none; };
notify yes;
min-refresh-time 60;
};
match-clients { public; };
########## ACLs ##########
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "key1name"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
acl public {
!10.0.0.0/8;
!172.16.0.0/12;
!192.168.0.0/16;
any;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_public";
masters { 127.0.0.1 port 5354 key "key1name"; };
notify no;
};
};
view "private" {
key "key2name" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
acl private {
10.0.0.0/8;
172.16.0.0/12;
192.168.0.0/16;
};
match-clients { private; };
########## ZONES ##########
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "key2name"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
view "public" {
key "public_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
match-clients { public; };
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "public_view_key"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_public";
masters { 127.0.0.1 port 5354 key "public_view_key"; };
notify no;
};
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_private";
masters { 127.0.0.1 port 5354 key "key2name"; };
notify no;
view "private" {
key "private_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
match-clients { private; };
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "private_view_key"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_private";
masters { 127.0.0.1 port 5354 key "private_view_key"; };
notify no;
};
};
};
```
```
7. Restart bind - Done
+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
```
+1
View File
@@ -1 +1,2 @@
netbox-plugin-dns>=1.2.0
dnspython>=2.8.0
+2 -20
View File
@@ -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.3"
__version__ = "1.0.2"
logger = logging.getLogger(__name__)
@@ -17,7 +17,7 @@ logging.basicConfig(
class BindProvisionerConfig(PluginConfig):
name = "netbox_bind_provisioner"
name = "netbox_plugin_bind_provisioner"
verbose_name = "Netbox Bind Provisioner"
description = "Provisions Zones to a Bind Server configured as hidden master"
version = __version__
@@ -32,24 +32,6 @@ class BindProvisionerConfig(PluginConfig):
f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox."
)
# from . import bind, rndc
#
# if not bind.zoneDirWritable():
# BIND_ZONE_DIR = settings.get("BIND_ZONE_DIR")
# logger.error(f"Bind's zone file directory {BIND_ZONE_DIR} is not writable.")
# raise RuntimeError(
# "Plugin initialization failed due to failed writable-zone-directory healthcheck."
# )
# if not rndc.tsigKeyFileAccessible():
# RNDC_KEY_FILE = settings.get("RNDC_KEY_FILE")
# logger.error(f"The RNDC key file {RNDC_KEY_FILE} is inaccessible.")
# raise RuntimeError(
# "Plugin initialization failed due to failed rndc-key-accessible healthcheck."
# )
#
# # Import signal callbacks from signals.py (With @receiver decorations)
# from . import signals
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
@@ -20,78 +21,65 @@ from django.utils import timezone
from netbox_dns.models import Zone, Record, View
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 CatalogZoneAssembler():
class CatalogZone:
lock = threading.Lock()
_serial_max = 0xFFFFFFFF
_serial = 1
_serial_file = None
_serial_obj = None
_previous_last_zone_update = None
# Following function loads the last serial from file. No return value
# Following function loads the last serial from the DB. No return value
# but it sets the setting "catalog_serial" or terminates the plugin on failure.
@classmethod
def initCatalogZoneSerial(cls, serial_file):
"""
Load a 32-bit unsigned int serial from a text file.
If the file doesn't exist, initialize it with 1.
Returns the serial as an int.
"""
cls._serial_file = serial_file
if not os.path.exists(cls._serial_file):
try:
with open(cls._serial_file, "w") as f:
f.write(f"{cls._serial}\n")
return
except OSError as e:
raise RuntimeError(
f"Failed to initialize serial file {cls._serial_file}: {e}"
)
else:
try:
with open(cls._serial_file, "r") as f:
value = int(f.read().strip())
if 0 < value <= cls._serial_max:
cls._serial = value
else:
raise ValueError(f"Serial value out of 32-bit range: {value}")
except (OSError, ValueError) as e:
raise RuntimeError(f"Failed to read serial from {cls._serial_file}: {e}")
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"
)
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}"
)
@classmethod
def incrementCatalogZoneSerial(cls):
"""
Increments the in-memory catalog serial and saves it to file.
Updates _serial.
"""
if 0 < cls._serial < cls._serial_max:
cls._serial += 1
def _incrementSerial(cls):
if 0 < cls._serial_obj.value < cls._serial_max:
cls._serial_obj.value += 1
cls._serial_obj.save()
else:
logger.warn(
f"Failed to incremenet catalog serial {cls._serial}. Will overflow serial back to 1."
f"Failed to incremenet catalog serial {cls._serial_obj.value}. Will overflow serial back to 1"
)
cls._serial_obj = 1
cls._serial_obj.save()
logger.debug(
f"Catalog zone SOA serial number incremented to {_serial_obj.value}"
)
cls._serial = 1
try:
with open(cls._serial_file, "w") as f:
f.write(f"{cls._serial}\n")
except OSError as e:
logger.error(f"Failed to write updated serial to {cls._serial_file}: {e}")
@classmethod
def createCatalogZone(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()
# 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()
)
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
@@ -102,13 +90,10 @@ class CatalogZoneAssembler():
)
# Setting previous last zone update for next iteration:
cls._previous_last_zone_update = last_zone_update
cls.incrementCatalogZoneSerial()
# If there was no update, do nothing.
#else:
# logger.debug(f"No need to update serial for catalog zone")
cls._incrementSerial()
# 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)
@@ -139,19 +124,21 @@ class CatalogZoneAssembler():
# 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
@@ -159,7 +146,7 @@ class CatalogZoneAssembler():
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)
serial = cls._serial
serial = cls._serial_obj.value
refresh = 60
retry = 10
expire = 1209600
@@ -180,7 +167,6 @@ class CatalogZoneAssembler():
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(
@@ -208,14 +194,33 @@ class CatalogZoneAssembler():
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
def getZoneFromNB(self, zone_name, view_name, soa_only=False):
# getZoneFromNB rewritten
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
@@ -242,10 +247,25 @@ 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 not value.startswith('"') and not value.endswith('"'):
value = value.replace('"', '\\"')
if len(value) <= 255:
value = f'"{value}"'
else:
# 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)
rdata = dns.rdata.from_text(
dns.rdataclass.IN,
rdtype,
record.value,
value,
relativize=False,
origin=zone.origin,
)
@@ -254,7 +274,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)
@@ -264,7 +286,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:
@@ -273,22 +297,14 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Replace the rdataset for the given name and type
zone.replace_rdataset(name, rdataset)
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)
@@ -299,12 +315,19 @@ 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)
class UDPRequestHandler(DNSBaseRequestHandler):
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)
sock = self.request[1]
sock.sendto(wire, self.client_address)
def handle(self):
data, sock = self.request
peer = self.client_address[0]
@@ -314,11 +337,11 @@ class UDPRequestHandler(DNSBaseRequestHandler):
query = dns.message.from_wire(
data,
keyring=self.server.keyring,
continue_on_error=True,
ignore_trailing=True
continue_on_error=False,
ignore_trailing=True,
)
except Exception as e:
logger.error("Error parsing query:", e)
logger.error("Error parsing query: ", e)
return
# Create a response
@@ -336,30 +359,35 @@ 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
# Identify TSIG key used
key_name = query.keyname.canonicalize().to_text() if query.keyname else None
if not key_name:
if not query.had_tsig:
logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query)
return
key_name = query.keyname.canonicalize().to_text()
# 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 = CatalogZoneAssembler.createCatalogZone(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")
@@ -373,7 +401,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
@@ -383,15 +410,21 @@ 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)
# Generate new key for keyname provided
#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)
# 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,
)
# Send back the response
response_wire = response.to_wire(max_size=512)
@@ -400,6 +433,13 @@ class UDPRequestHandler(DNSBaseRequestHandler):
class TCPRequestHandler(DNSBaseRequestHandler):
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 handle(self):
MAX_WIRE = 65535
# MAX_WIRE = 2000 # For testing fragmentation
@@ -419,7 +459,7 @@ class TCPRequestHandler(DNSBaseRequestHandler):
while len(wire) < length:
chunk = sock.recv(length - len(wire))
if not chunk:
return # connection closed
return # connection closed
wire += chunk
# Parse the query
@@ -428,7 +468,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:
@@ -450,31 +492,35 @@ 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})")
# if qtype != dns.rdatatype.AXFR or qtype != dns.rdatatype.SOA:
logger.warning(
f"Request denied from {peer}: Request was not AXFR (Type: {qtype})"
)
self.denyRequest(query)
return
# Identify TSIG key used
key_name = query.keyname.canonicalize().to_text() if query.keyname else None
if not key_name:
if not query.had_tsig:
logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query)
return
key_name = query.keyname.canonicalize().to_text()
# 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 = self.createCatalogZone(str(nb_view))
zone = CatalogZoneAssembler.createCatalogZone(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 +549,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 +587,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,
@@ -564,14 +616,14 @@ class TCPRequestHandler(DNSBaseRequestHandler):
wire = r.get_wire()
self.request.sendall(len(wire).to_bytes(2, "big") + wire)
#logger.debug(f"Zone transfer request for {nb_view.name}/{dname} from {peer}")
# logger.debug(f"Zone transfer request for {nb_view.name}/{dname} from {peer}")
logger.debug(f"{peer} AXFR {nb_view.name}/{dname}")
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 +635,6 @@ class TCPDNSServer(socketserver.TCPServer):
self.tsig_view_map = tsig_view_map
class UDPDNSServer(socketserver.UDPServer):
allow_reuse_address = True
@@ -593,12 +644,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_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,21 +658,10 @@ 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."
)
def loadCatalogZoneSettings(self):
serial_file = self.settings.get("catalog_serial_file", None)
if not serial_file:
raise RuntimeError(
"catalog_serial_file variable not set in plugin settings."
)
else:
CatalogZoneAssembler.initCatalogZoneSerial(serial_file)
raise RuntimeError("tsig_keys variable not set in plugin settings.")
# Load TSIG keys and map them to views
def loadTSIGKeySettings(self):
# Load TSIG keys and map them to views
self.keyring = {}
self.tsig_view_map = {}
@@ -653,7 +694,7 @@ class Command(BaseCommand):
name=key_name_obj, secret=secret, algorithm=algorithm_str
)
self.tsig_view_map[key_name_str] = nb_view
logger.debug(f"Loaded TSIG key: {key_name_str} (view: {nb_view.name})")
logger.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}")
if not self.keyring:
msg = "No TSIG keys found in database."
@@ -661,28 +702,42 @@ 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()
self.loadCatalogZoneSettings()
# 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)
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,51 @@
# Generated by Django 5.2.8 on 2025-11-23 14:36
import netbox.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("extras", "0133_make_cf_minmax_decimal"),
]
operations = [
migrations.CreateModel(
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"
),
),
],
options={
"abstract": False,
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
]
Whitespace-only changes.
@@ -0,0 +1,24 @@
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)
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 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}"; }};'