Compare commits

..
38 Commits
Author SHA1 Message Date
Sven Luthi 240f1293ae Bump to version v1.5.9-beta 2026-09-02 12:28:17 +02:00
sven a3bc64df62 Upgrade models to full NetboxModel to allow UI/API interaction:
modified:   netbox_dns_bridge/models.py
deleted:    netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
new file:   netbox_dns_bridge/migrations/0003_full_netbox_models.py
This assumes the migration starts at v1.5.8.

Adjust model access and function calls to match new models:
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py

Update UI elements and permission interactions:
modified:   netbox_dns_bridge/filtersets.py
modified:   netbox_dns_bridge/forms.py
modified:   netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/navigation.py
modified:   netbox_dns_bridge/tables.py
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html
modified:   netbox_dns_bridge/urls.py
modified:   netbox_dns_bridge/views.py
2026-09-02 12:08:27 +02:00
sven a3f53767e4 Move from hard-coded CatalogZone SOA values (refresh, retry, expire and minimum)
to DB driven to permit user modification:

modified:   netbox_dns_bridge/models.py
  Add 4 cols for values.

modified:   netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
  Adjust migration. This expects that the last migration the user has is 0002 (<=v1.5.8)

modified:   netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
  Update to use new db values for SOA creation.

Change UI to permit editing for SOA values and disable member identifier editing.
modified:   netbox_dns_bridge/forms.py
modified:   netbox_dns_bridge/navigation.py
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_edit.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_list.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html
modified:   netbox_dns_bridge/tables.py
modified:   netbox_dns_bridge/urls.py
modified:   netbox_dns_bridge/views.py
modified:   netbox_dns_bridge/filtersets.py
  GUI components are entirely AI generated. Plumbing also with AI assist but code verified.
2026-09-02 12:05:21 +02:00
sven 69781e2b08 renamed: netbox_dns_bridge/migrations/0004_catalogzone_and_per_zone_identifier.py -> netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
deleted:    netbox_dns_bridge/migrations/0003_integerkeyvaluesetting_key_unique.py

Migration 3 not required since model is being replaced anyway. Removed.
2026-09-02 12:03:21 +02:00
sven 6d4fb82ac5 modified: CHANGELOG.md
new file:   netbox_dns_bridge/jobs/__init__.py
renamed:    netbox_dns_bridge/signals/notify_handler.py -> netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Restructuring
2026-09-02 12:02:55 +02:00
sven 6be7359c27 Replace global IntegerKeyValueSetting with per-view CatalogZone model
Track the catalog zone SOA serial per view instead of a single global value.
The new CatalogZone model has a 1:1 FK to netbox_dns.models.View and its own
soa_serial field; each view's catalog zone now increments independently. The
existing global serial is preserved during migration by copying it into every
view's new CatalogZone row.

Change CatalogZoneMemberIdentifier.name from globally unique to unique per
catalog zone (UniqueConstraint(name, catalog_zone)) and add a catalog_zone FK
to enforce it.

Remove the dns-settings management command, which was a generic interface over
the now-removed IntegerKeyValueSetting model.

Increment both catalog zone serials when a zone moves between views: the old
view's catalog zone loses a member and the new view's gains one, so both must
be bumped. Also refresh the member identifier's catalog_zone FK on a view move
so it does not go stale. The pre_save handler now captures _old_view_id and the
post_save handler detects view changes, increments the old view's serial inside
on_commit (before the new view's), and re-creates the identifier.

AI generated, code manually verified.
2026-09-02 12:01:54 +02:00
sven e8762bf807 modified: CHANGELOG.md modified: netbox_dns_bridge/signals/netbox_dns_zones.py Defer catalog zone SOA serial increment to transaction.on_commit on zone deletion, matching the create/modify path and making delete rollback-safe 2026-08-12 01:21:11 +02:00
sven ddef4965d6 new file: netbox_dns_bridge/filtersets.py
new file:   netbox_dns_bridge/forms.py
new file:   netbox_dns_bridge/tables.py
modified:   netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html
modified:   netbox_dns_bridge/views.py
Change catz table to ObjectListView
2026-08-12 00:28:57 +02:00
Sven Luethi 9f71ff7332 modified: netbox_dns_bridge/navigation.py
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html
deleted:    netbox_dns_bridge/templates/netbox_dns_bridge/info.html
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html
modified:   netbox_dns_bridge/urls.py
modified:   netbox_dns_bridge/views.py
Extend views
2026-08-11 18:30:29 +02:00
Sven Luethi 60bc60ccc4 modified: MANIFEST.in
modified:   pyproject.toml
html template from commit ea1103a356 was
not included in package. Fixes it
2026-08-11 18:30:15 +02:00
Sven Luethi ac34afc46c modified: netbox_dns_bridge/__init__.py
deleted:    netbox_dns_bridge/logger.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
new file:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/__init__.py
renamed:    netbox_dns_bridge/catalog_zone_manager.py -> netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
renamed:    netbox_dns_bridge/dns_server.py -> netbox_dns_bridge/management/commands/dns_transfer_endpoint/dns_server.py
renamed:    netbox_dns_bridge/request_handler.py -> netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
renamed:    netbox_dns_bridge/notify_handler.py -> netbox_dns_bridge/signals/notify_handler.py
modified:   netbox_dns_bridge/utils.py
2026-08-11 18:30:07 +02:00
Sven Luethi 0ac23ff3a0 modified: netbox_dns_bridge/__init__.py
new file:   netbox_dns_bridge/navigation.py
new file:   netbox_dns_bridge/templates/netbox_dns_bridge/info.html
new file:   netbox_dns_bridge/urls.py
new file:   netbox_dns_bridge/views.py
Add information view
2026-08-11 18:30:00 +02:00
Sven Luethi 512dc6b247 Fix NOTIFY missing SOA serial by placing SOA in answer section (Fixes #26)
RFC 1996 section 3.10 requires the SOA RR carrying the new zone serial
to be placed in the answer section of a NOTIFY message. BIND9's notify
processing looks for the SOA there and, when it is absent, logs that the
NOTIFY does not contain a SOA serial.

Both SendClientDNSNotify and SendNSNotify were appending the SOA RRset
to the authority section instead of the answer section, which is why
BIND9 reported the missing serial. Move the append to msg.answer in both
handlers so receivers can correctly extract the new serial.
2026-08-11 18:29:40 +02:00
Sven Luethi edd57dd6f7 modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/utils.py
Black reformat
2026-08-11 18:29:29 +02:00
Sven Luethi 2276d770d1 Remove unreachable not-found branch in dns-export-zone
The command loaded the zone with Zone.objects.get(...) and then tested
the result for truthiness:

    nb_zone = Zone.objects.get(...)
    if nb_zone:
        ...export...
    else:
        self.stderr.write('Zone not found in Netbox. Aborting')

Zone.objects.get() either returns a model instance (always truthy) or
raises Zone.DoesNotExist. It never returns a falsy value, so the else
branch was unreachable dead code. A missing zone is already handled
correctly: DoesNotExist is a subclass of Exception, which the
surrounding try/except catches and converts into a CommandError.

Drop the dead if/else and call export_bind_zone_file directly.
2026-08-11 18:29:23 +02:00
Sven Luethi 0277063dcc Refuse AXFR over UDP instead of crashing
AXFR is a TCP-only operation (RFC 5936), but the request handler did
not enforce that. When a UDP datagram carrying an AXFR query arrived,
control reached _handle_axfr_request, which builds the multi-message
response and writes each chunk with:

    self.request.sendall(len(wire).to_bytes(2, 'big') + wire)

For a UDP handler self.request is a (data, sock) tuple, not a socket,
so sendall raised AttributeError. The exception was caught by the
outer try/except in UDPRequestHandler.handle, so the server stayed up,
but the client received no response at all and the error was only
visible in a generic 'Error handling request' traceback.

Add a 'transport' class attribute ('tcp' / 'udp') to the concrete
handler classes and, immediately after the qtype check (before TSIG
validation and zone lookup), deny AXFR-over-UDP with REFUSED. The
client now gets a proper refusal and the AXFR handler is never entered
on a UDP socket.
2026-08-11 18:29:12 +02:00
Sven Luethi 74ceb0206a Add unique constraint to IntegerKeyValueSetting.key
IntegerKeyValueSetting is used as a singleton-style key/value store:
callers do get(key=...) and get_or_create(key=..., ...) and assume at
most one row per key. The model, however, did not enforce uniqueness on
the key field, so nothing prevented duplicate rows at the database
level.

This was a real race for _create_soa_rdataset in catalog_zone_manager,
which calls get_or_create without select_for_update (unlike
increment_soa_serial, which does take a lock). Two concurrent AXFR
queries for a catalog zone could both pass the get_or_create existence
check and both insert a row for key='catalog-zone-soa-serial'. Every
subsequent IntegerKeyValueSetting.objects.get(key=...) call would then
raise MultipleObjectsReturned, breaking catalog-zone SOA rendering and
serial increments.

Add unique=True to the key CharField and ship migration 0003 to enforce
the invariant at the database level. Existing deployments are assumed to
have clean state (no duplicate keys); if duplicates exist, the migration
should be preceded by manual deduplication.
2026-08-11 18:29:09 +02:00
Sven Luethi 3affad4ed4 Fix fall-through after OperationalError in TSIG key loading
In _load_tsig_key_settings, the OperationalError handler for the
View.objects.get(name=view_name) lookup called close_old_connections()
but should have exited the script instead. This is the initial startup
and if loading of settings fails, the script should simply exit.

Also contains some code cleaning
2026-08-11 18:29:06 +02:00
Sven Luethi 25c1ab4e12 Make catalog zone name matching case-insensitive
DNS names are case-insensitive (RFC 4343), but the catalog-zone
dispatch compared dname verbatim:

    if dname == "catz" or dname == f"{nb_view.name}.catz":

dname is derived from qname.to_text(), which preserves the casing the
client sent. A query for CATZ, Public.catz, or any other mixed-case
variant failed the comparison and fell through to _get_zone_from_nb,
which returned None, so the request was denied instead of being served
as a catalog zone.

Lowercase both sides before comparing. Pass the lowercased name to
create_zone so catalog zones always get a canonical lowercase origin,
and pass the original dname to _get_zone_from_nb to leave the regular
zone-lookup behavior unchanged.
2026-08-11 18:28:57 +02:00
Sven Luethi 627a3a5712 Share zone-building logic between AXFR and zone-file export
export_bind_zone_file diverged from the AXFR path in four ways, each a
bug:

- It iterated nb_zone.records.all(), including inactive records, whereas
  the AXFR handler filtered Record.objects.filter(zone=nb_zone,
  active=True). Exported zone files could contain stale/withdrawn
  records.
- It passed record.ttl straight to rdataset.add(), crashing with a
  TypeError when the NetBox DNS Record had no TTL set. The AXFR path
  used 'ttl = record.ttl or nb_zone.default_ttl'.
- It had no TXT quoting logic, so TXT values containing spaces or
  semicolons were mis-tokenized by dns.rdata.from_text (a ';' was
  treated as the start of a comment). The AXFR path quotes and chunks
  TXT values for exactly this reason.
- It appended a fresh rdataset to the node for every record instead of
  merging records that share a name and type, producing duplicate
  rdatasets and malformed zone files.

Extract build_dns_zone(nb_zone) in utils.py containing the corrected,
AXFR-proven record-building logic (active filter, TTL fallback, TXT
quoting/chunking, rdataset merging via rdatasets_dict). Rewrite
export_bind_zone_file to build the zone through build_dns_zone and then
write it. Rewrite request_handler._get_zone_from_nb to look up the
NetBox DNS Zone and delegate to build_dns_zone, keeping only the
DoesNotExist/OperationalError handling that is specific to the query
path.

Both code paths now produce identical zone data.
2026-08-11 18:28:53 +02:00
Sven Luethi c579e49e78 Rename _getZoneFromNB to _get_zone_from_nb
_getZoneFromNB was the only camelCase function in the codebase,
inconsistent with the snake_case convention used everywhere else.
Rename it and update its callers and the adjacent comment.

Pure rename, no behavior change.
2026-08-11 18:28:42 +02:00
sven c6c7cf70ec Fix swallowed query-parse errors in request handler
The logging call passed the exception as a positional argument with no
%s placeholder in the format string:

    LOGGER.error("Error parsing query: ", e)

Python's logging applies msg % args, which raised TypeError (not all
arguments converted during string formatting) inside logging.handleError.
The logging framework swallows that secondary error, so neither the
intended message nor the exception was ever recorded.

Use an f-string so the exception is actually rendered and logged.
2026-08-11 11:10:20 +02:00
Sven Luthi 1b0008af4f modified: README.md 2026-07-20 17:06:49 +02:00
Sven Luthi cfb4633006 modified: netbox_dns_bridge/__init__.py
Bump version
2026-07-20 15:21:36 +02:00
Sven Luthi b4ab5596d6 modified: netbox_dns_bridge/notify_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Some code cleanup
2026-07-20 15:21:14 +02:00
Sven Luthi 11ea9a6f1a modified: README.md
modified:   netbox_dns_bridge/notify_handler.py
Remove hardcoded client notify port and connect to plugin setting
instead.
2026-07-20 15:07:21 +02:00
Sven Luethi 83965817d6 Update README 2026-07-16 14:54:35 +02:00
Sven Luethi c054a00039 Bump to v1.5.7 2026-07-16 14:27:49 +02:00
Sven Luethi 4eb018d8ca Merge pull request #25 from dmitriev-e/feature/force-notify-ns
Feature/force notify ns
2026-07-16 14:24:31 +02:00
d_ev 4ad41360de Address review: rename settings to notify_ns_* and add global toggle
Per PR review feedback:
- Rename settings: custom_field_force_notify -> notify_ns_custom_field_name,
  force_notify_port -> notify_ns_port.
- Add notify_ns_all_zones (bool, default False) to enable the feature globally
  for every zone; it coexists with the per-zone custom field — a NOTIFY is sent
  if the global switch is on OR the zone's custom field is set.
- Rename the internal job/scheduler/log strings to the notify_ns naming for
  consistency (SendZoneNSNotify, schedule_notify_ns).
- Update README and CHANGELOG.
2026-07-16 14:57:14 +03:00
Sven Luethi f80318707d Merge pull request #24 from dmitriev-e/fix/catalog-zone-get-or-create
Fix/catalog zone get or create
2026-07-16 09:07:45 +02:00
d_ev db09fd70be Fix get_or_create tuple unpacking in catalog_zone_manager
v1.5.6 fixed the wrong RelatedObjectDoesNotExist import and the catz
member-identifier creation, but two get_or_create() calls still used the
returned value directly instead of unpacking the (obj, created) tuple:

- increment_soa_serial(): `soa_serial_obj.value` on a tuple
- _create_soa_rdataset(): `.get_or_create(...).value` on a tuple

Both raised `'tuple' object has no attribute 'value'` at runtime — the
first on every zone save, the second on every catalog-zone (catz) query.
Unpack the tuple in both places.
2026-07-15 22:44:53 +03:00
d_ev dc1c223400 Add forced NOTIFY feature for DNS zones
- Introduced `custom_field_force_notify` setting to trigger immediate NOTIFY to a zone's nameservers when its SOA serial number changes and the custom field is set to True.
- Updated README to document the new feature and its configuration.
- Refactored notify handler to include a new job for sending forced NOTIFY messages directly to zone nameservers.
- Enhanced zone signal handling to check for SOA serial changes and trigger forced NOTIFY accordingly.
2026-07-15 22:34:41 +03:00
Sven Luthi 41d48f2807 modified: netbox_dns_bridge/__init__.py
modified:   netbox_dns_bridge/catalog_zone_manager.py
Fix wrong import in previous version and slight change to
meachanism to create catz member identifier.
2026-07-15 17:48:49 +02:00
Sven Luthi e50cfd7cb6 modified: netbox_dns_bridge/__init__.py
Bump version
2026-07-14 17:51:37 +02:00
Sven Luthi 55f6622997 modified: README.md 2026-07-14 14:53:12 +02:00
Sven Luethi fd8a5a0d9d modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Range of bug fixes and improvements
2026-07-13 13:41:29 +02:00
Sven Luethi b72fa9b664 modified: README.md
modified:   netbox_dns_bridge/notify_handler.py
2026-07-11 02:46:17 +02:00
31 changed files with 1484 additions and 462 deletions

No files matched your search

+34
View File
@@ -1,3 +1,37 @@
## Unreleased
- Move `notify_handler` from `signals/` to `jobs/` and rename to `notify`.
- Rename `SendClientDNSNotify` to `SendClientNotify`. It now sends a single NOTIFY
for the zone only; catalog zone entries (`catz`, `{view}.catz`) have been removed.
- Catalog zone NOTIFY is now scheduled by `increment_soa_serial` via
`schedule_catalog_zone_notify`, which enqueues `SendClientNotify` for `catz` and
`{view}.catz` — no separate job class needed.
- Extract a shared `_send_notify` helper used by both `SendClientNotify` and
`SendNSNotify` for message building and the send loop.
- Move all NOTIFY scheduling gates (`notify_clients`, `notify_ns_all_zones`, custom
field) into the scheduling functions. The signal handler calls the scheduling
functions unconditionally; each function decides whether to enqueue based on its
own settings gate.
- Replace the global `IntegerKeyValueSetting` model (single `catalog-zone-soa-serial`
value shared by all catalog zones) with a new `CatalogZone` model that has a 1:1
foreign key to `netbox_dns.models.View` and its own `soa_serial` field. Each view's
catalog zone now tracks and increments its SOA serial independently. Existing serials
are preserved during migration by copying the old global value into every view's new
`CatalogZone` row.
- Change the `CatalogZoneMemberIdentifier.name` uniqueness from globally unique to
unique per catalog zone (`UniqueConstraint(name, catalog_zone)`). A `catalog_zone`
foreign key has been added to the identifier model to enforce this.
- Remove the `dns-settings` management command, which was a generic get/set/list
interface over `IntegerKeyValueSetting` (now removed).
- Defer the catalog zone SOA serial increment on zone deletion to `transaction.on_commit`, matching
the create/modify path. Previously the increment ran immediately at `post_delete` time, so a
rollback of the delete transaction would have left the catalog serial bumped anyway.
- Add the option to send a NOTIFY directly to a zone's own nameservers when its SOA serial number
changes. Enable it per zone via a boolean custom field named by `notify_ns_custom_field_name`,
globally for all zones via `notify_ns_all_zones` (default `False`), or both. Nameserver addresses
are resolved from NetBox DNS A/AAAA records within the zone's view and the NOTIFY is signed with
the per-view TSIG key. A companion `notify_ns_port` setting (default 53) controls the destination
port.
## 1.0.7 - 2026-03-02
README Change - Moving private keys to global scope since Bind 9.20 view scoped keys have become unreliable and sometimes wouldnt match.
+1
View File
@@ -1 +1,2 @@
recursive-include netbox_dns_bridge *.py
recursive-include netbox_dns_bridge/templates *.html
+92 -13
View File
@@ -18,7 +18,8 @@ changes (RFC 1996).
<a href="https://pepy.tech/project/netbox-plugin-dns-bridge"><img src="https://static.pepy.tech/personalized-badge/netbox-plugin-dns-bridge?period=monthly&left_color=BLACK&right_color=BLUE&left_text=Downloads%2fMonth" alt="Downloads/Month"></a>
<a href="https://pepy.tech/project/netbox-plugin-dns-bridge"><img src="https://static.pepy.tech/personalized-badge/netbox-plugin-dns-bridge?period=weekly&left_color=BLACK&right_color=BLUE&left_text=Downloads%2fWeek" alt="Downloads/Week"></a>
## Plugin configuration
# Plugin configuration
While providing Zone transfers via AXFR, the Server also exposes specialized catalog zones that BIND
and other RFC9432 compliant DNS Servers use to automatically discover newly created zones and remove
deleted ones. The plugin supports views and basic DNS security via TSIG.
@@ -39,13 +40,15 @@ This process needs to be scheduled as a background service for the built-in DNS
correctly. For Linux users with Systemd (Ubuntu, etc), Matt Kollross provides a startup unit and
instructions [here](docs/install-systemd-service.md).
### Service parameters
## Service parameters
Parameter | Description
--------- | -------------------------------------------------------------------
--port | Port to listen on for requests (defaults to 5354)
--address | IP of interface to bind to (defaults to 0.0.0.0)
### Plugin settings
## Plugin settings
Plugin settings should be configured under `PLUGINS_CONFIG` in `netbox_dns_bridge`:
```
PLUGINS_CONFIG = {
@@ -56,8 +59,8 @@ PLUGINS_CONFIG = {
}
```
#### TSIG Authentication
Following sets the TSIG key that allows clients to query the transfer endpoint and also they key to
### TSIG Authentication
Following sets the TSIG key that allows clients to query the transfer endpoint and also the key to
be used to sign NOTIFY messages to clients on zone changes. Each view should have its own unique
key to allow the plugin to identify the view the client is trying to access. Re-using the key for
multiple views yields unpredicted behavior.
@@ -71,30 +74,105 @@ multiple views yields unpredicted behavior.
}
```
#### NOTIFY
### NOTIFY
The plugin has 2 different NOTIFY mechanisms that may be turned on individually or together.
1. [Client Notify](#client-notify)
Notify clients that are regularly contacting the transfer endpoint for zone updates.
2. [NS Notify](#ns-notify)
Notify the zone's NS Servers when a zone changes.
- [Per Zone](#ns-notify-per-zone)
Notify NS servers for zones that have a specific custom field set to true.
- [For all Zones](#ns-notify-for-all-zones)
Notify NS servers for all zones.
Note: This makes the *Per Zone* mechanism redundant. There is no point in enabling both.
#### Global NOTIFY Settings
```
'notify_over_tcp': True
```
Switch to using TCP instead of UDP. Defaults to False
#### Client NOTIFY
Clients may be notified of zone changes using the NOTIFY mechanism defined in RFC 1996.
When enabled, the zone transfer endpoint keeps track of any client that successfully queried the
endpoint and when a zone changes. Once a zone has changed, NetBox informs each client of change.
endpoint. Once a zone has changed, NetBox informs each client about the zone changed so that the
client can request a new zone transfer.
Note that this feature uses NetBox's background job system to schedule the messages asynchronously.
In order to work, you need at least one `rq-worker` service running in the background to handle
the queried jobs.
the queued jobs.
To enable the NOTIFY feature, set following:
```
'notify_clients': True
```
##### NOTIFY Settings
This enables the NOTIFY system. Defaults to False
```
notify_client_alive_threshold_hours: 24
'notify_client_alive_threshold_hours': 24
```
This sets how long a client is considred "alive" after it last queried the transfer endpoint.
Once the client has failed to check in for this amout of time, it is automatically removed from the
list of clients to be notified on zone changes. Default is 24 hours.
```
notify_over_tcp: True
'notify_client_port': 53
```
Switch to using TCP instead of UDP. Default: False
Destination port used to send NOTIFY messages to clients. Defaults to 53.
#### NS NOTIFY
In addition to notifying clients that queried the transfer endpoint, the plugin can send a NOTIFY
directly to a zone's own nameservers whenever that zone's SOA serial number changes. This is useful
when you want nameservers to pull updates immediately, independent of the `notify_clients` mechanism.
Only a change of the SOA serial triggers this — other zone edits that leave the serial untouched do
not.
This can be enabled per zone, globally, or both. If neither of the settings below is configured, the
feature is disabled entirely.
##### NS NOTIFY per Zone
This uses a True/False custom field attached to each Zone to enable/disable NOTIFY for each
individually.
Create a boolean custom field (e.g. `dns_bridge_notify_ns`) that applies
to `netbox_dns > zone`, then point the plugin at it:
```
'notify_ns_custom_field_name': 'dns_bridge_notify_ns'
```
A NOTIFY is then sent for any zone whose custom field is set to `True`. This lets you toggle the
behavior per zone straight from the NetBox UI, without changing the plugin config.
##### NS NOTIFY for all Zones
```
'notify_ns_all_zones': True
```
When enabled, a NOTIFY is sent for every zone on serial change, regardless of the custom field.
Defaults to `False`. The two options coexist: a NOTIFY is sent if the global switch is on **or** the
zone's custom field is set.
##### NS NOTIFY Settings
The nameserver addresses are resolved from NetBox DNS itself by looking up active A/AAAA records
(within the zone's view) matching each nameserver's hostname. The NOTIFY messages are signed with the
same per-view TSIG key configured under `tsig_keys`, so the receiving nameservers must accept NOTIFY
signed with that key (e.g. BIND's `allow-notify { key "..."; };`).
```
'notify_ns_port': 53
```
Destination port used for these NOTIFY messages. Defaults to 53.
As with the client NOTIFY feature, this uses NetBox's background job system, so at least one
`rq-worker` service must be running.
## Plugin compatibility
This plugin extends the netbox-plugin-dns plugin. As such the versioning was changed to match the
@@ -102,6 +180,7 @@ one of netbox-plugin-dns more closely. To guarantee compatability, ensure that t
version match between both plugins. For example, when using netbox-plugin-dns `v1.5.5` install
netbox-plugin-dns-bridge `v1.5.x`.
## Installation guide
This setup provisions a BIND9 server directly with DNS data from NetBox. BIND9 can optionally run on
a separate server. If so, any reference to 127.0.0.1 in step 6 must be replaced with the IP address
+4 -6
View File
@@ -1,11 +1,7 @@
from netbox.plugins import PluginConfig
from django.conf import settings
from .logger import get_logger
__version__ = "1.5.4"
LOGGER = get_logger(__name__)
__version__ = "1.5.9-beta"
class DNSBridgeConfig(PluginConfig):
name = "netbox_dns_bridge"
@@ -17,10 +13,12 @@ class DNSBridgeConfig(PluginConfig):
base_url = "dns-bridge"
def ready(self):
super().ready()
self.settings = settings.PLUGINS_CONFIG.get(self.name, None)
if not self.settings:
raise RuntimeError(
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."
)
from . import signals
+59
View File
@@ -0,0 +1,59 @@
import django_filters
from django.db.models import Q
import netbox_dns.models
from netbox.filtersets import NetBoxModelFilterSet
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter(
field_name="view",
queryset=netbox_dns.models.View.objects.all(),
)
class Meta:
model = CatalogZone
fields = ("id", "view")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(Q(view__name__icontains=value))
class CatalogZoneMemberFilterSet(NetBoxModelFilterSet):
catalog_zone = django_filters.ModelMultipleChoiceFilter(
field_name="catalog_zone",
queryset=CatalogZone.objects.all(),
)
class Meta:
model = CatalogZoneMember
fields = ("id", "catalog_zone", "zone")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) | Q(zone__name__icontains=value)
)
class SeenTransferClientFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter(
field_name="view",
queryset=netbox_dns.models.View.objects.all(),
)
class Meta:
model = SeenTransferClient
fields = ("id", "view", "source_ip")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(source_ip__icontains=value) | Q(view__name__icontains=value)
)
+64
View File
@@ -0,0 +1,64 @@
from django import forms
from django.utils.translation import gettext_lazy as _
import netbox_dns.models
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
from utilities.forms.fields import DynamicModelMultipleChoiceField
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneForm(NetBoxModelForm):
soa_serial = forms.IntegerField(label=_("SOA Serial"))
soa_refresh = forms.IntegerField(label=_("Refresh"))
soa_retry = forms.IntegerField(label=_("Retry"))
soa_expire = forms.IntegerField(label=_("Expire"))
soa_minimum = forms.IntegerField(label=_("Minimum"))
view = forms.ModelChoiceField(
queryset=netbox_dns.models.View.objects.all(),
disabled=True,
required=False,
label=_("View"),
)
class Meta:
model = CatalogZone
fields = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
class CatalogZoneFilterForm(NetBoxModelFilterSetForm):
model = CatalogZone
q = forms.CharField(required=False, label=_("Search"))
view = DynamicModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(),
required=False,
)
class CatalogZoneMemberFilterForm(NetBoxModelFilterSetForm):
model = CatalogZoneMember
q = forms.CharField(required=False, label=_("Search"))
catalog_zone = forms.ModelMultipleChoiceField(
queryset=CatalogZone.objects.all(),
required=False,
)
class SeenTransferClientFilterForm(NetBoxModelFilterSetForm):
model = SeenTransferClient
q = forms.CharField(required=False, label=_("Search"))
view = forms.ModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(),
required=False,
)
View File
Whitespace-only changes.
+318
View File
@@ -0,0 +1,318 @@
import dns.message
import dns.name
import dns.opcode
import dns.query
import dns.rdatatype
import dns.rdataclass
import dns.rrset
import dns.tsig
from django.conf import settings
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record
from netbox_dns_bridge.utils import get_logger
from netbox_dns_bridge.models import SeenTransferClient, CatalogZone
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def _load_tsig_key(view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(f"Incomplete TSIG key configuration for view '{view_name}'")
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
def _send_notify(
zone_name,
soa_serial,
targets,
tsig_key,
view_name,
notify_over_tcp,
port,
log_prefix="NOTIFY",
soa_refresh=60,
soa_retry=10,
soa_expire=1209600,
soa_minimum=0,
):
"""Build a DNS NOTIFY message for *zone_name* and send it to every target IP."""
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} {soa_refresh} {soa_retry} {soa_expire} {soa_minimum}",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for target in targets:
try:
if notify_over_tcp:
LOGGER.debug(f"Sending {log_prefix} over TCP")
dns.query.tcp(msg, target, port=port, timeout=2)
else:
LOGGER.debug(f"Sending {log_prefix} over UDP")
dns.query.udp(msg, target, port=port, timeout=2)
LOGGER.info(
f"{log_prefix} sent: {target} {view_name}/{fqdn} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"{log_prefix} failed: {target}:{port}"
f" {view_name}/{fqdn} {soa_serial}: {e}"
)
class SendClientNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
soa_refresh = kwargs.get("soa_refresh", 60)
soa_retry = kwargs.get("soa_retry", 10)
soa_expire = kwargs.get("soa_expire", 1209600)
soa_minimum = kwargs.get("soa_minimum", 0)
try:
view = View.objects.get(name=view_name)
tsig_key = _load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = list(
SeenTransferClient.objects.filter(
view=view, last_seen__gte=seen_cutoff
).values_list("source_ip", flat=True)
)
_send_notify(
zone_name, soa_serial, clients, tsig_key,
view_name, notify_over_tcp, port, log_prefix="NOTIFY",
soa_refresh=soa_refresh, soa_retry=soa_retry,
soa_expire=soa_expire, soa_minimum=soa_minimum,
)
except View.DoesNotExist as e:
LOGGER.error(f"Failed to get view for NOTIFY: {e}")
except OperationalError as e:
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
close_old_connections()
class SendNSNotify(JobRunner):
"""Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendClientNotify`` (which notifies clients that previously queried
the transfer endpoint), this job resolves the addresses of the zone's
configured nameservers from NetBox DNS itself and notifies each of them so
they pick up the changed zone quickly.
"""
class Meta:
name = "Send DNS NOTIFY to zone nameservers"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_ns_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
try:
zone = Zone.objects.get(pk=kwargs["zone_id"])
except Zone.DoesNotExist:
LOGGER.error(f"NS NOTIFY: zone id {kwargs['zone_id']} no longer exists")
return
except OperationalError as e:
LOGGER.error(f"NS NOTIFY failed due to unexpected error: {e}")
close_old_connections()
return
ns_targets = _resolve_zone_nameserver_ips(zone)
if not ns_targets:
LOGGER.warning(
f"NS NOTIFY: no nameserver addresses found in NetBox DNS for "
f"{view_name}/{zone_name} — skipping"
)
return
try:
tsig_key = _load_tsig_key(view_name)
except RuntimeError as e:
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
return
_send_notify(
zone_name, soa_serial, ns_targets, tsig_key,
view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
)
def _get_soa_serial(zone: Zone):
"""Return the current SOA serial of a zone parsed from its SOA record."""
try:
soa_record = Record.objects.filter(zone=zone, type="SOA", active=True).first()
if soa_record is None:
LOGGER.error(f"Zone {zone.name} has no active SOA record — skipping NOTIFY")
return None
return int(soa_record.value.split()[2])
except OperationalError as e:
LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}")
close_old_connections()
return None
except (ValueError, IndexError) as e:
LOGGER.error(f"Failed to parse SOA serial for zone {zone.name}: {e}")
return None
def _resolve_zone_nameserver_ips(zone: Zone) -> list:
"""Resolve the zone's nameserver hostnames to IPs using NetBox DNS records.
For each nameserver assigned to the zone, matching active A/AAAA records
within the same view are looked up in NetBox DNS. Returns a de-duplicated,
order-preserving list of IP addresses.
"""
ips = []
try:
nameservers = list(zone.nameservers.all())
view = zone.view
for nameserver in nameservers:
fqdn = dns.name.from_text(nameserver.name, dns.name.root).to_text()
# Match both with and without a trailing dot to be resilient against
# how the fqdn is stored on the Record model.
candidates = {fqdn, fqdn.rstrip(".")}
records = Record.objects.filter(
fqdn__in=list(candidates),
type__in=["A", "AAAA"],
active=True,
zone__view=view,
)
for record in records:
if record.value not in ips:
ips.append(record.value)
except OperationalError as e:
LOGGER.error(f"DB ERROR while resolving nameservers for {zone.name}: {e}")
close_old_connections()
return ips
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
def schedule_client_notify(zone: Zone):
if not SETTINGS.get("notify_clients", False):
return
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendClientNotify.enqueue(
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
def schedule_ns_notify(zone: Zone):
if not _notify_ns_enabled(zone):
return
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendNSNotify.enqueue(
zone_id=zone.pk,
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
def schedule_catalog_zone_notify(view: View):
if not SETTINGS.get("notify_clients", False):
return
try:
catalog_zone = view.catalog_zone
except CatalogZone.DoesNotExist:
return
serial = catalog_zone.soa_serial
soa_refresh = catalog_zone.soa_refresh
soa_retry = catalog_zone.soa_retry
soa_expire = catalog_zone.soa_expire
soa_minimum = catalog_zone.soa_minimum
SendClientNotify.enqueue(
zone_name="catz",
soa_serial=serial,
view_name=view.name,
soa_refresh=soa_refresh,
soa_retry=soa_retry,
soa_expire=soa_expire,
soa_minimum=soa_minimum,
)
SendClientNotify.enqueue(
zone_name=f"{view.name}.catz",
soa_serial=serial,
view_name=view.name,
soa_refresh=soa_refresh,
soa_retry=soa_retry,
soa_expire=soa_expire,
soa_minimum=soa_minimum,
)
-12
View File
@@ -1,12 +0,0 @@
import logging
PLUGIN_NAME = "dns-bridge"
def get_logger(name: str) -> logging.Logger:
# name = name.replace("_","-").rsplit(".", 1)[-1]
#
# if name.startswith(PLUGIN_NAME):
# return logging.getLogger(name)
# return logging.getLogger(f"{PLUGIN_NAME}.{name}")
return logging.getLogger(f"{PLUGIN_NAME}")
@@ -37,13 +37,10 @@ class Command(BaseCommand):
view__name=view_name, name=zone_name
)
if nb_zone:
export_bind_zone_file(nb_zone, file_path=file_path)
self.stdout.write(
self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'")
)
else:
self.stderr.write("Zone not found in Netbox. Aborting")
except Exception as e:
raise CommandError(f"Failed to export zone: {e}")
@@ -1,51 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from netbox_dns_bridge.models import IntegerKeyValueSetting
class Command(BaseCommand):
help = "Get or set integer key-value settings"
def add_arguments(self, parser):
parser.add_argument(
"action", choices=["get", "set", "list"], help="Action to perform"
)
parser.add_argument("key", nargs="?", help="Setting key")
parser.add_argument(
"value", nargs="?", type=int, help="Value to set (required for 'set')"
)
def handle(self, *args, **options):
action = options["action"]
key = options.get("key")
value = options.get("value")
if action == "list":
settings = IntegerKeyValueSetting.objects.all()
if not settings.exists():
self.stdout.write("No settings found.")
return
for setting in settings:
self.stdout.write(str(setting))
elif action == "get":
if not key:
raise CommandError("A key is required for GET")
try:
setting = IntegerKeyValueSetting.objects.get(key=key)
self.stdout.write(f"{setting.key}: {setting.value}")
except IntegerKeyValueSetting.DoesNotExist:
raise CommandError(f"No setting was found for {key}")
elif action == "set":
if not key:
raise CommandError("A setting is required for SET command")
if value is None:
raise CommandError("A value is required for SET command")
try:
setting = IntegerKeyValueSetting.objects.get(key=key)
old_value = setting.value
setting.value = value
setting.save()
self.stdout.write(f"Updated {key} from {old_value} to {value}")
except IntegerKeyValueSetting.DoesNotExist:
raise CommandError(f"No setting found for {key}")
@@ -1,4 +1,5 @@
import threading
import sys
import dns.query
import dns.message
import dns.tsigkeyring
@@ -12,12 +13,11 @@ import dns.renderer
from django.core.management.base import BaseCommand
from django.conf import settings
from django.db import close_old_connections, OperationalError
from django.db import OperationalError
from netbox_dns.models import View
from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler
from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer
from netbox_dns_bridge.logger import get_logger
from .dns_transfer_endpoint.request_handler import UDPRequestHandler, TCPRequestHandler
from .dns_transfer_endpoint.dns_server import UDPDNSServer, TCPDNSServer
from netbox_dns_bridge.utils import get_logger
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@@ -31,34 +31,42 @@ class Command(BaseCommand):
self.tsig_view_map = {}
for view_name, data in self.tsig_keys.items():
raw_key_name = data.get("keyname")
key_name = data.get("keyname")
secret = data.get("secret")
algorithm_str = data.get("algorithm", "hmac-sha256")
algorithm = data.get("algorithm", "hmac-sha256")
if not raw_key_name or not secret:
if not key_name:
LOGGER.error(
f"Skipping TSIG key for view {view_name}: missing keyname or secret."
f"TSIG key for view {view_name} not found. Cannot start transfer endpoint."
)
continue
sys.exit(1)
elif not secret:
LOGGER.error(
f"TSIG secret for key {key_name} not found. Cannot start transfer endpoint."
)
sys.exit(1)
try:
nb_view = View.objects.get(name=view_name)
except View.DoesNotExist:
LOGGER.error(
f"Skipping TSIG key {raw_key_name}: view '{view_name}' not found."
f"Skipping TSIG key {key_name}: View '{view_name}' not found in database."
)
continue
except OperationalError:
close_old_connections()
except OperationalError as e:
LOGGER.error(
f"There was an error retrieving view model from database: {e}"
)
sys.exit(1)
# Normalize key name to absolute DNS name
key_name_obj = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name_obj.is_absolute():
key_name_obj = key_name_obj.concatenate(dns.name.root)
key_name_obj = dns.name.from_text(
key_name, origin=dns.name.root
).canonicalize()
key_name_str = key_name_obj.to_text() # Will always include trailing do
self.keyring[key_name_obj] = dns.tsig.Key(
name=key_name_obj, secret=secret, algorithm=algorithm_str
name=key_name_obj, secret=secret, algorithm=algorithm
)
self.tsig_view_map[key_name_str] = nb_view
LOGGER.info(f"Loaded TSIG key {key_name_str} for view {nb_view.name}")
@@ -80,7 +88,6 @@ class Command(BaseCommand):
# Load parameters
port = options["port"]
address = options["address"]
catzm.init()
self.tsig_keys = SETTINGS.get("tsig_keys", None)
if not self.tsig_keys:
Whitespace-only changes.
@@ -2,45 +2,53 @@ import dns.name
import dns.zone
import dns.rdatatype
import dns.rdataclass
from .logger import get_logger
from netbox_dns.models import Zone
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
from netbox_dns_bridge.utils import get_logger
from netbox_dns.models import Zone as NBZone, View as NBView
from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember
from netbox_dns_bridge.jobs import notify
from uuid import uuid4
from base64 import b32encode
from django.db import transaction, close_old_connections, OperationalError
LOGGER = get_logger(__name__)
def init() -> None:
_init_soa_serial()
_create_missing_member_identifiers()
def increment_soa_serial() -> int:
def increment_soa_serial(view: NBView) -> int:
with transaction.atomic():
try:
soa_serial = IntegerKeyValueSetting.objects.select_for_update().get(
key="catalog-zone-soa-serial"
(
soa_serial_obj,
_,
) = CatalogZone.objects.select_for_update().get_or_create(
view=view,
defaults={"soa_serial": 1},
)
new_soa_serial = soa_serial.value + 1
new_soa_serial = soa_serial_obj.soa_serial + 1
if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning(
f"Catalog serial {soa_serial.value} reached max — wrapping back to 1"
f"Catalog serial {soa_serial_obj.soa_serial} reached max — wrapping back to 1"
)
new_soa_serial = 1
soa_serial.value = new_soa_serial
soa_serial.save()
soa_serial.refresh_from_db()
LOGGER.debug(f"Catalog zone SOA serial number is now {soa_serial.value}")
soa_serial_obj.soa_serial = new_soa_serial
soa_serial_obj.save()
soa_serial_obj.refresh_from_db()
LOGGER.debug(
f"Catalog zone SOA serial for view '{view.name}' "
f"is now {soa_serial_obj.soa_serial}"
)
return soa_serial.value
serial = soa_serial_obj.soa_serial
except OperationalError as e:
LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
)
close_old_connections()
return None
notify.schedule_catalog_zone_notify(view)
return serial
def create_zone(name, view_name) -> dns.zone.Zone:
@@ -53,9 +61,9 @@ def create_zone(name, view_name) -> dns.zone.Zone:
try:
# get zones from netbox
nb_zones = Zone.objects.filter(
nb_zones = NBZone.objects.filter(
view__name=view_name, active=True
).select_related("catz_identifier")
).select_related("catalog_zone_member")
ptr_base = dns.name.from_text("zones", origin)
@@ -64,12 +72,22 @@ def create_zone(name, view_name) -> dns.zone.Zone:
qname = dns.name.from_text(nb_zone.name, dns.name.root)
# Create PTR record
p_name = nb_zone.catz_identifier.name
try:
member = nb_zone.catalog_zone_member
except NBZone.catalog_zone_member.RelatedObjectDoesNotExist:
catalog_zone = _get_or_create_catalog_zone(nb_zone.view)
member = CatalogZoneMember.objects.create(
zone=nb_zone,
name=_generate_member_name(),
catalog_zone=catalog_zone,
)
p_name = member.name
ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin):
raise ValueError(
f"Catalog zone member identifier {ptr_name.to_text()} not a subdomain"
f"Catalog zone member {ptr_name.to_text()} not a subdomain"
)
rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
@@ -91,7 +109,8 @@ def create_zone(name, view_name) -> dns.zone.Zone:
zone_root_node = zone.find_node(origin, create=True)
# SOA record
soa_rdataset = _create_soa_rdataset()
view = NBView.objects.get(name=view_name)
soa_rdataset = _create_soa_rdataset(view)
zone_root_node.rdatasets.append(soa_rdataset)
# NS record
@@ -113,69 +132,38 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return None
def _init_soa_serial() -> None:
try:
IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
LOGGER.info("Catalog zone SOA serial number loaded from database")
except IntegerKeyValueSetting.DoesNotExist:
IntegerKeyValueSetting.objects.create(key="catalog-zone-soa-serial", value=1)
LOGGER.debug(
"Catalog zone SOA serial number was not set in the database. Set to 1"
)
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to initialize the Catalog Zone's SOA serial: {e}")
close_old_connections()
def _generate_member_identifier() -> str:
def _generate_member_name() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: Zone) -> None:
def _get_or_create_catalog_zone(view: NBView) -> CatalogZone:
catalog_zone, _ = CatalogZone.objects.get_or_create(
view=view, defaults={"soa_serial": 1}
)
return catalog_zone
def update_member(zone: NBZone) -> None:
try:
CatalogZoneMemberIdentifier.objects.update_or_create(
catalog_zone = _get_or_create_catalog_zone(zone.view)
CatalogZoneMember.objects.update_or_create(
zone=zone,
defaults={"name": _generate_member_identifier()},
defaults={
"name": _generate_member_name(),
"catalog_zone": catalog_zone,
},
)
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}")
LOGGER.error(f"ERROR: Failed to update Catalog Zone member: {e}")
close_old_connections()
def _create_missing_member_identifiers() -> None:
def _create_soa_rdataset(view: NBView) -> dns.rdataset:
try:
existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list(
"zone_id", flat=True
serial_obj, _ = CatalogZone.objects.get_or_create(
view=view, defaults={"soa_serial": 1}
)
missing_zones = Zone.objects.exclude(id__in=existing_zone_ids)
new_objects = [
CatalogZoneMemberIdentifier(
zone=zone,
name=_generate_member_identifier(),
)
for zone in missing_zones
]
for identifier in new_objects:
LOGGER.debug(
f"Zone {identifier.zone} missing catz member identifier. Creating..."
)
CatalogZoneMemberIdentifier.objects.bulk_create(
new_objects,
ignore_conflicts=False,
)
except OperationalError as e:
LOGGER.error(f"ERROR: Could not create catz member identifier: {e}")
close_old_connections()
def _create_soa_rdataset() -> dns.rdataset:
try:
serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value
serial = serial_obj.soa_serial
# SOA Record components
ttl = 0
@@ -186,7 +174,9 @@ def _create_soa_rdataset() -> dns.rdataset:
rdata = dns.rdata.from_text(
rclass,
rtype,
f"invalid. invalid. {serial} 60 10 1209600 0",
f"invalid. invalid. {serial} {serial_obj.soa_refresh} "
f"{serial_obj.soa_retry} {serial_obj.soa_expire} "
f"{serial_obj.soa_minimum}",
)
# Create Rdataset and add the RDATA to it
@@ -1,6 +1,6 @@
import socketserver
import socket
from .logger import get_logger
from netbox_dns_bridge.utils import get_logger
LOGGER = get_logger(__name__)
@@ -10,26 +10,28 @@ import dns.rdataclass
import dns.rdtypes
import dns.exception
import dns.renderer
from .logger import get_logger
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from django.conf import settings
from netbox_dns.models import Zone, Record, View
from netbox_dns_bridge import catalog_zone_manager as catzm
from .models import SeenTransferClients
from netbox_dns.models import Zone, View
from . import catalog_zone_manager as catzm
from netbox_dns_bridge.models import SeenTransferClient
from netbox_dns_bridge.utils import build_dns_zone, get_logger
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
transport = None
def __init__(self, request, client_address, server) -> None:
self.MAX_WIRE = 65535
self.RESERVED_TSIG = 300
super().__init__(request, client_address, server)
# _getZoneFromNB rewritten
def _getZoneFromNB(self, zone_name, view_name) -> dns.zone.Zone:
# _get_zone_from_nb rewritten
def _get_zone_from_nb(self, zone_name, view_name) -> dns.zone.Zone:
# Find the zone
try:
nb_zone = Zone.objects.get(
@@ -37,83 +39,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
view__name=view_name,
active=True,
)
# Build DNS zone
zone = dns.zone.Zone(zone_name, dns.name.root)
zone.rdclass = dns.rdataclass.IN
nb_records = Record.objects.filter(zone=nb_zone, active=True)
rdatasets_dict = {}
for record in nb_records:
rdtype = dns.rdatatype.from_text(record.type)
if not record.name:
name = zone.origin
elif record.name.endswith("."):
name = dns.name.from_text(record.name)
else:
name = dns.name.from_text(record.name, origin=zone.origin)
# If the record has no TTL, use the zone default
ttl = record.ttl or nb_zone.default_ttl
# Apply quoting for TXT records to stop tokanizer
# from cutting it up:
value = record.value
if rdtype == dns.rdatatype.TXT:
if 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(
dns.rdataclass.IN,
rdtype,
value,
relativize=False,
origin=zone.origin,
)
# Initialize rdataset if it doesn't exist for this name and type
if name not in rdatasets_dict:
rdatasets_dict[name] = {}
if rdtype not in rdatasets_dict[name]:
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(
dns.rdataclass.IN, rdtype
)
# Add the rdata to the appropriate rdataset
rdatasets_dict[name][rdtype].add(rdata, ttl)
# Now, add all rdatasets to the zone
for name, rdtypes in rdatasets_dict.items():
for rdtype, rdataset in rdtypes.items():
# Ensure rdataset has the same rdclass as the zone
if rdataset.rdclass != zone.rdclass:
raise ValueError(
f"rdataset rdclass {rdataset.rdclass} does not match "
f"zone rdclass {zone.rdclass}"
)
# Check if the rdataset has any rdata before creating an RRset
if not rdataset:
LOGGER.debug(f"Skipping empty rdataset for {name} {rdtype}")
continue # Skip empty rdataset
# Replace the rdataset for the given name and type
zone.replace_rdataset(name, rdataset)
return zone
return build_dns_zone(nb_zone)
except Zone.DoesNotExist:
return None
except OperationalError as e:
@@ -151,7 +77,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
def _track_seen_client(self, source_ip: str, view: View) -> None:
if SETTINGS.get("notify_clients", False):
try:
SeenTransferClients.objects.update_or_create(
SeenTransferClient.objects.update_or_create(
source_ip=source_ip,
view=view,
defaults={"last_seen": timezone.now()},
@@ -215,6 +141,10 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
else:
rrsets.append(rrset)
if soa_rrset is None:
LOGGER.error(f"Zone {dname} has no SOA — aborting")
return
rrsets.insert(0, soa_rrset) # Opening SOA
rrsets.append(soa_rrset) # Closing SOA
@@ -315,7 +245,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
return
except Exception as e:
LOGGER.error("Error parsing query: ", e)
LOGGER.error(f"Error parsing query: {e}")
return
# If there was no question in the query, refuse
@@ -336,6 +266,11 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
self._deny_request(query)
return
if qtype == dns.rdatatype.AXFR and self.transport != "tcp":
LOGGER.warning(f"Request denied from {peer}: AXFR requires TCP")
self._deny_request(query, dns.rcode.REFUSED)
return
# Identify TSIG key used
if not query.had_tsig:
LOGGER.warning(f"Request denied from {peer}: No TSIG key used")
@@ -354,10 +289,11 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
return
# Check if catalog zone
if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = catzm.create_zone(dname, nb_view.name)
dname_lower = dname.lower()
if dname_lower == "catz" or dname_lower == f"{nb_view.name.lower()}.catz":
zone = catzm.create_zone(dname_lower, nb_view.name)
else:
zone = self._getZoneFromNB(dname, nb_view.name)
zone = self._get_zone_from_nb(dname, nb_view.name)
# When zone was not found, let client know
if not zone:
LOGGER.warning(f"Zone {dname} not found in view {nb_view.name}")
@@ -379,6 +315,8 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
class UDPRequestHandler(DNSBaseRequestHandler):
transport = "udp"
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
@@ -399,6 +337,8 @@ class UDPRequestHandler(DNSBaseRequestHandler):
class TCPRequestHandler(DNSBaseRequestHandler):
transport = "tcp"
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
@@ -0,0 +1,233 @@
import taggit.managers
import utilities.json
import django.db.models.deletion
from django.db import migrations, models
def copy_serials_and_link_members(apps, schema_editor):
"""Seed one CatalogZone per view from the old global serial and link members."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Preserve the current global serial; default to 1 when never initialized.
seed_serial = 1
kv = IntegerKeyValueSetting.objects.filter(key="catalog-zone-soa-serial").first()
if kv is not None:
seed_serial = kv.value
# Create a CatalogZone for every view that currently has at least one
# member, seeding each with the old global serial.
view_ids = set(
CatalogZoneMember.objects.exclude(
zone__view__isnull=True
).values_list("zone__view_id", flat=True)
)
view_id_to_catalog_zone = {}
for view_id in view_ids:
cz, _ = CatalogZone.objects.get_or_create(
view_id=view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[view_id] = cz
# Link each member to the CatalogZone matching its zone's view.
for member in CatalogZoneMember.objects.select_related("zone__view"):
cz = view_id_to_catalog_zone.get(member.zone.view_id)
if cz is None:
# Defensive: member existed without a resolvable view. Create
# the CatalogZone on demand so the NOT NULL constraint can be set.
cz, _ = CatalogZone.objects.get_or_create(
view_id=member.zone.view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[member.zone.view_id] = cz
member.catalog_zone = cz
member.save(update_fields=["catalog_zone"])
def reverse_migration(apps, schema_editor):
"""Best-effort reverse: restore the KV row and null the FK."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Pick a representative serial (first catalog zone) to write back.
first = CatalogZone.objects.order_by("pk").first()
serial = first.soa_serial if first is not None else 1
IntegerKeyValueSetting.objects.update_or_create(
key="catalog-zone-soa-serial", defaults={"value": serial}
)
CatalogZoneMember.objects.update(catalog_zone=None)
class Migration(migrations.Migration):
dependencies = [
("extras", "0140_imageattachment_image_size"),
(
"netbox_dns",
"0032_record_expiration_date",
),
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
# --- CatalogZone (full NetBoxModel, born with all fields) ---
migrations.CreateModel(
name="CatalogZone",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("soa_serial", models.IntegerField(default=1)),
("soa_refresh", models.IntegerField(default=60)),
("soa_retry", models.IntegerField(default=10)),
("soa_expire", models.IntegerField(default=1209600)),
("soa_minimum", models.IntegerField(default=0)),
("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,
),
),
(
"tags",
taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
(
"view",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone",
to="netbox_dns.view",
),
),
],
options={
"ordering": ("view__name",),
},
),
# --- CatalogZoneMemberIdentifier → CatalogZoneMember (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="CatalogZoneMemberIdentifier",
new_name="CatalogZoneMember",
),
migrations.AddField(
model_name="catalogzonemember",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="catalogzonemember",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
migrations.AlterField(
model_name="catalogzonemember",
name="name",
field=models.CharField(max_length=26),
),
migrations.AlterField(
model_name="catalogzonemember",
name="zone",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone_member",
to="netbox_dns.zone",
),
),
migrations.AddField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.RunPython(
copy_serials_and_link_members,
reverse_code=reverse_migration,
),
migrations.AlterField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.AddConstraint(
model_name="catalogzonemember",
constraint=models.UniqueConstraint(
fields=("name", "catalog_zone"),
name="unique_name_per_catalog_zone",
),
),
migrations.DeleteModel(
name="IntegerKeyValueSetting",
),
# --- SeenTransferClients → SeenTransferClient (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="SeenTransferClients",
new_name="SeenTransferClient",
),
migrations.AddField(
model_name="seentransferclient",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="seentransferclient",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
]
+36 -13
View File
@@ -2,42 +2,63 @@ from django.db import models
# import netbox.models
import netbox_dns.models
from netbox.models import NetBoxModel
class IntegerKeyValueSetting(models.Model):
_netbox_private = True
class CatalogZone(NetBoxModel):
view = models.OneToOneField(
to=netbox_dns.models.View,
on_delete=models.CASCADE,
related_name="catalog_zone",
)
soa_serial = models.IntegerField(default=1)
soa_refresh = models.IntegerField(default=60)
soa_retry = models.IntegerField(default=10)
soa_expire = models.IntegerField(default=1209600)
soa_minimum = models.IntegerField(default=0)
key = models.CharField(max_length=64)
value = models.IntegerField()
class Meta:
ordering = ("view__name",)
verbose_name = "Catalog Zone"
verbose_name_plural = "Catalog Zones"
def __str__(self):
return f"{self.key}: {str(self.value)}"
return f"{self.view.name}: {self.soa_serial}"
class CatalogZoneMemberIdentifier(models.Model):
_netbox_private = True
class CatalogZoneMember(NetBoxModel):
name = models.CharField(
max_length=26,
unique=True,
)
zone = models.OneToOneField(
to=netbox_dns.models.Zone,
on_delete=models.CASCADE,
related_name="catz_identifier",
related_name="catalog_zone_member",
)
catalog_zone = models.ForeignKey(
to=CatalogZone,
on_delete=models.CASCADE,
related_name="members",
)
class Meta:
ordering = ("name",)
verbose_name = "Catalog Zone Member"
verbose_name_plural = "Catalog Zone Members"
constraints = [
models.UniqueConstraint(
fields=["name", "catalog_zone"],
name="unique_name_per_catalog_zone",
),
]
def __str__(self):
return self.name
class SeenTransferClients(models.Model):
_netbox_private = True
class SeenTransferClient(NetBoxModel):
source_ip = models.GenericIPAddressField()
last_seen = models.DateTimeField()
view = models.ForeignKey(
@@ -51,6 +72,8 @@ class SeenTransferClients(models.Model):
class Meta:
ordering = ("source_ip",)
verbose_name = "Seen Transfer Client"
verbose_name_plural = "Seen Transfer Clients"
constraints = [
models.UniqueConstraint(
fields=["source_ip", "view"],
+35
View File
@@ -0,0 +1,35 @@
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuItem
catalog_zones_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:catalogzone_list",
link_text=_("Catalog Zones"),
permissions=["netbox_dns_bridge.view_catalogzone"],
)
catalog_zone_members_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:catalogzonemember_list",
link_text=_("Catalog Zone Members"),
permissions=["netbox_dns_bridge.view_catalogzonemember"],
)
seen_transfer_clients_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:seentransferclient_list",
link_text=_("Clients"),
permissions=["netbox_dns_bridge.view_seentransferclient"],
)
menu = PluginMenu(
label=_("DNS Bridge"),
groups=(
(
_("Catalog Zone"),
(catalog_zones_menu_item, catalog_zone_members_menu_item),
),
(
_("Notify"),
(seen_transfer_clients_menu_item,),
),
),
icon_class="mdi mdi-bridge",
)
-142
View File
@@ -1,142 +0,0 @@
import dns.message
import dns.name
import dns.opcode
import dns.query
import dns.rdatatype
import dns.rdataclass
import dns.rrset
import dns.tsig
from django.conf import settings
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record
from .logger import get_logger
from .models import SeenTransferClients, IntegerKeyValueSetting
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
class SendDNSNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def _load_tsig_key(self, view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(
f"Incomplete TSIG key configuration for view '{view_name}'"
)
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
view_name = kwargs["view_name"]
try:
catz_serial = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial").value
zones = [
(kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial),
(f"{view_name}.catz", catz_serial)
]
view = View.objects.get(name=view_name)
tsig_key = self._load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = SeenTransferClients.objects.filter(
view=view, last_seen__gte=seen_cutoff
)
for zone_name, soa_serial in zones:
zone_name = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(zone_name, dns.rdatatype.SOA)
msg.flags = 0
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(
zone_name, 0, soa_rdata
)
msg.authority.append(soa_rrset)
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for client in clients:
try:
if notify_over_tcp:
LOGGER.debug("Sending NOTIFY over TCP")
dns.query.tcp(msg, client.source_ip, port=5355, timeout=2)
else:
LOGGER.debug("Sending NOTIFY over UDP")
dns.query.udp(msg, client.source_ip, port=5355, timeout=2)
LOGGER.info(
f"NOTIFY sent: {client.source_ip} {view_name}/{zone_name} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"NOTIFY failed: {client.source_ip}"
f" {view_name}/{zone_name} {soa_serial}: {e}"
)
except IntegerKeyValueSetting.DoesNotExist as e:
LOGGER.error(f"Failed to get catalog zone serial: {e}")
except OperationalError as e:
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
close_old_connections()
def schedule(zone: Zone):
try:
soa_record = Record.objects.filter(
zone=zone, type="SOA", active=True
).first()
if soa_record is None:
LOGGER.error(
f"Zone {zone.name} has no active SOA record — skipping NOTIFY"
)
return
soa_serial = int(soa_record.value.split()[2])
except OperationalError as e:
LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}")
close_old_connections()
return
except (ValueError, IndexError) as e:
LOGGER.error(
f"Failed to parse SOA serial for zone {zone.name}: {e}"
)
return
SendDNSNotify.enqueue(
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
+56 -18
View File
@@ -1,13 +1,10 @@
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.db import transaction
from django.conf import settings
from netbox.context import current_request
from netbox_dns.models import Zone
from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge import notify_handler
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
from netbox_dns.models import Zone, View
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm
from netbox_dns_bridge.jobs import notify
@receiver(pre_save, sender=Zone)
@@ -15,11 +12,18 @@ def zone_pre_save(sender, instance, **kwargs):
zone = instance
if zone.pk:
try:
zone._old_name = sender.objects.only("name").get(pk=zone.pk).name
old = sender.objects.only("name", "soa_serial", "view_id").get(pk=zone.pk)
zone._old_name = old.name
zone._old_soa_serial = old.soa_serial
zone._old_view_id = old.view_id
except sender.DoesNotExist:
zone._old_name = None
zone._old_soa_serial = None
zone._old_view_id = None
else:
zone._old_name = None
zone._old_soa_serial = None
zone._old_view_id = None
@receiver(post_save, sender=Zone)
@@ -27,32 +31,66 @@ def zone_post_save(sender, instance, created, **kwargs):
zone = instance
# Quit if this is not the first signal in this session for this zone.
try:
request = current_request.get()
if request is not None:
if request is None:
raise LookupError
handled_zones = getattr(request, "_handled_zones", set())
if zone.pk in handled_zones:
return
handled_zones.add(zone.pk)
request._handled_zones = handled_zones
except LookupError:
pass
# On commit, increment soa serial and if notify is enabled, schedule bg job.
# Determine whether the zone's SOA serial number changed with this save.
# A newly created zone is always considered to have a "changed" serial.
old_soa_serial = getattr(zone, "_old_soa_serial", None)
new_soa_serial = getattr(zone, "soa_serial", None)
soa_serial_changed = created or (old_soa_serial != new_soa_serial)
# Detect whether the zone moved between views. When it does, the old
# view's catalog zone loses a member and the new view's gains one, so
# both serials must be incremented.
old_view_id = getattr(zone, "_old_view_id", None)
view_changed = (
not created
and old_view_id is not None
and old_view_id != zone.view_id
)
# On commit, increment soa serial and schedule notify jobs. All settings
# gating happens inside the scheduling functions.
def _on_commit():
catzm.increment_soa_serial()
if view_changed:
try:
old_view = View.objects.get(pk=old_view_id)
catzm.increment_soa_serial(old_view)
except View.DoesNotExist:
pass
catzm.increment_soa_serial(zone.view)
if SETTINGS.get("notify_clients", False):
notify_handler.schedule(zone)
if soa_serial_changed:
notify.schedule_client_notify(zone)
notify.schedule_ns_notify(zone)
transaction.on_commit(_on_commit)
if created:
# If zone was created, create catz member identifier record for this zone.
catzm.update_member_identifier(zone)
# If zone was created, create the catalog zone member record for this zone.
catzm.update_member(zone)
else:
# Check if zone name has changed and change the catz member identifier if so.
# Re-create the catalog zone member when the zone name or its view
# changed. A view move leaves the member pointing at the wrong
# catalog zone, so it must be refreshed.
old_name = getattr(zone, "_old_name", None)
if old_name and zone.name != old_name:
catzm.update_member_identifier(zone)
if (old_name and zone.name != old_name) or view_changed:
catzm.update_member(zone)
@receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs):
catzm.increment_soa_serial()
def _on_commit():
catzm.increment_soa_serial(instance.view)
transaction.on_commit(_on_commit)
+78
View File
@@ -0,0 +1,78 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable
from netbox.tables.columns import ActionsColumn
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneMemberTable(NetBoxTable):
name = tables.Column(linkify=True)
zone = tables.Column(
accessor="zone__name",
verbose_name=_("Zone"),
linkify=True,
)
view = tables.Column(
accessor="catalog_zone__view__name",
verbose_name=_("View"),
)
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = CatalogZoneMember
fields = ("name", "zone", "view")
default_columns = ("name", "zone", "view")
class CatalogZoneTable(NetBoxTable):
view = tables.Column(
accessor="view__name",
verbose_name=_("View"),
linkify=True,
)
soa_serial = tables.Column(verbose_name=_("SOA Serial"))
soa_refresh = tables.Column(verbose_name=_("Refresh"))
soa_retry = tables.Column(verbose_name=_("Retry"))
soa_expire = tables.Column(verbose_name=_("Expire"))
soa_minimum = tables.Column(verbose_name=_("Minimum"))
actions = ActionsColumn(actions=("edit", "changelog"))
class Meta(NetBoxTable.Meta):
model = CatalogZone
fields = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
default_columns = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
class SeenTransferClientTable(NetBoxTable):
source_ip = tables.Column(linkify=True)
view = tables.Column(
accessor="view__name",
verbose_name=_("View"),
linkify=True,
)
last_seen = tables.DateTimeColumn(verbose_name=_("Last Seen"))
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = SeenTransferClient
fields = ("source_ip", "view", "last_seen")
default_columns = ("source_ip", "view", "last_seen")
@@ -0,0 +1,4 @@
{% extends 'generic/object_edit.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Catalog Zone" %}{% endblock %}
@@ -0,0 +1,4 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block title %}{% trans "Catalog Zones" %}{% endblock %}
@@ -0,0 +1,50 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone SOA" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "SOA Serial" %}</th>
<td>{{ object.soa_serial }}</td>
</tr>
<tr>
<th scope="row">{% trans "Refresh" %}</th>
<td>{{ object.soa_refresh }}</td>
</tr>
<tr>
<th scope="row">{% trans "Retry" %}</th>
<td>{{ object.soa_retry }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expire" %}</th>
<td>{{ object.soa_expire }}</td>
</tr>
<tr>
<th scope="row">{% trans "Minimum" %}</th>
<td>{{ object.soa_minimum }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone Member" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Zone" %}</th>
<td>{{ object.zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Catalog Zone" %}</th>
<td>{{ object.catalog_zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.catalog_zone.view|linkify }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Seen Transfer Client" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Source IP" %}</th>
<td>{{ object.source_ip }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Seen" %}</th>
<td>{{ object.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
from django.urls import include, path
from utilities.urls import get_model_urls
# Import views so register_model_view decorators run.
from .views import ( # noqa: F401
CatalogZoneEditView,
CatalogZoneListView,
CatalogZoneMemberListView,
CatalogZoneMemberView,
CatalogZoneView,
SeenTransferClientListView,
SeenTransferClientView,
)
urlpatterns = [
path(
"catalog-zones/",
include(
get_model_urls("netbox_dns_bridge", "catalogzone", detail=False)
),
),
path(
"catalog-zones/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzone")),
),
path(
"catalog-zone-members/",
include(
get_model_urls("netbox_dns_bridge", "catalogzonemember", detail=False)
),
),
path(
"catalog-zone-members/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzonemember")),
),
path(
"seen-transfer-clients/",
include(
get_model_urls("netbox_dns_bridge", "seentransferclient", detail=False)
),
),
path(
"seen-transfer-clients/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "seentransferclient")),
),
]
+90 -21
View File
@@ -1,34 +1,103 @@
import dns.name
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import dns.rdataset
import netbox_dns.models
import logging
def get_logger(name: str) -> logging.Logger:
return logging.getLogger("dns-bridge")
LOGGER = get_logger(__name__)
def build_dns_zone(nb_zone: netbox_dns.models.Zone) -> dns.zone.Zone:
# Build DNS zone
zone = dns.zone.Zone(nb_zone.name, dns.name.root)
zone.rdclass = dns.rdataclass.IN
nb_records = nb_zone.records.filter(active=True)
rdatasets_dict = {}
for record in nb_records:
rdtype = dns.rdatatype.from_text(record.type)
if not record.name:
name = zone.origin
elif record.name.endswith("."):
name = dns.name.from_text(record.name)
else:
name = dns.name.from_text(record.name, origin=zone.origin)
# If the record has no TTL, use the zone default
ttl = record.ttl or nb_zone.default_ttl
# Apply quoting for TXT records to stop tokanizer
# from cutting it up:
value = record.value
if rdtype == dns.rdatatype.TXT:
if 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(
dns.rdataclass.IN,
rdtype,
value,
relativize=False,
origin=zone.origin,
)
# Initialize rdataset if it doesn't exist for this name and type
if name not in rdatasets_dict:
rdatasets_dict[name] = {}
if rdtype not in rdatasets_dict[name]:
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(
dns.rdataclass.IN, rdtype
)
# Add the rdata to the appropriate rdataset
rdatasets_dict[name][rdtype].add(rdata, ttl)
# Now, add all rdatasets to the zone
for name, rdtypes in rdatasets_dict.items():
for rdtype, rdataset in rdtypes.items():
# Ensure rdataset has the same rdclass as the zone
if rdataset.rdclass != zone.rdclass:
raise ValueError(
f"rdataset rdclass {rdataset.rdclass} does not match "
f"zone rdclass {zone.rdclass}"
)
# Check if the rdataset has any rdata before creating an RRset
if not rdataset:
LOGGER.debug(f"Skipping empty rdataset for {name} {rdtype}")
continue # Skip empty rdataset
# Replace the rdataset for the given name and type
zone.replace_rdataset(name, rdataset)
return zone
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)
rdataset.add(rdata, record.ttl)
# Add to zone
node = dp_zone.find_node(rname, create=True)
node.rdatasets.append(rdataset)
zone = build_dns_zone(nb_zone)
# Write zone to file in BIND format
try:
with open(file_path, "w") as f:
dp_zone.to_file(f, sorted=True)
zone.to_file(f, sorted=True)
except IOError as e:
raise IOError(f"Failed to write zone file to {file_path}: {e}")
+76
View File
@@ -0,0 +1,76 @@
from netbox.object_actions import EditObject
from netbox.views import generic
from utilities.views import register_model_view
from .filtersets import (
CatalogZoneFilterSet,
CatalogZoneMemberFilterSet,
SeenTransferClientFilterSet,
)
from .forms import (
CatalogZoneFilterForm,
CatalogZoneForm,
CatalogZoneMemberFilterForm,
SeenTransferClientFilterForm,
)
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
from .tables import (
CatalogZoneMemberTable,
CatalogZoneTable,
SeenTransferClientTable,
)
@register_model_view(CatalogZone, "list", path="", detail=False)
class CatalogZoneListView(generic.ObjectListView):
queryset = CatalogZone.objects.select_related("view").order_by("view__name")
table = CatalogZoneTable
filterset = CatalogZoneFilterSet
filterset_form = CatalogZoneFilterForm
actions = ()
@register_model_view(CatalogZone)
class CatalogZoneView(generic.ObjectView):
queryset = CatalogZone.objects.select_related("view")
actions = (EditObject,)
@register_model_view(CatalogZone, "edit")
class CatalogZoneEditView(generic.ObjectEditView):
queryset = CatalogZone.objects.select_related("view")
form = CatalogZoneForm
@register_model_view(CatalogZoneMember, "list", path="", detail=False)
class CatalogZoneMemberListView(generic.ObjectListView):
queryset = CatalogZoneMember.objects.select_related(
"zone", "catalog_zone__view"
).order_by("catalog_zone__view__name", "name")
table = CatalogZoneMemberTable
filterset = CatalogZoneMemberFilterSet
filterset_form = CatalogZoneMemberFilterForm
actions = ()
@register_model_view(CatalogZoneMember)
class CatalogZoneMemberView(generic.ObjectView):
queryset = CatalogZoneMember.objects.select_related(
"zone", "catalog_zone__view"
)
actions = ()
@register_model_view(SeenTransferClient, "list", path="", detail=False)
class SeenTransferClientListView(generic.ObjectListView):
queryset = SeenTransferClient.objects.select_related("view").order_by("source_ip")
table = SeenTransferClientTable
filterset = SeenTransferClientFilterSet
filterset_form = SeenTransferClientFilterForm
actions = ()
@register_model_view(SeenTransferClient)
class SeenTransferClientView(generic.ObjectView):
queryset = SeenTransferClient.objects.select_related("view")
actions = ()
+3
View File
@@ -22,6 +22,9 @@ include-package-data = true
zip-safe = false
package-dir = { "" = "." }
[tool.setuptools.package-data]
netbox_dns_bridge = ["templates/**/*.html"]
[tool.setuptools.packages.find]
where = ["."]