Compare commits

..
11 Commits
Author SHA1 Message Date
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
8 changed files with 252 additions and 155 deletions

No files matched your search

+12 -40
View File
@@ -6,7 +6,7 @@
# 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:
@@ -16,9 +16,12 @@ permissions:
contents: read contents: read
jobs: jobs:
release-build: build-and-publish:
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
@@ -26,45 +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
- 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: Publish package distributions to PyPI
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
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
+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__
+6 -3
View File
@@ -109,15 +109,18 @@ This guide assumes:
This allows the plugin to map requests to the right dns view using the tsig This allows the plugin to map requests to the right dns view using the tsig
signature from each request. signature from each request.
4. Run migrations (This step is not yet required. Just adding for completeness) 4. Run migrations
``` ```
python3 netbox/manage.py migrate python3 netbox/manage.py migrate
``` ```
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.1"
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"
@@ -22,9 +22,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 +36,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 +61,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 +90,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)
@@ -113,19 +123,21 @@ 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 = 3600
@@ -154,7 +166,6 @@ 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(
@@ -190,19 +201,25 @@ class CatalogZone():
serial = int(f.read().strip()) serial = int(f.read().strip())
cls._serial_obj.value = int(serial) cls._serial_obj.value = int(serial)
cls._serial_obj.save() 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) os.remove(file_path)
except OSError as e: 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): 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 +246,21 @@ 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 special quoting for TXT records to stop tokanizer
# from cutting it up:
if rdtype == dns.rdatatype.TXT:
if len(record.value) <= 255:
record_value = f'"{record.value}"'
else:
chunks = ['"{}"'.format(record.value[i:i+255]) for i in range(0, len(record.value), 255)]
record_value = " ".join(chunks)
else:
record_value = record.value
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdataclass.IN,
rdtype, rdtype,
record.value, record_value, #asd
relativize=False, relativize=False,
origin=zone.origin, origin=zone.origin,
) )
@@ -241,7 +269,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 +281,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:
@@ -260,22 +292,14 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Replace the rdataset for the given name and type # Replace the rdataset for the given name and type
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)
@@ -286,12 +310,19 @@ 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)
class UDPRequestHandler(DNSBaseRequestHandler): 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): def handle(self):
data, sock = self.request data, sock = self.request
peer = self.client_address[0] peer = self.client_address[0]
@@ -301,11 +332,11 @@ class UDPRequestHandler(DNSBaseRequestHandler):
query = dns.message.from_wire( query = dns.message.from_wire(
data, data,
keyring=self.server.keyring, keyring=self.server.keyring,
continue_on_error=True, 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)
return return
# Create a response # Create a response
@@ -323,30 +354,35 @@ 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
# Identify TSIG key used # Identify TSIG key used
key_name = query.keyname.canonicalize().to_text() if query.keyname else None if not query.had_tsig:
if not key_name:
logger.warning(f"Request denied from {peer}: No TSIG key used") logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query) self.denyRequest(query)
return return
key_name = query.keyname.canonicalize().to_text()
# 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")
@@ -360,7 +396,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
@@ -370,15 +405,21 @@ 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)
# Generate new key for keyname provided # Generate new key for keyname provided
#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)
@@ -387,6 +428,13 @@ class UDPRequestHandler(DNSBaseRequestHandler):
class TCPRequestHandler(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): def handle(self):
MAX_WIRE = 65535 MAX_WIRE = 65535
# MAX_WIRE = 2000 # For testing fragmentation # MAX_WIRE = 2000 # For testing fragmentation
@@ -406,7 +454,7 @@ class TCPRequestHandler(DNSBaseRequestHandler):
while len(wire) < length: while len(wire) < length:
chunk = sock.recv(length - len(wire)) chunk = sock.recv(length - len(wire))
if not chunk: if not chunk:
return # connection closed return # connection closed
wire += chunk wire += chunk
# Parse the query # Parse the query
@@ -415,7 +463,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:
@@ -437,30 +487,35 @@ 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
# Identify TSIG key used # Identify TSIG key used
key_name = query.keyname.canonicalize().to_text() if query.keyname else None if not query.had_tsig:
if not key_name:
logger.warning(f"Request denied from {peer}: No TSIG key used") logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query) self.denyRequest(query)
return return
key_name = query.keyname.canonicalize().to_text()
# 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:
@@ -489,7 +544,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,
@@ -524,7 +582,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,
@@ -550,14 +611,14 @@ class TCPRequestHandler(DNSBaseRequestHandler):
wire = r.get_wire() wire = r.get_wire()
self.request.sendall(len(wire).to_bytes(2, "big") + 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}") logger.debug(f"{peer} AXFR {nb_view.name}/{dname}")
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):
@@ -569,7 +630,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
@@ -579,12 +639,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."
@@ -592,9 +653,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):
@@ -630,7 +689,7 @@ class Command(BaseCommand):
name=key_name_obj, secret=secret, algorithm=algorithm_str name=key_name_obj, secret=secret, algorithm=algorithm_str
) )
self.tsig_view_map[key_name_str] = nb_view 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: if not self.keyring:
msg = "No TSIG keys found in database." msg = "No TSIG keys found in database."
@@ -638,33 +697,42 @@ 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.loadCatalogZoneSettings()
self.loadTSIGKeySettings() self.loadTSIGKeySettings()
# Following is temporary. Function and invocation can be deleted once v1.0 is reached. # Following is temporary. Function and invocation can be deleted once v1.0 is reached.
serial_file = self.settings.get("catalog_serial_file", None) serial_file = self.settings.get("catalog_serial_file", None)
if serial_file: if serial_file:
CatalogZone.importLegacySerialFile(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()
@@ -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),
), ),
+8 -7
View File
@@ -2,18 +2,19 @@ from django.db import models
import netbox.models import netbox.models
import netbox_dns.models import netbox_dns.models
#class CatalogZoneSerial(models.Model):
# class CatalogZoneSerial(models.Model):
class IntegerKeyValueSetting(netbox.models.NetBoxModel): class IntegerKeyValueSetting(netbox.models.NetBoxModel):
#view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE) # 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.view.name}: {self.serial}'
return f'{self.key}: {str(self.value)}' return f"{self.key}: {str(self.value)}"
#class TSIGKey(netbox.models.NetBoxModel): # class TSIGKey(netbox.models.NetBoxModel):
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE) # view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
# name = models.CharField(max_length=256) # name = models.CharField(max_length=256)
# algorithm = models.CharField(max_length=25) # algorithm = models.CharField(max_length=25)