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