Compare commits

...
68 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
Sven Luethi a5d163b4af modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/notify_handler.py
2026-07-11 01:44:21 +02:00
Sven Luethi f5791e94d5 modified: ../README.md
modified:   __init__.py
modified:   catalog_zone_manager.py
modified:   dns_server.py
modified:   notify_handler.py
Adding serial to notify messages to allow client to determine state.
2026-07-11 00:04:45 +02:00
Sven Luethi 3eb17187b4 modified: netbox_dns_bridge/__init__.py
modified:   netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/notify_handler.py
2026-07-10 22:05:51 +02:00
Sven Luethi 5747b044b6 modified: catalog_zone_manager.py
modified:   management/commands/dns-export-zone.py
modified:   management/commands/dns-export-zones.py
modified:   management/commands/dns-transfer-endpoint.py
modified:   notify_handler.py
modified:   request_handler.py
Guard all db requests against broken database connections.
2026-07-10 17:10:19 +02:00
Sven Luthi d18e0fc262 Update README 2026-07-08 16:21:55 +02:00
Sven Luethi 2a89f4ae75 modified: netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py

1. On zone deletion the catalog zone serial was not incremented, causing clients
   not to delete the zone until another zone is created/modified. Fixed using
   post_delete signal.

2. Even with the notify_clients feature disabled, the client IPs were still
   being tracked. This disables the unnecessary behavior.

3. _track_seen_client function in DNSBaseRequestHandler did not have the 'self'
   reference parameter, causing calls to fail. Fixed.
2026-06-13 00:19:45 +02:00
Sven Luethi b78ae930e7 modified: README.md 2026-06-12 18:30:12 +02:00
Sven Luethi e89a6dfbd6 modified: README.md 2026-06-11 17:24:57 +02:00
Sven Luethi 73544a4335 modified: README.md
modified:   netbox_dns_bridge/notify_handler.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Disable NOTIFY feature by default and add setting to enable it.
2026-06-11 16:33:47 +02:00
Sven Luethi cb4886f032 new file: .flake8
deleted:    pylint.conf
Switch to flake8 for code checking
2026-06-11 16:33:47 +02:00
Sven Luethi e5808b4a57 Merge branch 'main' into dev 2026-06-11 14:07:44 +02:00
Sven Luethi 547fe92b8b Merge pull request #23 from peteeckel/fix/active-record-zone-check
Replace explicit checks for STATUS_ACTIVE with the annotated "active" value
2026-06-11 10:39:13 +02:00
Peter Eckel 3f3426886e Replace explicit checks for STATUS_ACTIVE with the annotated "active" value 2026-06-11 10:11:19 +02:00
Sven Luethi 3eef3b4d7b modified: netbox_dns_bridge/__init__.py
modified:   netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/dns_server.py
modified:   netbox_dns_bridge/logger.py
new file:   netbox_dns_bridge/migrations/0002_alter_integerkeyvaluesetting_options_and_more.py
modified:   netbox_dns_bridge/models.py
new file:   netbox_dns_bridge/notify_handler.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals/__init__.py
new file:   netbox_dns_bridge/signals/netbox_dns_zones.py
deleted:    netbox_dns_bridge/signals/transfer_endpoint.py
modified:   netbox_dns_bridge/utils.py

Implement notify mechanism
2026-06-11 00:32:26 +02:00
Sven Luethi a4036c285c modified: netbox_dns_bridge/catalog_zone_manager.py
Move state to DB
2026-05-23 14:58:24 +02:00
Sven Luethi 1426ef2f70 modified: .gitignore
Update gitignore
2026-05-22 12:02:43 +02:00
sven ea1b5ee2b4 renamed: signals.py -> signals/transfer_endpoint.py
new file:   signals/__init__.py
2026-04-10 15:31:19 +02:00
sven 67ccf83b1c modified: netbox_dns_bridge/__init__.py
modified:   netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/dns_server.py
new file:   netbox_dns_bridge/logger.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
modified:   netbox_dns_bridge/request_handler.py
Clean up logging
2026-04-10 15:08:10 +02:00
sven da6a5e09a5 modified: netbox_dns_bridge/__init__.py
Remove unneeded definition.

modified:   MANIFEST.in
Correct path

new file:   netbox_dns_bridge/migrations/__init__.py
Fixes missing migration.

new file:   netbox_dns_bridge/management/__init__.py
new file:   netbox_dns_bridge/management/commands/__init__.py
Just in case
2026-04-10 13:23:37 +02:00
Sven Luthi 3eb58f9b29 modified: docs/install-systemd-service.md
Correct naming in service unit
2026-04-10 11:16:58 +02:00
Sven Luthi ad52457c48 modified: README.md
Fix link alt text
2026-04-10 10:59:49 +02:00
Sven Luthi 9c3d7cce7e modified: README.md 2026-04-09 19:18:35 +02:00
Sven Luthi 6418ed56e6 modified: README.md 2026-04-09 19:10:39 +02:00
Sven Luthi fd16f7e701 modified: netbox_dns_bridge/__init__.py
Bump version to 1.5.2
2026-04-09 18:51:54 +02:00
Sven Luthi f926795d1b modified: netbox_dns_bridge/request_handler.py
Change query logging from debug to info. Seeing queries is very useful :)
2026-04-09 18:36:14 +02:00
Sven Luthi eedc01303b modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/utils.py
Various little bug fixes. Analysed by AI but fixed by hand to ensure integrity.
2026-04-09 18:28:05 +02:00
Sven Luthi 646059bb07 modified: netbox_dns_bridge/signals.py
Fix import path
2026-04-09 17:27:00 +02:00
Sven Luthi f41b4cea56 modified: netbox_dns_bridge/request_handler.py
Closes issue #15
2026-04-09 15:04:39 +02:00
Sven Luthi 3dec22b9f3 modified: README.md 2026-04-09 14:32:50 +02:00
Sven Luthi b23c773bee modified: README.md 2026-04-09 14:27:46 +02:00
41 changed files with 1879 additions and 1269 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
[flake8]
max-line-length = 100
+3 -1
View File
@@ -1,4 +1,6 @@
netbox_dns_bridge.egg-info
dist/
build/
__pycache__
venv/
.venv/
netbox_plugin_dns_bridge.egg-info/
+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.
+2 -1
View File
@@ -1 +1,2 @@
recursive-include netbox_bind_provisioner *.py
recursive-include netbox_dns_bridge *.py
recursive-include netbox_dns_bridge/templates *.html
+156 -63
View File
@@ -1,29 +1,25 @@
# Netbox DNS Bridge
(Formerly Netbox Bind Provisioner)
# NetBox DNS Bridge
The Netbox DNS Bridge plugin implements a lightweight DNS server inside Netbox and builds a bridge
for BIND and other DNS Servers implementing RFC9432 to retrieve DNS Zones directly from Netbox
using DNS native mechanisms.
The NetBox DNS Bridge plugin extends [NetBox DNS](https://github.com/sys4/netbox-plugin-dns) by
embedding a lightweight DNS server directly within NetBox. It acts as a bridge between NetBox DNS
and your existing DNS infrastructure, leveraging native DNS mechanisms for seamless integration.
These include zone transfers (RFC 5936), catalog zones (RFC 9432), and notifying clients about zone
changes (RFC 1996).
<a href="https://pypi.org/project/netbox-plugin-dns-bridge/"><img src="https://img.shields.io/pypi/v/netbox-plugin-dns-bridge" alt="PyPi"></a>
<a href="https://github.com/suraxius/netbox-plugin-bind-provisioner/stargazers"><img src="https://img.shields.io/github/stars/suraxius/netbox-plugin-bind-provisioner?style=flat" alt="Stars Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-bind-provisioner/network/members"><img src="https://img.shields.io/github/forks/suraxius/netbox-plugin-bind-provisioner?style=flat" alt="Forks Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-bind-provisioner/issues"><img src="https://img.shields.io/github/issues/suraxius/netbox-plugin-bind-provisioner" alt="Issues Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-bind-provisioner/pulls"><img src="https://img.shields.io/github/issues-pr/suraxius/netbox-plugin-bind-provisioner" alt="Pull Requests Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-bind-provisioner/graphs/contributors"><img src="https://img.shields.io/github/contributors/suraxius/netbox-plugin-bind-provisioner?color=2b9348" alt="GitHub contributors"></a>
<a href="https://github.com/suraxius/netbox-plugin-bind-provisioner/blob/master/LICENSE"><img src="https://img.shields.io/github/license/suraxius/netbox-plugin-bind-provisioner?color=2b9348" alt="License Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-dns-bridge/stargazers"><img src="https://img.shields.io/github/stars/suraxius/netbox-plugin-dns-bridge?style=flat" alt="Stars Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-dns-bridge/network/members"><img src="https://img.shields.io/github/forks/suraxius/netbox-plugin-dns-bridge?style=flat" alt="Forks Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-dns-bridge/issues"><img src="https://img.shields.io/github/issues/suraxius/netbox-plugin-dns-bridge" alt="Issues Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-dns-bridge/pulls"><img src="https://img.shields.io/github/issues-pr/suraxius/netbox-plugin-dns-bridge" alt="Pull Requests Badge"></a>
<a href="https://github.com/suraxius/netbox-plugin-dns-bridge/graphs/contributors"><img src="https://img.shields.io/github/contributors/suraxius/netbox-plugin-dns-bridge?color=2b9348" alt="GitHub contributors"></a>
<a href="https://github.com/suraxius/netbox-plugin-dns-bridge/blob/master/LICENSE"><img src="https://img.shields.io/github/license/suraxius/netbox-plugin-dns-bridge?color=2b9348" alt="License Badge"></a>
<a href="https://github.com/psf/black"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" alt="Code Style Black"></a>
<a href="https://pepy.tech/project/netbox-plugin-dns-bridge"><img src="https://static.pepy.tech/personalized-badge/netbox-plugin-dns-bridge?period=total&left_color=BLACK&right_color=BLUE&left_text=Downloads" alt="Downloads"></a>
<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/Week"></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/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=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>
![Architecture Overview](docs/architecture-overview.svg)
DISCLAIMER: The dynamic update endpoint isn't here yet. Its planned - without a deadline.
## 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.
@@ -44,51 +40,146 @@ 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
Setting | Description
--------------------| ---------------------------------------------------------
tsig_keys | Maps a TSIG Key to be used for each view.
## Plugin settings
Plugin settings should be configured under `PLUGINS_CONFIG` in `netbox_dns_bridge`:
```
PLUGINS_CONFIG = {
'netbox_dns_bridge': {
...
...
}
}
```
### 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.
```
'tsig_keys': {
'the-view-name-here': {
'keyname': 'the-tsig-key-name-here',
'algorithm': 'hmac-sha256',
'secret': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
}
}
```
### 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. 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 queued jobs.
```
'notify_clients': True
```
This enables the NOTIFY system. Defaults to False
```
'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_client_port': 53
```
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 is an extension to the netbox-plugin-dns plugin. As such the versioning of this plugin
was changed to match the one of the netbox-plugin-dns plugin closely. To guarantee compatability,
ensure that the major and minor version match between both plugins.
For example, when using netbox-plugin-dns `v1.5.5` install netbox-plugin-dns-bridge `v1.5.x`.
This plugin extends the netbox-plugin-dns plugin. As such the versioning was changed to match the
one of netbox-plugin-dns more closely. To guarantee compatability, ensure that the major and minor
version match between both plugins. For example, when using netbox-plugin-dns `v1.5.5` install
netbox-plugin-dns-bridge `v1.5.x`.
## Post 1.0.7 Upgrade Guide
Applies only if you have a <= 1.0.7 installation. If you are freshly installing this plugin,
go on to [Installation guide](installation-guide).
After version 1.0.7, the project was restructured and renamed. Until that point version updates
happened more or less automatically. However, this shift is more of a move from one plugin to
another as the package name has changed.
This should not be an issue since the slave DNS Servers connected to Netbox can operate
independently while this plugin is being upgraded.
Make sure to note down the catalog zone serial number before going further.
1. Remove Netbox Bind Provisioner package
- Remove `netbox-plugin-bind-provisioner` from **local_dependencies.txt**
- Uninstall the package: `pip uninstall netbox-plugin-bind-provisioner`
2. Install Netbox DNS Bridge package
- Install the package `pip install netbox-plugin-dns-bridge`
- Put `netbox-plugin-dns-bridge` in your **local_dependencies.txt** so it will be installed on
next upgrade.
3. Adjust your `configuration.py`
- Change the plugin name from `netbox-plugin-bind-provisioner` to `netbox-plugin-dns-bridge`
- Change the key in `PLUGIN_CONFIG` from `netbox_plugin_bind_provisioner` to `netbox_dns_bridge`
4. Ensure the `dns-transfer-endpoint` service is not running before the next step.
5. Restore the catalog zone serial you noted down previously so that your slave dns servers continue
to pull the changes: `manage.py dns-settings set catalog-zone-soa-serial yourserialnumber`
6. Start the `dns-transfer-endpoint` service.
7. Mission accomplished.
## Installation guide
This setup provisions a BIND9 server directly with DNS data from NetBox. BIND9 can optionally run on
@@ -97,10 +188,10 @@ of the NetBox host. TCP and UDP traffic from the BIND9 server to the NetBox host
port 5354 (or the port you have configured).
This guide assumes:
- Netbox has been installed under /opt/netbox
- Bind9 is installed on the same host as Netbox
- The Netbox DNS Plugin netbox-plugin-dns is installed
- The following dns views exist in Netbox DNS:
- NetBox has been installed under /opt/netbox
- Bind9 is installed on the same host as NetBox
- The NetBox DNS Plugin netbox-plugin-dns is installed
- The following dns views exist in NetBox DNS:
- `public` (the default)
- `private`
@@ -160,7 +251,7 @@ This guide assumes:
4. Run migrations
```
python3 netbox/manage.py migrate
manage.py migrate
```
5. Start listener
@@ -172,10 +263,10 @@ This guide assumes:
Note that `--port 5354` is optional. The listener will bind this port by default.
```
python3 netbox/manage.py dns-transfer-endpoint --port 5354
manage.py dns-transfer-endpoint --port 5354
```
6. Configuring a Bind9 to interact with Netbox via the dns-transfer-endpoint endpoint. Note that its
6. Configuring a Bind9 to interact with NetBox via the dns-transfer-endpoint endpoint. Note that its
not possible to give all the correct details of the `options` block as it is heavily dependent
on the Operating System used. Please dont forget to adjust as required.
@@ -221,6 +312,7 @@ This guide assumes:
########## ZONES ##########
view "public" {
match-clients { public; };
allow-notify { key "public_view_key"; };
catalog-zones {
zone "catz"
@@ -239,6 +331,7 @@ This guide assumes:
view "private" {
match-clients { private; };
allow-notify { key "private_view_key"; };
catalog-zones {
zone "catz"
+7 -7
View File
@@ -1,9 +1,9 @@
1. Create System Unit file
```
cat <<EOF > /etc/systemd/system/netbox-bind-endpoint.service
cat <<EOF > /etc/systemd/system/netbox-dns-endpoint.service
[Unit]
Description=Netbox BIND Catalog Endpoing
Documentation=https://github.com/Suraxius/netbox-plugin-bind-provisioner
Description=Netbox DNS transfer endpoint
Documentation=https://github.com/Suraxius/netbox-plugin-dns-bridge
After=network-online.target
Wants=network-online.target
@@ -15,7 +15,7 @@
PIDFile=/var/tmp/netbox.pid
WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py dns-transfer-endpoint --port 5354
ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py dns-transfer-endpoint --address 0.0.0.0 --port 5354
Restart=on-failure
RestartSec=30
@@ -28,7 +28,7 @@
2. Enable and start the service
```
systemctl enable netbox-bind-endpoint
systemctl start netbox-bind-endpoint
systemctl status netbox-bind-endpoint
systemctl enable netbox-dns-endpoint
systemctl start netbox-dns-endpoint
systemctl status netbox-dns-endpoint
```
+5 -14
View File
@@ -1,17 +1,7 @@
import logging
from netbox.plugins import PluginConfig
from django.conf import settings
__version__ = "1.5.1"
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.DEBUG, # Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", # Log format
datefmt="%Y-%m-%d %H:%M:%S", # Date format for timestamps
)
__version__ = "1.5.9-beta"
class DNSBridgeConfig(PluginConfig):
name = "netbox_dns_bridge"
@@ -23,14 +13,15 @@ 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 as signals
from . import signals
config = DNSBridgeConfig
default_app_config = ".apps.NetboxDNSBridgeConfig"
-234
View File
@@ -1,234 +0,0 @@
import logging
import threading
import dns.query
import dns.message
import dns.tsigkeyring
import dns.name
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.rdtypes
import dns.exception
import dns.renderer
from netbox_dns.models import Zone
from netbox_dns.choices import ZoneStatusChoices
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
from uuid import uuid4
from base64 import b32encode
logger = logging.getLogger(__name__)
_LOCK = threading.Lock()
_SERIAL_MAX = 0xFFFFFFFF
_SERIAL_OBJ = None
_PREVIOUS_LAST_ZONE_UPDATE = None
def init() -> None:
_init_serial()
_create_missing_member_identifiers()
# Following function loads the last serial from the DB. No return value
# but it sets the setting "catalog_serial" or terminates the plugin on failure.
def _init_serial() -> None:
global _SERIAL_OBJ
try:
_SERIAL_OBJ = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
logger.debug(
f"Catalog zone SOA serial number {_SERIAL_OBJ.value} loaded from database"
)
except IntegerKeyValueSetting.DoesNotExist:
_SERIAL_OBJ = IntegerKeyValueSetting.objects.create(
key="catalog-zone-soa-serial", value=1
)
logger.debug(
f"Catalog zone SOA serial number was not set in the database. Set to {_SERIAL_OBJ.value}"
)
def _set_serial(new_serial: int) -> bool:
global _SERIAL_OBJ
if 0 < new_serial < _SERIAL_MAX:
_SERIAL_OBJ.value = new_serial
_SERIAL_OBJ.save()
return True
else:
return False
def _increment_serial() -> None:
if not _set_serial(_SERIAL_OBJ.value + 1):
logger.warning(
f"Failed to incremenet catalog serial {_SERIAL_OBJ.value}. Will overflow serial back to 1"
)
_set_serial(1)
logger.debug(
f"Catalog zone SOA serial number incremented to {_SERIAL_OBJ.value}"
)
# If a zone has no catz identifier yet, create it:
def _create_missing_member_identifiers() -> None:
existing_zone_ids = CatalogZoneMemberIdentifier.objects.values_list(
"zone_id", flat=True
)
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} has no catz member identifier. Creating..."
)
CatalogZoneMemberIdentifier.objects.bulk_create(
new_objects,
ignore_conflicts=False,
)
def create_zone(name, view_name) -> dns.zone.Zone:
global _PREVIOUS_LAST_ZONE_UPDATE
# Synchronize following across threads as TCP and UDP listener both use it.
with _LOCK:
latest_zone = (
Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE)
.order_by("-last_updated")
.first()
)
last_zone_update = getattr(latest_zone, "last_updated", None)
# Check if there was a zone updated since last call
# If no zone was found previously then this will be false since (None != None) = False
if _PREVIOUS_LAST_ZONE_UPDATE != last_zone_update:
if last_zone_update is not None:
logger.debug(
f"Zone {latest_zone.name} was updated in view {latest_zone.view.name}"
)
# Setting previous last zone update for next iteration:
_PREVIOUS_LAST_ZONE_UPDATE = last_zone_update
_increment_serial()
# Zone origin
origin = dns.name.from_text(name, dns.name.root)
# Create a new empty zone
zone = dns.zone.Zone(origin)
zone.rdclass = dns.rdataclass.IN
# get zones from netbox
nb_zones = Zone.objects.filter(
view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE
).select_related("catz_identifier")
ptr_base = dns.name.from_text("zones", origin)
for nb_zone in nb_zones:
ttl = 0
qname = dns.name.from_text(nb_zone.name, dns.name.root)
# Create PTR record
# p_name = f"zid-{nb_zone.id:09d}"
p_name = nb_zone.catz_identifier.name
ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin):
raise ValueError(
"Catalog zone member identifier {ptr_name.to_text()} not a subdomain"
)
rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
)
rdataset = zone.find_rdataset(ptr_name, dns.rdatatype.PTR, create=True)
rdataset.add(rdata, ttl)
# Configure DNSSec Policy for member Zone if DNSSec is enabled
if nb_zone.dnssec_policy:
# Configure policy
rid = dns.name.from_text("group", ptr_name)
policy_name = nb_zone.dnssec_policy.name.rstrip(" ")
group_name = f"dnssec-policy-{policy_name}"
rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.TXT, group_name
)
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
rdataset.add(rdata, ttl)
## Configure dnssec status for member zone
# status = str(1 if nb_zone.dnssec_policy else 0)
# rid = dns.name.from_text("enabled.dnssec.ext", ptr_name)
# rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
# rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
# rdataset.add(rdata, ttl)
# SOA Record components
ttl = 0
rclass = dns.rdataclass.IN
rtype = dns.rdatatype.SOA
mname = dns.name.from_text("invalid", dns.name.root)
rname = dns.name.from_text("invalid", dns.name.root)
serial = _SERIAL_OBJ.value
refresh = 60
retry = 10
expire = 1209600
minimum = 0
# Create SOA rdata object
soa_rdata = dns.rdata.from_text(
rclass,
rtype,
f"{mname} {rname} {serial} {refresh} {retry} {expire} {minimum}",
)
# Create Rdataset and add the RDATA to it
soa_rdataset = dns.rdataset.Rdataset(rclass, rtype)
soa_rdataset.add(soa_rdata, ttl)
# Add to the origin node in the zone
node = zone.find_node(origin, create=True)
node.rdatasets.append(soa_rdataset)
# NS record for catz.
ns_name = dns.name.from_text("invalid", dns.name.root)
ns_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, str(ns_name))
ns_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS)
ns_rdataset.add(ns_rdata, 0)
# Add to node (catz. is the origin)
ns_node = zone.find_node(origin, create=True)
ns_node.rdatasets.append(ns_rdataset)
# TXT record for version.catz.
version_name = dns.name.from_text("version", origin) # relative to origin "catz."
txt_rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"')
txt_rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT)
txt_rdataset.add(txt_rdata, 0)
# Add to node version.catz.
txt_node = zone.find_node(version_name, create=True)
txt_node.rdatasets.append(txt_rdataset)
return zone
def _generate_member_identifier() -> None:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: Zone) -> None:
CatalogZoneMemberIdentifier.objects.update_or_create(
zone=zone,
defaults={"name": _generate_member_identifier},
)
+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,
)
Whitespace-only changes.
Whitespace-only changes.
@@ -1,8 +1,3 @@
import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models
from netbox_dns_bridge.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError
@@ -24,10 +19,6 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
# if len(options) < 2:
# print("export-zone <zone name> <file path>")
# sys.exit(1)
# Load parameters
view_name = options["view"]
zone_name = options["zone"]
@@ -46,15 +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)
else:
print("Zone not found in Netbox. Aborting")
sys.exit(1)
export_bind_zone_file(nb_zone, file_path=file_path)
self.stdout.write(
self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'")
)
except Exception as e:
raise CommandError(f"Failed to export zone: {e}")
self.stdout.write(
self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'")
)
@@ -1,8 +1,4 @@
import os
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models
from netbox_dns_bridge.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError
@@ -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,5 +1,5 @@
import logging
import threading
import sys
import dns.query
import dns.message
import dns.tsigkeyring
@@ -13,68 +13,67 @@ import dns.renderer
from django.core.management.base import BaseCommand
from django.conf import settings
from django.db import OperationalError
from netbox_dns.models import View
from netbox_dns_bridge.request_handler import UDPRequestHandler, TCPRequestHandler
from netbox_dns_bridge.dns_server import UDPDNSServer, TCPDNSServer
from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge.models import IntegerKeyValueSetting
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 = logging.getLogger(__name__)
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
class Command(BaseCommand):
help = "Run a minimal AXFR DNS server using data from NetBox DNS plugin"
def load_settings(self):
self.settings = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", None)
if not self.settings:
raise RuntimeError(
"Command failed to initialize due to missing settings. Terminating Netbox."
)
self.tsig_keys = self.settings.get("tsig_keys", None)
if not self.tsig_keys:
raise RuntimeError("tsig_keys variable not set in plugin settings.")
# Load TSIG keys and map them to views
def load_tsig_key_settings(self):
def _load_tsig_key_settings(self):
self.keyring = {}
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:
logger.error(
f"Skipping TSIG key for view {view_name}: missing keyname or secret."
if not key_name:
LOGGER.error(
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."
LOGGER.error(
f"Skipping TSIG key {key_name}: View '{view_name}' not found in database."
)
continue
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.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}")
LOGGER.info(f"Loaded TSIG key {key_name_str} for view {nb_view.name}")
if not self.keyring:
msg = "No TSIG keys found in database."
logger.critical(msg)
LOGGER.critical(msg)
raise RuntimeError(msg)
def add_arguments(self, parser):
@@ -89,11 +88,12 @@ class Command(BaseCommand):
# Load parameters
port = options["port"]
address = options["address"]
catzm.init()
# Initialize settings
self.load_settings()
self.load_tsig_key_settings()
self.tsig_keys = SETTINGS.get("tsig_keys", None)
if not self.tsig_keys:
raise RuntimeError("tsig_keys variable not set in plugin settings.")
self._load_tsig_key_settings()
udp_server = UDPDNSServer(
(address, port), UDPRequestHandler, self.keyring, self.tsig_view_map
@@ -103,15 +103,15 @@ class Command(BaseCommand):
(address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
)
def run_udp_server(server):
logger.debug(f"Query endpoint listening on {address} udp/{port}")
def _run_udp_server(server):
LOGGER.info(f"Query endpoint listening on {address} udp/{port}")
server.serve_forever()
udp_thread = threading.Thread(
target=run_udp_server, args=(udp_server,), daemon=True
target=_run_udp_server, args=(udp_server,), daemon=True
)
udp_thread.start()
logger.debug(f"Query endpoint listening on {address} tcp/{port}")
LOGGER.info(f"Query endpoint listening on {address} tcp/{port}")
tcp_server.serve_forever()
Whitespace-only changes.
@@ -0,0 +1,205 @@
import dns.name
import dns.zone
import dns.rdatatype
import dns.rdataclass
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 increment_soa_serial(view: NBView) -> int:
with transaction.atomic():
try:
(
soa_serial_obj,
_,
) = CatalogZone.objects.select_for_update().get_or_create(
view=view,
defaults={"soa_serial": 1},
)
new_soa_serial = soa_serial_obj.soa_serial + 1
if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning(
f"Catalog serial {soa_serial_obj.soa_serial} reached max — wrapping back to 1"
)
new_soa_serial = 1
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}"
)
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:
# Zone origin
origin = dns.name.from_text(name, dns.name.root)
# Create a new empty zone
zone = dns.zone.Zone(origin)
zone.rdclass = dns.rdataclass.IN
try:
# get zones from netbox
nb_zones = NBZone.objects.filter(
view__name=view_name, active=True
).select_related("catalog_zone_member")
ptr_base = dns.name.from_text("zones", origin)
for nb_zone in nb_zones:
ttl = 0
qname = dns.name.from_text(nb_zone.name, dns.name.root)
# Create PTR record
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 {ptr_name.to_text()} not a subdomain"
)
rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
)
rdataset = zone.find_rdataset(ptr_name, dns.rdatatype.PTR, create=True)
rdataset.add(rdata, ttl)
# Configure DNSSec Policy for member Zone if DNSSec is enabled
if nb_zone.dnssec_policy:
rid = dns.name.from_text("group", ptr_name)
policy_name = nb_zone.dnssec_policy.name.rstrip(" ")
group_name = f"dnssec-policy-{policy_name}"
rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.TXT, group_name
)
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
rdataset.add(rdata, ttl)
zone_root_node = zone.find_node(origin, create=True)
# SOA record
view = NBView.objects.get(name=view_name)
soa_rdataset = _create_soa_rdataset(view)
zone_root_node.rdatasets.append(soa_rdataset)
# NS record
ns_rdataset = _create_ns_rdataset()
zone_root_node.rdatasets.append(ns_rdataset)
# version.catz. record
version_rdataset = _create_version_rdataset()
zone_version_node = zone.find_node(
dns.name.from_text("version", origin), create=True
)
zone_version_node.rdatasets.append(version_rdataset)
return zone
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to create Catalog Zone: {e}")
close_old_connections()
return None
def _generate_member_name() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
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:
catalog_zone = _get_or_create_catalog_zone(zone.view)
CatalogZoneMember.objects.update_or_create(
zone=zone,
defaults={
"name": _generate_member_name(),
"catalog_zone": catalog_zone,
},
)
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to update Catalog Zone member: {e}")
close_old_connections()
def _create_soa_rdataset(view: NBView) -> dns.rdataset:
try:
serial_obj, _ = CatalogZone.objects.get_or_create(
view=view, defaults={"soa_serial": 1}
)
serial = serial_obj.soa_serial
# SOA Record components
ttl = 0
rclass = dns.rdataclass.IN
rtype = dns.rdatatype.SOA
# Create SOA rdata object
rdata = dns.rdata.from_text(
rclass,
rtype,
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
rdataset = dns.rdataset.Rdataset(rclass, rtype)
rdataset.add(rdata, ttl)
return rdataset
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to access catz SOA record in database: {e}")
close_old_connections()
def _create_ns_rdataset() -> dns.rdataset:
# NS record for catz.
name = dns.name.from_text("invalid", dns.name.root)
rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, str(name))
rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.NS)
rdataset.add(rdata, 0)
return rdataset
def _create_version_rdataset() -> dns.rdataset:
rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"2"')
rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.TXT)
rdataset.add(rdata, 0)
return rdataset
@@ -1,8 +1,8 @@
import logging
import socketserver
import socket
from netbox_dns_bridge.utils import get_logger
logger = logging.getLogger(__name__)
LOGGER = get_logger(__name__)
class DNSAddressMixin:
@@ -1,4 +1,3 @@
import logging
import socketserver
import socket
import dns.query
@@ -11,108 +10,41 @@ import dns.rdataclass
import dns.rdtypes
import dns.exception
import dns.renderer
from netbox_dns.models import Zone, Record
from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
from netbox_dns_bridge import catalog_zone_manager as catzm
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from django.conf import settings
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 = logging.getLogger(__name__)
LOGGER = get_logger(__name__)
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(
name=zone_name,
view__name=view_name,
status=ZoneStatusChoices.STATUS_ACTIVE,
active=True,
)
return build_dns_zone(nb_zone)
except Zone.DoesNotExist:
return None
# 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, status=RecordStatusChoices.STATUS_ACTIVE
)
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 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
except OperationalError as e:
LOGGER.error(f"DB ERROR: {e}")
close_old_connections()
def _deny_request_bad_tsig(self, wire, tsig_error: dns.rcode) -> None:
# Use empty keyring to parse TSIG without validating
@@ -125,13 +57,13 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
response.set_rcode(dns.rcode.REFUSED)
if query.had_tsig:
# Add TSIG with BADKEY, but do not sign (no MAC)
# Add TSIG with error code, but do not sign (no MAC, empty keyring)
response.use_tsig(
keyring={}, # empty; we're not signing
keyname=query.keyname,
tsig_error=tsig_error,
)
self._deny_request(query)
self._send_response(response.to_wire())
def _send_response(self, data) -> None:
raise NotImplementedError
@@ -142,6 +74,19 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
wire = response.to_wire(multi=False)
self._send_response(wire)
def _track_seen_client(self, source_ip: str, view: View) -> None:
if SETTINGS.get("notify_clients", False):
try:
SeenTransferClient.objects.update_or_create(
source_ip=source_ip,
view=view,
defaults={"last_seen": timezone.now()},
)
except OperationalError as e:
LOGGER.error(f"DB ERROR: {e}")
close_old_connections()
def _handle_soa_request(self, query, soa_rrset, zone, peer, nb_view, dname) -> None:
# We assume that the SOA rdataset has at least one record (it usually does).
soa_rdata = soa_rrset[0] # Get the first SOA record
@@ -164,23 +109,27 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
self.server.keyring, keyname=query.keyname, original_id=query.id
)
else:
# If key is unknown
# unknown key — use an empty keyring so use_tsig() does not
# raise KeyError trying to look up a key that isn't present.
response.set_rcode(dns.rcode.REFUSED)
# Generate new key for keyname provided
# keyname = query.keyname
# b64 = base64.b64encode(urandom(32)).decode("ascii")
# newkey = dns.tsigkeyring.from_text({ keyname: b64 })
response.use_tsig(
self.server.keyring,
{}, # empty keyring; no signing
keyname=query.keyname,
tsig_error=dns.rcode.BADKEY,
)
data = response.to_wire(max_size=512)
self._send_response(data)
logger.debug(f"{peer} SOA {nb_view.name}/{dname}")
LOGGER.info(f"{peer} SOA {nb_view.name}/{dname}")
def _handle_axfr_request(self, query, zone, peer, nb_view, dname) -> None:
if query.keyname not in self.server.keyring:
LOGGER.error(f"AXFR aborted: keyname {query.keyname} not in keyring")
self._deny_request(query)
return
tsig_key = self.server.keyring[query.keyname]
rrsets = []
soa_rrset = None
for name, rdataset in zone.iterate_rdatasets():
@@ -192,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
@@ -211,9 +164,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# 3. Loop through RRsets
tsig_ctx = None
for rrset in rrsets:
# logger.debug(f"Iterating over rrset {rrset}. tsig_ctx: {tsig_ctx}")
try:
# logger.debug(f"Adding {rrset} to renderer object")
r.add_rrset(dns.renderer.ANSWER, rrset)
if r.max_size - len(r.output.getvalue()) < self.RESERVED_TSIG:
raise dns.exception.TooBig("TSIG wont fit")
@@ -222,7 +173,8 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
r.write_header()
tsig_ctx = r.add_multi_tsig(
ctx=tsig_ctx,
secret=self.server.keyring[query.keyname],
secret=tsig_key.secret,
algorithm=tsig_key.algorithm,
keyname=query.keyname,
fudge=300,
id=query.id,
@@ -245,26 +197,32 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
query.question[0].rdtype,
query.question[0].rdclass,
)
r.add_rrset(dns.renderer.ANSWER, rrset)
try:
r.add_rrset(dns.renderer.ANSWER, rrset)
except dns.exception.TooBig:
LOGGER.error(
f"RRset {rrset.name}/{dns.rdatatype.to_text(rrset.rdtype)} "
f"exceeds MAX_WIRE ({self.MAX_WIRE}) and cannot be sent; "
f"skipping."
)
# 4. Final message with terminating TSIG
r.write_header()
# logger.debug(f"Final message. tsig_ctx: {tsig_ctx}")
tsig_ctx = r.add_multi_tsig(
ctx=tsig_ctx,
secret=self.server.keyring[query.keyname],
secret=tsig_key.secret,
algorithm=tsig_key.algorithm,
keyname=query.keyname,
fudge=300,
id=query.id,
tsig_error=0,
other_data=b"",
# request_mac=r.mac if tsig_ctx else None,
request_mac=r.mac if tsig_ctx else query.mac,
)
wire = r.get_wire()
self._send_response(wire)
logger.debug(f"{peer} AXFR {nb_view.name}/{dname}")
LOGGER.info(f"{peer} AXFR {nb_view.name}/{dname}")
def _handle_dns_query(self, wire) -> None:
peer = self.client_address[0]
@@ -277,17 +235,17 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
)
except dns.tsig.BadSignature as e:
logger.warning(f"Request denied from {peer} failed TSIG verification: {e}")
LOGGER.warning(f"Request denied from {peer} failed TSIG verification: {e}")
self._deny_request_bad_tsig(wire, dns.rcode.BADSIG)
return
except (dns.message.UnknownTSIGKey, dns.tsig.BadAlgorithm) as e:
logger.warning(f"Request denied from {peer} with bad TSIG key: {e}")
LOGGER.warning(f"Request denied from {peer} with bad TSIG key: {e}")
self._deny_request_bad_tsig(wire, dns.rcode.BADKEY)
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
@@ -302,15 +260,20 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Only process AXFR/SOA queris
if qtype not in (dns.rdatatype.AXFR, dns.rdatatype.SOA):
logger.warning(
LOGGER.warning(
f"Request denied from {peer}: Request was not AXFR or SOA (Type: {qtype})"
)
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")
LOGGER.warning(f"Request denied from {peer}: No TSIG key used")
self._deny_request(query)
return
@@ -319,36 +282,41 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# Check if the key matches a view
nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view:
logger.warning(
LOGGER.warning(
f"Request denied from {peer}: {key_name} does not match a view"
)
self._deny_request(query)
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}")
LOGGER.warning(f"Zone {dname} not found in view {nb_view.name}")
self._deny_request(query)
return
# Retrieve the existing SOA record from the Zone
soa_rrset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA)
if soa_rrset is None:
logger.error(f"Zone {dname} has no SOA — aborting")
LOGGER.error(f"Zone {dname} has no SOA — aborting")
return
if qtype == dns.rdatatype.SOA:
self._handle_soa_request(query, soa_rrset, zone, peer, nb_view, dname)
self._track_seen_client(peer, nb_view)
elif qtype == dns.rdatatype.AXFR:
self._handle_axfr_request(query, zone, peer, nb_view, dname)
self._track_seen_client(peer, nb_view)
class UDPRequestHandler(DNSBaseRequestHandler):
transport = "udp"
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
@@ -362,13 +330,15 @@ class UDPRequestHandler(DNSBaseRequestHandler):
try:
self._handle_dns_query(data)
except Exception as e:
logger.error(f"Error handling request from {peer}: {e}")
LOGGER.error(f"Error handling request from {peer}: {e}")
import traceback
traceback.print_exc()
class TCPRequestHandler(DNSBaseRequestHandler):
transport = "tcp"
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
@@ -404,9 +374,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
self._handle_dns_query(wire)
except socket.timeout:
logger.debug(f"Connection from {peer} timed out")
LOGGER.debug(f"Connection from {peer} timed out")
except Exception as e:
logger.error(f"Error handling request from {peer}: {e}")
LOGGER.error(f"Error handling request from {peer}: {e}")
import traceback
traceback.print_exc()
@@ -0,0 +1,32 @@
# Generated by Django 5.2.9 on 2026-06-09 21:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_dns', '0030_dnsseckeytemplate_comments_dnsseckeytemplate_owner_and_more'),
('netbox_dns_bridge', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='integerkeyvaluesetting',
options={},
),
migrations.CreateModel(
name='SeenTransferClients',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('source_ip', models.GenericIPAddressField()),
('last_seen', models.DateTimeField()),
('view', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='seen_transfer_clients', to='netbox_dns.view')),
],
options={
'ordering': ('source_ip',),
'constraints': [models.UniqueConstraint(fields=('source_ip', 'view'), name='unique_ip_view_seen_client')],
},
),
]
@@ -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"
),
),
]
Whitespace-only changes.
+60 -11
View File
@@ -1,33 +1,82 @@
from django.db import models
import netbox.models
# import netbox.models
import netbox_dns.models
from netbox.models import NetBoxModel
class IntegerKeyValueSetting(models.Model):
key = models.CharField(max_length=64)
value = models.IntegerField()
def __str__(self):
return f"{self.key}: {str(self.value)}"
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)
class Meta:
default_permissions = ()
ordering = ("view__name",)
verbose_name = "Catalog Zone"
verbose_name_plural = "Catalog Zones"
def __str__(self):
return f"{self.view.name}: {self.soa_serial}"
class CatalogZoneMemberIdentifier(models.Model):
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 SeenTransferClient(NetBoxModel):
source_ip = models.GenericIPAddressField()
last_seen = models.DateTimeField()
view = models.ForeignKey(
to=netbox_dns.models.View,
on_delete=models.PROTECT,
related_name="seen_transfer_clients",
)
def __str__(self):
return f"{self.source_ip} ({self.view.name})"
class Meta:
ordering = ("source_ip",)
verbose_name = "Seen Transfer Client"
verbose_name_plural = "Seen Transfer Clients"
constraints = [
models.UniqueConstraint(
fields=["source_ip", "view"],
name="unique_ip_view_seen_client",
),
]
+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",
)
-37
View File
@@ -1,37 +0,0 @@
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from netbox_dns.models import Zone
from .service.endpoint import catalog_zone_manager as catzm
@receiver(pre_save, sender=Zone)
def zone_pre_save(sender, instance, **kwargs):
"""
Cache the old name so post_save can see if it changed.
"""
if instance.pk:
try:
instance._old_name = sender.objects.only("name").get(pk=instance.pk).name
except sender.DoesNotExist:
instance._old_name = None
else:
instance._old_name = None
@receiver(post_save, sender=Zone)
def sync_catalog_zone_identifier(sender, instance, created, **kwargs):
"""
Ensure CatalogZoneMemberIdentifier exists for each Zone
and keep its identifier in sync.
"""
if created:
# Create the related object on Zone creation
catzm.update_member_identifier(instance)
else:
old_name = getattr(instance, "_old_name", None)
# If the name did not change, do nothing
if old_name == instance.name:
return
catzm.update_member_identifier(instance)
+1
View File
@@ -0,0 +1 @@
from . import netbox_dns_zones
@@ -0,0 +1,96 @@
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.db import transaction
from netbox.context import current_request
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)
def zone_pre_save(sender, instance, **kwargs):
zone = instance
if zone.pk:
try:
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)
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 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
# 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():
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 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 the catalog zone member record for this zone.
catzm.update_member(zone)
else:
# 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) or view_changed:
catzm.update_member(zone)
@receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs):
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 -22
View File
@@ -1,35 +1,103 @@
import os
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, record.ttl)
rdataset.add(rdata)
# 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 = ()
-645
View File
@@ -1,645 +0,0 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
# in a server-like mode.
clear-cache-post-run=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\\' represents the directory delimiter on Windows systems,
# it can't be used as an escape character.
ignore-paths=
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked and
# will not be imported (useful for modules/projects where namespaces are
# manipulated during runtime and thus existing member attributes cannot be
# deduced by static analysis). It supports qualified module names, as well as
# Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Resolve imports to .pyi stubs if available. May reduce no-member messages and
# increase not-an-iterable messages.
prefer-stubs=no
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.13
# Discover python modules and packages in the file system subtree.
recursive=no
# Add paths to the list of the source roots. Supports globbing patterns. The
# source root is an absolute path or a path relative to the current working
# directory used to determine a package namespace for modules located under the
# source root.
source-roots=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
#argument-naming-style=snake_case
argument-naming-style=camelCase
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=camelCase
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=camelCase
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type alias names. If left empty, type
# alias names will be checked with the set naming style.
#typealias-rgx=
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
asyncSetUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=120
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=no
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
use-implicit-booleaness-not-comparison-to-string,
use-implicit-booleaness-not-comparison-to-zero
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
# Let 'consider-using-join' be raised when the separator to join on would be
# non-empty (resulting in expected fixes of the type: ``"- " + " -
# ".join(items)``)
suggest-join-with-non-empty-separator=yes
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are: text, parseable, colorized,
# json2 (improved json format), json (old json format) and msvs (visual
# studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. No available dictionaries : You need to install
# both the python package and the system dependency for enchant to work.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+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 = ["."]