Compare commits

...
13 Commits
Author SHA1 Message Date
Sven Luthi 7536fc4e6c modified: README.md 2026-10-27 13:01:07 +01:00
Sven Luthi 74d8e644d1 new file: .gitea/workflows/publish.yml
renamed:    .github/workflows/python-publish.yml -> .github/workflows/publish.yml
2026-10-27 10:59:15 +01:00
Sven Luethi 5999db72f9 modified: README.md
deleted:    docs/architecture-overview.drawio
deleted:    docs/architecture-overview.svg
modified:   docs/install-systemd-service.md
new file:   docs/installation-guide.md
  Documentation cleanup

modified:   pyproject.toml
  change old provisioner link to bridge
2026-09-29 14:09:19 +02:00
sven 3fa3cd6a01 Well tested core components (DNS flow, signal handling, background jobs).
Finally able to bump to version v1.7.0
2026-09-22 23:54:36 +02:00
sven c15bdfca05 Refactor: move shared NOTIFY helpers into a NotifyJobRunner base class
- New NotifyJobRunner base class (extends netbox.jobs.JobRunner) holds
  the plugin SETTINGS and the helpers shared by both NOTIFY jobs:
  load_tsig_key(), send_notify() and get_soa_serial() (promoted from
  the former module-level _-prefixed functions).
- SendClientNotify and SendNSNotify extend the base and access the
  helpers via self. Behavior, job names and kwargs are unchanged.
- The nameserver-only helpers moved into SendNSNotify as
  notify_ns_enabled() (classmethod, reads cls.SETTINGS) and
  resolve_zone_nameserver_ips() (instance method).
- No changes to any callers: schedule_client_notify(),
  schedule_ns_notify() and the catalog zone NOTIFY scheduling keep
  their names and call sites.
2026-09-22 23:15:09 +02:00
sven fe24dbdd98 Add configurable catalog zone naming
- New setting 'catalog_zone_name' (default: 'catz') to change the name of
  the per-view catalog zone. Full DNS names (multiple labels) are
  supported; names are handled as dns.name.Name objects, making the
  comparison case-insensitive and tolerant of trailing dots.
- New setting 'catalog_zone_name_includes_view' (default: False). When
  enabled, the catalog zone name is prefixed with the view name:
  '<view>.<catalog_zone_name>'.
- The transfer endpoint now serves exactly the one configured catalog
  zone name per view instead of accepting both 'catz' and
  '<view>.catz'.
- The catalog zone NOTIFY now targets only the configured name instead
  of notifying for both variants. Scheduling moved to
  catalog_zone_manager, which owns all catalog zone naming.
2026-09-22 22:22:26 +02:00
Sven Luthi 5c673d9903 deleted: CHANGELOG.md
Not really helpful. Just more work. Bye bye!

modified:   netbox_dns_bridge/__init__.py
Bump version to 1.6.0-rc1

new file:   netbox_dns_bridge/api/__init__.py
new file:   netbox_dns_bridge/api/serializers.py
new file:   netbox_dns_bridge/api/urls.py
new file:   netbox_dns_bridge/api/views.py
Adds API to the plugin. In case someone needs it :P
This fixes a serializer bug on Zone creation in the same stroke.
2026-09-21 16:46:33 +02:00
Sven Luthi 240f1293ae Bump to version v1.5.9-beta 2026-09-02 12:28:17 +02:00
sven a3bc64df62 Upgrade models to full NetboxModel to allow UI/API interaction:
modified:   netbox_dns_bridge/models.py
deleted:    netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py
new file:   netbox_dns_bridge/migrations/0003_full_netbox_models.py
This assumes the migration starts at v1.5.8.

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

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

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

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

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

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

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

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

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

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

AI generated, code manually verified.
2026-09-02 12:01:54 +02:00
37 changed files with 1810 additions and 918 deletions

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
name: Publish Python package to PyPI
#on:
# push:
# tags:
# - "v*"
on:
release:
types: [published]
jobs:
publish:
runs-on: self-hosted
steps:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Create virtual environment
run: python3 -m venv .venv
- name: Install build tools
run: .venv/bin/pip install --upgrade build twine
- name: Verify tag matches package version
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="$(grep -m1 '^version = ' pyproject.toml | cut -d '"' -f2)"
if [ "$TAG" != "v$VERSION" ]; then
echo "::error::Tag ${TAG} does not match package version v${VERSION}"
exit 1
fi
- name: Build package
run: .venv/bin/python3 -m build
- name: Check package
run: .venv/bin/python3 -m twine check dist/*
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
TWINE_NON_INTERACTIVE: "1"
run: .venv/bin/python3 -m twine upload dist/*
File renamed without changes.
-23
View File
@@ -1,23 +0,0 @@
## Unreleased
- Defer the catalog zone SOA serial increment on zone deletion to `transaction.on_commit`, matching
the create/modify path. Previously the increment ran immediately at `post_delete` time, so a
rollback of the delete transaction would have left the catalog serial bumped anyway.
- Add the option to send a NOTIFY directly to a zone's own nameservers when its SOA serial number
changes. Enable it per zone via a boolean custom field named by `notify_ns_custom_field_name`,
globally for all zones via `notify_ns_all_zones` (default `False`), or both. Nameserver addresses
are resolved from NetBox DNS A/AAAA records within the zone's view and the NOTIFY is signed with
the per-view TSIG key. A companion `notify_ns_port` setting (default 53) controls the destination
port.
## 1.0.7 - 2026-03-02
README Change - Moving private keys to global scope since Bind 9.20 view scoped keys have become unreliable and sometimes wouldnt match.
## 1.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.
+257 -254
View File
@@ -6,65 +6,204 @@ and your existing DNS infrastructure, leveraging native DNS mechanisms for seaml
These include zone transfers (RFC 5936), catalog zones (RFC 9432), and notifying clients about zone These include zone transfers (RFC 5936), catalog zones (RFC 9432), and notifying clients about zone
changes (RFC 1996). 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://pypi.org/project/netbox-plugin-dns-bridge/">
<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> <img src="https://img.shields.io/pypi/v/netbox-plugin-dns-bridge" alt="PyPI">
<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>
<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/stargazers">
<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> <img src="https://img.shields.io/github/stars/Suraxius/netbox-plugin-dns-bridge?style=flat" alt="Stars Badge">
<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>
<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/Suraxius/netbox-plugin-dns-bridge/network/members">
<a href="https://github.com/psf/black"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" alt="Code Style Black"></a> <img src="https://img.shields.io/github/forks/Suraxius/netbox-plugin-dns-bridge?style=flat" alt="Forks Badge">
<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>
<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://github.com/Suraxius/netbox-plugin-dns-bridge/issues">
<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> <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>
## Contents
# Plugin configuration - [Overview](#overview)
While providing Zone transfers via AXFR, the Server also exposes specialized catalog zones that BIND - [Compatibility](#compatibility)
and other RFC9432 compliant DNS Servers use to automatically discover newly created zones and remove - [Requirements](#requirements)
deleted ones. The plugin supports views and basic DNS security via TSIG. - [Installation](#installation)
- [Configuration](#configuration)
- [Settings reference](#settings-reference)
- [Catalog zone naming](#catalog-zone-naming)
- [TSIG authentication](#tsig-authentication)
- [NOTIFY](#notify)
- [Running the transfer endpoint](#running-the-transfer-endpoint)
- [Notes and troubleshooting](#notes-and-troubleshooting)
- [License](#license)
The plugin exposes one catalog zone per view. Each catalog zone is made available under the special ## Overview
zone name **"catz"** and addtionally under **"[viewname].catz"** and may be queried through the
built-in DNS server just like any other dns zone.
For proper operation, each view requires an installed TSIG key, and the `dns-transfer-endpoint` must ```mermaid
be running as a separate background service using the `manage.py` command. Note that DNSSEC support flowchart LR
will be added once BIND9 provides a mechanism to configure it through the Catalog Zones system. subgraph netbox ["NetBox"]
DNS[DNS]
QUEUE["Job Queue"]
subgraph bridge ["DNS Bridge"]
TE["Transfer<br/>Endpoint"]
end
end
To start the transfer endpoint service in the foreground: DNSSRV["DNS Server<br/>(RFC 9432)<br/>e.g. BIND9"]
DNSSRV -- "AXFR (RFC 5936)<br/>Catalog Zone (RFC 9432)" --> TE
bridge -.->|"Listening for zone change signals"| DNS
bridge -- "Queue NOTIFY job on zone change" --> QUEUE
QUEUE -- "NOTIFY (RFC 1996)" --> DNSSRV
``` ```
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
correctly. For Linux users with Systemd (Ubuntu, etc), Matt Kollross provides a startup unit and
instructions [here](docs/install-systemd-service.md).
- **Zone transfers (RFC 5936)** — DNS servers pull authoritative zone data directly from NetBox.
- **Catalog zones (RFC 9432)** — consumers automatically discover newly created and deleted zones.
One catalog zone is exposed per view and may be queried through the built-in DNS server just like
any other zone. The catalog zone name is configurable (see
[Catalog zone naming](#catalog-zone-naming)) and defaults to `catz`.
- **NOTIFY (RFC 1996)** — clients and zone nameservers are informed about zone changes so they can
request a fresh zone transfer (see [NOTIFY](#notify)).
- **Views and TSIG** — each view requires its own TSIG key, which is also used to map incoming
requests to the correct view (see [TSIG authentication](#tsig-authentication)).
- **DNSSEC** — not supported yet; will be added once BIND9 provides a mechanism to configure it
through the catalog zones system.
## Service parameters ## Compatibility
Parameter | Description
--------- | -------------------------------------------------------------------
--port | Port to listen on for requests (defaults to 5354)
--address | IP of interface to bind to (defaults to 0.0.0.0)
This plugin extends netbox-plugin-dns, and versioning is aligned with it: the major and minor
version of both plugins must match. For example, when using netbox-plugin-dns `v1.5.5`, install
netbox-plugin-dns-bridge `v1.5.x`.
## Plugin settings | netbox-plugin-dns | netbox-plugin-dns-bridge |
Plugin settings should be configured under `PLUGINS_CONFIG` in `netbox_dns_bridge`: | :---------------: | :----------------------: |
``` | 1.5.x | 1.5.x |
| 1.6.x | 1.6.x |
| 1.7.x | 1.7.x (current) |
NetBox and Python version requirements follow netbox-plugin-dns; see its
[documentation](https://github.com/sys4/netbox-plugin-dns).
## Requirements
- [NetBox](https://github.com/netbox-community/netbox) with the
[netbox-plugin-dns](https://github.com/sys4/netbox-plugin-dns) plugin installed.
- A DNS server with RFC 9432 catalog zone support (e.g. BIND9) as the consumer.
- Network reachability from the consumer to the NetBox host on TCP and UDP port `5354` (or the
port you have configured).
- At least one running `rq-worker` service if you enable any of the NOTIFY features, since they
use NetBox's background job system.
## Installation
The full walkthrough — including a complete BIND9 configuration with two views — is available in
[docs/installation-guide.md](docs/installation-guide.md). The short version:
1. Install the package:
```
cd netbox
echo netbox-plugin-dns-bridge >> local_requirements.txt
. venv/bin/activate
pip install -r local_requirements.txt
```
2. Enable the plugin in `configuration.py`:
```python
PLUGINS = ['netbox_dns', 'netbox_dns_bridge']
```
3. Configure the plugin under `PLUGINS_CONFIG` (see [Configuration](#configuration)).
4. Run migrations:
```
manage.py migrate
```
5. Start the transfer endpoint as a background service (see
[Running the transfer endpoint](#running-the-transfer-endpoint)).
## Configuration
Plugin settings are configured under `PLUGINS_CONFIG` in `configuration.py`:
```python
PLUGINS_CONFIG = { PLUGINS_CONFIG = {
'netbox_dns_bridge': { 'netbox_dns_bridge': {
... ...
...
} }
} }
``` ```
### TSIG Authentication ### Settings reference
Following sets the TSIG key that allows clients to query the transfer endpoint and also the key to
be used to sign NOTIFY messages to clients on zone changes. Each view should have its own unique | Setting | Type | Default | Description |
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. | `catalog_zone_name` | string | `catz` | Name of the catalog zone exposed for each view |
| `catalog_zone_name_includes_view` | bool | `False` | Prepend the view name: `<view>.<catalog_zone_name>` |
| `tsig_keys` | dict | — | Per-view TSIG keys used to authenticate transfers and sign NOTIFY messages |
| `notify_over_tcp` | bool | `False` | Send NOTIFY messages over TCP instead of UDP |
| `notify_clients` | bool | `False` | NOTIFY clients that have queried the transfer endpoint |
| `notify_client_alive_threshold_hours` | int | `24` | How long a client is considered "alive" after its last query |
| `notify_client_port` | int | `53` | Destination port for client NOTIFY messages |
| `notify_ns_custom_field_name` | string | — | Boolean zone custom field enabling NS NOTIFY per zone |
| `notify_ns_all_zones` | bool | `False` | Send NS NOTIFY for every zone on serial change |
| `notify_ns_port` | int | `53` | Destination port for NS NOTIFY messages |
### Catalog zone naming
The name of the catalog zone exposed for each view is configurable:
| Setting | Default | Description |
| ------- | ------- | ----------- |
| `catalog_zone_name` | `catz` | The name of the catalog zone. May be a full DNS name. |
| `catalog_zone_name_includes_view` | `False` | Prepend the view name: `<view>.<catalog_zone_name>`. |
`catalog_zone_name` accepts full DNS names with multiple labels. Case and a trailing dot are
irrelevant — names are handled and compared as `dns.name.Name` objects. With
`catalog_zone_name_includes_view` disabled (the default), the plain `catalog_zone_name` is used for
every view; when enabled, the view name is prepended (e.g. `_default_.catz`).
```python
'netbox_dns_bridge': {
'catalog_zone_name': 'catz',
'catalog_zone_name_includes_view': False,
...
}
``` ```
Note that the DNS server consuming the catalog zone must be configured with the same zone name,
e.g. BIND9's `catalog-zones { zone "<catalog_zone_name>" ... }` statement and the corresponding
slave zone declaration must match.
### TSIG authentication
The TSIG keys allow clients to query the transfer endpoint and are also used to sign NOTIFY
messages sent to clients on zone changes. Each view should have its own unique key so the plugin
can identify the view the client is trying to access. Reusing a key for multiple views yields
unpredictable behavior.
```python
'tsig_keys': { 'tsig_keys': {
'the-view-name-here': { 'the-view-name-here': {
'keyname': 'the-tsig-key-name-here', 'keyname': 'the-tsig-key-name-here',
@@ -74,281 +213,145 @@ multiple views yields unpredicted behavior.
} }
``` ```
The `keyname`, `algorithm` and `secret` attributes form a dictionary under the following Python
structure path:
```
PLUGINS_CONFIG.netbox_dns_bridge.tsig_keys.<dns_view_name>
```
### NOTIFY ### NOTIFY
The plugin has 2 different NOTIFY mechanisms that may be turned on individually or together. The plugin has 2 different NOTIFY mechanisms that may be turned on individually or together.
1. [Client Notify](#client-notify) 1. [Client NOTIFY](#client-notify) — notify clients that are regularly contacting the transfer
Notify clients that are regularly contacting the transfer endpoint for zone updates. endpoint for zone updates.
2. [NS NOTIFY](#ns-notify) — notify the zone's NS servers when a zone changes.
- [Per zone](#ns-notify-per-zone) — for zones that have a specific custom field set to true.
- [For all zones](#ns-notify-for-all-zones) — for all zones. This makes the *per zone*
mechanism redundant; there is no point in enabling both.
2. [NS Notify](#ns-notify) #### Global NOTIFY settings
Notify the zone's NS Servers when a zone changes.
- [Per Zone](#ns-notify-per-zone)
Notify NS servers for zones that have a specific custom field set to true.
- [For all Zones](#ns-notify-for-all-zones)
Notify NS servers for all zones.
Note: This makes the *Per Zone* mechanism redundant. There is no point in enabling both.
#### Global NOTIFY Settings
``` ```
'notify_over_tcp': True 'notify_over_tcp': True
``` ```
Switch to using TCP instead of UDP. Defaults to False
Switch to using TCP instead of UDP. Defaults to `False`.
#### Client NOTIFY #### Client NOTIFY
Clients may be notified of zone changes using the NOTIFY mechanism defined in RFC 1996.
When enabled, the zone transfer endpoint keeps track of any client that successfully queried the Clients may be notified of zone changes using the NOTIFY mechanism defined in RFC 1996. When
endpoint. Once a zone has changed, NetBox informs each client about the zone changed so that the enabled, the zone transfer endpoint keeps track of any client that successfully queried the
endpoint. Once a zone has changed, NetBox informs each client that the zone changed so that the
client can request a new zone transfer. client can request a new zone transfer.
Note that this feature uses NetBox's background job system to schedule the messages asynchronously. Note that this feature uses NetBox's background job system to schedule the messages
In order to work, you need at least one `rq-worker` service running in the background to handle asynchronously. In order to work, you need at least one `rq-worker` service running in the
the queued jobs. background to handle the queued jobs.
``` ```
'notify_clients': True 'notify_clients': True
``` ```
This enables the NOTIFY system. Defaults to False
This enables the NOTIFY system. Defaults to `False`.
``` ```
'notify_client_alive_threshold_hours': 24 'notify_client_alive_threshold_hours': 24
``` ```
This sets how long a client is considred "alive" after it last queried the transfer endpoint.
Once the client has failed to check in for this amout of time, it is automatically removed from the This sets how long a client is considered "alive" after it last queried the transfer endpoint. Once
the client has failed to check in for this amount of time, it is automatically removed from the
list of clients to be notified on zone changes. Default is 24 hours. list of clients to be notified on zone changes. Default is 24 hours.
``` ```
'notify_client_port': 53 'notify_client_port': 53
``` ```
Destination port used to send NOTIFY messages to clients. Defaults to 53. Destination port used to send NOTIFY messages to clients. Defaults to 53.
#### NS NOTIFY #### NS NOTIFY
In addition to notifying clients that queried the transfer endpoint, the plugin can send a NOTIFY In addition to notifying clients that queried the transfer endpoint, the plugin can send a NOTIFY
directly to a zone's own nameservers whenever that zone's SOA serial number changes. This is useful directly to a zone's own nameservers whenever that zone's SOA serial number changes. This is useful
when you want nameservers to pull updates immediately, independent of the `notify_clients` mechanism. when you want nameservers to pull updates immediately, independent of the `notify_clients`
Only a change of the SOA serial triggers this — other zone edits that leave the serial untouched do mechanism. Only a change of the SOA serial triggers this — other zone edits that leave the serial
not. untouched do not.
This can be enabled per zone, globally, or both. If neither of the settings below is configured, the This can be enabled per zone, globally, or both. If neither of the settings below is configured,
feature is disabled entirely. the feature is disabled entirely.
##### NS NOTIFY per zone
##### NS NOTIFY per Zone This uses a True/False custom field attached to each zone to enable/disable NOTIFY for each
This uses a True/False custom field attached to each Zone to enable/disable NOTIFY for each
individually. individually.
Create a boolean custom field (e.g. `dns_bridge_notify_ns`) that applies Create a boolean custom field (e.g. `dns_bridge_notify_ns`) that applies to `netbox_dns > zone`,
to `netbox_dns > zone`, then point the plugin at it: then point the plugin at it:
```
```python
'notify_ns_custom_field_name': 'dns_bridge_notify_ns' 'notify_ns_custom_field_name': 'dns_bridge_notify_ns'
``` ```
A NOTIFY is then sent for any zone whose custom field is set to `True`. This lets you toggle the A NOTIFY is then sent for any zone whose custom field is set to `True`. This lets you toggle the
behavior per zone straight from the NetBox UI, without changing the plugin config. behavior per zone straight from the NetBox UI, without changing the plugin config.
##### NS NOTIFY for all zones
##### NS NOTIFY for all Zones
``` ```
'notify_ns_all_zones': True 'notify_ns_all_zones': True
``` ```
When enabled, a NOTIFY is sent for every zone on serial change, regardless of the custom field. When enabled, a NOTIFY is sent for every zone on serial change, regardless of the custom field.
Defaults to `False`. The two options coexist: a NOTIFY is sent if the global switch is on **or** the Defaults to `False`. The two options coexist: a NOTIFY is sent if the global switch is on **or**
zone's custom field is set. the zone's custom field is set.
##### NS NOTIFY settings
##### NS NOTIFY Settings
The nameserver addresses are resolved from NetBox DNS itself by looking up active A/AAAA records The nameserver addresses are resolved from NetBox DNS itself by looking up active A/AAAA records
(within the zone's view) matching each nameserver's hostname. The NOTIFY messages are signed with the (within the zone's view) matching each nameserver's hostname. The NOTIFY messages are signed with
same per-view TSIG key configured under `tsig_keys`, so the receiving nameservers must accept NOTIFY the same per-view TSIG key configured under `tsig_keys`, so the receiving nameservers must accept
signed with that key (e.g. BIND's `allow-notify { key "..."; };`). NOTIFY signed with that key (e.g. BIND's `allow-notify { key "..."; };`).
``` ```
'notify_ns_port': 53 'notify_ns_port': 53
``` ```
Destination port used for these NOTIFY messages. Defaults to 53. Destination port used for these NOTIFY messages. Defaults to 53.
As with the client NOTIFY feature, this uses NetBox's background job system, so at least one As with the client NOTIFY feature, this uses NetBox's background job system, so at least one
`rq-worker` service must be running. `rq-worker` service must be running.
## Running the transfer endpoint
## Plugin compatibility To start the transfer endpoint in the foreground:
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`.
```
manage.py dns-transfer-endpoint --port 5354
```
## Installation guide | Parameter | Description |
This setup provisions a BIND9 server directly with DNS data from NetBox. BIND9 can optionally run on | --------- | ----------- |
a separate server. If so, any reference to 127.0.0.1 in step 6 must be replaced with the IP address | `--port` | Port to listen on for requests (defaults to 5354) |
of the NetBox host. TCP and UDP traffic from the BIND9 server to the NetBox host must be allowed on | `--address` | IP of interface to bind to (defaults to 0.0.0.0) |
port 5354 (or the port you have configured).
This guide assumes: This process needs to run as a background service for the built-in DNS server to work correctly.
- NetBox has been installed under /opt/netbox For Linux users with systemd (Ubuntu, etc), Matt Kollross provides a startup unit and instructions
- Bind9 is installed on the same host as NetBox in [docs/install-systemd-service.md](docs/install-systemd-service.md). Remember to activate the
- The NetBox DNS Plugin netbox-plugin-dns is installed venv when running the service in the background.
- The following dns views exist in NetBox DNS:
- `public` (the default)
- `private`
1. Preliminaries ## Notes and troubleshooting
- Install Bind9 on the same host that netbox is on.
- Generate a TSIG Key for the `public` and `private` dns views respectively.
2. Adding required package - **TSIG key reuse** — each view must have its own unique TSIG key. Reusing a key across multiple
``` views yields unpredictable behavior, since the plugin uses the key to map requests to views.
cd netbox - **Catalog zone name mismatch** — the zone name configured on the consumer (e.g. BIND9's
echo netbox-plugin-dns-bridge >> local_requirements.txt `catalog-zones { zone "..." }` statement and the corresponding slave zone declaration) must match
. venv/bin/activate the plugin's `catalog_zone_name` setting.
pip install -r local_requirements.txt - **NS NOTIFY not firing** — NS NOTIFY is only triggered by a change of the zone's SOA serial
``` number. Other zone edits that leave the serial untouched do not trigger a NOTIFY.
- **NOTIFY jobs are not sent** — both NOTIFY mechanisms require at least one `rq-worker` service to
3. Updating netbox plugin configuration (configuration.py) be running, since the messages are scheduled through NetBox's background job system.
Change following line from
```
PLUGINS = ['netbox_dns']
```
to
```
PLUGINS = ['netbox_dns', 'netbox_dns_bridge']
```
Configure the DNS Bridge Plugin using the PLUGINS_CONFIG dictionary.
Change
```
PLUGINS_CONFIG = {}
```
to
```
PLUGINS_CONFIG = {
"netbox_dns_bridge": {
"tsig_keys": {
"public": {
"keyname": "public_view_key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
},
"private": {
"keyname": "private_view_key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
}
}
}
}
```
Note that the tsig-key attributes keyname, algorithm and secret form a
dictionary in following python structure path:
```
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
signature from each request.
4. Run migrations
```
manage.py migrate
```
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 by default.
```
manage.py dns-transfer-endpoint --port 5354
```
6. Configuring a Bind9 to interact with NetBox via the dns-transfer-endpoint endpoint. Note that its
not possible to give all the correct details of the `options` block as it is heavily dependent
on the Operating System used. Please dont forget to adjust as required.
```
########## OPTIONS ##########
options {
allow-update { none; };
allow-query { any; };
allow-recursion { none; };
notify yes;
min-refresh-time 60;
};
########## ACLs ##########
acl public {
!10.0.0.0/8;
!172.16.0.0/12;
!192.168.0.0/16;
any;
};
acl private {
10.0.0.0/8;
172.16.0.0/12;
192.168.0.0/16;
};
######## TSIG Keys ########
key "public_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
key "private_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
###########################
########## ZONES ##########
view "public" {
match-clients { public; };
allow-notify { key "public_view_key"; };
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "public_view_key"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_public";
masters { 127.0.0.1 port 5354 key "public_view_key"; };
notify no;
};
};
view "private" {
match-clients { private; };
allow-notify { key "private_view_key"; };
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "private_view_key"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_private";
masters { 127.0.0.1 port 5354 key "private_view_key"; };
notify no;
};
};
```
7. Restart bind - Done
## License
This project is licensed under the [MIT License](LICENSE.md).
-76
View File
@@ -1,76 +0,0 @@
<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
@@ -1,4 +0,0 @@
<?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

Before

Width:  |  Height:  |  Size: 112 KiB

+3 -1
View File
@@ -1,4 +1,6 @@
1. Create System Unit file # NetBox DNS transfer endpoint systemd service
1. Create a systemd unit file
``` ```
cat <<EOF > /etc/systemd/system/netbox-dns-endpoint.service cat <<EOF > /etc/systemd/system/netbox-dns-endpoint.service
[Unit] [Unit]
+192
View File
@@ -0,0 +1,192 @@
# Installation guide
This setup provisions a BIND9 server directly with DNS data from NetBox. BIND9 can optionally run on
a separate server. If so, any reference to 127.0.0.1 in step 6 must be replaced with the IP address
of the NetBox host. TCP and UDP traffic from the BIND9 server to the NetBox host must be allowed on
port 5354 (or the port you have configured).
This guide assumes:
- NetBox has been installed under /opt/netbox
- BIND9 is installed on the same host as NetBox
- The NetBox DNS plugin netbox-plugin-dns is installed
- The following DNS views exist in NetBox DNS:
- `public` (the default)
- `private`
## 1. Preliminaries
- Install BIND9 on the same host that NetBox is on.
- Generate a TSIG key for the `public` and `private` DNS views respectively.
## 2. Adding required package
```
cd netbox
echo netbox-plugin-dns-bridge >> local_requirements.txt
. venv/bin/activate
pip install -r local_requirements.txt
```
## 3. Updating netbox plugin configuration (configuration.py)
Change the following line from
```
PLUGINS = ['netbox_dns']
```
to
```
PLUGINS = ['netbox_dns', 'netbox_dns_bridge']
```
Configure the DNS Bridge plugin using the `PLUGINS_CONFIG` dictionary. Change
```
PLUGINS_CONFIG = {}
```
to
```
PLUGINS_CONFIG = {
"netbox_dns_bridge": {
"tsig_keys": {
"public": {
"keyname": "public_view_key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
},
"private": {
"keyname": "private_view_key",
"algorithm": "hmac-sha256",
"secret": "base64-encoded-secret"
}
}
}
}
```
Note that the tsig-key attributes keyname, algorithm and secret form a dictionary in the following
Python structure path:
```
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 signature from each
request.
## 4. Run migrations
```
manage.py migrate
```
## 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 in [install-systemd-service.md](install-systemd-service.md). Don't 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.
```
manage.py dns-transfer-endpoint --port 5354
```
## 6. Configuring BIND9
Configure BIND9 to interact with NetBox via the dns-transfer-endpoint endpoint. Note that it is
not possible to give all the correct details of the `options` block as it is heavily dependent on
the operating system used. Please don't forget to adjust it as required.
> **Note:** Since BIND 9.18, `type slave` is deprecated in favor of `type secondary` and
> `masters` in favor of `primaries`. Both still work, but newer deployments may prefer the new
> keywords.
```
########## OPTIONS ##########
options {
allow-update { none; };
allow-query { any; };
allow-recursion { none; };
notify yes;
min-refresh-time 60;
};
########## ACLs ##########
acl public {
!10.0.0.0/8;
!172.16.0.0/12;
!192.168.0.0/16;
any;
};
acl private {
10.0.0.0/8;
172.16.0.0/12;
192.168.0.0/16;
};
######## TSIG Keys ########
key "public_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
key "private_view_key" {
algorithm hmac-sha256;
secret "base64-encoded-secret";
};
########## VIEWS & ZONES ##########
view "public" {
match-clients { public; };
allow-notify { key "public_view_key"; };
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "public_view_key"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_public";
masters { 127.0.0.1 port 5354 key "public_view_key"; };
notify no;
};
};
view "private" {
match-clients { private; };
allow-notify { key "private_view_key"; };
catalog-zones {
zone "catz"
default-masters { 127.0.0.1 port 5354 key "private_view_key"; }
zone-directory "/var/lib/bind/zones"
min-update-interval 1;
};
zone "catz" {
type slave;
file "/var/lib/bind/zones/catz_private";
masters { 127.0.0.1 port 5354 key "private_view_key"; };
notify no;
};
};
```
## 7. Restart BIND9
Restart BIND9 — done. The server will now discover zones in its views through the catalog zone and
transfer them from NetBox.
+2 -1
View File
@@ -1,7 +1,8 @@
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from django.conf import settings from django.conf import settings
__version__ = "1.5.8" __version__ = "1.7.0"
class DNSBridgeConfig(PluginConfig): class DNSBridgeConfig(PluginConfig):
name = "netbox_dns_bridge" name = "netbox_dns_bridge"
View File
Whitespace-only changes.
+125
View File
@@ -0,0 +1,125 @@
from rest_framework import serializers
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
import netbox_dns.models
from netbox_dns.api.nested_serializers import NestedZoneSerializer
from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember, SeenTransferClient
__all__ = (
"CatalogZoneSerializer",
"CatalogZoneMemberSerializer",
"SeenTransferClientSerializer",
"NestedCatalogZoneSerializer",
"NestedCatalogZoneMemberSerializer",
)
class NestedViewSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_dns-api:view-detail"
)
class Meta:
model = netbox_dns.models.View
fields = ("id", "url", "display", "name")
brief_fields = ("id", "url", "display", "name")
class NestedCatalogZoneSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_dns_bridge-api:catalogzone-detail"
)
class Meta:
model = CatalogZone
fields = ("id", "url", "display", "view", "soa_serial")
brief_fields = ("id", "url", "display", "view", "soa_serial")
class NestedCatalogZoneMemberSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_dns_bridge-api:catalogzonemember-detail"
)
class Meta:
model = CatalogZoneMember
fields = ("id", "url", "display", "name", "zone", "catalog_zone")
brief_fields = ("id", "url", "display", "name", "zone", "catalog_zone")
class CatalogZoneSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_dns_bridge-api:catalogzone-detail"
)
view = NestedViewSerializer()
class Meta:
model = CatalogZone
fields = (
"id",
"url",
"display",
"display_url",
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
"created",
"last_updated",
"tags",
"custom_fields",
)
brief_fields = ("id", "url", "display", "view", "soa_serial")
class CatalogZoneMemberSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_dns_bridge-api:catalogzonemember-detail"
)
zone = NestedZoneSerializer()
catalog_zone = NestedCatalogZoneSerializer()
class Meta:
model = CatalogZoneMember
fields = (
"id",
"url",
"display",
"display_url",
"name",
"zone",
"catalog_zone",
"created",
"last_updated",
"tags",
"custom_fields",
)
brief_fields = ("id", "url", "display", "name", "zone", "catalog_zone")
class SeenTransferClientSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_dns_bridge-api:seentransferclient-detail"
)
view = NestedViewSerializer()
class Meta:
model = SeenTransferClient
fields = (
"id",
"url",
"display",
"display_url",
"source_ip",
"last_seen",
"view",
"created",
"last_updated",
"tags",
"custom_fields",
)
brief_fields = ("id", "url", "display", "source_ip", "last_seen", "view")
+12
View File
@@ -0,0 +1,12 @@
from netbox.api.routers import NetBoxRouter
from . import views
router = NetBoxRouter()
router.APIRootView = views.NetBoxDNSBridgeRootView
router.register("catalog-zones", views.CatalogZoneViewSet)
router.register("catalog-zone-members", views.CatalogZoneMemberViewSet)
router.register("seen-transfer-clients", views.SeenTransferClientViewSet)
urlpatterns = router.urls
+46
View File
@@ -0,0 +1,46 @@
from rest_framework.routers import APIRootView
from netbox.api.viewsets import NetBoxModelViewSet
from netbox_dns_bridge.filtersets import (
CatalogZoneFilterSet,
CatalogZoneMemberFilterSet,
SeenTransferClientFilterSet,
)
from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember, SeenTransferClient
from .serializers import (
CatalogZoneSerializer,
CatalogZoneMemberSerializer,
SeenTransferClientSerializer,
)
__all__ = (
"NetBoxDNSBridgeRootView",
"CatalogZoneViewSet",
"CatalogZoneMemberViewSet",
"SeenTransferClientViewSet",
)
class NetBoxDNSBridgeRootView(APIRootView):
def get_view_name(self):
return "NetBoxDNSBridge"
class CatalogZoneViewSet(NetBoxModelViewSet):
queryset = CatalogZone.objects.all()
serializer_class = CatalogZoneSerializer
filterset_class = CatalogZoneFilterSet
class CatalogZoneMemberViewSet(NetBoxModelViewSet):
queryset = CatalogZoneMember.objects.all()
serializer_class = CatalogZoneMemberSerializer
filterset_class = CatalogZoneMemberFilterSet
class SeenTransferClientViewSet(NetBoxModelViewSet):
queryset = SeenTransferClient.objects.all()
serializer_class = SeenTransferClientSerializer
filterset_class = SeenTransferClientFilterSet
+39 -9
View File
@@ -3,27 +3,57 @@ from django.db.models import Q
import netbox_dns.models import netbox_dns.models
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
from netbox_dns.choices import ZoneStatusChoices
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class ZoneFilterSet(NetBoxModelFilterSet): class CatalogZoneFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter( view = django_filters.ModelMultipleChoiceFilter(
field_name="view", field_name="view",
queryset=netbox_dns.models.View.objects.all(), queryset=netbox_dns.models.View.objects.all(),
) )
status = django_filters.MultipleChoiceFilter(
choices=ZoneStatusChoices, class Meta:
null_value=None, model = CatalogZone
fields = ("id", "view")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(Q(view__name__icontains=value))
class CatalogZoneMemberFilterSet(NetBoxModelFilterSet):
catalog_zone = django_filters.ModelMultipleChoiceFilter(
field_name="catalog_zone",
queryset=CatalogZone.objects.all(),
) )
class Meta: class Meta:
model = netbox_dns.models.Zone model = CatalogZoneMember
fields = ("id", "name", "view", "status", "catz_identifier") fields = ("id", "catalog_zone", "zone")
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) Q(name__icontains=value) | Q(zone__name__icontains=value)
| Q(catz_identifier__name__icontains=value) )
class SeenTransferClientFilterSet(NetBoxModelFilterSet):
view = django_filters.ModelMultipleChoiceFilter(
field_name="view",
queryset=netbox_dns.models.View.objects.all(),
)
class Meta:
model = SeenTransferClient
fields = ("id", "view", "source_ip")
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(source_ip__icontains=value) | Q(view__name__icontains=value)
) )
+48 -5
View File
@@ -2,20 +2,63 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import netbox_dns.models import netbox_dns.models
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm
from netbox_dns.choices import ZoneStatusChoices
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneForm(NetBoxModelForm):
soa_serial = forms.IntegerField(label=_("SOA Serial"))
soa_refresh = forms.IntegerField(label=_("Refresh"))
soa_retry = forms.IntegerField(label=_("Retry"))
soa_expire = forms.IntegerField(label=_("Expire"))
soa_minimum = forms.IntegerField(label=_("Minimum"))
view = forms.ModelChoiceField(
queryset=netbox_dns.models.View.objects.all(),
disabled=True,
required=False,
label=_("View"),
)
class Meta:
model = CatalogZone
fields = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
class CatalogZoneFilterForm(NetBoxModelFilterSetForm): class CatalogZoneFilterForm(NetBoxModelFilterSetForm):
model = netbox_dns.models.Zone model = CatalogZone
q = forms.CharField(required=False, label=_("Search")) q = forms.CharField(required=False, label=_("Search"))
view = DynamicModelMultipleChoiceField( view = DynamicModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(), queryset=netbox_dns.models.View.objects.all(),
required=False, required=False,
) )
status = forms.MultipleChoiceField(
choices=ZoneStatusChoices,
class CatalogZoneMemberFilterForm(NetBoxModelFilterSetForm):
model = CatalogZoneMember
q = forms.CharField(required=False, label=_("Search"))
catalog_zone = forms.ModelMultipleChoiceField(
queryset=CatalogZone.objects.all(),
required=False,
)
class SeenTransferClientFilterForm(NetBoxModelFilterSetForm):
model = SeenTransferClient
q = forms.CharField(required=False, label=_("Search"))
view = forms.ModelMultipleChoiceField(
queryset=netbox_dns.models.View.objects.all(),
required=False, required=False,
) )
View File
Whitespace-only changes.
+286
View File
@@ -0,0 +1,286 @@
import dns.message
import dns.name
import dns.opcode
import dns.query
import dns.rdatatype
import dns.rdataclass
import dns.rrset
import dns.tsig
from django.conf import settings
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record
from netbox_dns_bridge.utils import get_logger
from netbox_dns_bridge.models import SeenTransferClient
LOGGER = get_logger(__name__)
class NotifyJobRunner(JobRunner):
"""Base class for NOTIFY jobs: shared plugin settings and helpers."""
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def load_tsig_key(self, view_name: str) -> dns.tsig.Key:
tsig_keys = self.SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(f"Incomplete TSIG key configuration for view '{view_name}'")
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
def send_notify(
self,
zone_name,
soa_serial,
targets,
tsig_key,
view_name,
notify_over_tcp,
port,
log_prefix="NOTIFY",
soa_refresh=60,
soa_retry=10,
soa_expire=1209600,
soa_minimum=0,
):
"""Build a DNS NOTIFY message for *zone_name* and send it to every target IP."""
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} {soa_refresh} {soa_retry} {soa_expire} {soa_minimum}",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for target in targets:
try:
if notify_over_tcp:
LOGGER.debug(f"Sending {log_prefix} over TCP")
dns.query.tcp(msg, target, port=port, timeout=2)
else:
LOGGER.debug(f"Sending {log_prefix} over UDP")
dns.query.udp(msg, target, port=port, timeout=2)
LOGGER.info(
f"{log_prefix} sent: {target} {view_name}/{fqdn} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"{log_prefix} failed: {target}:{port}"
f" {view_name}/{fqdn} {soa_serial}: {e}"
)
@staticmethod
def get_soa_serial(zone: Zone):
"""Return the current SOA serial of a zone parsed from its SOA record."""
try:
soa_record = Record.objects.filter(zone=zone, type="SOA", active=True).first()
if soa_record is None:
LOGGER.error(f"Zone {zone.name} has no active SOA record — skipping NOTIFY")
return None
return int(soa_record.value.split()[2])
except OperationalError as e:
LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}")
close_old_connections()
return None
except (ValueError, IndexError) as e:
LOGGER.error(f"Failed to parse SOA serial for zone {zone.name}: {e}")
return None
class SendClientNotify(NotifyJobRunner):
class Meta:
name = "Send DNS NOTIFY"
def run(self, *args, **kwargs):
notify_over_tcp = self.SETTINGS.get("notify_over_tcp", False)
port = self.SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
soa_refresh = kwargs.get("soa_refresh", 60)
soa_retry = kwargs.get("soa_retry", 10)
soa_expire = kwargs.get("soa_expire", 1209600)
soa_minimum = kwargs.get("soa_minimum", 0)
try:
view = View.objects.get(name=view_name)
tsig_key = self.load_tsig_key(view_name)
cutoff_hours = self.SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = list(
SeenTransferClient.objects.filter(
view=view, last_seen__gte=seen_cutoff
).values_list("source_ip", flat=True)
)
self.send_notify(
zone_name, soa_serial, clients, tsig_key,
view_name, notify_over_tcp, port, log_prefix="NOTIFY",
soa_refresh=soa_refresh, soa_retry=soa_retry,
soa_expire=soa_expire, soa_minimum=soa_minimum,
)
except View.DoesNotExist as e:
LOGGER.error(f"Failed to get view for NOTIFY: {e}")
except OperationalError as e:
LOGGER.error(f"NOTIFY failed due to unexpected error: {e}")
close_old_connections()
class SendNSNotify(NotifyJobRunner):
"""Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendClientNotify`` (which notifies clients that previously queried
the transfer endpoint), this job resolves the addresses of the zone's
configured nameservers from NetBox DNS itself and notifies each of them so
they pick up the changed zone quickly.
"""
class Meta:
name = "Send DNS NOTIFY to zone nameservers"
def run(self, *args, **kwargs):
notify_over_tcp = self.SETTINGS.get("notify_over_tcp", False)
port = self.SETTINGS.get("notify_ns_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
try:
zone = Zone.objects.get(pk=kwargs["zone_id"])
except Zone.DoesNotExist:
LOGGER.error(f"NS NOTIFY: zone id {kwargs['zone_id']} no longer exists")
return
except OperationalError as e:
LOGGER.error(f"NS NOTIFY failed due to unexpected error: {e}")
close_old_connections()
return
ns_targets = self.resolve_zone_nameserver_ips(zone)
if not ns_targets:
LOGGER.warning(
f"NS NOTIFY: no nameserver addresses found in NetBox DNS for "
f"{view_name}/{zone_name} — skipping"
)
return
try:
tsig_key = self.load_tsig_key(view_name)
except RuntimeError as e:
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
return
self.send_notify(
zone_name, soa_serial, ns_targets, tsig_key,
view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
)
@classmethod
def notify_ns_enabled(cls, zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if cls.SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = cls.SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
def resolve_zone_nameserver_ips(self, zone: Zone) -> list:
"""Resolve the zone's nameserver hostnames to IPs using NetBox DNS records.
For each nameserver assigned to the zone, matching active A/AAAA records
within the same view are looked up in NetBox DNS. Returns a de-duplicated,
order-preserving list of IP addresses.
"""
ips = []
try:
nameservers = list(zone.nameservers.all())
view = zone.view
for nameserver in nameservers:
fqdn = dns.name.from_text(nameserver.name, dns.name.root).to_text()
# Match both with and without a trailing dot to be resilient against
# how the fqdn is stored on the Record model.
candidates = {fqdn, fqdn.rstrip(".")}
records = Record.objects.filter(
fqdn__in=list(candidates),
type__in=["A", "AAAA"],
active=True,
zone__view=view,
)
for record in records:
if record.value not in ips:
ips.append(record.value)
except OperationalError as e:
LOGGER.error(f"DB ERROR while resolving nameservers for {zone.name}: {e}")
close_old_connections()
return ips
def schedule_client_notify(zone: Zone):
if not SendClientNotify.SETTINGS.get("notify_clients", False):
return
soa_serial = SendClientNotify.get_soa_serial(zone)
if soa_serial is None:
return
SendClientNotify.enqueue(
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
def schedule_ns_notify(zone: Zone):
if not SendNSNotify.notify_ns_enabled(zone):
return
soa_serial = SendNSNotify.get_soa_serial(zone)
if soa_serial is None:
return
SendNSNotify.enqueue(
zone_id=zone.pk,
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
@@ -1,51 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from netbox_dns_bridge.models import IntegerKeyValueSetting
class Command(BaseCommand):
help = "Get or set integer key-value settings"
def add_arguments(self, parser):
parser.add_argument(
"action", choices=["get", "set", "list"], help="Action to perform"
)
parser.add_argument("key", nargs="?", help="Setting key")
parser.add_argument(
"value", nargs="?", type=int, help="Value to set (required for 'set')"
)
def handle(self, *args, **options):
action = options["action"]
key = options.get("key")
value = options.get("value")
if action == "list":
settings = IntegerKeyValueSetting.objects.all()
if not settings.exists():
self.stdout.write("No settings found.")
return
for setting in settings:
self.stdout.write(str(setting))
elif action == "get":
if not key:
raise CommandError("A key is required for GET")
try:
setting = IntegerKeyValueSetting.objects.get(key=key)
self.stdout.write(f"{setting.key}: {setting.value}")
except IntegerKeyValueSetting.DoesNotExist:
raise CommandError(f"No setting was found for {key}")
elif action == "set":
if not key:
raise CommandError("A setting is required for SET command")
if value is None:
raise CommandError("A value is required for SET command")
try:
setting = IntegerKeyValueSetting.objects.get(key=key)
old_value = setting.value
setting.value = value
setting.save()
self.stdout.write(f"Updated {key} from {old_value} to {value}")
except IntegerKeyValueSetting.DoesNotExist:
raise CommandError(f"No setting found for {key}")
@@ -3,51 +3,83 @@ import dns.zone
import dns.rdatatype import dns.rdatatype
import dns.rdataclass import dns.rdataclass
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns.models import Zone as NBZone from netbox_dns.models import Zone as NBZone, View as NBView
from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier from netbox_dns_bridge.models import CatalogZone, CatalogZoneMember
from netbox_dns_bridge.jobs import notify
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.conf import settings
from django.db import transaction, close_old_connections, OperationalError from django.db import transaction, close_old_connections, OperationalError
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def increment_soa_serial() -> int:
def catalog_zone_name(view_name: str) -> dns.name.Name:
"""Return the fully qualified catalog zone name for a view as a
dns.name.Name.
The name is configured via the ``catalog_zone_name`` setting (fallback:
``catz``). With ``catalog_zone_name_includes_view`` enabled, the view
name is prepended: ``<view>.<catalog_zone_name>``.
"""
base = dns.name.from_text(SETTINGS.get("catalog_zone_name") or "catz")
if SETTINGS.get("catalog_zone_name_includes_view", False):
return dns.name.from_text(view_name, origin=base)
return base
def is_catalog_zone_query(qname: dns.name.Name, view_name: str) -> bool:
"""Check whether a queried name is the catalog zone name of a view.
dns.name.Name equality is case-insensitive, so queries from BIND match
regardless of case.
"""
return qname == catalog_zone_name(view_name)
def increment_soa_serial(view: NBView) -> int:
with transaction.atomic(): with transaction.atomic():
try: try:
( (
soa_serial_obj, soa_serial_obj,
_, _,
) = IntegerKeyValueSetting.objects.select_for_update().get_or_create( ) = CatalogZone.objects.select_for_update().get_or_create(
key="catalog-zone-soa-serial", view=view,
defaults={"value": 1}, defaults={"soa_serial": 1},
) )
new_soa_serial = soa_serial_obj.value + 1 new_soa_serial = soa_serial_obj.soa_serial + 1
if new_soa_serial > 0xFFFFFFFF: if new_soa_serial > 0xFFFFFFFF:
LOGGER.warning( LOGGER.warning(
f"Catalog serial {soa_serial_obj.value} reached max — wrapping back to 1" f"Catalog serial {soa_serial_obj.soa_serial} reached max — wrapping back to 1"
) )
new_soa_serial = 1 new_soa_serial = 1
soa_serial_obj.value = new_soa_serial soa_serial_obj.soa_serial = new_soa_serial
soa_serial_obj.save() soa_serial_obj.save()
soa_serial_obj.refresh_from_db() soa_serial_obj.refresh_from_db()
LOGGER.debug( LOGGER.debug(
f"Catalog zone SOA serial number is now {soa_serial_obj.value}" f"Catalog zone SOA serial for view '{view.name}' "
f"is now {soa_serial_obj.soa_serial}"
) )
return soa_serial_obj.value serial = soa_serial_obj.soa_serial
except OperationalError as e: except OperationalError as e:
LOGGER.error( LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
) )
close_old_connections() close_old_connections()
return None
schedule_catalog_zone_notify(view)
return serial
def create_zone(name, view_name) -> dns.zone.Zone: def create_zone(view_name) -> dns.zone.Zone:
# Zone origin # Zone origin: the configured catalog zone name for this view
origin = dns.name.from_text(name, dns.name.root) origin = catalog_zone_name(view_name)
# Create a new empty zone # Create a new empty zone
zone = dns.zone.Zone(origin) zone = dns.zone.Zone(origin)
@@ -57,7 +89,7 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# get zones from netbox # get zones from netbox
nb_zones = NBZone.objects.filter( nb_zones = NBZone.objects.filter(
view__name=view_name, active=True view__name=view_name, active=True
).select_related("catz_identifier") ).select_related("catalog_zone_member")
ptr_base = dns.name.from_text("zones", origin) ptr_base = dns.name.from_text("zones", origin)
@@ -67,18 +99,21 @@ def create_zone(name, view_name) -> dns.zone.Zone:
# Create PTR record # Create PTR record
try: try:
catz_identifier = nb_zone.catz_identifier member = nb_zone.catalog_zone_member
except NBZone.catz_identifier.RelatedObjectDoesNotExist: except NBZone.catalog_zone_member.RelatedObjectDoesNotExist:
catz_identifier = CatalogZoneMemberIdentifier.objects.create( catalog_zone = _get_or_create_catalog_zone(nb_zone.view)
zone=nb_zone, name=_generate_member_identifier() member = CatalogZoneMember.objects.create(
zone=nb_zone,
name=_generate_member_name(),
catalog_zone=catalog_zone,
) )
p_name = catz_identifier.name p_name = member.name
ptr_name = dns.name.from_text(p_name, ptr_base) ptr_name = dns.name.from_text(p_name, ptr_base)
if not ptr_name.is_subdomain(origin): if not ptr_name.is_subdomain(origin):
raise ValueError( raise ValueError(
f"Catalog zone member identifier {ptr_name.to_text()} not a subdomain" f"Catalog zone member {ptr_name.to_text()} not a subdomain"
) )
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text() dns.rdataclass.IN, dns.rdatatype.PTR, qname.to_text()
@@ -100,14 +135,15 @@ def create_zone(name, view_name) -> dns.zone.Zone:
zone_root_node = zone.find_node(origin, create=True) zone_root_node = zone.find_node(origin, create=True)
# SOA record # SOA record
soa_rdataset = _create_soa_rdataset() view = NBView.objects.get(name=view_name)
soa_rdataset = _create_soa_rdataset(view)
zone_root_node.rdatasets.append(soa_rdataset) zone_root_node.rdatasets.append(soa_rdataset)
# NS record # NS record
ns_rdataset = _create_ns_rdataset() ns_rdataset = _create_ns_rdataset()
zone_root_node.rdatasets.append(ns_rdataset) zone_root_node.rdatasets.append(ns_rdataset)
# version.catz. record # version.<catalog zone name>. record
version_rdataset = _create_version_rdataset() version_rdataset = _create_version_rdataset()
zone_version_node = zone.find_node( zone_version_node = zone.find_node(
dns.name.from_text("version", origin), create=True dns.name.from_text("version", origin), create=True
@@ -122,27 +158,64 @@ def create_zone(name, view_name) -> dns.zone.Zone:
return None return None
def _generate_member_identifier() -> str: def _generate_member_name() -> str:
return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8") return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8")
def update_member_identifier(zone: NBZone) -> None: def _get_or_create_catalog_zone(view: NBView) -> CatalogZone:
catalog_zone, _ = CatalogZone.objects.get_or_create(
view=view, defaults={"soa_serial": 1}
)
return catalog_zone
def update_member(zone: NBZone) -> None:
try: try:
CatalogZoneMemberIdentifier.objects.update_or_create( catalog_zone = _get_or_create_catalog_zone(zone.view)
CatalogZoneMember.objects.update_or_create(
zone=zone, zone=zone,
defaults={"name": _generate_member_identifier()}, defaults={
"name": _generate_member_name(),
"catalog_zone": catalog_zone,
},
) )
except OperationalError as e: except OperationalError as e:
LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}") LOGGER.error(f"ERROR: Failed to update Catalog Zone member: {e}")
close_old_connections() close_old_connections()
def _create_soa_rdataset() -> dns.rdataset: def schedule_catalog_zone_notify(view: NBView) -> None:
"""Schedule a NOTIFY for the catalog zone of a view.
Notifies transfer clients for the single configured catalog zone name
(see ``catalog_zone_name``). The job receives the name as text, since
job kwargs are serialized to JSON.
"""
if not SETTINGS.get("notify_clients", False):
return
try: try:
serial_obj, _ = IntegerKeyValueSetting.objects.get_or_create( catalog_zone = view.catalog_zone
key="catalog-zone-soa-serial", defaults={"value": 1} except CatalogZone.DoesNotExist:
return
notify.SendClientNotify.enqueue(
zone_name=catalog_zone_name(view.name).to_text(omit_final_dot=True),
soa_serial=catalog_zone.soa_serial,
view_name=view.name,
soa_refresh=catalog_zone.soa_refresh,
soa_retry=catalog_zone.soa_retry,
soa_expire=catalog_zone.soa_expire,
soa_minimum=catalog_zone.soa_minimum,
)
def _create_soa_rdataset(view: NBView) -> dns.rdataset:
try:
serial_obj, _ = CatalogZone.objects.get_or_create(
view=view, defaults={"soa_serial": 1}
) )
serial = serial_obj.value serial = serial_obj.soa_serial
# SOA Record components # SOA Record components
ttl = 0 ttl = 0
@@ -153,7 +226,9 @@ def _create_soa_rdataset() -> dns.rdataset:
rdata = dns.rdata.from_text( rdata = dns.rdata.from_text(
rclass, rclass,
rtype, rtype,
f"invalid. invalid. {serial} 60 10 1209600 0", f"invalid. invalid. {serial} {serial_obj.soa_refresh} "
f"{serial_obj.soa_retry} {serial_obj.soa_expire} "
f"{serial_obj.soa_minimum}",
) )
# Create Rdataset and add the RDATA to it # Create Rdataset and add the RDATA to it
@@ -15,7 +15,7 @@ from django.db import close_old_connections, OperationalError
from django.conf import settings from django.conf import settings
from netbox_dns.models import Zone, View from netbox_dns.models import Zone, View
from . import catalog_zone_manager as catzm from . import catalog_zone_manager as catzm
from netbox_dns_bridge.models import SeenTransferClients from netbox_dns_bridge.models import SeenTransferClient
from netbox_dns_bridge.utils import build_dns_zone, get_logger from netbox_dns_bridge.utils import build_dns_zone, get_logger
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
@@ -77,7 +77,7 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
def _track_seen_client(self, source_ip: str, view: View) -> None: def _track_seen_client(self, source_ip: str, view: View) -> None:
if SETTINGS.get("notify_clients", False): if SETTINGS.get("notify_clients", False):
try: try:
SeenTransferClients.objects.update_or_create( SeenTransferClient.objects.update_or_create(
source_ip=source_ip, source_ip=source_ip,
view=view, view=view,
defaults={"last_seen": timezone.now()}, defaults={"last_seen": timezone.now()},
@@ -289,9 +289,8 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
return return
# Check if catalog zone # Check if catalog zone
dname_lower = dname.lower() if catzm.is_catalog_zone_query(qname, nb_view.name):
if dname_lower == "catz" or dname_lower == f"{nb_view.name.lower()}.catz": zone = catzm.create_zone(nb_view.name)
zone = catzm.create_zone(dname_lower, nb_view.name)
else: else:
zone = self._get_zone_from_nb(dname, nb_view.name) zone = self._get_zone_from_nb(dname, nb_view.name)
# When zone was not found, let client know # When zone was not found, let client know
@@ -0,0 +1,233 @@
import taggit.managers
import utilities.json
import django.db.models.deletion
from django.db import migrations, models
def copy_serials_and_link_members(apps, schema_editor):
"""Seed one CatalogZone per view from the old global serial and link members."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Preserve the current global serial; default to 1 when never initialized.
seed_serial = 1
kv = IntegerKeyValueSetting.objects.filter(key="catalog-zone-soa-serial").first()
if kv is not None:
seed_serial = kv.value
# Create a CatalogZone for every view that currently has at least one
# member, seeding each with the old global serial.
view_ids = set(
CatalogZoneMember.objects.exclude(
zone__view__isnull=True
).values_list("zone__view_id", flat=True)
)
view_id_to_catalog_zone = {}
for view_id in view_ids:
cz, _ = CatalogZone.objects.get_or_create(
view_id=view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[view_id] = cz
# Link each member to the CatalogZone matching its zone's view.
for member in CatalogZoneMember.objects.select_related("zone__view"):
cz = view_id_to_catalog_zone.get(member.zone.view_id)
if cz is None:
# Defensive: member existed without a resolvable view. Create
# the CatalogZone on demand so the NOT NULL constraint can be set.
cz, _ = CatalogZone.objects.get_or_create(
view_id=member.zone.view_id, defaults={"soa_serial": seed_serial}
)
view_id_to_catalog_zone[member.zone.view_id] = cz
member.catalog_zone = cz
member.save(update_fields=["catalog_zone"])
def reverse_migration(apps, schema_editor):
"""Best-effort reverse: restore the KV row and null the FK."""
IntegerKeyValueSetting = apps.get_model(
"netbox_dns_bridge", "IntegerKeyValueSetting"
)
CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone")
CatalogZoneMember = apps.get_model("netbox_dns_bridge", "CatalogZoneMember")
# Pick a representative serial (first catalog zone) to write back.
first = CatalogZone.objects.order_by("pk").first()
serial = first.soa_serial if first is not None else 1
IntegerKeyValueSetting.objects.update_or_create(
key="catalog-zone-soa-serial", defaults={"value": serial}
)
CatalogZoneMember.objects.update(catalog_zone=None)
class Migration(migrations.Migration):
dependencies = [
("extras", "0140_imageattachment_image_size"),
(
"netbox_dns",
"0032_record_expiration_date",
),
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
# --- CatalogZone (full NetBoxModel, born with all fields) ---
migrations.CreateModel(
name="CatalogZone",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("soa_serial", models.IntegerField(default=1)),
("soa_refresh", models.IntegerField(default=60)),
("soa_retry", models.IntegerField(default=10)),
("soa_expire", models.IntegerField(default=1209600)),
("soa_minimum", models.IntegerField(default=0)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
(
"custom_field_data",
models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
(
"tags",
taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
(
"view",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone",
to="netbox_dns.view",
),
),
],
options={
"ordering": ("view__name",),
},
),
# --- CatalogZoneMemberIdentifier → CatalogZoneMember (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="CatalogZoneMemberIdentifier",
new_name="CatalogZoneMember",
),
migrations.AddField(
model_name="catalogzonemember",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="catalogzonemember",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="catalogzonemember",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
migrations.AlterField(
model_name="catalogzonemember",
name="name",
field=models.CharField(max_length=26),
),
migrations.AlterField(
model_name="catalogzonemember",
name="zone",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalog_zone_member",
to="netbox_dns.zone",
),
),
migrations.AddField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.RunPython(
copy_serials_and_link_members,
reverse_code=reverse_migration,
),
migrations.AlterField(
model_name="catalogzonemember",
name="catalog_zone",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="netbox_dns_bridge.catalogzone",
),
),
migrations.AddConstraint(
model_name="catalogzonemember",
constraint=models.UniqueConstraint(
fields=("name", "catalog_zone"),
name="unique_name_per_catalog_zone",
),
),
migrations.DeleteModel(
name="IntegerKeyValueSetting",
),
# --- SeenTransferClients → SeenTransferClient (rename + full NetBoxModel) ---
migrations.RenameModel(
old_name="SeenTransferClients",
new_name="SeenTransferClient",
),
migrations.AddField(
model_name="seentransferclient",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="seentransferclient",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder,
),
),
migrations.AddField(
model_name="seentransferclient",
name="tags",
field=taggit.managers.TaggableManager(
through="extras.TaggedItem", to="extras.Tag"
),
),
]
@@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_dns_bridge", "0002_alter_integerkeyvaluesetting_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="integerkeyvaluesetting",
name="key",
field=models.CharField(max_length=64, unique=True),
),
]
+36 -13
View File
@@ -2,42 +2,63 @@ from django.db import models
# import netbox.models # import netbox.models
import netbox_dns.models import netbox_dns.models
from netbox.models import NetBoxModel
class IntegerKeyValueSetting(models.Model): class CatalogZone(NetBoxModel):
_netbox_private = True view = models.OneToOneField(
to=netbox_dns.models.View,
on_delete=models.CASCADE,
related_name="catalog_zone",
)
soa_serial = models.IntegerField(default=1)
soa_refresh = models.IntegerField(default=60)
soa_retry = models.IntegerField(default=10)
soa_expire = models.IntegerField(default=1209600)
soa_minimum = models.IntegerField(default=0)
key = models.CharField(max_length=64, unique=True) class Meta:
value = models.IntegerField() ordering = ("view__name",)
verbose_name = "Catalog Zone"
verbose_name_plural = "Catalog Zones"
def __str__(self): def __str__(self):
return f"{self.key}: {str(self.value)}" return f"{self.view.name}: {self.soa_serial}"
class CatalogZoneMemberIdentifier(models.Model): class CatalogZoneMember(NetBoxModel):
_netbox_private = True
name = models.CharField( name = models.CharField(
max_length=26, max_length=26,
unique=True,
) )
zone = models.OneToOneField( zone = models.OneToOneField(
to=netbox_dns.models.Zone, to=netbox_dns.models.Zone,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="catz_identifier", related_name="catalog_zone_member",
)
catalog_zone = models.ForeignKey(
to=CatalogZone,
on_delete=models.CASCADE,
related_name="members",
) )
class Meta: class Meta:
ordering = ("name",) ordering = ("name",)
verbose_name = "Catalog Zone Member"
verbose_name_plural = "Catalog Zone Members"
constraints = [
models.UniqueConstraint(
fields=["name", "catalog_zone"],
name="unique_name_per_catalog_zone",
),
]
def __str__(self): def __str__(self):
return self.name return self.name
class SeenTransferClients(models.Model): class SeenTransferClient(NetBoxModel):
_netbox_private = True
source_ip = models.GenericIPAddressField() source_ip = models.GenericIPAddressField()
last_seen = models.DateTimeField() last_seen = models.DateTimeField()
view = models.ForeignKey( view = models.ForeignKey(
@@ -51,6 +72,8 @@ class SeenTransferClients(models.Model):
class Meta: class Meta:
ordering = ("source_ip",) ordering = ("source_ip",)
verbose_name = "Seen Transfer Client"
verbose_name_plural = "Seen Transfer Clients"
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["source_ip", "view"], fields=["source_ip", "view"],
+21 -10
View File
@@ -1,24 +1,35 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginMenu, PluginMenuItem from netbox.plugins import PluginMenu, PluginMenuItem
information_menu_item = PluginMenuItem( catalog_zones_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:catz", link="plugins:netbox_dns_bridge:catalogzone_list",
link_text=_("Catalog Zones"), link_text=_("Catalog Zones"),
permissions=[], permissions=["netbox_dns_bridge.view_catalogzone"],
staff_only=True,
) )
notify_menu_item = PluginMenuItem( catalog_zone_members_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:notify", link="plugins:netbox_dns_bridge:catalogzonemember_list",
link_text=_("Notify"), link_text=_("Catalog Zone Members"),
permissions=[], permissions=["netbox_dns_bridge.view_catalogzonemember"],
staff_only=True, )
seen_transfer_clients_menu_item = PluginMenuItem(
link="plugins:netbox_dns_bridge:seentransferclient_list",
link_text=_("Clients"),
permissions=["netbox_dns_bridge.view_seentransferclient"],
) )
menu = PluginMenu( menu = PluginMenu(
label=_("DNS Bridge"), label=_("DNS Bridge"),
groups=( groups=(
(_("Information"), (information_menu_item, notify_menu_item)), (
_("Catalog Zone"),
(catalog_zones_menu_item, catalog_zone_members_menu_item),
),
(
_("Notify"),
(seen_transfer_clients_menu_item,),
),
), ),
icon_class="mdi mdi-bridge", icon_class="mdi mdi-bridge",
) )
+38 -38
View File
@@ -1,28 +1,29 @@
from django.db.models.signals import pre_save, post_save, post_delete from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.db import transaction from django.db import transaction
from django.conf import settings
from netbox.context import current_request from netbox.context import current_request
from netbox_dns.models import Zone from netbox_dns.models import Zone, View
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm
from . import notify_handler from netbox_dns_bridge.jobs import notify
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@receiver(pre_save, sender=Zone) @receiver(pre_save, sender=Zone)
def zone_pre_save(sender, instance, **kwargs): def zone_pre_save(sender, instance, **kwargs):
zone = instance zone = instance
if zone.pk: if zone.pk:
try: try:
old = sender.objects.only("name", "soa_serial").get(pk=zone.pk) old = sender.objects.only("name", "soa_serial", "view_id").get(pk=zone.pk)
zone._old_name = old.name zone._old_name = old.name
zone._old_soa_serial = old.soa_serial zone._old_soa_serial = old.soa_serial
zone._old_view_id = old.view_id
except sender.DoesNotExist: except sender.DoesNotExist:
zone._old_name = None zone._old_name = None
zone._old_soa_serial = None zone._old_soa_serial = None
zone._old_view_id = None
else: else:
zone._old_name = None zone._old_name = None
zone._old_soa_serial = None zone._old_soa_serial = None
zone._old_view_id = None
@receiver(post_save, sender=Zone) @receiver(post_save, sender=Zone)
@@ -32,6 +33,8 @@ def zone_post_save(sender, instance, created, **kwargs):
# Quit if this is not the first signal in this session for this zone. # Quit if this is not the first signal in this session for this zone.
try: try:
request = current_request.get() request = current_request.get()
if request is None:
raise LookupError
handled_zones = getattr(request, "_handled_zones", set()) handled_zones = getattr(request, "_handled_zones", set())
if zone.pk in handled_zones: if zone.pk in handled_zones:
return return
@@ -46,51 +49,48 @@ def zone_post_save(sender, instance, created, **kwargs):
new_soa_serial = getattr(zone, "soa_serial", None) new_soa_serial = getattr(zone, "soa_serial", None)
soa_serial_changed = created or (old_soa_serial != new_soa_serial) soa_serial_changed = created or (old_soa_serial != new_soa_serial)
# On commit, increment soa serial and if notify is enabled, schedule bg job. # Detect whether the zone moved between views. When it does, the old
# view's catalog zone loses a member and the new view's gains one, so
# both serials must be incremented.
old_view_id = getattr(zone, "_old_view_id", None)
view_changed = (
not created
and old_view_id is not None
and old_view_id != zone.view_id
)
# On commit, increment soa serial and schedule notify jobs. All settings
# gating happens inside the scheduling functions.
def _on_commit(): def _on_commit():
catzm.increment_soa_serial() if view_changed:
try:
old_view = View.objects.get(pk=old_view_id)
catzm.increment_soa_serial(old_view)
except View.DoesNotExist:
pass
catzm.increment_soa_serial(zone.view)
if SETTINGS.get("notify_clients", False): if soa_serial_changed:
notify_handler.schedule_client_notify(zone) notify.schedule_client_notify(zone)
notify.schedule_ns_notify(zone)
# Send a NOTIFY to the zone's own nameservers when its SOA serial changed
# and the feature is enabled — globally or per zone via a custom field.
if soa_serial_changed and _notify_ns_enabled(zone):
notify_handler.schedule_ns_notify(zone)
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
if created: if created:
# If zone was created, create catz member identifier record for this zone. # If zone was created, create the catalog zone member record for this zone.
catzm.update_member_identifier(zone) catzm.update_member(zone)
else: else:
# Check if zone name has changed and change the catz member identifier if so. # Re-create the catalog zone member when the zone name or its view
# changed. A view move leaves the member pointing at the wrong
# catalog zone, so it must be refreshed.
old_name = getattr(zone, "_old_name", None) old_name = getattr(zone, "_old_name", None)
if old_name and zone.name != old_name: if (old_name and zone.name != old_name) or view_changed:
catzm.update_member_identifier(zone) catzm.update_member(zone)
@receiver(post_delete, sender=Zone) @receiver(post_delete, sender=Zone)
def zone_post_delete(sender, instance, **kwargs): def zone_post_delete(sender, instance, **kwargs):
def _on_commit(): def _on_commit():
catzm.increment_soa_serial() catzm.increment_soa_serial(instance.view)
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
-262
View File
@@ -1,262 +0,0 @@
import dns.message
import dns.name
import dns.opcode
import dns.query
import dns.rdatatype
import dns.rdataclass
import dns.rrset
import dns.tsig
from django.conf import settings
from django.utils import timezone
from django.db import close_old_connections, OperationalError
from netbox.jobs import JobRunner
from netbox_dns.models import View, Zone, Record
from netbox_dns_bridge.utils import get_logger
from netbox_dns_bridge.models import SeenTransferClients, IntegerKeyValueSetting
LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def _load_tsig_key(view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys:
raise RuntimeError(f"No TSIG key configured for view '{view_name}'")
key_data = tsig_keys[view_name]
raw_key_name = key_data.get("keyname")
secret = key_data.get("secret")
algorithm = key_data.get("algorithm")
if not algorithm:
raise RuntimeError(
f"Missing 'algorithm' in TSIG key configuration for view '{view_name}'"
)
if not raw_key_name or not secret:
raise RuntimeError(f"Incomplete TSIG key configuration for view '{view_name}'")
key_name = dns.name.from_text(raw_key_name, origin=None).canonicalize()
if not key_name.is_absolute():
key_name = key_name.concatenate(dns.name.root)
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
class SendClientDNSNotify(JobRunner):
class Meta:
name = "Send DNS NOTIFY"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"]
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 = _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.answer.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=port, timeout=2)
else:
LOGGER.debug("Sending NOTIFY over UDP")
dns.query.udp(msg, client.source_ip, port=port, 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}:{port}"
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()
class SendNSNotify(JobRunner):
"""Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendClientDNSNotify`` (which notifies clients that previously queried the
transfer endpoint), this job resolves the addresses of the zone's configured
nameservers from NetBox DNS itself and notifies each of them so they pick up
the changed zone quickly.
"""
class Meta:
name = "Send DNS NOTIFY to zone nameservers"
def run(self, *args, **kwargs):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_ns_port", 53)
view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
try:
zone = Zone.objects.get(pk=kwargs["zone_id"])
except Zone.DoesNotExist:
LOGGER.error(f"NS NOTIFY: zone id {kwargs['zone_id']} no longer exists")
return
except OperationalError as e:
LOGGER.error(f"NS NOTIFY failed due to unexpected error: {e}")
close_old_connections()
return
ns_targets = _resolve_zone_nameserver_ips(zone)
if not ns_targets:
LOGGER.warning(
f"NS NOTIFY: no nameserver addresses found in NetBox DNS for "
f"{view_name}/{zone_name} — skipping"
)
return
try:
tsig_key = _load_tsig_key(view_name)
except RuntimeError as e:
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
return
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for ip in ns_targets:
try:
if notify_over_tcp:
LOGGER.debug("Sending NS NOTIFY over TCP")
dns.query.tcp(msg, ip, port=port, timeout=2)
else:
LOGGER.debug("Sending NS NOTIFY over UDP")
dns.query.udp(msg, ip, port=port, timeout=2)
LOGGER.info(f"NS NOTIFY sent: {ip} {view_name}/{fqdn} {soa_serial}")
except Exception as e:
LOGGER.error(
f"NS NOTIFY failed: {ip} {view_name}/{fqdn} {soa_serial}: {e}"
)
def _get_soa_serial(zone: Zone):
"""Return the current SOA serial of a zone parsed from its SOA record."""
try:
soa_record = Record.objects.filter(zone=zone, type="SOA", active=True).first()
if soa_record is None:
LOGGER.error(f"Zone {zone.name} has no active SOA record — skipping NOTIFY")
return None
return int(soa_record.value.split()[2])
except OperationalError as e:
LOGGER.error(f"DB ERROR while fetching SOA for zone {zone.name}: {e}")
close_old_connections()
return None
except (ValueError, IndexError) as e:
LOGGER.error(f"Failed to parse SOA serial for zone {zone.name}: {e}")
return None
def _resolve_zone_nameserver_ips(zone: Zone) -> list:
"""Resolve the zone's nameserver hostnames to IPs using NetBox DNS records.
For each nameserver assigned to the zone, matching active A/AAAA records
within the same view are looked up in NetBox DNS. Returns a de-duplicated,
order-preserving list of IP addresses.
"""
ips = []
try:
nameservers = list(zone.nameservers.all())
view = zone.view
for nameserver in nameservers:
fqdn = dns.name.from_text(nameserver.name, dns.name.root).to_text()
# Match both with and without a trailing dot to be resilient against
# how the fqdn is stored on the Record model.
candidates = {fqdn, fqdn.rstrip(".")}
records = Record.objects.filter(
fqdn__in=list(candidates),
type__in=["A", "AAAA"],
active=True,
zone__view=view,
)
for record in records:
if record.value not in ips:
ips.append(record.value)
except OperationalError as e:
LOGGER.error(f"DB ERROR while resolving nameservers for {zone.name}: {e}")
close_old_connections()
return ips
def schedule_client_notify(zone: Zone):
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendClientDNSNotify.enqueue(
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
def schedule_ns_notify(zone: Zone):
soa_serial = _get_soa_serial(zone)
if soa_serial is None:
return
SendNSNotify.enqueue(
zone_id=zone.pk,
zone_name=zone.name,
view_name=zone.view.name,
soa_serial=soa_serial,
)
+65 -11
View File
@@ -1,24 +1,78 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import netbox_dns.models
from netbox.tables import NetBoxTable from netbox.tables import NetBoxTable
from netbox.tables.columns import ActionsColumn
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
class CatalogZoneMemberTable(NetBoxTable):
name = tables.Column(linkify=True)
zone = tables.Column(
accessor="zone__name",
verbose_name=_("Zone"),
linkify=True,
)
view = tables.Column(
accessor="catalog_zone__view__name",
verbose_name=_("View"),
)
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = CatalogZoneMember
fields = ("name", "zone", "view")
default_columns = ("name", "zone", "view")
class CatalogZoneTable(NetBoxTable): class CatalogZoneTable(NetBoxTable):
name = tables.Column(linkify=True)
view = tables.Column( view = tables.Column(
accessor="view__name", accessor="view__name",
verbose_name=_("View"), verbose_name=_("View"),
linkify=True,
) )
status = tables.Column(verbose_name=_("Status")) soa_serial = tables.Column(verbose_name=_("SOA Serial"))
catz_identifier = tables.Column( soa_refresh = tables.Column(verbose_name=_("Refresh"))
accessor="catz_identifier__name", soa_retry = tables.Column(verbose_name=_("Retry"))
verbose_name=_("Catalog Zone Identifier"), soa_expire = tables.Column(verbose_name=_("Expire"))
default="—", soa_minimum = tables.Column(verbose_name=_("Minimum"))
)
actions = ActionsColumn(actions=("edit", "changelog"))
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = netbox_dns.models.Zone model = CatalogZone
fields = ("name", "view", "status", "catz_identifier") fields = (
default_columns = ("name", "view", "status", "catz_identifier") "view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
default_columns = (
"view",
"soa_serial",
"soa_refresh",
"soa_retry",
"soa_expire",
"soa_minimum",
)
class SeenTransferClientTable(NetBoxTable):
source_ip = tables.Column(linkify=True)
view = tables.Column(
accessor="view__name",
verbose_name=_("View"),
linkify=True,
)
last_seen = tables.DateTimeColumn(verbose_name=_("Last Seen"))
actions = ActionsColumn(actions=())
class Meta(NetBoxTable.Meta):
model = SeenTransferClient
fields = ("source_ip", "view", "last_seen")
default_columns = ("source_ip", "view", "last_seen")
@@ -0,0 +1,4 @@
{% extends 'generic/object_edit.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Catalog Zone" %}{% endblock %}
@@ -0,0 +1,4 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block title %}{% trans "Catalog Zones" %}{% endblock %}
@@ -0,0 +1,50 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone SOA" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "SOA Serial" %}</th>
<td>{{ object.soa_serial }}</td>
</tr>
<tr>
<th scope="row">{% trans "Refresh" %}</th>
<td>{{ object.soa_refresh }}</td>
</tr>
<tr>
<th scope="row">{% trans "Retry" %}</th>
<td>{{ object.soa_retry }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expire" %}</th>
<td>{{ object.soa_expire }}</td>
</tr>
<tr>
<th scope="row">{% trans "Minimum" %}</th>
<td>{{ object.soa_minimum }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone Member" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Zone" %}</th>
<td>{{ object.zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Catalog Zone" %}</th>
<td>{{ object.catalog_zone|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.catalog_zone.view|linkify }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,22 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block title %}{% trans "Catalog Zone Info" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Catalog Zone SOA Serial Number" %}</h5>
<div class="card-body">
{% if soa_serial is not None %}
<span>{{ soa_serial }}</span>
{% else %}
<span class="text-muted">{% trans "Not yet initialized." %}</span>
{% endif %}
</div>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}
@@ -1,37 +0,0 @@
{% extends 'base/layout.html' %}
{% block title %}DNS Bridge Notify{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="card mb-3">
<h5 class="card-header">Seen Transfer Clients</h5>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>Source IP</th>
<th>View</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{% for c in seen_clients %}
<tr>
<td>{{ c.source_ip }}</td>
<td>{{ c.view.name }}</td>
<td>{{ c.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-muted">No seen transfer clients.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends 'generic/object.html' %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Seen Transfer Client" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Source IP" %}</th>
<td>{{ object.source_ip }}</td>
</tr>
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{{ object.view|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Seen" %}</th>
<td>{{ object.last_seen|date:"Y-m-d H:i:s" }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+43 -4
View File
@@ -1,8 +1,47 @@
from django.urls import path from django.urls import include, path
from .views import CatzOverviewView, NotifyOverviewView from utilities.urls import get_model_urls
# Import views so register_model_view decorators run.
from .views import ( # noqa: F401
CatalogZoneEditView,
CatalogZoneListView,
CatalogZoneMemberListView,
CatalogZoneMemberView,
CatalogZoneView,
SeenTransferClientListView,
SeenTransferClientView,
)
urlpatterns = [ urlpatterns = [
path("catz/", CatzOverviewView.as_view(), name="catz"), path(
path("notify/", NotifyOverviewView.as_view(), name="notify"), "catalog-zones/",
include(
get_model_urls("netbox_dns_bridge", "catalogzone", detail=False)
),
),
path(
"catalog-zones/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzone")),
),
path(
"catalog-zone-members/",
include(
get_model_urls("netbox_dns_bridge", "catalogzonemember", detail=False)
),
),
path(
"catalog-zone-members/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "catalogzonemember")),
),
path(
"seen-transfer-clients/",
include(
get_model_urls("netbox_dns_bridge", "seentransferclient", detail=False)
),
),
path(
"seen-transfer-clients/<int:pk>/",
include(get_model_urls("netbox_dns_bridge", "seentransferclient")),
),
] ]
+67 -41
View File
@@ -1,50 +1,76 @@
from django.contrib.auth.decorators import user_passes_test from netbox.object_actions import EditObject
from django.shortcuts import render from netbox.views import generic
from django.utils.decorators import method_decorator from utilities.views import register_model_view
from django.views import View
import netbox_dns.models from .filtersets import (
from netbox.views.generic import ObjectListView CatalogZoneFilterSet,
CatalogZoneMemberFilterSet,
from .filtersets import ZoneFilterSet SeenTransferClientFilterSet,
from .forms import CatalogZoneFilterForm
from .models import IntegerKeyValueSetting, SeenTransferClients
from .tables import CatalogZoneTable
@method_decorator(
user_passes_test(lambda u: u.is_superuser), name="dispatch"
) )
class CatzOverviewView(ObjectListView): from .forms import (
queryset = netbox_dns.models.Zone.objects.select_related( CatalogZoneFilterForm,
"view", "catz_identifier" CatalogZoneForm,
).order_by("view__name", "name") CatalogZoneMemberFilterForm,
SeenTransferClientFilterForm,
)
from .models import CatalogZone, CatalogZoneMember, SeenTransferClient
from .tables import (
CatalogZoneMemberTable,
CatalogZoneTable,
SeenTransferClientTable,
)
@register_model_view(CatalogZone, "list", path="", detail=False)
class CatalogZoneListView(generic.ObjectListView):
queryset = CatalogZone.objects.select_related("view").order_by("view__name")
table = CatalogZoneTable table = CatalogZoneTable
filterset = ZoneFilterSet filterset = CatalogZoneFilterSet
filterset_form = CatalogZoneFilterForm filterset_form = CatalogZoneFilterForm
template_name = "netbox_dns_bridge/catz_overview.html"
actions = () actions = ()
def get_extra_context(self, request):
context = super().get_extra_context(request) @register_model_view(CatalogZone)
soa_serial_obj = IntegerKeyValueSetting.objects.filter( class CatalogZoneView(generic.ObjectView):
key="catalog-zone-soa-serial" queryset = CatalogZone.objects.select_related("view")
).first() actions = (EditObject,)
context["soa_serial"] = soa_serial_obj.value if soa_serial_obj else None
return context
@method_decorator( @register_model_view(CatalogZone, "edit")
user_passes_test(lambda u: u.is_superuser), name="dispatch" class CatalogZoneEditView(generic.ObjectEditView):
) queryset = CatalogZone.objects.select_related("view")
class NotifyOverviewView(View): form = CatalogZoneForm
template_name = "netbox_dns_bridge/notify_overview.html"
def get(self, request):
seen_clients = ( @register_model_view(CatalogZoneMember, "list", path="", detail=False)
SeenTransferClients.objects.select_related("view") class CatalogZoneMemberListView(generic.ObjectListView):
.all() queryset = CatalogZoneMember.objects.select_related(
.order_by("source_ip") "zone", "catalog_zone__view"
) ).order_by("catalog_zone__view__name", "name")
context = {"seen_clients": seen_clients} table = CatalogZoneMemberTable
return render(request, self.template_name, context) filterset = CatalogZoneMemberFilterSet
filterset_form = CatalogZoneMemberFilterForm
actions = ()
@register_model_view(CatalogZoneMember)
class CatalogZoneMemberView(generic.ObjectView):
queryset = CatalogZoneMember.objects.select_related(
"zone", "catalog_zone__view"
)
actions = ()
@register_model_view(SeenTransferClient, "list", path="", detail=False)
class SeenTransferClientListView(generic.ObjectListView):
queryset = SeenTransferClient.objects.select_related("view").order_by("source_ip")
table = SeenTransferClientTable
filterset = SeenTransferClientFilterSet
filterset_form = SeenTransferClientFilterForm
actions = ()
@register_model_view(SeenTransferClient)
class SeenTransferClientView(generic.ObjectView):
queryset = SeenTransferClient.objects.select_related("view")
actions = ()
+2 -2
View File
@@ -32,5 +32,5 @@ where = ["."]
version = { attr = "netbox_dns_bridge.__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-dns-bridge"
Issues = "https://github.com/Suraxius/netbox-plugin-bind-provisioner/issues" Issues = "https://github.com/Suraxius/netbox-plugin-dns-bridge/issues"