netbox-plugin-dns-bridge 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- build/lib/netbox_dns_bridge/__init__.py +36 -0
- build/lib/netbox_dns_bridge/catalog_zone_manager.py +234 -0
- build/lib/netbox_dns_bridge/dns_server.py +46 -0
- build/lib/netbox_dns_bridge/management/commands/dns-export-zone.py +60 -0
- build/lib/netbox_dns_bridge/management/commands/dns-export-zones.py +52 -0
- build/lib/netbox_dns_bridge/management/commands/dns-settings.py +51 -0
- build/lib/netbox_dns_bridge/management/commands/dns-transfer-endpoint.py +117 -0
- build/lib/netbox_dns_bridge/migrations/0001_initial.py +58 -0
- build/lib/netbox_dns_bridge/models.py +33 -0
- build/lib/netbox_dns_bridge/request_handler.py +412 -0
- build/lib/netbox_dns_bridge/signals.py +37 -0
- build/lib/netbox_dns_bridge/utils.py +35 -0
- netbox_dns_bridge/__init__.py +36 -0
- netbox_dns_bridge/catalog_zone_manager.py +234 -0
- netbox_dns_bridge/dns_server.py +46 -0
- netbox_dns_bridge/management/commands/dns-export-zone.py +60 -0
- netbox_dns_bridge/management/commands/dns-export-zones.py +52 -0
- netbox_dns_bridge/management/commands/dns-settings.py +51 -0
- netbox_dns_bridge/management/commands/dns-transfer-endpoint.py +117 -0
- netbox_dns_bridge/migrations/0001_initial.py +58 -0
- netbox_dns_bridge/models.py +33 -0
- netbox_dns_bridge/request_handler.py +412 -0
- netbox_dns_bridge/signals.py +37 -0
- netbox_dns_bridge/utils.py +35 -0
- netbox_plugin_dns_bridge-1.5.0.dist-info/METADATA +286 -0
- netbox_plugin_dns_bridge-1.5.0.dist-info/RECORD +29 -0
- netbox_plugin_dns_bridge-1.5.0.dist-info/WHEEL +5 -0
- netbox_plugin_dns_bridge-1.5.0.dist-info/licenses/LICENSE.md +18 -0
- netbox_plugin_dns_bridge-1.5.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from netbox.plugins import PluginConfig
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
__version__ = "1.5.0"
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.DEBUG, # Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
11
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", # Log format
|
|
12
|
+
datefmt="%Y-%m-%d %H:%M:%S", # Date format for timestamps
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DNSBridgeConfig(PluginConfig):
|
|
17
|
+
name = "netbox_dns_bridge"
|
|
18
|
+
verbose_name = "Netbox DNS Bridge"
|
|
19
|
+
description = ""
|
|
20
|
+
version = __version__
|
|
21
|
+
author = "Sven Luethi"
|
|
22
|
+
author_email = "dev@sven.luethi.co"
|
|
23
|
+
base_url = "dns-bridge"
|
|
24
|
+
|
|
25
|
+
def ready(self):
|
|
26
|
+
self.settings = settings.PLUGINS_CONFIG.get(self.name, None)
|
|
27
|
+
if not self.settings:
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from . import signals as signals
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
config = DNSBridgeConfig
|
|
36
|
+
default_app_config = ".apps.NetboxDNSBridgeConfig"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import dns.query
|
|
4
|
+
import dns.message
|
|
5
|
+
import dns.tsigkeyring
|
|
6
|
+
import dns.name
|
|
7
|
+
import dns.zone
|
|
8
|
+
import dns.rdatatype
|
|
9
|
+
import dns.rdataclass
|
|
10
|
+
import dns.rdtypes
|
|
11
|
+
import dns.exception
|
|
12
|
+
import dns.renderer
|
|
13
|
+
from netbox_dns.models import Zone
|
|
14
|
+
from netbox_dns.choices import ZoneStatusChoices
|
|
15
|
+
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
from base64 import b32encode
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_LOCK = threading.Lock()
|
|
22
|
+
_SERIAL_MAX = 0xFFFFFFFF
|
|
23
|
+
_SERIAL_OBJ = None
|
|
24
|
+
_PREVIOUS_LAST_ZONE_UPDATE = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init() -> None:
|
|
28
|
+
_init_serial()
|
|
29
|
+
_create_missing_member_identifiers()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Following function loads the last serial from the DB. No return value
|
|
33
|
+
# but it sets the setting "catalog_serial" or terminates the plugin on failure.
|
|
34
|
+
def _init_serial() -> None:
|
|
35
|
+
global _SERIAL_OBJ
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
_SERIAL_OBJ = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
|
|
39
|
+
logger.debug(
|
|
40
|
+
f"Catalog zone SOA serial number {_SERIAL_OBJ.value} loaded from database"
|
|
41
|
+
)
|
|
42
|
+
except IntegerKeyValueSetting.DoesNotExist:
|
|
43
|
+
_SERIAL_OBJ = IntegerKeyValueSetting.objects.create(
|
|
44
|
+
key="catalog-zone-soa-serial", value=1
|
|
45
|
+
)
|
|
46
|
+
logger.debug(
|
|
47
|
+
f"Catalog zone SOA serial number was not set in the database. Set to {_SERIAL_OBJ.value}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _set_serial(new_serial: int) -> bool:
|
|
52
|
+
global _SERIAL_OBJ
|
|
53
|
+
|
|
54
|
+
if 0 < new_serial < _SERIAL_MAX:
|
|
55
|
+
_SERIAL_OBJ.value = new_serial
|
|
56
|
+
_SERIAL_OBJ.save()
|
|
57
|
+
return True
|
|
58
|
+
else:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _increment_serial() -> None:
|
|
63
|
+
if not _set_serial(_SERIAL_OBJ.value + 1):
|
|
64
|
+
logger.warning(
|
|
65
|
+
f"Failed to incremenet catalog serial {_SERIAL_OBJ.value}. Will overflow serial back to 1"
|
|
66
|
+
)
|
|
67
|
+
_set_serial(1)
|
|
68
|
+
logger.debug(
|
|
69
|
+
f"Catalog zone SOA serial number incremented to {_SERIAL_OBJ.value}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# If a zone has no catz identifier yet, create it:
|
|
74
|
+
def _create_missing_member_identifiers() -> None:
|
|
75
|
+
existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list(
|
|
76
|
+
"zone_id", flat=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
missing_zones = Zone.objects.exclude(id__in=existing_zone_ids)
|
|
80
|
+
|
|
81
|
+
new_objects = [
|
|
82
|
+
CatalogZoneMemberIdentifier(
|
|
83
|
+
zone=zone,
|
|
84
|
+
name=_generate_member_identifier(),
|
|
85
|
+
)
|
|
86
|
+
for zone in missing_zones
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
for identifier in new_objects:
|
|
90
|
+
logger.debug(
|
|
91
|
+
f"Zone {identifier.zone} has no catz member identifier. Creating..."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
CatalogZoneMemberIdentifier.objects.bulk_create(
|
|
95
|
+
new_objects,
|
|
96
|
+
ignore_conflicts=False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_zone(name, view_name) -> dns.zone.Zone:
|
|
101
|
+
global _PREVIOUS_LAST_ZONE_UPDATE
|
|
102
|
+
# Synchronize following across threads as TCP and UDP listener both use it.
|
|
103
|
+
with _LOCK:
|
|
104
|
+
latest_zone = (
|
|
105
|
+
Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE)
|
|
106
|
+
.order_by("-last_updated")
|
|
107
|
+
.first()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
last_zone_update = getattr(latest_zone, "last_updated", None)
|
|
111
|
+
|
|
112
|
+
# Check if there was a zone updated since last call
|
|
113
|
+
# If no zone was found previously then this will be false since (None != None) = False
|
|
114
|
+
if _PREVIOUS_LAST_ZONE_UPDATE != last_zone_update:
|
|
115
|
+
if last_zone_update is not None:
|
|
116
|
+
logger.debug(
|
|
117
|
+
f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}"
|
|
118
|
+
)
|
|
119
|
+
# Setting previous last zone update for next iteration:
|
|
120
|
+
_PREVIOUS_LAST_ZONE_UPDATE = last_zone_update
|
|
121
|
+
_increment_serial()
|
|
122
|
+
|
|
123
|
+
# Zone origin
|
|
124
|
+
origin = dns.name.from_text(name, dns.name.root)
|
|
125
|
+
|
|
126
|
+
# Create a new empty zone
|
|
127
|
+
zone = dns.zone.Zone(origin)
|
|
128
|
+
zone.rdclass = dns.rdataclass.IN
|
|
129
|
+
|
|
130
|
+
# get zones from netbox
|
|
131
|
+
nb_zones = Zone.objects.filter(
|
|
132
|
+
view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE
|
|
133
|
+
).select_related("catz_identifier")
|
|
134
|
+
|
|
135
|
+
ptr_base = dns.name.from_text("zones", origin)
|
|
136
|
+
|
|
137
|
+
for nb_zone in nb_zones:
|
|
138
|
+
ttl = 0
|
|
139
|
+
qname = dns.name.from_text(nb_zone.name, dns.name.root)
|
|
140
|
+
|
|
141
|
+
# Create PTR record
|
|
142
|
+
# p_name = f"zid-{nb_zone.id:09d}"
|
|
143
|
+
p_name = nb_zone.catz_identifier.name
|
|
144
|
+
|
|
145
|
+
ptr_name = dns.name.from_text(p_name, ptr_base)
|
|
146
|
+
if not ptr_name.is_subdomain(origin):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
"Catalog zone member identifier {ptr_name.to_text()} not a subdomain"
|
|
149
|
+
)
|
|
150
|
+
rdata = dns.rdata.from_text(
|
|
151
|
+
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
|
|
152
|
+
)
|
|
153
|
+
rdataset = zone.find_rdataset(ptr_name, dns.rdatatype.PTR, create=True)
|
|
154
|
+
rdataset.add(rdata, ttl)
|
|
155
|
+
|
|
156
|
+
# Configure DNSSec Policy for member Zone if DNSSec is enabled
|
|
157
|
+
if nb_zone.dnssec_policy:
|
|
158
|
+
# Configure policy
|
|
159
|
+
rid = dns.name.from_text("group", ptr_name)
|
|
160
|
+
policy_name = nb_zone.dnssec_policy.name.rstrip(" ")
|
|
161
|
+
group_name = f"dnssec-policy-{policy_name}"
|
|
162
|
+
rdata = dns.rdata.from_text(
|
|
163
|
+
dns.rdataclass.IN, dns.rdatatype.TXT, group_name
|
|
164
|
+
)
|
|
165
|
+
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
|
|
166
|
+
rdataset.add(rdata, ttl)
|
|
167
|
+
|
|
168
|
+
## Configure dnssec status for member zone
|
|
169
|
+
# status = str(1 if nb_zone.dnssec_policy else 0)
|
|
170
|
+
# rid = dns.name.from_text("enabled.dnssec.ext", ptr_name)
|
|
171
|
+
# rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
|
|
172
|
+
# rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
|
|
173
|
+
# rdataset.add(rdata, ttl)
|
|
174
|
+
|
|
175
|
+
# SOA Record components
|
|
176
|
+
ttl = 0
|
|
177
|
+
rclass = dns.rdataclass.IN
|
|
178
|
+
rtype = dns.rdatatype.SOA
|
|
179
|
+
mname = dns.name.from_text("invalid", dns.name.root)
|
|
180
|
+
rname = dns.name.from_text("invalid", dns.name.root)
|
|
181
|
+
serial = _SERIAL_OBJ.value
|
|
182
|
+
refresh = 60
|
|
183
|
+
retry = 10
|
|
184
|
+
expire = 1209600
|
|
185
|
+
minimum = 0
|
|
186
|
+
|
|
187
|
+
# Create SOA rdata object
|
|
188
|
+
soa_rdata = dns.rdata.from_text(
|
|
189
|
+
rclass,
|
|
190
|
+
rtype,
|
|
191
|
+
f"{mname} {rname} {serial} {refresh} {retry} {expire} {minimum}",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Create Rdataset and add the RDATA to it
|
|
195
|
+
soa_rdataset = dns.rdataset.Rdataset(rclass, rtype)
|
|
196
|
+
soa_rdataset.add(soa_rdata, ttl)
|
|
197
|
+
|
|
198
|
+
# Add to the origin node in the zone
|
|
199
|
+
node = zone.find_node(origin, create=True)
|
|
200
|
+
node.rdatasets.append(soa_rdataset)
|
|
201
|
+
|
|
202
|
+
# NS record for catz.
|
|
203
|
+
ns_name = dns.name.from_text("invalid", dns.name.root)
|
|
204
|
+
ns_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name))
|
|
205
|
+
ns_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS)
|
|
206
|
+
ns_rdataset.add(ns_rdata, 0)
|
|
207
|
+
|
|
208
|
+
# Add to node (catz. is the origin)
|
|
209
|
+
ns_node = zone.find_node(origin, create=True)
|
|
210
|
+
ns_node.rdatasets.append(ns_rdataset)
|
|
211
|
+
|
|
212
|
+
# TXT record for version.catz.
|
|
213
|
+
version_name = dns.name.from_text("version", origin) # relative to origin "catz."
|
|
214
|
+
txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"')
|
|
215
|
+
|
|
216
|
+
txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT)
|
|
217
|
+
txt_rdataset.add(txt_rdata, 0)
|
|
218
|
+
|
|
219
|
+
# Add to node version.catz.
|
|
220
|
+
txt_node = zone.find_node(version_name, create=True)
|
|
221
|
+
txt_node.rdatasets.append(txt_rdataset)
|
|
222
|
+
|
|
223
|
+
return zone
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _generate_member_identifier() -> None:
|
|
227
|
+
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def update_member_identifier(zone: Zone) -> None:
|
|
231
|
+
CatalogZoneMemberIdentifier.objects.update_or_create(
|
|
232
|
+
zone=zone,
|
|
233
|
+
defaults={"name": _generate_member_identifier},
|
|
234
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import socketserver
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DNSAddressMixin:
|
|
9
|
+
def _resolve_address(self, server_address, socktype, proto):
|
|
10
|
+
host, port = server_address
|
|
11
|
+
|
|
12
|
+
infos = socket.getaddrinfo(
|
|
13
|
+
host, port, socket.AF_UNSPEC, socktype, proto, socket.AI_PASSIVE
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
family, _, _, _, sockaddr = infos[0]
|
|
17
|
+
self.address_family = family
|
|
18
|
+
return sockaddr
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TCPDNSServer(DNSAddressMixin, socketserver.TCPServer):
|
|
22
|
+
allow_reuse_address = True
|
|
23
|
+
|
|
24
|
+
def __init__(self, server_address, handler_class, keyring, tsig_view_map):
|
|
25
|
+
sockaddr = self._resolve_address(
|
|
26
|
+
server_address, socket.SOCK_STREAM, socket.IPPROTO_TCP
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
super().__init__(sockaddr, handler_class)
|
|
30
|
+
|
|
31
|
+
self.keyring = keyring
|
|
32
|
+
self.tsig_view_map = tsig_view_map
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UDPDNSServer(DNSAddressMixin, socketserver.UDPServer):
|
|
36
|
+
allow_reuse_address = True
|
|
37
|
+
|
|
38
|
+
def __init__(self, server_address, handler_class, keyring, tsig_view_map):
|
|
39
|
+
sockaddr = self._resolve_address(
|
|
40
|
+
server_address, socket.SOCK_DGRAM, socket.IPPROTO_UDP
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
super().__init__(sockaddr, handler_class)
|
|
44
|
+
|
|
45
|
+
self.keyring = keyring
|
|
46
|
+
self.tsig_view_map = tsig_view_map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import dns.zone
|
|
3
|
+
import dns.rdatatype
|
|
4
|
+
import dns.rdataclass
|
|
5
|
+
import dns.exception
|
|
6
|
+
import netbox_dns.models
|
|
7
|
+
from netbox_dns_bridge.utils import export_bind_zone_file
|
|
8
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
|
|
13
|
+
def add_arguments(self, parser):
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--view",
|
|
16
|
+
type=str,
|
|
17
|
+
help="The name of the view the Zone to be exported is in",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--zone", type=str, help="The FQDN of the Zone to be exported"
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--file", type=str, help="Path of the zone file to be written"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def handle(self, *args, **options):
|
|
27
|
+
# if len(options) < 2:
|
|
28
|
+
# print("export-zone <zone name> <file path>")
|
|
29
|
+
# sys.exit(1)
|
|
30
|
+
|
|
31
|
+
# Load parameters
|
|
32
|
+
view_name = options["view"]
|
|
33
|
+
zone_name = options["zone"]
|
|
34
|
+
file_path = options["file"]
|
|
35
|
+
|
|
36
|
+
if not view_name:
|
|
37
|
+
raise CommandError("No --view parameter given")
|
|
38
|
+
elif not zone_name:
|
|
39
|
+
raise CommandError("No --zone parameter given")
|
|
40
|
+
elif not file_path:
|
|
41
|
+
raise CommandError("No --file parameter given")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Load the zone from NetBox DNS
|
|
45
|
+
nb_zone = netbox_dns.models.Zone.objects.get(
|
|
46
|
+
view__name=view_name, name=zone_name
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if nb_zone:
|
|
50
|
+
export_bind_zone_file(nb_zone, file_path=file_path)
|
|
51
|
+
else:
|
|
52
|
+
print("Zone not found in Netbox. Aborting")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise CommandError(f"Failed to export zone: {e}")
|
|
57
|
+
|
|
58
|
+
self.stdout.write(
|
|
59
|
+
self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'")
|
|
60
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import dns.zone
|
|
3
|
+
import dns.rdatatype
|
|
4
|
+
import dns.rdataclass
|
|
5
|
+
import dns.exception
|
|
6
|
+
import netbox_dns.models
|
|
7
|
+
from netbox_dns_bridge.utils import export_bind_zone_file
|
|
8
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
|
|
13
|
+
def add_arguments(self, parser):
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--path",
|
|
16
|
+
type=str,
|
|
17
|
+
help="Path to the directory where zone files are to be written",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def handle(self, *args, **options):
|
|
21
|
+
# Load parameters
|
|
22
|
+
base_path = options["path"]
|
|
23
|
+
|
|
24
|
+
if not base_path:
|
|
25
|
+
raise CommandError("No --path parameter given")
|
|
26
|
+
|
|
27
|
+
# Ensure base_path exists
|
|
28
|
+
os.makedirs(base_path, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
# Iterate through all zones
|
|
31
|
+
nb_zones = netbox_dns.models.Zone.objects.prefetch_related("view").all()
|
|
32
|
+
if not nb_zones.exists():
|
|
33
|
+
self.stdout.write(self.style.WARNING("No zones found in NetBox DNS."))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
for zone in nb_zones:
|
|
37
|
+
# Determine view directory
|
|
38
|
+
view_name = zone.view.name
|
|
39
|
+
view_dir = os.path.join(base_path, view_name)
|
|
40
|
+
os.makedirs(view_dir, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
# Zone file path
|
|
43
|
+
file_path = os.path.join(view_dir, zone.name)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
# Call existing export function
|
|
47
|
+
export_bind_zone_file(zone, file_path=file_path)
|
|
48
|
+
self.stdout.write(
|
|
49
|
+
self.style.SUCCESS(f"Exported zone '{zone.name}' to '{file_path}'")
|
|
50
|
+
)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
self.stderr.write(f"Failed to export zone '{zone.name}': {e}")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
2
|
+
from netbox_dns_bridge.models import IntegerKeyValueSetting
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Command(BaseCommand):
|
|
6
|
+
help = "Get or set integer key-value settings"
|
|
7
|
+
|
|
8
|
+
def add_arguments(self, parser):
|
|
9
|
+
parser.add_argument(
|
|
10
|
+
"action", choices=["get", "set", "list"], help="Action to perform"
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument("key", nargs="?", help="Setting key")
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
"value", nargs="?", type=int, help="Value to set (required for 'set')"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def handle(self, *args, **options):
|
|
18
|
+
action = options["action"]
|
|
19
|
+
key = options.get("key")
|
|
20
|
+
value = options.get("value")
|
|
21
|
+
|
|
22
|
+
if action == "list":
|
|
23
|
+
settings = IntegerKeyValueSetting.objects.all()
|
|
24
|
+
if not settings.exists():
|
|
25
|
+
self.stdout.write("No settings found.")
|
|
26
|
+
return
|
|
27
|
+
for setting in settings:
|
|
28
|
+
self.stdout.write(str(setting))
|
|
29
|
+
|
|
30
|
+
elif action == "get":
|
|
31
|
+
if not key:
|
|
32
|
+
raise CommandError("A key is required for GET")
|
|
33
|
+
try:
|
|
34
|
+
setting = IntegerKeyValueSetting.objects.get(key=key)
|
|
35
|
+
self.stdout.write(f"{setting.key}: {setting.value}")
|
|
36
|
+
except IntegerKeyValueSetting.DoesNotExist:
|
|
37
|
+
raise CommandError(f"No setting was found for {key}")
|
|
38
|
+
|
|
39
|
+
elif action == "set":
|
|
40
|
+
if not key:
|
|
41
|
+
raise CommandError("A setting is required for SET command")
|
|
42
|
+
if value is None:
|
|
43
|
+
raise CommandError("A value is required for SET command")
|
|
44
|
+
try:
|
|
45
|
+
setting = IntegerKeyValueSetting.objects.get(key=key)
|
|
46
|
+
old_value = setting.value
|
|
47
|
+
setting.value = value
|
|
48
|
+
setting.save()
|
|
49
|
+
self.stdout.write(f"Updated {key} from {old_value} to {value}")
|
|
50
|
+
except IntegerKeyValueSetting.DoesNotExist:
|
|
51
|
+
raise CommandError(f"No setting found for {key}")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import dns.query
|
|
4
|
+
import dns.message
|
|
5
|
+
import dns.tsigkeyring
|
|
6
|
+
import dns.name
|
|
7
|
+
import dns.zone
|
|
8
|
+
import dns.rdatatype
|
|
9
|
+
import dns.rdataclass
|
|
10
|
+
import dns.rdtypes
|
|
11
|
+
import dns.exception
|
|
12
|
+
import dns.renderer
|
|
13
|
+
|
|
14
|
+
from django.core.management.base import BaseCommand
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from netbox_dns.models import View
|
|
17
|
+
from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler
|
|
18
|
+
from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer
|
|
19
|
+
from netbox_dns_bridge import catalog_zone_manager as catzm
|
|
20
|
+
from netbox_dns_bridge.models import IntegerKeyValueSetting
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Command(BaseCommand):
|
|
26
|
+
help = "Run a minimal AXFR DNS server using data from NetBox DNS plugin"
|
|
27
|
+
|
|
28
|
+
def load_settings(self):
|
|
29
|
+
self.settings = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", None)
|
|
30
|
+
if not self.settings:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"Command failed to initialize due to missing settings. Terminating Netbox."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
self.tsig_keys = self.settings.get("tsig_keys", None)
|
|
36
|
+
if not self.tsig_keys:
|
|
37
|
+
raise RuntimeError("tsig_keys variable not set in plugin settings.")
|
|
38
|
+
|
|
39
|
+
# Load TSIG keys and map them to views
|
|
40
|
+
def load_tsig_key_settings(self):
|
|
41
|
+
self.keyring = {}
|
|
42
|
+
self.tsig_view_map = {}
|
|
43
|
+
|
|
44
|
+
for view_name, data in self.tsig_keys.items():
|
|
45
|
+
raw_key_name = data.get("keyname")
|
|
46
|
+
secret = data.get("secret")
|
|
47
|
+
algorithm_str = data.get("algorithm", "hmac-sha256")
|
|
48
|
+
|
|
49
|
+
if not raw_key_name or not secret:
|
|
50
|
+
logger.error(
|
|
51
|
+
f"Skipping TSIG key for view {view_name}: missing keyname or secret."
|
|
52
|
+
)
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
nb_view = View.objects.get(name=view_name)
|
|
57
|
+
except View.DoesNotExist:
|
|
58
|
+
logger.error(
|
|
59
|
+
f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found."
|
|
60
|
+
)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# Normalize key name to absolute DNS name
|
|
64
|
+
key_name_obj = dns.name.from_text(raw_key_name, origin=None).canonicalize()
|
|
65
|
+
if not key_name_obj.is_absolute():
|
|
66
|
+
key_name_obj = key_name_obj.concatenate(dns.name.root)
|
|
67
|
+
key_name_str = key_name_obj.to_text() # Will always include trailing do
|
|
68
|
+
|
|
69
|
+
self.keyring[key_name_obj] = dns.tsig.Key(
|
|
70
|
+
name=key_name_obj, secret=secret, algorithm=algorithm_str
|
|
71
|
+
)
|
|
72
|
+
self.tsig_view_map[key_name_str] = nb_view
|
|
73
|
+
logger.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}")
|
|
74
|
+
|
|
75
|
+
if not self.keyring:
|
|
76
|
+
msg = "No TSIG keys found in database."
|
|
77
|
+
logger.critical(msg)
|
|
78
|
+
raise RuntimeError(msg)
|
|
79
|
+
|
|
80
|
+
def add_arguments(self, parser):
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--port", type=int, default=5354, help="Port number to listen on"
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--address", type=str, default="0.0.0.0", help="IP to bind to"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def handle(self, *args, **options):
|
|
89
|
+
# Load parameters
|
|
90
|
+
port = options["port"]
|
|
91
|
+
address = options["address"]
|
|
92
|
+
catzm.init()
|
|
93
|
+
|
|
94
|
+
# Initialize settings
|
|
95
|
+
self.load_settings()
|
|
96
|
+
self.load_tsig_key_settings()
|
|
97
|
+
|
|
98
|
+
udp_server = UDPDNSServer(
|
|
99
|
+
(address, port), UDPRequestHandler, self.keyring, self.tsig_view_map
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
tcp_server = TCPDNSServer(
|
|
103
|
+
(address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def run_udp_server(server):
|
|
107
|
+
logger.debug(f"Query endpoint listening on {address} udp/{port}")
|
|
108
|
+
server.serve_forever()
|
|
109
|
+
|
|
110
|
+
udp_thread = threading.Thread(
|
|
111
|
+
target=run_udp_server, args=(udp_server,), daemon=True
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
udp_thread.start()
|
|
115
|
+
|
|
116
|
+
logger.debug(f"Query endpoint listening on {address} tcp/{port}")
|
|
117
|
+
tcp_server.serve_forever()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Generated by Django 5.2.9 on 2026-04-08 11:29
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
(
|
|
13
|
+
"netbox_dns",
|
|
14
|
+
"0030_dnsseckeytemplate_comments_dnsseckeytemplate_owner_and_more",
|
|
15
|
+
),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
operations = [
|
|
19
|
+
migrations.CreateModel(
|
|
20
|
+
name="IntegerKeyValueSetting",
|
|
21
|
+
fields=[
|
|
22
|
+
(
|
|
23
|
+
"id",
|
|
24
|
+
models.BigAutoField(
|
|
25
|
+
auto_created=True, primary_key=True, serialize=False
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
("key", models.CharField(max_length=64)),
|
|
29
|
+
("value", models.IntegerField()),
|
|
30
|
+
],
|
|
31
|
+
options={
|
|
32
|
+
"default_permissions": (),
|
|
33
|
+
},
|
|
34
|
+
),
|
|
35
|
+
migrations.CreateModel(
|
|
36
|
+
name="CatalogZoneMemberIdentifier",
|
|
37
|
+
fields=[
|
|
38
|
+
(
|
|
39
|
+
"id",
|
|
40
|
+
models.BigAutoField(
|
|
41
|
+
auto_created=True, primary_key=True, serialize=False
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
("name", models.CharField(max_length=26, unique=True)),
|
|
45
|
+
(
|
|
46
|
+
"zone",
|
|
47
|
+
models.OneToOneField(
|
|
48
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
49
|
+
related_name="catz_identifier",
|
|
50
|
+
to="netbox_dns.zone",
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
],
|
|
54
|
+
options={
|
|
55
|
+
"ordering": ("name",),
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
import netbox.models
|
|
3
|
+
import netbox_dns.models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IntegerKeyValueSetting(models.Model):
|
|
7
|
+
key = models.CharField(max_length=64)
|
|
8
|
+
value = models.IntegerField()
|
|
9
|
+
|
|
10
|
+
def __str__(self):
|
|
11
|
+
return f"{self.key}: {str(self.value)}"
|
|
12
|
+
|
|
13
|
+
class Meta:
|
|
14
|
+
default_permissions = ()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CatalogZoneMemberIdentifier(models.Model):
|
|
18
|
+
name = models.CharField(
|
|
19
|
+
max_length=26,
|
|
20
|
+
unique=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
zone = models.OneToOneField(
|
|
24
|
+
to=netbox_dns.models.Zone,
|
|
25
|
+
on_delete=models.CASCADE,
|
|
26
|
+
related_name="catz_identifier",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class Meta:
|
|
30
|
+
ordering = ("name",)
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
return self.name
|