Compare commits

..
72 Commits
Author SHA1 Message Date
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
Sven Luthi 53b12c9e15 modified: README.md - Improve post 1.0.7 upgrade guide.
modified:   pyproject.toml - Fix to licensing file reference.
2026-04-09 13:58:55 +02:00
Sven Luthi d56a8f9140 modified: README.md 2026-04-08 20:32:00 +02:00
Sven Luthi c32161b8a2 modified: netbox_dns_bridge/catalog_zone_manager.py
modified:   netbox_dns_bridge/dns_server.py
modified:   netbox_dns_bridge/management/commands/dns-export-zone.py
modified:   netbox_dns_bridge/management/commands/dns-export-zones.py
modified:   netbox_dns_bridge/management/commands/dns-settings.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
modified:   netbox_dns_bridge/migrations/0001_initial.py
modified:   netbox_dns_bridge/models.py
modified:   netbox_dns_bridge/request_handler.py
modified:   netbox_dns_bridge/signals.py
modified:   netbox_dns_bridge/utils.py
Black code formating corrected
2026-04-08 20:14:38 +02:00
Sven Luthi 165a0faa67 modified: docs/architecture-overview.drawio
modified:   docs/architecture-overview.svg
2026-04-08 20:09:46 +02:00
Sven Luthi 5a20737ba9 modified: README.md 2026-04-08 20:09:46 +02:00
sven f713719543 modified: pyproject.toml
Fix paths after restructuring

renamed:    netbox_dns_bridge/management/commands/export-dns-zone.py -> netbox_dns_bridge/management/commands/dns-export-zone.py
renamed:    netbox_dns_bridge/management/commands/export-all-dns-zones.py -> netbox_dns_bridge/management/commands/dns-export-zones.py
Unify command naming scheme

new file:   netbox_dns_bridge/migrations/0001_initial.py
Adds the missing migration. Since the plugin was renamed this is also an opportunity to merge all changes into a single migration.

renamed:    netbox_dns_bridge/service/transfer/catalog_zone_manager.py -> netbox_dns_bridge/catalog_zone_manager.py
renamed:    netbox_dns_bridge/service/transfer/dns_server.py -> netbox_dns_bridge/dns_server.py
new file:   netbox_dns_bridge/management/commands/dns-settings.py
modified:   netbox_dns_bridge/management/commands/dns-transfer-endpoint.py
renamed:    netbox_dns_bridge/service/transfer/request_handler.py -> netbox_dns_bridge/request_handler.py
deleted:    netbox_dns_bridge/service/transfer/__init__.py
Another restructure to simplify code re-use. New command dns-settings added to allow setting of catalog zone serial.
This will be needed to migrate from previous versions.
2026-04-08 20:09:13 +02:00
sven 5f48472ba4 modified: README.md 2026-04-08 18:58:49 +02:00
sven 79ae6c2068 modified: README.md
modified:   netbox_dns_bridge/__init__.py
modified:   CHANGELOG.md
Change versioning scheme to mach netbox-plugin-dns
2026-04-08 18:58:37 +02:00
Sven Luthi 756c491e28 Renaming Project and changing license 2026-04-08 10:24:24 +02:00
sven 4a1930a896 new file: CHANGELOG.md
modified:   README.md
2026-03-02 18:44:50 +01:00
Sven Luthi 5040ce70a0 modified: README.md
modified:   pyproject.toml
deleted:    requirements.txt
2026-02-18 16:59:37 +01:00
Sven Luthi 4d3b01127b Bump to v1.0.7 2026-02-18 15:51:55 +01:00
Sven Luethi 997f36264a modified: src/netbox_plugin_bind_provisioner/__init__.py
Bumping version to v1.0.6
2026-02-09 16:54:17 +01:00
Sven Luethi 19a946c880 - Split bind transfer command into multiple files for better code readability.
- Rename bind-transfer-endpoint command -> dns-transfer-endpoint.
2026-02-05 12:27:28 +01:00
Sven Luethi 9afdfe869b - Store catz member zone identifiers in database, linked to the corresponding zone object.
- Catz member identifiers are created from uuid encoded in base32, lower-cased, and padding removed.
- On endpoint startup, make sure all zones have a catz identifier assigned. If not, create them.
- Use signals to create or update a zone's catz identifier.
2026-02-05 12:26:55 +01:00
Sven Luethi 1fae3bfe54 Adds back the changes made to support ipv6 in commit 8fba131230 but that were reverted with commit ca51a6054d to allow proper merging of another complex PR. Closes #11 again
Also includes a small bug fix that was added with commit 6bbb2e9905 but that was also temporarily reverted with 45f9631a98.
2026-01-31 09:43:19 +01:00
Sven Luethi 562f54d481 Merge pull request #12 from spamik/support-tcp-soa
Support SOA request on TCP by merging the dns handling while leaving the transport handling in protocol specific handlers (partially). Further review pending
2026-01-31 09:08:42 +01:00
Sven Luethi ca51a6054d This reverts commit 8fba131230. 2026-01-31 09:01:04 +01:00
Sven Luethi 45f9631a98 This reverts commit 6bbb2e9905. 2026-01-31 09:00:44 +01:00
spm 0d041a0830 Handle more DNS requests via one TCP connection 2026-01-29 15:58:32 +01:00
Sven Luethi 6bbb2e9905 modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Catches a further exception if the wrong algorithm is specified for the tsig key used.
2026-01-29 11:11:49 +01:00
Sven Luethi 8fba131230 modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fixes issue #11 (IPv6 support)
2026-01-29 11:10:31 +01:00
spm a9dbbe8c88 Support SOA request on TCP
- unified handling of DNS queries on TCP and UDP channels
- as a result it now responds on SOA requests over TCP
2026-01-28 17:35:58 +01:00
Sven Luethi 9a2b0a2c2b modified: src/netbox_plugin_bind_provisioner/__init__.py
Bump version to v1.0.5
2026-01-09 14:46:09 +01:00
Sven Luethi 55d891f38c modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Slight changes to previous commit:

1. Stop converting the zone ID to string for catalog zone PTR record creation
   and simply use its int type.

2. Extend padding of ID in catalog zone PTR record from 6 digits to 9.
   Auto-incrementing of zone IDs may create larger IDs then expected if the
   turn-over of zones is high. Precautionary measure.

3. Fix catalog zone SOA record creation - A slight mistake was introduced in
   previous commit that would render the set the MNAME field to
   "invalid.zones.catz." rather than the intended "invalid." value.

4. Run code through black formater to stay compliant.
2026-01-09 13:36:08 +01:00
Sven Luethi 1375bbb7e3 Merge pull request #9 from jpmens/ttl_mname
Use TTL=0, alter MNAME,RNAME and rename PTR owners
2026-01-09 13:19:44 +01:00
Jan-Piet Mens 723566db70 Use TTL=0, alter MNAME,RNAME and rename PTR owners
I've opted for a slightly shorter name for PTR owner names
  The result looks like this:

  ```
  catz.			0	IN	SOA	invalid. invalid. 23 60 10 1209600 0
  zid-000002.zones.catz.	0	IN	PTR	2.0.192.in-addr.arpa.
  zid-000001.zones.catz.	0	IN	PTR	example.net.
  zid-000003.zones.catz.	0	IN	PTR	jpmens.net.
  zid-000004.zones.catz.	0	IN	PTR	tcp53.ch.
  catz.			0	IN	NS	invalid.
  version.catz.		0	IN	TXT	"2"
  ```
2026-01-09 08:53:59 +01:00
Sven Luethi e8450c90da modified: README.md
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Remove code and description that imported old catz serials from flat files that were used before switch to DB model.
Code and docs no longer applicable so removed.
2026-01-09 02:59:36 +01:00
Sven Luethi ae6bb3c1b6 modified: README.md 2026-01-09 02:50:58 +01:00
Sven Luthi 0028ba8d80 new file: src/netbox_plugin_bind_provisioner/migrations/0002_alter_integerkeyvaluesetting_options_and_more.py
modified:   src/netbox_plugin_bind_provisioner/models.py
Remove the settings model from Netbox's Permission system as these values are for internal use of the plugin only.
2025-12-18 15:53:38 +01:00
Sven Luthi 78ed040495 modified: README.md
Update readme
2025-12-18 15:03:10 +01:00
Sven Luthi 20064ec9bd modified: src/netbox_plugin_bind_provisioner/__init__.py
new file:   src/netbox_plugin_bind_provisioner/management/commands/export-all-dns-zones.py
new file:   src/netbox_plugin_bind_provisioner/management/commands/export-dns-zone.py
new file:   src/netbox_plugin_bind_provisioner/utils.py
Adds export commands for zones to bind zone files and bump version to v1.0.4
2025-12-11 15:59:21 +01:00
Sven Luethi 6ac1548eef modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Improve TXT record formating.
2025-12-01 19:22:11 +01:00
Sven Luethi d8f4b83c2a modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Allows for TXT records to be added without quotation. This will apply the correct quoting and
chunking where needed.
2025-11-29 13:36:18 +01:00
Sven Luethi fa032b1ea3 modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fixes bug: TXT records were truncated instead of chunked when larger then 255 characters and on semicolons.
2025-11-28 18:52:00 +01:00
Sven Luethi e6aaa3a54d modified: src/netbox_plugin_bind_provisioner/__init__.py
modified:   src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
modified:   src/netbox_plugin_bind_provisioner/migrations/0001_initial.py
modified:   src/netbox_plugin_bind_provisioner/models.py
Run code through black formater and bump version string to v1.0.0
2025-11-27 11:26:35 +01:00
Sven Luethi eee5395ea6 modified: src/netbox_plugin_bind_provisioner/management/commands/bind-transfer-endpoint.py
Fixes custom properties in catalog zone, adding dnssec policy as group property instead
(as spcified in RFC9432)
2025-11-27 11:25:30 +01:00
35 changed files with 1604 additions and 1823 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 @@
src/netbox_plugin_bind_provisioner.egg-info
dist/ dist/
build/ build/
__pycache__ __pycache__
venv/
.venv/
netbox_plugin_dns_bridge.egg-info/
+12
View File
@@ -0,0 +1,12 @@
## 1.0.7 - 2026-03-02
README Change - Moving private keys to global scope since Bind 9.20 view scoped keys have become unreliable and sometimes wouldnt match.
## 1.0.8 - 2026-03-12
- Change License to MIT to match the netbox-plugin-dns License. This project rests on the netbox-plugin-dns so a matching
license makes more sense.
- Renaming Project from Netbox Plugin Bind Provisioner to Netbox DNS Bridge as new code will be contributed that allows
data to flow in both directions (Dynamic Updates), not just out of Netbox DNS.
## 1.5.0 - 2026-04-08
- Change to versioning scheme - Now matches the major and minor version number to the one of netbox-plugin-dns. Only the minor sub version
will be used to track incremental changes to this plugin.
+14 -335
View File
@@ -1,339 +1,18 @@
GNU GENERAL PUBLIC LICENSE MIT License
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc., Copyright (c) 2025-2026 Sven Luethi
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the “Software”), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The licenses for most software are designed to take away your The above copyright notice and this permission notice shall be included in all copies or substantial
freedom to share and change it. By contrast, the GNU General Public portions of the Software.
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
price. Our General Public Licenses are designed to make sure that you NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
have the freedom to distribute copies of free software (and charge for NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
this service if you wish), that you receive source code or can get it OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
if you want it, that you can change the software or use pieces of it CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+1 -1
View File
@@ -1 +1 @@
recursive-include netbox_bind_provisioner *.py recursive-include netbox_dns_bridge *.py
+126 -60
View File
@@ -1,32 +1,43 @@
# Netbox Bind Provisioner # NetBox DNS Bridge
A NetBox plugin that implements a lightweight DNS server to automate the
provisioning and configuration of a BIND9 server based on DNS records The NetBox DNS Bridge plugin extends [NetBox DNS](https://github.com/sys4/netbox-plugin-dns) by
maintained in NetBox. (netbox-plugin-dns). 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-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/Month"></a>
<a href="https://pepy.tech/project/netbox-plugin-dns-bridge"><img src="https://static.pepy.tech/personalized-badge/netbox-plugin-dns-bridge?period=weekly&left_color=BLACK&right_color=BLUE&left_text=Downloads%2fWeek" alt="Downloads/Week"></a>
## Plugin configuration ## Plugin configuration
The plugin has been reworked: instead of exporting zone files, it now provides While providing Zone transfers via AXFR, the Server also exposes specialized catalog zones that BIND
a minimal DNS server that is fed directly from NetBox’s DNS data. This server and other RFC9432 compliant DNS Servers use to automatically discover newly created zones and remove
also exposes specialized catalog zones that BIND uses to automatically discover deleted ones. The plugin supports views and basic DNS security via TSIG.
newly created zones and remove deleted ones. The plugin supports BIND views and
basic DNS security via TSIG.
The plugin exposes one catalog zone per view. Each catalog zone is available The plugin exposes one catalog zone per view. Each catalog zone is made available under the special
under the special zone name **"catz"** and can be queried through the built-in zone name **"catz"** and addtionally under **"[viewname].catz"** and may be queried through the
DNS server like any other zone. built-in DNS server just like any other dns zone.
For proper operation, each view requires an installed TSIG key, and the For proper operation, each view requires an installed TSIG key, and the `dns-transfer-endpoint` must
`bind-transfer-endpoint` must be running as a separate background service using be running as a separate background service using the `manage.py` command. Note that DNSSEC support
the `manage.py` command. Note that DNSSEC support will be added once BIND9 will be added once BIND9 provides a mechanism to configure it through the Catalog Zones system.
provides a mechanism to configure it through the Catalog Zones system.
To start the service: To start the transfer endpoint service in the foreground:
``` ```
manage.py bind-transfer-endpoint --port 5354 manage.py dns-transfer-endpoint --port 5354
``` ```
This process needs to be scheduled as a background service for the built-in DNS Server to work
When configuring the plugin, you need to provide a path on the filesystem for correctly. For Linux users with Systemd (Ubuntu, etc), Matt Kollross provides a startup unit and
this file where the plugin can create and manage it. Please do not update it by instructions [here](docs/install-systemd-service.md).
hand or remove it unless you know what you are doing.
### Service parameters ### Service parameters
Parameter | Description Parameter | Description
@@ -35,23 +46,75 @@ Parameter | Description
--address | IP of interface to bind to (defaults to 0.0.0.0) --address | IP of interface to bind to (defaults to 0.0.0.0)
### Plugin settings ### Plugin settings
Setting | Description Plugin settings should be configured under `PLUGINS_CONFIG` in `netbox_dns_bridge`:
--------------------| --------------------------------------------------------- ```
catalog_serial_file | !!!NO LONGER USED!!! Replaced by DB entry. PLUGINS_CONFIG = {
tsig_keys | Maps a TSIG Key to be used for each view. 'netbox_dns_bridge': {
...
...
}
}
```
#### TSIG Authentication
Following sets the TSIG key that allows clients to query the transfer endpoint and also they 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
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 Settings
```
'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_over_tcp': True
```
Switch to using TCP instead of UDP. Defaults to False
## Plugin compatibility
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`.
## Installation guide ## Installation guide
This setup provisions a BIND9 server directly with DNS data from NetBox. This setup provisions a BIND9 server directly with DNS data from NetBox. BIND9 can optionally run on
BIND9 can optionally run on a separate server. If so, any reference to a separate server. If so, any reference to 127.0.0.1 in step 6 must be replaced with the IP address
127.0.0.1 in step 6 must be replaced with the IP address of the NetBox host. of the NetBox host. TCP and UDP traffic from the BIND9 server to the NetBox host must be allowed on
TCP and UDP traffic from the BIND9 server to the NetBox host must be allowed on
port 5354 (or the port you have configured). port 5354 (or the port you have configured).
This guide assumes: This guide assumes:
- Netbox has been installed under /opt/netbox - NetBox has been installed under /opt/netbox
- Bind9 is installed on the same host as Netbox - Bind9 is installed on the same host as NetBox
- The Netbox DNS Plugin netbox-plugin-dns is installed - The NetBox DNS Plugin netbox-plugin-dns is installed
- The following dns views exist in Netbox DNS: - The following dns views exist in NetBox DNS:
- `public` (the default) - `public` (the default)
- `private` - `private`
@@ -62,7 +125,7 @@ This guide assumes:
2. Adding required package 2. Adding required package
``` ```
cd netbox cd netbox
echo netbox-plugin-bind-provisioner >> local_requirements.txt echo netbox-plugin-dns-bridge >> local_requirements.txt
. venv/bin/activate . venv/bin/activate
pip install -r local_requirements.txt pip install -r local_requirements.txt
``` ```
@@ -74,10 +137,10 @@ This guide assumes:
``` ```
to to
``` ```
PLUGINS = ['netbox_dns', 'netbox_plugin_bind_provisioner'] PLUGINS = ['netbox_dns', 'netbox_dns_bridge']
``` ```
Configure the Bind Exporter Plugin using the PLUGINS_CONFIG dictionary. Configure the DNS Bridge Plugin using the PLUGINS_CONFIG dictionary.
Change Change
``` ```
PLUGINS_CONFIG = {} PLUGINS_CONFIG = {}
@@ -85,7 +148,7 @@ This guide assumes:
to to
``` ```
PLUGINS_CONFIG = { PLUGINS_CONFIG = {
"netbox_plugin_bind_provisioner": { "netbox_dns_bridge": {
"tsig_keys": { "tsig_keys": {
"public": { "public": {
"keyname": "public_view_key", "keyname": "public_view_key",
@@ -104,33 +167,31 @@ This guide assumes:
Note that the tsig-key attributes keyname, algorithm and secret form a Note that the tsig-key attributes keyname, algorithm and secret form a
dictionary in following python structure path: dictionary in following python structure path:
``` ```
PLUGINS_CONFIG.netbox_plugin_bind_provisioner.tsig_keys.<dns_view_name> PLUGINS_CONFIG.netbox_dns_bridge.tsig_keys.<dns_view_name>
``` ```
This allows the plugin to map requests to the right dns view using the tsig This allows the plugin to map requests to the right dns view using the tsig
signature from each request. signature from each request.
4. Run migrations 4. Run migrations
``` ```
python3 netbox/manage.py migrate manage.py migrate
``` ```
5. Start listener 5. Start listener
This step runs the DNS endpoint used by bind to configure itself. You may want
to write a service wrapper that runs this in the background.
A guide for setting up a systemd service on Ubuntu is provided by Matt
Kollross [here](docs/install-systemd-service.md). Dont forget to activate
the venv if you do decide to run this service in the background.
Note that `--port 5354` is optional. The listener will bind this port This step runs the DNS endpoint used by bind to configure itself. You may want to write a
by default. service wrapper that runs this in the background. A guide for setting up a systemd service on
Ubuntu is provided by Matt Kollross [here](docs/install-systemd-service.md). Dont forget to
activate the venv if you do decide to run this service in the background.
Note that `--port 5354` is optional. The listener will bind this port by default.
``` ```
python3 netbox/manage.py bind-transfer-endpoint --port 5354 manage.py dns-transfer-endpoint --port 5354
``` ```
6. Configuring a Bind9 to interact with Netbox via the bind-transfer-endpoint 6. Configuring a Bind9 to interact with NetBox via the dns-transfer-endpoint endpoint. Note that its
endpoint. Note that its not possible to give all the correct details of the not possible to give all the correct details of the `options` block as it is heavily dependent
`options` block as it is heavily dependent on the Operating System used. on the Operating System used. Please dont forget to adjust as required.
Please dont forget to adjust as required.
``` ```
########## OPTIONS ########## ########## OPTIONS ##########
@@ -158,15 +219,23 @@ This guide assumes:
192.168.0.0/16; 192.168.0.0/16;
}; };
########## ZONES ########## ######## TSIG Keys ########
view "public" {
key "public_view_key" { key "public_view_key" {
algorithm hmac-sha256; algorithm hmac-sha256;
secret "base64-encoded-secret"; secret "base64-encoded-secret";
}; };
key "private_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
###########################
########## ZONES ##########
view "public" {
match-clients { public; }; match-clients { public; };
allow-notify { key "public_view_key"; };
catalog-zones { catalog-zones {
zone "catz" zone "catz"
@@ -184,12 +253,8 @@ This guide assumes:
}; };
view "private" { view "private" {
key "private_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
match-clients { private; }; match-clients { private; };
allow-notify { key "private_view_key"; };
catalog-zones { catalog-zones {
zone "catz" zone "catz"
@@ -209,3 +274,4 @@ This guide assumes:
7. Restart bind - Done 7. Restart bind - Done
+76
View File
@@ -0,0 +1,76 @@
<mxfile host="Electron" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.0.3 Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36" version="29.0.3">
<diagram name="Page-1" id="inkmD5hxPkRoHiEHOzAS">
<mxGraphModel dx="831" dy="2028" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="WlUYMBEI5UY_wtPVGHlU-24" value="" style="whiteSpace=wrap;html=1;aspect=fixed;" parent="1" vertex="1">
<mxGeometry x="560" y="457.5" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-13" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;fontColor=#333333;strokeColor=#666666;" parent="1" vertex="1">
<mxGeometry x="70" y="210" width="600" height="220" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-12" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="1" vertex="1">
<mxGeometry x="430" y="260" width="230" height="150" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-3" value="&lt;font style=&quot;font-size: 18px;&quot;&gt;Netbox&lt;/font&gt;" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" parent="1" vertex="1">
<mxGeometry x="80" y="220" width="60" height="30" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-4" value="DNS" style="whiteSpace=wrap;html=1;aspect=fixed;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=1;" parent="1" vertex="1">
<mxGeometry x="300" y="260" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-6" value="IPAM" style="whiteSpace=wrap;html=1;aspect=fixed;fillColor=#f8cecc;strokeColor=#b85450;gradientColor=none;rounded=1;" parent="1" vertex="1">
<mxGeometry x="80" y="260" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-7" value="DHCP&lt;br&gt;(WIP)" style="whiteSpace=wrap;html=1;aspect=fixed;fillColor=#fff2cc;strokeColor=#d6b656;rounded=1;" parent="1" vertex="1">
<mxGeometry x="190" y="260" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="MSiTSJWh-g0ETCA7Z0x5-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="WlUYMBEI5UY_wtPVGHlU-9" target="WlUYMBEI5UY_wtPVGHlU-4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-9" value="Transfer&lt;br&gt;Endpoint" style="whiteSpace=wrap;html=1;aspect=fixed;fillColor=#d5e8d4;strokeColor=#82b366;rounded=1;" parent="1" vertex="1">
<mxGeometry x="440" y="300" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-10" value="Dynamic Update&lt;br&gt;Endpoint" style="whiteSpace=wrap;html=1;aspect=fixed;fillColor=#d5e8d4;strokeColor=#82b366;rounded=1;" parent="1" vertex="1">
<mxGeometry x="550" y="300" width="100" height="100" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-11" value="DNS Bridge" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" parent="1" vertex="1">
<mxGeometry x="440" y="270" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;startArrow=classic;startFill=1;" parent="1" source="WlUYMBEI5UY_wtPVGHlU-16" target="WlUYMBEI5UY_wtPVGHlU-9" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="490" y="450" />
<mxPoint x="490" y="450" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-27" value="AXFR" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="WlUYMBEI5UY_wtPVGHlU-18" connectable="0" vertex="1">
<mxGeometry x="-0.2264" y="2" relative="1" as="geometry">
<mxPoint x="2" y="5" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-16" value="" style="whiteSpace=wrap;html=1;aspect=fixed;" parent="1" vertex="1">
<mxGeometry x="440" y="457.5" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-20" value="" style="whiteSpace=wrap;html=1;aspect=fixed;" parent="1" vertex="1">
<mxGeometry x="450" y="467.5" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-19" value="DNS Server&lt;br&gt;(RFC9432)" style="whiteSpace=wrap;html=1;aspect=fixed;" parent="1" vertex="1">
<mxGeometry x="460" y="477.5" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-23" value="" style="whiteSpace=wrap;html=1;aspect=fixed;" parent="1" vertex="1">
<mxGeometry x="570" y="467.5" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="WlUYMBEI5UY_wtPVGHlU-25" value="Client&lt;br&gt;(RFC2136)" style="whiteSpace=wrap;html=1;aspect=fixed;" parent="1" vertex="1">
<mxGeometry x="580" y="477.5" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="MSiTSJWh-g0ETCA7Z0x5-3" value="Update" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="WlUYMBEI5UY_wtPVGHlU-24" target="WlUYMBEI5UY_wtPVGHlU-10">
<mxGeometry x="-0.3913" relative="1" as="geometry">
<mxPoint y="1" as="offset" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Do not edit this file with editors other than draw.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" style="background: #ffffff; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="601px" height="349px" viewBox="0 0 601 349" content="&lt;mxfile host=&quot;Electron&quot; agent=&quot;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.0.3 Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36&quot; version=&quot;29.0.3&quot; scale=&quot;1&quot; border=&quot;0&quot;&gt;&#10; &lt;diagram name=&quot;Page-1&quot; id=&quot;inkmD5hxPkRoHiEHOzAS&quot;&gt;&#10; &lt;mxGraphModel dx=&quot;706&quot; dy=&quot;1724&quot; grid=&quot;1&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;850&quot; pageHeight=&quot;1100&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;&#10; &lt;root&gt;&#10; &lt;mxCell id=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-24&quot; value=&quot;&quot; style=&quot;whiteSpace=wrap;html=1;aspect=fixed;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;560&quot; y=&quot;457.5&quot; width=&quot;80&quot; height=&quot;80&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-13&quot; value=&quot;&quot; style=&quot;rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;fontColor=#333333;strokeColor=#666666;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;70&quot; y=&quot;210&quot; width=&quot;600&quot; height=&quot;220&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-12&quot; value=&quot;&quot; style=&quot;rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;430&quot; y=&quot;260&quot; width=&quot;230&quot; height=&quot;150&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-3&quot; value=&quot;&amp;lt;font style=&amp;quot;font-size: 18px;&amp;quot;&amp;gt;Netbox&amp;lt;/font&amp;gt;&quot; style=&quot;text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;80&quot; y=&quot;220&quot; width=&quot;60&quot; height=&quot;30&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-4&quot; value=&quot;DNS&quot; style=&quot;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=1;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;300&quot; y=&quot;260&quot; width=&quot;100&quot; height=&quot;100&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-6&quot; value=&quot;IPAM&quot; style=&quot;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#f8cecc;strokeColor=#b85450;gradientColor=none;rounded=1;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;80&quot; y=&quot;260&quot; width=&quot;100&quot; height=&quot;100&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-7&quot; value=&quot;DHCP&amp;lt;br&amp;gt;(WIP)&quot; style=&quot;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#fff2cc;strokeColor=#d6b656;rounded=1;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;190&quot; y=&quot;260&quot; width=&quot;100&quot; height=&quot;100&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;MSiTSJWh-g0ETCA7Z0x5-4&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;&quot; parent=&quot;1&quot; source=&quot;WlUYMBEI5UY_wtPVGHlU-9&quot; target=&quot;WlUYMBEI5UY_wtPVGHlU-4&quot; edge=&quot;1&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-9&quot; value=&quot;Transfer&amp;lt;br&amp;gt;Endpoint&quot; style=&quot;whiteSpace=wrap;html=1;aspect=fixed;fillColor=#d5e8d4;strokeColor=#82b366;rounded=1;&quot; parent=&quot;1&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry x=&quot;440&quot; y=&quot;300&quot; width=&quot;100&quot; height=&quot;100&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;WlUYMBEI5UY_wtPVGHlU-10&quot; value=&quot;Dynamic Update&amp;lt;br&amp;gt;Endpoint&quot; style=&quot;whiteSpace=wrap;html=1;aspect=fixedLine truncated

After

Width:  |  Height:  |  Size: 112 KiB

+7 -7
View File
@@ -1,9 +1,9 @@
1. Create System Unit file 1. Create System Unit file
``` ```
cat <<EOF > /etc/systemd/system/netbox-bind-endpoint.service cat <<EOF > /etc/systemd/system/netbox-dns-endpoint.service
[Unit] [Unit]
Description=Netbox BIND Catalog Endpoing Description=Netbox DNS transfer endpoint
Documentation=https://github.com/Suraxius/netbox-plugin-bind-provisioner Documentation=https://github.com/Suraxius/netbox-plugin-dns-bridge
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -15,7 +15,7 @@
PIDFile=/var/tmp/netbox.pid PIDFile=/var/tmp/netbox.pid
WorkingDirectory=/opt/netbox WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py bind-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 Restart=on-failure
RestartSec=30 RestartSec=30
@@ -28,7 +28,7 @@
2. Enable and start the service 2. Enable and start the service
``` ```
systemctl enable netbox-bind-endpoint systemctl enable netbox-dns-endpoint
systemctl start netbox-bind-endpoint systemctl start netbox-dns-endpoint
systemctl status netbox-bind-endpoint systemctl status netbox-dns-endpoint
``` ```
+29
View File
@@ -0,0 +1,29 @@
from netbox.plugins import PluginConfig
from django.conf import settings
from .logger import get_logger
__version__ = "1.5.6"
LOGGER = get_logger(__name__)
class DNSBridgeConfig(PluginConfig):
name = "netbox_dns_bridge"
verbose_name = "Netbox DNS Bridge"
description = ""
version = __version__
author = "Sven Luethi"
author_email = "dev@sven.luethi.co"
base_url = "dns-bridge"
def ready(self):
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."
)
from . import signals
config = DNSBridgeConfig
+179
View File
@@ -0,0 +1,179 @@
import dns.name
import dns.zone
import dns.rdatatype
import dns.rdataclass
from .logger import get_logger
from netbox_dns.models import Zone as NBZone
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier
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() -> int:
with transaction.atomic():
try:
soa_serial_obj = (
IntegerKeyValueSetting.objects.select_for_update().get_or_create(
key="catalog-zone-soa-serial",
defaults={"value": 1},
)
)
new_soa_serial = soa_serial_obj.value + 1
if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning(
f"Catalog serial {soa_serial_obj.value} reached max — wrapping back to 1"
)
new_soa_serial = 1
soa_serial_obj.value = new_soa_serial
soa_serial_obj.save()
soa_serial_obj.refresh_from_db()
LOGGER.debug(
f"Catalog zone SOA serial number is now {soa_serial_obj.value}"
)
return soa_serial_obj.value
except OperationalError as e:
LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
)
close_old_connections()
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("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
try:
catz_identifier = nb_zone.catz_identifier
except NBZone.catz_identifier.RelatedObjectDoesNotExist:
catz_identifier = CatalogZoneMemberIdentifier.objects.create(
zone=nb_zone, name=_generate_member_identifier()
)
p_name = catz_identifier.name
ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin):
raise ValueError(
f"Catalog zone member identifier {ptr_name.to_text()} not a subdomain"
)
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
soa_rdataset = _create_soa_rdataset()
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_identifier() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: NBZone) -> None:
try:
CatalogZoneMemberIdentifier.objects.update_or_create(
zone=zone,
defaults={"name": _generate_member_identifier()},
)
except OperationalError as e:
LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}")
close_old_connections()
def _create_soa_rdataset() -> dns.rdataset:
try:
serial = IntegerKeyValueSetting.objects.get_or_create(
key="catalog-zone-soa-serial", defaults={"value": 1}
).value
# 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} 60 10 1209600 0",
)
# 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
+46
View File
@@ -0,0 +1,46 @@
import socketserver
import socket
from .logger import get_logger
LOGGER = get_logger(__name__)
class DNSAddressMixin:
def _resolve_address(self, server_address, socktype, proto):
host, port = server_address
infos = socket.getaddrinfo(
host, port, socket.AF_UNSPEC, socktype, proto, socket.AI_PASSIVE
)
family, _, _, _, sockaddr = infos[0]
self.address_family = family
return sockaddr
class TCPDNSServer(DNSAddressMixin, socketserver.TCPServer):
allow_reuse_address = True
def __init__(self, server_address, handler_class, keyring, tsig_view_map):
sockaddr = self._resolve_address(
server_address, socket.SOCK_STREAM, socket.IPPROTO_TCP
)
super().__init__(sockaddr, handler_class)
self.keyring = keyring
self.tsig_view_map = tsig_view_map
class UDPDNSServer(DNSAddressMixin, socketserver.UDPServer):
allow_reuse_address = True
def __init__(self, server_address, handler_class, keyring, tsig_view_map):
sockaddr = self._resolve_address(
server_address, socket.SOCK_DGRAM, socket.IPPROTO_UDP
)
super().__init__(sockaddr, handler_class)
self.keyring = keyring
self.tsig_view_map = tsig_view_map
+12
View File
@@ -0,0 +1,12 @@
import logging
PLUGIN_NAME = "dns-bridge"
def get_logger(name: str) -> logging.Logger:
# name = name.replace("_","-").rsplit(".", 1)[-1]
#
# if name.startswith(PLUGIN_NAME):
# return logging.getLogger(name)
# return logging.getLogger(f"{PLUGIN_NAME}.{name}")
return logging.getLogger(f"{PLUGIN_NAME}")
Whitespace-only changes.
@@ -0,0 +1,49 @@
import netbox_dns.models
from netbox_dns_bridge.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--view",
type=str,
help="The name of the view the Zone to be exported is in",
)
parser.add_argument(
"--zone", type=str, help="The FQDN of the Zone to be exported"
)
parser.add_argument(
"--file", type=str, help="Path of the zone file to be written"
)
def handle(self, *args, **options):
# Load parameters
view_name = options["view"]
zone_name = options["zone"]
file_path = options["file"]
if not view_name:
raise CommandError("No --view parameter given")
elif not zone_name:
raise CommandError("No --zone parameter given")
elif not file_path:
raise CommandError("No --file parameter given")
try:
# Load the zone from NetBox DNS
nb_zone = netbox_dns.models.Zone.objects.get(
view__name=view_name, name=zone_name
)
if nb_zone:
export_bind_zone_file(nb_zone, file_path=file_path)
self.stdout.write(
self.style.SUCCESS(f"Zone '{zone_name}' exported to '{file_path}'")
)
else:
self.stderr.write("Zone not found in Netbox. Aborting")
except Exception as e:
raise CommandError(f"Failed to export zone: {e}")
@@ -0,0 +1,48 @@
import os
import netbox_dns.models
from netbox_dns_bridge.utils import export_bind_zone_file
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--path",
type=str,
help="Path to the directory where zone files are to be written",
)
def handle(self, *args, **options):
# Load parameters
base_path = options["path"]
if not base_path:
raise CommandError("No --path parameter given")
# Ensure base_path exists
os.makedirs(base_path, exist_ok=True)
# Iterate through all zones
nb_zones = netbox_dns.models.Zone.objects.prefetch_related("view").all()
if not nb_zones.exists():
self.stdout.write(self.style.WARNING("No zones found in NetBox DNS."))
return
for zone in nb_zones:
# Determine view directory
view_name = zone.view.name
view_dir = os.path.join(base_path, view_name)
os.makedirs(view_dir, exist_ok=True)
# Zone file path
file_path = os.path.join(view_dir, zone.name)
try:
# Call existing export function
export_bind_zone_file(zone, file_path=file_path)
self.stdout.write(
self.style.SUCCESS(f"Exported zone '{zone.name}' to '{file_path}'")
)
except Exception as e:
self.stderr.write(f"Failed to export zone '{zone.name}': {e}")
@@ -0,0 +1,51 @@
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}")
@@ -0,0 +1,108 @@
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 django.core.management.base import BaseCommand
from django.conf import settings
from django.db import close_old_connections, 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.logger import get_logger
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_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")
secret = data.get("secret")
algorithm_str = 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."
)
continue
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."
)
continue
except OperationalError:
close_old_connections()
# 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_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
)
self.tsig_view_map[key_name_str] = nb_view
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)
raise RuntimeError(msg)
def add_arguments(self, parser):
parser.add_argument(
"--port", type=int, default=5354, help="Port number to listen on"
)
parser.add_argument(
"--address", type=str, default="0.0.0.0", help="IP to bind to"
)
def handle(self, *args, **options):
# Load parameters
port = options["port"]
address = options["address"]
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
)
tcp_server = TCPDNSServer(
(address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
)
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
)
udp_thread.start()
LOGGER.info(f"Query endpoint listening on {address} tcp/{port}")
tcp_server.serve_forever()
@@ -0,0 +1,58 @@
# Generated by Django 5.2.9 on 2026-04-08 11:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
(
"netbox_dns",
"0030_dnsseckeytemplate_comments_dnsseckeytemplate_owner_and_more",
),
]
operations = [
migrations.CreateModel(
name="IntegerKeyValueSetting",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("key", models.CharField(max_length=64)),
("value", models.IntegerField()),
],
options={
"default_permissions": (),
},
),
migrations.CreateModel(
name="CatalogZoneMemberIdentifier",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("name", models.CharField(max_length=26, unique=True)),
(
"zone",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catz_identifier",
to="netbox_dns.zone",
),
),
],
options={
"ordering": ("name",),
},
),
]
@@ -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')],
},
),
]
Whitespace-only changes.
+59
View File
@@ -0,0 +1,59 @@
from django.db import models
# import netbox.models
import netbox_dns.models
class IntegerKeyValueSetting(models.Model):
_netbox_private = True
key = models.CharField(max_length=64)
value = models.IntegerField()
def __str__(self):
return f"{self.key}: {str(self.value)}"
class CatalogZoneMemberIdentifier(models.Model):
_netbox_private = True
name = models.CharField(
max_length=26,
unique=True,
)
zone = models.OneToOneField(
to=netbox_dns.models.Zone,
on_delete=models.CASCADE,
related_name="catz_identifier",
)
class Meta:
ordering = ("name",)
def __str__(self):
return self.name
class SeenTransferClients(models.Model):
_netbox_private = True
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",)
constraints = [
models.UniqueConstraint(
fields=["source_ip", "view"],
name="unique_ip_view_seen_client",
),
]
+136
View File
@@ -0,0 +1,136 @@
import dns.message
import dns.name
import dns.opcode
import dns.query
import dns.rdatatype
import dns.rdataclass
import dns.rrset
import dns.tsig
from django.conf import settings
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record
from .logger import get_logger
from .models import SeenTransferClients, IntegerKeyValueSetting
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
class SendDNSNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def _load_tsig_key(self, view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(
f"Incomplete TSIG key configuration for view '{view_name}'"
)
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
view_name = kwargs["view_name"]
try:
catz_serial = IntegerKeyValueSetting.objects.get(
key="catalog-zone-soa-serial"
).value
zones = [
(kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial),
(f"{view_name}.catz", catz_serial),
]
view = View.objects.get(name=view_name)
tsig_key = self._load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = SeenTransferClients.objects.filter(
view=view, last_seen__gte=seen_cutoff
)
for zone_name, soa_serial in zones:
zone_name = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(zone_name, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA # = 0
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(zone_name, 0, soa_rdata)
msg.authority.append(soa_rrset)
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for client in clients:
try:
if notify_over_tcp:
LOGGER.debug("Sending NOTIFY over TCP")
dns.query.tcp(msg, client.source_ip, port=5355, timeout=2)
else:
LOGGER.debug("Sending NOTIFY over UDP")
dns.query.udp(msg, client.source_ip, port=5355, timeout=2)
LOGGER.info(
f"NOTIFY sent: {client.source_ip} {view_name}/{zone_name} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"NOTIFY failed: {client.source_ip}"
f" {view_name}/{zone_name} {soa_serial}: {e}"
)
except IntegerKeyValueSetting.DoesNotExist as e:
LOGGER.error(f"Failed to get catalog zone serial: {e}")
except OperationalError as e:
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
close_old_connections()
def schedule(zone: Zone):
try:
soa_record = Record.objects.filter(zone=zone, type="SOA", active=True).first()
if soa_record is None:
LOGGER.error(f"Zone {zone.name} has no active SOA record — skipping NOTIFY")
return
soa_serial = int(soa_record.value.split()[2])
except OperationalError as e:
LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}")
close_old_connections()
return
except (ValueError, IndexError) as e:
LOGGER.error(f"Failed to parse SOA serial for zone {zone.name}: {e}")
return
SendDNSNotify.enqueue(
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
+446
View File
@@ -0,0 +1,446 @@
import socketserver
import socket
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 .logger import get_logger
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from django.conf import settings
from netbox_dns.models import Zone, Record, View
from netbox_dns_bridge import catalog_zone_manager as catzm
from .models import SeenTransferClients
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
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:
# Find the zone
try:
nb_zone = Zone.objects.get(
name=zone_name,
view__name=view_name,
active=True,
)
# Build DNS zone
zone = dns.zone.Zone(zone_name, dns.name.root)
zone.rdclass = dns.rdataclass.IN
nb_records = Record.objects.filter(zone=nb_zone, active=True)
rdatasets_dict = {}
for record in nb_records:
rdtype = dns.rdatatype.from_text(record.type)
if not record.name:
name = zone.origin
elif record.name.endswith("."):
name = dns.name.from_text(record.name)
else:
name = dns.name.from_text(record.name, origin=zone.origin)
# If the record has no TTL, use the zone default
ttl = record.ttl or nb_zone.default_ttl
# Apply quoting for TXT records to stop tokanizer
# from cutting it up:
value = record.value
if rdtype == dns.rdatatype.TXT:
if value.startswith('"') and value.endswith('"'):
value = value[1:-1].replace('" "', "").replace('"', '"')
if len(value) > 255:
# This is a bug fix for netbox. If netbox allowed for
# an unquoted value to be larger then 255 characters,
# it misunderstood everything behind a ; as a comment.
chunks = [
'"{}"'.format(value[i : i + 255])
for i in range(0, len(value), 255)
]
value = " ".join(chunks)
else:
value = f'"{value}"'
rdata = dns.rdata.from_text(
dns.rdataclass.IN,
rdtype,
value,
relativize=False,
origin=zone.origin,
)
# Initialize rdataset if it doesn't exist for this name and type
if name not in rdatasets_dict:
rdatasets_dict[name] = {}
if rdtype not in rdatasets_dict[name]:
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(
dns.rdataclass.IN, rdtype
)
# Add the rdata to the appropriate rdataset
rdatasets_dict[name][rdtype].add(rdata, ttl)
# Now, add all rdatasets to the zone
for name, rdtypes in rdatasets_dict.items():
for rdtype, rdataset in rdtypes.items():
# Ensure rdataset has the same rdclass as the zone
if rdataset.rdclass != zone.rdclass:
raise ValueError(
f"rdataset rdclass {rdataset.rdclass} does not match "
f"zone rdclass {zone.rdclass}"
)
# Check if the rdataset has any rdata before creating an RRset
if not rdataset:
LOGGER.debug(f"Skipping empty rdataset for {name} {rdtype}")
continue # Skip empty rdataset
# Replace the rdataset for the given name and type
zone.replace_rdataset(name, rdataset)
return zone
except Zone.DoesNotExist:
return None
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
query = dns.message.from_wire(
wire, keyring={}, ignore_trailing=True, continue_on_error=True
)
# Make a response
response = dns.message.make_response(query)
response.set_rcode(dns.rcode.REFUSED)
if query.had_tsig:
# 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._send_response(response.to_wire())
def _send_response(self, data) -> None:
raise NotImplementedError
def _deny_request(self, query, rcode: dns.rcode = dns.rcode.REFUSED) -> None:
response = dns.message.make_response(query)
response.set_rcode(rcode)
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:
SeenTransferClients.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
# Now, create the rrset from the soa_rdata
rrset = dns.rrset.from_rdata(zone.origin, soa_rrset.ttl, soa_rdata)
response = dns.message.make_response(query)
# Set the Authoritative Answer flag
response.flags |= dns.flags.AA
# Append the rrset to the response's answer section
response.answer.append(rrset)
# TSIG response
if query.had_tsig:
# If key was found in DB
if query.keyname in self.server.keyring:
response.use_tsig(
self.server.keyring, keyname=query.keyname, original_id=query.id
)
else:
# 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)
response.use_tsig(
{}, # empty keyring; no signing
keyname=query.keyname,
tsig_error=dns.rcode.BADKEY,
)
data = response.to_wire(max_size=512)
self._send_response(data)
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():
if not name.is_absolute():
name = name.concatenate(zone.origin)
rrset = dns.rrset.from_rdata_list(name, rdataset.ttl, rdataset)
if rdataset.rdtype == dns.rdatatype.SOA and soa_rrset is None:
soa_rrset = rrset
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
# 2. Create a Renderer for the first message
r = dns.renderer.Renderer(
id=query.id,
flags=(dns.flags.QR | dns.flags.AA),
max_size=self.MAX_WIRE,
origin=None,
)
r.add_question(
query.question[0].name,
query.question[0].rdtype,
query.question[0].rdclass,
)
# 3. Loop through RRsets
tsig_ctx = None
for rrset in rrsets:
try:
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")
except dns.exception.TooBig:
# TSIG chain previous message
r.write_header()
tsig_ctx = r.add_multi_tsig(
ctx=tsig_ctx,
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 query.mac,
)
wire = r.get_wire()
self.request.sendall(len(wire).to_bytes(2, "big") + wire)
# Start new renderer
r = dns.renderer.Renderer(
id=query.id,
flags=(dns.flags.QR | dns.flags.AA),
max_size=self.MAX_WIRE,
origin=None,
)
r.add_question(
query.question[0].name,
query.question[0].rdtype,
query.question[0].rdclass,
)
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()
tsig_ctx = r.add_multi_tsig(
ctx=tsig_ctx,
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 query.mac,
)
wire = r.get_wire()
self._send_response(wire)
LOGGER.info(f"{peer} AXFR {nb_view.name}/{dname}")
def _handle_dns_query(self, wire) -> None:
peer = self.client_address[0]
try:
query = dns.message.from_wire(
wire,
keyring=self.server.keyring,
continue_on_error=False,
ignore_trailing=True,
)
except dns.tsig.BadSignature as 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}")
self._deny_request_bad_tsig(wire, dns.rcode.BADKEY)
return
except Exception as e:
LOGGER.error("Error parsing query: ", e)
return
# If there was no question in the query, refuse
if len(query.question) != 1:
self._deny_request(query)
return
question = query.question[0]
qname = question.name
qtype = question.rdtype
dname = qname.to_text().rstrip(".")
# Only process AXFR/SOA queris
if qtype not in (dns.rdatatype.AXFR, dns.rdatatype.SOA):
LOGGER.warning(
f"Request denied from {peer}: Request was not AXFR or SOA (Type: {qtype})"
)
self._deny_request(query)
return
# Identify TSIG key used
if not query.had_tsig:
LOGGER.warning(f"Request denied from {peer}: No TSIG key used")
self._deny_request(query)
return
key_name = query.keyname.canonicalize().to_text()
# Check if the key matches a view
nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view:
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)
else:
zone = self._getZoneFromNB(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}")
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")
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):
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
def _send_response(self, data) -> None:
sock = self.request[1]
sock.sendto(data, self.client_address)
def handle(self) -> None:
data, sock = self.request
peer = self.client_address[0]
try:
self._handle_dns_query(data)
except Exception as e:
LOGGER.error(f"Error handling request from {peer}: {e}")
import traceback
traceback.print_exc()
class TCPRequestHandler(DNSBaseRequestHandler):
def __init__(self, request, client_address, server) -> None:
super().__init__(request, client_address, server)
def _send_response(self, data) -> None:
length = len(data).to_bytes(2, byteorder="big")
self.request.sendall(length + data)
def handle(self) -> None:
peer = self.client_address[0]
sock = self.request # TCP socket
sock.settimeout(10.0) # Default 10 second timeout for inactivity
try:
while True:
# Read 2-byte length prefix
length_data = sock.recv(2)
if not length_data:
return # connection closed by peer
if len(length_data) < 2:
# Incomplete length data, wait for more or fail
while len(length_data) < 2:
chunk = sock.recv(2 - len(length_data))
if not chunk:
return
length_data += chunk
length = int.from_bytes(length_data, byteorder="big")
wire = b""
while len(wire) < length:
chunk = sock.recv(length - len(wire))
if not chunk:
return # connection closed
wire += chunk
self._handle_dns_query(wire)
except socket.timeout:
LOGGER.debug(f"Connection from {peer} timed out")
except Exception as e:
LOGGER.error(f"Error handling request from {peer}: {e}")
import traceback
traceback.print_exc()
+1
View File
@@ -0,0 +1 @@
from . import netbox_dns_zones
@@ -0,0 +1,61 @@
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.db import transaction
from django.conf import settings
from netbox.context import current_request
from netbox_dns.models import Zone
from netbox_dns_bridge import catalog_zone_manager as catzm
from netbox_dns_bridge import notify_handler
SETTINGS = settings.PLUGINS_CONFIG["netbox_dns_bridge"]
@receiver(pre_save, sender=Zone)
def zone_pre_save(sender, instance, **kwargs):
zone = instance
if zone.pk:
try:
zone._old_name = sender.objects.only("name").get(pk=zone.pk).name
except sender.DoesNotExist:
zone._old_name = None
else:
zone._old_name = 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()
handled_zones = getattr(request, "_handled_zones", set())
if zone.pk in handled_zones:
return
handled_zones.add(zone.pk)
request._handled_zones = handled_zones
except LookupError:
pass
# On commit, increment soa serial and if notify is enabled, schedule bg job.
def _on_commit():
catzm.increment_soa_serial()
if SETTINGS.get("notify_clients", False):
notify_handler.schedule(zone)
transaction.on_commit(_on_commit)
if created:
# If zone was created, create catz member identifier record for this zone.
catzm.update_member_identifier(zone)
else:
# Check if zone name has changed and change the catz member identifier if so.
old_name = getattr(zone, "_old_name", None)
if old_name and zone.name != old_name:
catzm.update_member_identifier(zone)
@receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs):
catzm.increment_soa_serial()
+34
View File
@@ -0,0 +1,34 @@
import dns.zone
import dns.rdatatype
import dns.rdataclass
import dns.exception
import netbox_dns.models
def export_bind_zone_file(nb_zone: netbox_dns.models.Zone, file_path: str):
# Create dnspython zone object
dp_zone = dns.zone.Zone(origin=dns.name.from_text(nb_zone.name))
for record in nb_zone.records.all():
rname = record.name or "@"
rdclass = dns.rdataclass.IN
rdatatype = dns.rdatatype.from_text(record.type)
# Create rdata object
rdata = dns.rdata.from_text(rdclass, rdatatype, record.value)
# Create rdataset
rdataset = dns.rdataset.Rdataset(rdclass, rdatatype)
rdataset.add(rdata, record.ttl)
# Add to zone
node = dp_zone.find_node(rname, create=True)
node.rdatasets.append(rdataset)
# Write zone to file in BIND format
try:
with open(file_path, "w") as f:
dp_zone.to_file(f, sorted=True)
except IOError as e:
raise IOError(f"Failed to write zone file to {file_path}: {e}")
-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
+10 -7
View File
@@ -3,27 +3,30 @@ requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-plugin-bind-provisioner" name = "netbox-plugin-dns-bridge"
dynamic = ["version"] dynamic = ["version"]
description = "A Bind provisioning plugin that uses netbox_dns for its data source" description = "A bridge between netbox-plugin-dns and your DNS infrastructure."
authors = [ authors = [
{ name = "Sven Luethi" }, { name = "Sven Luethi" },
] ]
license = { text = "GPL-2.0" } license = { file = "LICENSE.md" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.7" requires-python = ">=3.7"
dependencies = ["netbox-plugin-dns"] dependencies = [
"netbox-plugin-dns>=1.5.0",
"dnspython>=2.8.0"
]
[tool.setuptools] [tool.setuptools]
include-package-data = true include-package-data = true
zip-safe = false zip-safe = false
package-dir = { "" = "src" } package-dir = { "" = "." }
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["."]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = { attr = "netbox_plugin_bind_provisioner.__version__" } version = { attr = "netbox_dns_bridge.__version__" }
[project.urls] [project.urls]
Homepage = "https://github.com/Suraxius/netbox-plugin-bind-provisioner" Homepage = "https://github.com/Suraxius/netbox-plugin-bind-provisioner"
-2
View File
@@ -1,2 +0,0 @@
netbox-plugin-dns>=1.2.0
dnspython>=2.8.0
@@ -1,36 +0,0 @@
import logging
from netbox.plugins import PluginConfig
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver
from django.conf import settings
__version__ = "0.9.9"
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
)
class BindProvisionerConfig(PluginConfig):
name = "netbox_plugin_bind_provisioner"
verbose_name = "Netbox Bind Provisioner"
description = "Provisions Zones to a Bind Server configured as hidden master"
version = __version__
author = "Sven Luethi"
author_email = "sven.luethi@everyware.ch"
base_url = "bind_provisioner"
def ready(self):
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."
)
config = BindProvisionerConfig
default_app_config = ".apps.NetboxBindProvisionerConfig"
@@ -1,672 +0,0 @@
import logging
import socketserver
import os
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 hashlib import sha256
from django.core.management.base import BaseCommand
from django.conf import settings
from django.utils import timezone
from netbox_dns.models import Zone, Record, View
from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
from datetime import datetime
from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting
logger = logging.getLogger('bind-transfer-endpoint')
class CatalogZone():
lock = threading.Lock()
_serial_max = 0xFFFFFFFF
_serial_obj = None
_previous_last_zone_update = None
# 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.
@classmethod
def initSerial(cls):
try:
cls._serial_obj = IntegerKeyValueSetting.objects.get(key="catalog-zone-soa-serial")
logger.debug(f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database")
except IntegerKeyValueSetting.DoesNotExist:
cls._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 {cls._serial_obj.value}")
@classmethod
def _incrementSerial(cls):
if 0 < cls._serial_obj.value < cls._serial_max:
cls._serial_obj.value += 1
cls._serial_obj.save()
else:
logger.warn(
f"Failed to incremenet catalog serial {cls._serial_obj.value}. Will overflow serial back to 1"
)
cls._serial_obj = 1
cls._serial_obj.save()
logger.debug(f"Catalog zone SOA serial number incremented to {_serial_obj.value}")
@classmethod
def create(cls, name, view_name):
# Synchronize following across threads as TCP and UDP listener both use it.
with cls.lock:
#cls = self.__class__
#datestamp = datetime.now().strftime("%y%m%d")
#latest_zone = Zone.objects.filter().order_by("-last_updated").first()
latest_zone = Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE).order_by('-last_updated').first()
last_zone_update = getattr(latest_zone, 'last_updated', None)
# 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 cls._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:
cls._previous_last_zone_update = last_zone_update
cls._incrementSerial()
# 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
)
ptr_base = dns.name.from_text("zones", origin)
for nb_zone in nb_zones:
ttl = nb_zone.soa_ttl
qname = dns.name.from_text(nb_zone.name, dns.name.root)
# zone_id = sha256(qname.to_text().encode()).hexdigest()[0:63]
zone_id = str(nb_zone.id)
# Create PTR record
ptr_name = dns.name.from_text(zone_id, ptr_base)
assert ptr_name.is_subdomain(origin)
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(f"policy.dnssec", ptr_name)
rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.TXT, nb_zone.dnssec_policy.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(f"enabled.dnssec", 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 = 3600
rclass = dns.rdataclass.IN
rtype = dns.rdatatype.SOA
mname = dns.name.root # dns.name.from_text("invalid.", origin)
rname = dns.name.root # dns.name.from_text("contact.master.", origin)
serial = cls._serial_obj.value
refresh = 60
retry = 10
expire = 1209600
minimum = 86400
# 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, 86400)
# 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, 86400)
# Add to node version.catz.
txt_node = zone.find_node(version_name, create=True)
txt_node.rdatasets.append(txt_rdataset)
return zone
@classmethod
def importLegacySerialFile(cls, file_path):
if os.path.exists(file_path):
try:
with open(file_path, "r") as f:
serial = int(f.read().strip())
cls._serial_obj.value = int(serial)
cls._serial_obj.save()
logger.info(f"Legacy catalog serial file {file_path} imported into database and file removed. Serial is now {serial}")
os.remove(file_path)
except OSError as e:
logger.error(f"Failed to import catalog zone serial from legacy file {file_path}")
class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
# getZoneFromNB rewritten
def getZoneFromNB(self, zone_name, view_name):
# Find the zone
try:
nb_zone = Zone.objects.get(
name=zone_name, view__name=view_name, status=ZoneStatusChoices.STATUS_ACTIVE
)
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
rdata = dns.rdata.from_text(
dns.rdataclass.IN,
rdtype,
record.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
def denyRequestBadTSIG(self, wire, tsig_error: dns.rcode):
# Use empty keyring to parse TSIG without validating
query = dns.message.from_wire(wire, keyring={}, ignore_trailing=True, continue_on_error=True)
# Make a response
response = dns.message.make_response(query)
response.set_rcode(dns.rcode.REFUSED)
if query.had_tsig:
# Add TSIG with BADKEY, but do not sign (no MAC)
response.use_tsig(
keyring={}, # empty; we're not signing
keyname=query.keyname,
tsig_error=tsig_error
)
self.denyRequest(query)
class UDPRequestHandler(DNSBaseRequestHandler):
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
response = dns.message.make_response(query)
response.set_rcode(rcode)
wire = response.to_wire(multi=False)
sock = self.request[1]
sock.sendto(wire, self.client_address)
def handle(self):
data, sock = self.request
peer = self.client_address[0]
query = None
try:
query = dns.message.from_wire(
data,
keyring=self.server.keyring,
continue_on_error=False,
ignore_trailing=True
)
except Exception as e:
logger.error("Error parsing query: ", e)
return
# Create a response
response = dns.message.make_response(query)
# Set the Authoritative Answer flag
response.flags |= dns.flags.AA
question = query.question[0]
# Get the queried record name and type:
qname = question.name
qtype = question.rdtype
dname = qname.to_text().rstrip(".")
# Only process SOA queries
if qtype != dns.rdatatype.SOA:
logger.warning(f"Request denied from {peer}: Request was not SOA (Type: {qtype})")
self.denyRequest(query)
return
# Identify TSIG key used
if not query.had_tsig:
logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query)
return
key_name = query.keyname.canonicalize().to_text()
# Check if key matches a view
nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view:
logger.warning(f"Request denied from {peer}: {key_name} does not match a view")
self.denyRequestBadTSIG(wire, dns.rcode.BADKEY)
return
# Check if catalog zone
if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(dname, nb_view.name)
# If generic zone, retreive from NB
else:
zone = self.getZoneFromNB(dname, nb_view.name)
# When zone was not found, let client know
if not zone:
logger.warning(f"Zone {nb_view.name}/{dname} not found in NB")
self.denyRequest(query)
return
# Retrieve the existing SOA record from the Zone
soa_rdataset = zone.get_rdataset(zone.origin, dns.rdatatype.SOA)
# We assume that the SOA rdataset has at least one record (it usually does).
soa_rdata = soa_rdataset[0] # Get the first SOA record
# Now, create the rrset from the soa_rdata
rrset = dns.rrset.from_rdata(zone.origin, soa_rdataset.ttl, soa_rdata)
# Append the rrset to the response's answer section
response.answer.append(rrset)
# TSIG response
if query.had_tsig:
# If key was found in DB
if query.keyname in self.server.keyring:
response.use_tsig(self.server.keyring, keyname=query.keyname, original_id=query.id)
else:
# If key is unknown
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, keyname=query.keyname, tsig_error=dns.rcode.BADKEY)
# Send back the response
response_wire = response.to_wire(max_size=512)
sock.sendto(response_wire, self.client_address)
logger.debug(f"{peer} SOA {nb_view.name}/{dname}")
class TCPRequestHandler(DNSBaseRequestHandler):
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
response = dns.message.make_response(query)
response.set_rcode(rcode)
wire = response.to_wire(multi=False)
length = len(wire).to_bytes(2, byteorder="big")
self.request.sendall(length + wire)
def handle(self):
MAX_WIRE = 65535
# MAX_WIRE = 2000 # For testing fragmentation
RESERVED_TSIG = 300
# create a var for setting the client ip in log messages
peer = self.client_address[0]
try:
# Receive the entire message....
sock = self.request # TCP socket
# Read 2-byte length prefix
length_data = sock.recv(2)
if len(length_data) < 2:
return
length = int.from_bytes(length_data, byteorder="big")
wire = b""
while len(wire) < length:
chunk = sock.recv(length - len(wire))
if not chunk:
return # connection closed
wire += chunk
# Parse the query
query = None
try:
query = dns.message.from_wire(wire, keyring=self.server.keyring)
# If TSIG signature doesnt match our key, refuse query:
except dns.tsig.BadSignature as e:
logger.warning(f"Request denied from {peer} failed TSIG verification: {e}")
self.denyRequestBadTSIG(wire, dns.rcode.BADSIG)
return
# If TSIG Key used is not in our list, refuse query:
except dns.message.UnknownTSIGKey as e:
logger.warning(f"Request denied from {peer} with bad TSIG key: {e}")
self.denyRequestBadTSIG(wire, dns.rcode.BADKEY)
return
# If there was no question in the query, refuse
if len(query.question) != 1:
self.denyRequest(query)
return
# Get the queried record name and type:
qname = query.question[0].name
qtype = query.question[0].rdtype
dname = qname.to_text().rstrip(".")
# Only process AXFR queries
if qtype != dns.rdatatype.AXFR:
#if qtype != dns.rdatatype.AXFR or qtype != dns.rdatatype.SOA:
logger.warning(f"Request denied from {peer}: Request was not AXFR (Type: {qtype})")
self.denyRequest(query)
return
# Identify TSIG key used
if not query.had_tsig:
logger.warning(f"Request denied from {peer}: No TSIG key used")
self.denyRequest(query)
return
key_name = query.keyname.canonicalize().to_text()
# Check if the key matches a view
nb_view = self.server.tsig_view_map.get(key_name)
if not nb_view:
logger.warning(f"Request denied from {peer}: {key_name} does not match a view")
self.denyRequest(query)
return
# Check if catalog zone
if dname == "catz" or dname == f"{nb_view.name}.catz":
zone = CatalogZone.create(dname, nb_view.name)
else:
zone = self.getZoneFromNB(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}")
self.denyRequest(query)
return
# 1. Prepare SOA + RRsets (identical to your code)
soa_rrset = None
rrsets = []
for name, rdataset in zone.iterate_rdatasets():
if not name.is_absolute():
name = name.concatenate(zone.origin)
rrset = dns.rrset.from_rdata_list(name, rdataset.ttl, rdataset)
if rdataset.rdtype == dns.rdatatype.SOA and soa_rrset is None:
soa_rrset = rrset
else:
rrsets.append(rrset)
if soa_rrset is None:
logger.error(f"Zone {dname} has no SOA — aborting AXFR")
return
rrsets.insert(0, soa_rrset) # Opening SOA
rrsets.append(soa_rrset) # Closing SOA
# 2. Create a Renderer for the first message
r = dns.renderer.Renderer(
id=query.id, flags=(dns.flags.QR | dns.flags.AA), max_size=MAX_WIRE, origin=None
)
r.add_question(
query.question[0].name,
query.question[0].rdtype,
query.question[0].rdclass,
)
# 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()) < RESERVED_TSIG:
raise dns.exception.TooBig("TSIG wont fit")
except dns.exception.TooBig:
# TSIG chain previous message
r.write_header()
tsig_ctx = r.add_multi_tsig(
ctx=tsig_ctx,
secret=self.server.keyring[query.keyname],
keyname=query.keyname,
fudge=300,
id=query.id,
tsig_error=0,
other_data=b"",
request_mac=r.mac if tsig_ctx else query.mac,
)
wire = r.get_wire()
self.request.sendall(len(wire).to_bytes(2, "big") + wire)
# Start new renderer
r = dns.renderer.Renderer(
id=query.id, flags=(dns.flags.QR | dns.flags.AA), max_size=MAX_WIRE, origin=None
)
r.add_question(
query.question[0].name,
query.question[0].rdtype,
query.question[0].rdclass,
)
r.add_rrset(dns.renderer.ANSWER, rrset)
# 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],
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.request.sendall(len(wire).to_bytes(2, "big") + wire)
#logger.debug(f"Zone transfer request for {nb_view.name}/{dname} from {peer}")
logger.debug(f"{peer} AXFR {nb_view.name}/{dname}")
except Exception as e:
logger.error(f"Error handling request from {peer}: {e}")
import traceback
traceback.print_exc()
class TCPDNSServer(socketserver.TCPServer):
allow_reuse_address = True
def __init__(self, server_address, handler_class, keyring, tsig_view_map):
super().__init__(server_address, handler_class)
self.keyring = keyring
self.tsig_view_map = tsig_view_map
class UDPDNSServer(socketserver.UDPServer):
allow_reuse_address = True
def __init__(self, server_address, handler_class, keyring, tsig_view_map):
super().__init__(server_address, handler_class)
self.keyring = keyring
self.tsig_view_map = tsig_view_map
class Command(BaseCommand):
help = "Run a minimal AXFR DNS server using NetBox DNS plugin data"
def loadSettings(self):
self.settings = settings.PLUGINS_CONFIG.get("netbox_plugin_bind_provisioner", 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 loadTSIGKeySettings(self):
self.keyring = {}
self.tsig_view_map = {}
for view_name, data in self.tsig_keys.items():
raw_key_name = data.get("keyname")
secret = data.get("secret")
algorithm_str = 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."
)
continue
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."
)
continue
# 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_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
)
self.tsig_view_map[key_name_str] = nb_view
logger.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}")
if not self.keyring:
msg = "No TSIG keys found in database."
logger.critical(msg)
raise RuntimeError(msg)
def add_arguments(self, parser):
parser.add_argument('--port', type=int, default=5354, help='Port number to listen on')
parser.add_argument('--address', type=str, default="0.0.0.0", help='IP to bind to')
def handle(self, *args, **options):
# Load parameters
port = options['port']
address = options['address']
CatalogZone.initSerial()
# Initialize settings
self.loadSettings()
#self.loadCatalogZoneSettings()
self.loadTSIGKeySettings()
# Following is temporary. Function and invocation can be deleted once v1.0 is reached.
serial_file = self.settings.get("catalog_serial_file", None)
if serial_file:
CatalogZone.importLegacySerialFile(serial_file)
udp_server = UDPDNSServer((address, port), UDPRequestHandler, self.keyring, self.tsig_view_map)
tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map)
def run_udp_server(server):
logger.debug(f"SOA endpoint listening on {address} udp/{port}")
server.serve_forever()
udp_thread = threading.Thread(target=run_udp_server, args=(udp_server,), daemon=True)
udp_thread.start()
logger.debug(f"AXFR endpoint listening on {address} tcp/{port}")
tcp_server.serve_forever()
@@ -1,34 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-23 14:36
import netbox.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('extras', '0133_make_cf_minmax_decimal'),
]
operations = [
migrations.CreateModel(
name='IntegerKeyValueSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('key', models.CharField(max_length=64)),
('value', models.IntegerField()),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'abstract': False,
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
]
@@ -1,23 +0,0 @@
from django.db import models
import netbox.models
import netbox_dns.models
#class CatalogZoneSerial(models.Model):
class IntegerKeyValueSetting(netbox.models.NetBoxModel):
#view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
key = models.CharField(max_length=64)
value = models.IntegerField()
def __str__(self):
#return f'{self.view.name}: {self.serial}'
return f'{self.key}: {str(self.value)}'
#class TSIGKey(netbox.models.NetBoxModel):
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
# name = models.CharField(max_length=256)
# algorithm = models.CharField(max_length=25)
# secret = models.CharField(max_length=50)
#
# def __str__(self):
# return f'key "{self.name}" {{ algorithm {self.algorithm}; secret "{self.secret}"; }};'