Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa032b1ea3 | ||
|
|
e6aaa3a54d | ||
|
|
eee5395ea6 | ||
|
|
a9c59f4c57 | ||
|
|
787c3d0a20 | ||
|
|
57195a822b | ||
|
|
0590caeebd | ||
|
|
962be7a048 | ||
|
|
204a946a84 | ||
|
|
d72dc5a115 | ||
|
|
b659268117 | ||
|
|
8c4bc91f6e | ||
|
|
c8e28d2397 | ||
|
|
0349204aff | ||
|
|
e4dbd7b014 | ||
|
|
07f8387963 | ||
|
|
d8acd3f525 | ||
|
|
8c1e9502f4 | ||
|
|
86457cc858 | ||
|
|
0bad0e31df |
No files matched your search
@@ -6,7 +6,7 @@
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: Upload Python Package
|
||||
name: Create and Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -16,9 +16,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-build:
|
||||
build-and-publish:
|
||||
name: Build and Publish
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -26,45 +29,14 @@ jobs:
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Build release distributions
|
||||
- name: Install build tools
|
||||
run: |
|
||||
# NOTE: put your own distribution build steps here.
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload distributions
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-dists
|
||||
path: dist/
|
||||
- name: Build release
|
||||
run: |
|
||||
python -m build --sdist --wheel
|
||||
|
||||
pypi-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- release-build
|
||||
permissions:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing
|
||||
id-token: write
|
||||
|
||||
# Dedicated environments with protections for publishing are strongly recommended.
|
||||
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
|
||||
environment:
|
||||
name: pypi
|
||||
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
|
||||
# url: https://pypi.org/p/YOURPROJECT
|
||||
#
|
||||
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
|
||||
# ALTERNATIVE: exactly, uncomment the following line instead:
|
||||
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
|
||||
|
||||
steps:
|
||||
- name: Retrieve release distributions
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-dists
|
||||
path: dist/
|
||||
|
||||
- name: Publish release distributions to PyPI
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: dist/
|
||||
@@ -1,2 +1,4 @@
|
||||
src/netbox_plugin_bind_provisioner.egg-info
|
||||
dist/
|
||||
build/
|
||||
__pycache__
|
||||
@@ -1,64 +1,149 @@
|
||||
# Netbox Bind Provisioner
|
||||
A Netbox Plugin that provides a minimal DNS Server for the automatic
|
||||
provisioning of a Bind9 Server from Netbox DNS data (netbox-plugin-dns).
|
||||
A NetBox plugin that implements a lightweight DNS server to automate the
|
||||
provisioning and configuration of a BIND9 server based on DNS records
|
||||
maintained in NetBox. (netbox-plugin-dns).
|
||||
|
||||
## Plugin configuration
|
||||
The plugin was re-worked; Instead of exporting zone files, it provides an
|
||||
entire (though minimal) DNS Server that is fed directly from Netbox's DNS data.
|
||||
The Server also provides specialized "catalog" zones that bind uses to
|
||||
automatically discover new zones and remove deleted ones. The plugin supports
|
||||
view as well as basic dns security using TSIG.
|
||||
The plugin has been reworked: instead of exporting zone files, it now provides
|
||||
a minimal DNS server that is fed directly from NetBox’s DNS data. This server
|
||||
also exposes specialized catalog zones that BIND uses to automatically discover
|
||||
newly created zones and remove deleted ones. The plugin supports BIND views and
|
||||
basic DNS security via TSIG.
|
||||
|
||||
To work correctly, each view needs a tsig key installed and the
|
||||
bind-transfer-endpoint needs to be running as its own service in the background
|
||||
via the `manage.py` command. Note that dnssec support will be implemented as
|
||||
soon as bind9 has a mechanism to allow configuration of such via the Catalog
|
||||
Zones mechanism.
|
||||
The plugin exposes one catalog zone per view. Each catalog zone is available
|
||||
under the special zone name **"catz"** and can be queried through the built-in
|
||||
DNS server like any other zone.
|
||||
|
||||
For proper operation, each view requires an installed TSIG key, and the
|
||||
`bind-transfer-endpoint` must be running as a separate background service using
|
||||
the `manage.py` command. Note that DNSSEC support will be added once BIND9
|
||||
provides a mechanism to configure it through the Catalog Zones system.
|
||||
|
||||
To start the service:
|
||||
```
|
||||
venv/bin/python3 netbox/netbox/manage.py bind-transfer-endpoint --port 5354
|
||||
manage.py bind-transfer-endpoint --port 5354
|
||||
```
|
||||
|
||||
### Parameters
|
||||
When configuring the plugin, you need to provide a path on the filesystem for
|
||||
this file where the plugin can create and manage it. Please do not update it by
|
||||
hand or remove it unless you know what you are doing.
|
||||
|
||||
### Service parameters
|
||||
Parameter | Description
|
||||
--------- | --------------------------------------------------------
|
||||
--------- | -------------------------------------------------------------------
|
||||
--port | Port to listen on for requests (defaults to 5354)
|
||||
--address | IP of interface to bind to (defaults to 0.0.0.0)
|
||||
|
||||
### Plugin settings
|
||||
Setting | Description
|
||||
--------------------| ---------------------------------------------------------
|
||||
catalog_serial_file | !!!NO LONGER USED!!! Replaced by DB entry.
|
||||
tsig_keys | Maps a TSIG Key to be used for each view.
|
||||
|
||||
## 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
|
||||
```
|
||||
PLUGINS_CONFIG = {
|
||||
"netbox_bind_provisioner": {
|
||||
"tsig_keys": {
|
||||
"key1name": {
|
||||
"view": "public",
|
||||
"keyname": "view1key",
|
||||
"algorithm": "hmac-sha256",
|
||||
"secret": "base64-encoded-secret"
|
||||
},
|
||||
"key2name": {
|
||||
"view": "private",
|
||||
"keyname": "view2key",
|
||||
"algorithm": "hmac-sha256",
|
||||
"secret": "base64-encoded-secret"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
cd netbox
|
||||
echo netbox-plugin-bind-provisioner >> local_requirements.txt
|
||||
. venv/bin/activate
|
||||
pip install -r local_requirements.txt
|
||||
```
|
||||
|
||||
## Bind configuration
|
||||
3. Updating netbox plugin configuration (configuration.py)
|
||||
Change following line from
|
||||
```
|
||||
PLUGINS = ['netbox_dns']
|
||||
```
|
||||
to
|
||||
```
|
||||
PLUGINS = ['netbox_dns', 'netbox_plugin_bind_provisioner']
|
||||
```
|
||||
|
||||
Configure the Bind Exporter Plugin using the PLUGINS_CONFIG dictionary.
|
||||
Change
|
||||
```
|
||||
PLUGINS_CONFIG = {}
|
||||
```
|
||||
to
|
||||
```
|
||||
PLUGINS_CONFIG = {
|
||||
"netbox_plugin_bind_provisioner": {
|
||||
"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_plugin_bind_provisioner.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
|
||||
```
|
||||
python3 netbox/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.
|
||||
```
|
||||
python3 netbox/manage.py bind-transfer-endpoint --port 5354
|
||||
```
|
||||
|
||||
6. Configuring a Bind9 to interact with Netbox via the bind-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
|
||||
########## ACLs ##########
|
||||
|
||||
acl public {
|
||||
!10.0.0.0/8;
|
||||
@@ -73,10 +158,10 @@ acl private {
|
||||
192.168.0.0/16;
|
||||
};
|
||||
|
||||
# Zone definitions
|
||||
########## ZONES ##########
|
||||
|
||||
view "public" {
|
||||
key "key1name" {
|
||||
key "public_view_key" {
|
||||
algorithm hmac-sha256;
|
||||
secret "base64-encoded-secret";
|
||||
};
|
||||
@@ -85,7 +170,7 @@ view "public" {
|
||||
|
||||
catalog-zones {
|
||||
zone "catz"
|
||||
default-masters { 127.0.0.1 port 5354 key "key1name"; }
|
||||
default-masters { 127.0.0.1 port 5354 key "public_view_key"; }
|
||||
zone-directory "/var/lib/bind/zones"
|
||||
min-update-interval 1;
|
||||
};
|
||||
@@ -93,13 +178,13 @@ view "public" {
|
||||
zone "catz" {
|
||||
type slave;
|
||||
file "/var/lib/bind/zones/catz_public";
|
||||
masters { 127.0.0.1 port 5354 key "key1name"; };
|
||||
masters { 127.0.0.1 port 5354 key "public_view_key"; };
|
||||
notify no;
|
||||
};
|
||||
};
|
||||
|
||||
view "private" {
|
||||
key "key2name" {
|
||||
key "private_view_key" {
|
||||
algorithm hmac-sha256;
|
||||
secret "base64-encoded-secret";
|
||||
};
|
||||
@@ -108,7 +193,7 @@ view "private" {
|
||||
|
||||
catalog-zones {
|
||||
zone "catz"
|
||||
default-masters { 127.0.0.1 port 5354 key "key2name"; }
|
||||
default-masters { 127.0.0.1 port 5354 key "private_view_key"; }
|
||||
zone-directory "/var/lib/bind/zones"
|
||||
min-update-interval 1;
|
||||
};
|
||||
@@ -116,9 +201,11 @@ view "private" {
|
||||
zone "catz" {
|
||||
type slave;
|
||||
file "/var/lib/bind/zones/catz_private";
|
||||
masters { 127.0.0.1 port 5354 key "key2name"; };
|
||||
masters { 127.0.0.1 port 5354 key "private_view_key"; };
|
||||
notify no;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
7. Restart bind - Done
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
1. Create System Unit file
|
||||
```
|
||||
cat <<EOF > /etc/systemd/system/netbox-bind-endpoint.service
|
||||
[Unit]
|
||||
Description=Netbox BIND Catalog Endpoing
|
||||
Documentation=https://github.com/Suraxius/netbox-plugin-bind-provisioner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=netbox
|
||||
Group=netbox
|
||||
PIDFile=/var/tmp/netbox.pid
|
||||
WorkingDirectory=/opt/netbox
|
||||
|
||||
ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py bind-transfer-endpoint --port 5354
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
2. Enable and start the service
|
||||
```
|
||||
systemctl enable netbox-bind-endpoint
|
||||
systemctl start netbox-bind-endpoint
|
||||
systemctl status netbox-bind-endpoint
|
||||
```
|
||||
@@ -1 +1,2 @@
|
||||
netbox-plugin-dns>=1.2.0
|
||||
dnspython>=2.8.0
|
||||
@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
|
||||
__version__ = "0.9.5"
|
||||
__version__ = "1.0.1"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,24 +32,6 @@ class BindProvisionerConfig(PluginConfig):
|
||||
f"{self.name}: Plugin {self.verbose_name} failed to initialize due to missing settings. Terminating Netbox."
|
||||
)
|
||||
|
||||
# from . import bind, rndc
|
||||
#
|
||||
# if not bind.zoneDirWritable():
|
||||
# BIND_ZONE_DIR = settings.get("BIND_ZONE_DIR")
|
||||
# logger.error(f"Bind's zone file directory {BIND_ZONE_DIR} is not writable.")
|
||||
# raise RuntimeError(
|
||||
# "Plugin initialization failed due to failed writable-zone-directory healthcheck."
|
||||
# )
|
||||
# if not rndc.tsigKeyFileAccessible():
|
||||
# RNDC_KEY_FILE = settings.get("RNDC_KEY_FILE")
|
||||
# logger.error(f"The RNDC key file {RNDC_KEY_FILE} is inaccessible.")
|
||||
# raise RuntimeError(
|
||||
# "Plugin initialization failed due to failed rndc-key-accessible healthcheck."
|
||||
# )
|
||||
#
|
||||
# # Import signal callbacks from signals.py (With @receiver decorations)
|
||||
# from . import signals
|
||||
|
||||
|
||||
config = BindProvisionerConfig
|
||||
default_app_config = ".apps.NetboxBindProvisionerConfig"
|
||||
+181
-131
@@ -20,78 +20,65 @@ from django.utils import timezone
|
||||
from netbox_dns.models import Zone, Record, View
|
||||
from netbox_dns.choices import ZoneStatusChoices, RecordStatusChoices
|
||||
from datetime import datetime
|
||||
from netbox_plugin_bind_provisioner.models import IntegerKeyValueSetting
|
||||
|
||||
logger = logging.getLogger('bind-transfer-endpoint')
|
||||
logger = logging.getLogger("bind-transfer-endpoint")
|
||||
|
||||
class CatalogZoneAssembler():
|
||||
|
||||
class CatalogZone:
|
||||
lock = threading.Lock()
|
||||
_serial_max = 0xFFFFFFFF
|
||||
_serial = 1
|
||||
_serial_file = None
|
||||
_serial_obj = None
|
||||
_previous_last_zone_update = None
|
||||
|
||||
# Following function loads the last serial from file. No return value
|
||||
# Following function loads the last serial from the DB. No return value
|
||||
# but it sets the setting "catalog_serial" or terminates the plugin on failure.
|
||||
@classmethod
|
||||
def initCatalogZoneSerial(cls, serial_file):
|
||||
"""
|
||||
Load a 32-bit unsigned int serial from a text file.
|
||||
If the file doesn't exist, initialize it with 1.
|
||||
Returns the serial as an int.
|
||||
"""
|
||||
cls._serial_file = serial_file
|
||||
|
||||
if not os.path.exists(cls._serial_file):
|
||||
def initSerial(cls):
|
||||
try:
|
||||
with open(cls._serial_file, "w") as f:
|
||||
f.write(f"{cls._serial}\n")
|
||||
return
|
||||
except OSError as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to initialize serial file {cls._serial_file}: {e}"
|
||||
cls._serial_obj = IntegerKeyValueSetting.objects.get(
|
||||
key="catalog-zone-soa-serial"
|
||||
)
|
||||
logger.debug(
|
||||
f"Catalog zone SOA serial number {cls._serial_obj.value} loaded from database"
|
||||
)
|
||||
except IntegerKeyValueSetting.DoesNotExist:
|
||||
cls._serial_obj = IntegerKeyValueSetting.objects.create(
|
||||
key="catalog-zone-soa-serial", value=1
|
||||
)
|
||||
logger.debug(
|
||||
f"Catalog zone SOA serial number was not set in the database. Set to {cls._serial_obj.value}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
with open(cls._serial_file, "r") as f:
|
||||
value = int(f.read().strip())
|
||||
|
||||
if 0 < value <= cls._serial_max:
|
||||
cls._serial = value
|
||||
else:
|
||||
raise ValueError(f"Serial value out of 32-bit range: {value}")
|
||||
except (OSError, ValueError) as e:
|
||||
raise RuntimeError(f"Failed to read serial from {cls._serial_file}: {e}")
|
||||
|
||||
@classmethod
|
||||
def incrementCatalogZoneSerial(cls):
|
||||
"""
|
||||
Increments the in-memory catalog serial and saves it to file.
|
||||
Updates _serial.
|
||||
"""
|
||||
if 0 < cls._serial < cls._serial_max:
|
||||
cls._serial += 1
|
||||
def _incrementSerial(cls):
|
||||
if 0 < cls._serial_obj.value < cls._serial_max:
|
||||
cls._serial_obj.value += 1
|
||||
cls._serial_obj.save()
|
||||
else:
|
||||
logger.warn(
|
||||
f"Failed to incremenet catalog serial {cls._serial}. Will overflow serial back to 1."
|
||||
f"Failed to incremenet catalog serial {cls._serial_obj.value}. Will overflow serial back to 1"
|
||||
)
|
||||
cls._serial_obj = 1
|
||||
cls._serial_obj.save()
|
||||
logger.debug(
|
||||
f"Catalog zone SOA serial number incremented to {_serial_obj.value}"
|
||||
)
|
||||
cls._serial = 1
|
||||
|
||||
try:
|
||||
with open(cls._serial_file, "w") as f:
|
||||
f.write(f"{cls._serial}\n")
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to write updated serial to {cls._serial_file}: {e}")
|
||||
|
||||
@classmethod
|
||||
def createCatalogZone(cls, view_name, soa_only=False):
|
||||
def create(cls, name, view_name):
|
||||
# Synchronize following across threads as TCP and UDP listener both use it.
|
||||
with cls.lock:
|
||||
# cls = self.__class__
|
||||
# datestamp = datetime.now().strftime("%y%m%d")
|
||||
# latest_zone = Zone.objects.filter().order_by("-last_updated").first()
|
||||
latest_zone = Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE).order_by('-last_updated').first()
|
||||
latest_zone = (
|
||||
Zone.objects.filter(status=ZoneStatusChoices.STATUS_ACTIVE)
|
||||
.order_by("-last_updated")
|
||||
.first()
|
||||
)
|
||||
|
||||
last_zone_update = getattr(latest_zone, 'last_updated', None)
|
||||
last_zone_update = getattr(latest_zone, "last_updated", None)
|
||||
|
||||
# Check if there was a zone updated since last call
|
||||
# If no zone was found previously then this will be false since (None != None) = False
|
||||
@@ -102,13 +89,10 @@ class CatalogZoneAssembler():
|
||||
)
|
||||
# Setting previous last zone update for next iteration:
|
||||
cls._previous_last_zone_update = last_zone_update
|
||||
cls.incrementCatalogZoneSerial()
|
||||
# If there was no update, do nothing.
|
||||
#else:
|
||||
# logger.debug(f"No need to update serial for catalog zone")
|
||||
cls._incrementSerial()
|
||||
|
||||
# Zone origin
|
||||
origin = dns.name.from_text("catz", dns.name.root)
|
||||
origin = dns.name.from_text(name, dns.name.root)
|
||||
|
||||
# Create a new empty zone
|
||||
zone = dns.zone.Zone(origin)
|
||||
@@ -139,19 +123,21 @@ class CatalogZoneAssembler():
|
||||
# Configure DNSSec Policy for member Zone if DNSSec is enabled
|
||||
if nb_zone.dnssec_policy:
|
||||
# Configure policy
|
||||
rid = dns.name.from_text(f"policy.dnssec", ptr_name)
|
||||
rid = dns.name.from_text("group", ptr_name)
|
||||
policy_name = nb_zone.dnssec_policy.name.rstrip(" ")
|
||||
group_name = f"dnssec-policy-{policy_name}"
|
||||
rdata = dns.rdata.from_text(
|
||||
dns.rdataclass.IN, dns.rdatatype.TXT, nb_zone.dnssec_policy.name
|
||||
dns.rdataclass.IN, dns.rdatatype.TXT, group_name
|
||||
)
|
||||
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
|
||||
rdataset.add(rdata, ttl)
|
||||
|
||||
# Configure dnssec status for member zone
|
||||
status = str(1 if nb_zone.dnssec_policy else 0)
|
||||
rid = dns.name.from_text(f"enabled.dnssec", ptr_name)
|
||||
rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
|
||||
rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
|
||||
rdataset.add(rdata, ttl)
|
||||
## Configure dnssec status for member zone
|
||||
# status = str(1 if nb_zone.dnssec_policy else 0)
|
||||
# rid = dns.name.from_text("enabled.dnssec.ext", ptr_name)
|
||||
# rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, status)
|
||||
# rdataset = zone.find_rdataset(rid, dns.rdatatype.TXT, create=True)
|
||||
# rdataset.add(rdata, ttl)
|
||||
|
||||
# SOA Record components
|
||||
ttl = 3600
|
||||
@@ -159,7 +145,7 @@ class CatalogZoneAssembler():
|
||||
rtype = dns.rdatatype.SOA
|
||||
mname = dns.name.root # dns.name.from_text("invalid.", origin)
|
||||
rname = dns.name.root # dns.name.from_text("contact.master.", origin)
|
||||
serial = cls._serial
|
||||
serial = cls._serial_obj.value
|
||||
refresh = 60
|
||||
retry = 10
|
||||
expire = 1209600
|
||||
@@ -180,7 +166,6 @@ class CatalogZoneAssembler():
|
||||
node = zone.find_node(origin, create=True)
|
||||
node.rdatasets.append(soa_rdataset)
|
||||
|
||||
|
||||
# NS record for catz.
|
||||
ns_name = dns.name.from_text("invalid", dns.name.root)
|
||||
ns_rdata = dns.rdata.from_text(
|
||||
@@ -208,14 +193,33 @@ class CatalogZoneAssembler():
|
||||
|
||||
return zone
|
||||
|
||||
@classmethod
|
||||
def importLegacySerialFile(cls, file_path):
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
serial = int(f.read().strip())
|
||||
cls._serial_obj.value = int(serial)
|
||||
cls._serial_obj.save()
|
||||
logger.info(
|
||||
f"Legacy catalog serial file {file_path} imported into database and file removed. Serial is now {serial}"
|
||||
)
|
||||
os.remove(file_path)
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
f"Failed to import catalog zone serial from legacy file {file_path}"
|
||||
)
|
||||
|
||||
|
||||
class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
# getZoneFromNB rewritten
|
||||
def getZoneFromNB(self, zone_name, view_name, soa_only=False):
|
||||
def getZoneFromNB(self, zone_name, view_name):
|
||||
# Find the zone
|
||||
try:
|
||||
nb_zone = Zone.objects.get(
|
||||
name=zone_name, view=view_name, status=ZoneStatusChoices.STATUS_ACTIVE
|
||||
name=zone_name,
|
||||
view__name=view_name,
|
||||
status=ZoneStatusChoices.STATUS_ACTIVE,
|
||||
)
|
||||
except Zone.DoesNotExist:
|
||||
return None
|
||||
@@ -242,10 +246,21 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
# If the record has no TTL, use the zone default
|
||||
ttl = record.ttl or nb_zone.default_ttl
|
||||
|
||||
# Apply special quoting for TXT records to stop tokanizer
|
||||
# from cutting it up:
|
||||
if rdtype == dns.rdatatype.TXT:
|
||||
if len(record.value) <= 255:
|
||||
record_value = f'"{record.value}"'
|
||||
else:
|
||||
chunks = ['"{}"'.format(record.value[i:i+255]) for i in range(0, len(record.value), 255)]
|
||||
record_value = " ".join(chunks)
|
||||
else:
|
||||
record_value = record.value
|
||||
|
||||
rdata = dns.rdata.from_text(
|
||||
dns.rdataclass.IN,
|
||||
rdtype,
|
||||
record.value,
|
||||
record_value, #asd
|
||||
relativize=False,
|
||||
origin=zone.origin,
|
||||
)
|
||||
@@ -254,7 +269,9 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
if name not in rdatasets_dict:
|
||||
rdatasets_dict[name] = {}
|
||||
if rdtype not in rdatasets_dict[name]:
|
||||
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(dns.rdataclass.IN, rdtype)
|
||||
rdatasets_dict[name][rdtype] = dns.rdataset.Rdataset(
|
||||
dns.rdataclass.IN, rdtype
|
||||
)
|
||||
|
||||
# Add the rdata to the appropriate rdataset
|
||||
rdatasets_dict[name][rdtype].add(rdata, ttl)
|
||||
@@ -264,7 +281,9 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
for rdtype, rdataset in rdtypes.items():
|
||||
# Ensure rdataset has the same rdclass as the zone
|
||||
if rdataset.rdclass != zone.rdclass:
|
||||
raise ValueError(f"rdataset rdclass {rdataset.rdclass} does not match zone rdclass {zone.rdclass}")
|
||||
raise ValueError(
|
||||
f"rdataset rdclass {rdataset.rdclass} does not match zone rdclass {zone.rdclass}"
|
||||
)
|
||||
|
||||
# Check if the rdataset has any rdata before creating an RRset
|
||||
if not rdataset:
|
||||
@@ -273,22 +292,14 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
|
||||
# Replace the rdataset for the given name and type
|
||||
zone.replace_rdataset(name, rdataset)
|
||||
|
||||
return zone
|
||||
|
||||
|
||||
|
||||
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
|
||||
response = dns.message.make_response(query)
|
||||
response.set_rcode(rcode)
|
||||
wire = response.to_wire(multi=False)
|
||||
length = len(wire).to_bytes(2, byteorder="big")
|
||||
self.request.sendall(length + wire)
|
||||
|
||||
|
||||
def denyRequestBadTSIG(self, wire, tsig_error: dns.rcode):
|
||||
# Use empty keyring to parse TSIG without validating
|
||||
query = dns.message.from_wire(wire, keyring={}, ignore_trailing=True, continue_on_error=True)
|
||||
query = dns.message.from_wire(
|
||||
wire, keyring={}, ignore_trailing=True, continue_on_error=True
|
||||
)
|
||||
|
||||
# Make a response
|
||||
response = dns.message.make_response(query)
|
||||
@@ -299,12 +310,19 @@ class DNSBaseRequestHandler(socketserver.BaseRequestHandler):
|
||||
response.use_tsig(
|
||||
keyring={}, # empty; we're not signing
|
||||
keyname=query.keyname,
|
||||
tsig_error=tsig_error
|
||||
tsig_error=tsig_error,
|
||||
)
|
||||
self.denyRequest(query)
|
||||
|
||||
|
||||
class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
|
||||
response = dns.message.make_response(query)
|
||||
response.set_rcode(rcode)
|
||||
wire = response.to_wire(multi=False)
|
||||
sock = self.request[1]
|
||||
sock.sendto(wire, self.client_address)
|
||||
|
||||
def handle(self):
|
||||
data, sock = self.request
|
||||
peer = self.client_address[0]
|
||||
@@ -314,8 +332,8 @@ class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
query = dns.message.from_wire(
|
||||
data,
|
||||
keyring=self.server.keyring,
|
||||
continue_on_error=True,
|
||||
ignore_trailing=True
|
||||
continue_on_error=False,
|
||||
ignore_trailing=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error parsing query: ", e)
|
||||
@@ -336,30 +354,35 @@ class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
|
||||
# Only process SOA queries
|
||||
if qtype != dns.rdatatype.SOA:
|
||||
logger.warning(f"Request denied from {peer}: Request was not SOA (Type: {qtype})")
|
||||
logger.warning(
|
||||
f"Request denied from {peer}: Request was not SOA (Type: {qtype})"
|
||||
)
|
||||
self.denyRequest(query)
|
||||
return
|
||||
|
||||
# Identify TSIG key used
|
||||
key_name = query.keyname.canonicalize().to_text() if query.keyname else None
|
||||
if not key_name:
|
||||
if not query.had_tsig:
|
||||
logger.warning(f"Request denied from {peer}: No TSIG key used")
|
||||
self.denyRequest(query)
|
||||
return
|
||||
|
||||
key_name = query.keyname.canonicalize().to_text()
|
||||
|
||||
# Check if key matches a view
|
||||
nb_view = self.server.tsig_view_map.get(key_name)
|
||||
if not nb_view:
|
||||
logger.warning(f"Request denied from {peer}: {key_name} does not match a view")
|
||||
logger.warning(
|
||||
f"Request denied from {peer}: {key_name} does not match a view"
|
||||
)
|
||||
self.denyRequestBadTSIG(wire, dns.rcode.BADKEY)
|
||||
return
|
||||
|
||||
# Check if catalog zone
|
||||
if dname == "catz":
|
||||
zone = CatalogZoneAssembler.createCatalogZone(nb_view.name, soa_only=True)
|
||||
if dname == "catz" or dname == f"{nb_view.name}.catz":
|
||||
zone = CatalogZone.create(dname, nb_view.name)
|
||||
# If generic zone, retreive from NB
|
||||
else:
|
||||
zone = self.getZoneFromNB(dname, nb_view, soa_only=True)
|
||||
zone = self.getZoneFromNB(dname, nb_view.name)
|
||||
# When zone was not found, let client know
|
||||
if not zone:
|
||||
logger.warning(f"Zone {nb_view.name}/{dname} not found in NB")
|
||||
@@ -373,7 +396,6 @@ class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
soa_rdata = soa_rdataset[0] # Get the first SOA record
|
||||
|
||||
# Now, create the rrset from the soa_rdata
|
||||
#rrset = dns.rrset.from_rdata(dname, soa_rdataset.ttl, soa_rdata)
|
||||
rrset = dns.rrset.from_rdata(zone.origin, soa_rdataset.ttl, soa_rdata)
|
||||
|
||||
# Append the rrset to the response's answer section
|
||||
@@ -383,7 +405,9 @@ class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
if query.had_tsig:
|
||||
# If key was found in DB
|
||||
if query.keyname in self.server.keyring:
|
||||
response.use_tsig(self.server.keyring, keyname=query.keyname, original_id=query.id)
|
||||
response.use_tsig(
|
||||
self.server.keyring, keyname=query.keyname, original_id=query.id
|
||||
)
|
||||
else:
|
||||
# If key is unknown
|
||||
response.set_rcode(dns.rcode.REFUSED)
|
||||
@@ -391,7 +415,11 @@ class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
# keyname = query.keyname
|
||||
# b64 = base64.b64encode(urandom(32)).decode("ascii")
|
||||
# newkey = dns.tsigkeyring.from_text({ keyname: b64 })
|
||||
response.use_tsig(self.server.keyring, keyname=query.keyname, tsig_error=dns.rcode.BADKEY)
|
||||
response.use_tsig(
|
||||
self.server.keyring,
|
||||
keyname=query.keyname,
|
||||
tsig_error=dns.rcode.BADKEY,
|
||||
)
|
||||
|
||||
# Send back the response
|
||||
response_wire = response.to_wire(max_size=512)
|
||||
@@ -400,6 +428,13 @@ class UDPRequestHandler(DNSBaseRequestHandler):
|
||||
|
||||
|
||||
class TCPRequestHandler(DNSBaseRequestHandler):
|
||||
def denyRequest(self, query, rcode: dns.rcode = dns.rcode.REFUSED):
|
||||
response = dns.message.make_response(query)
|
||||
response.set_rcode(rcode)
|
||||
wire = response.to_wire(multi=False)
|
||||
length = len(wire).to_bytes(2, byteorder="big")
|
||||
self.request.sendall(length + wire)
|
||||
|
||||
def handle(self):
|
||||
MAX_WIRE = 65535
|
||||
# MAX_WIRE = 2000 # For testing fragmentation
|
||||
@@ -428,7 +463,9 @@ class TCPRequestHandler(DNSBaseRequestHandler):
|
||||
query = dns.message.from_wire(wire, keyring=self.server.keyring)
|
||||
# If TSIG signature doesnt match our key, refuse query:
|
||||
except dns.tsig.BadSignature as e:
|
||||
logger.warning(f"Request denied from {peer} failed TSIG verification: {e}")
|
||||
logger.warning(
|
||||
f"Request denied from {peer} failed TSIG verification: {e}"
|
||||
)
|
||||
self.denyRequestBadTSIG(wire, dns.rcode.BADSIG)
|
||||
return
|
||||
# If TSIG Key used is not in our list, refuse query:
|
||||
@@ -451,30 +488,34 @@ class TCPRequestHandler(DNSBaseRequestHandler):
|
||||
# Only process AXFR queries
|
||||
if qtype != dns.rdatatype.AXFR:
|
||||
# if qtype != dns.rdatatype.AXFR or qtype != dns.rdatatype.SOA:
|
||||
logger.warning(f"Request denied from {peer}: Request was not AXFR (Type: {qtype})")
|
||||
logger.warning(
|
||||
f"Request denied from {peer}: Request was not AXFR (Type: {qtype})"
|
||||
)
|
||||
self.denyRequest(query)
|
||||
return
|
||||
|
||||
# Identify TSIG key used
|
||||
key_name = query.keyname.canonicalize().to_text() if query.keyname else None
|
||||
if not key_name:
|
||||
if not query.had_tsig:
|
||||
logger.warning(f"Request denied from {peer}: No TSIG key used")
|
||||
self.denyRequest(query)
|
||||
return
|
||||
|
||||
key_name = query.keyname.canonicalize().to_text()
|
||||
|
||||
# Check if the key matches a view
|
||||
nb_view = self.server.tsig_view_map.get(key_name)
|
||||
if not nb_view:
|
||||
logger.warning(f"Request denied from {peer}: {key_name} does not match a view")
|
||||
logger.warning(
|
||||
f"Request denied from {peer}: {key_name} does not match a view"
|
||||
)
|
||||
self.denyRequest(query)
|
||||
return
|
||||
|
||||
# Check if catalog zone
|
||||
if dname == "catz":
|
||||
#zone = self.createCatalogZone(str(nb_view))
|
||||
zone = CatalogZoneAssembler.createCatalogZone(nb_view.name)
|
||||
if dname == "catz" or dname == f"{nb_view.name}.catz":
|
||||
zone = CatalogZone.create(dname, nb_view.name)
|
||||
else:
|
||||
zone = self.getZoneFromNB(dname, nb_view)
|
||||
zone = self.getZoneFromNB(dname, nb_view.name)
|
||||
|
||||
# When zone was not found, let client know
|
||||
if not zone:
|
||||
@@ -503,7 +544,10 @@ class TCPRequestHandler(DNSBaseRequestHandler):
|
||||
|
||||
# 2. Create a Renderer for the first message
|
||||
r = dns.renderer.Renderer(
|
||||
id=query.id, flags=(dns.flags.QR | dns.flags.AA), max_size=MAX_WIRE, origin=None
|
||||
id=query.id,
|
||||
flags=(dns.flags.QR | dns.flags.AA),
|
||||
max_size=MAX_WIRE,
|
||||
origin=None,
|
||||
)
|
||||
r.add_question(
|
||||
query.question[0].name,
|
||||
@@ -538,7 +582,10 @@ class TCPRequestHandler(DNSBaseRequestHandler):
|
||||
|
||||
# Start new renderer
|
||||
r = dns.renderer.Renderer(
|
||||
id=query.id, flags=(dns.flags.QR | dns.flags.AA), max_size=MAX_WIRE, origin=None
|
||||
id=query.id,
|
||||
flags=(dns.flags.QR | dns.flags.AA),
|
||||
max_size=MAX_WIRE,
|
||||
origin=None,
|
||||
)
|
||||
r.add_question(
|
||||
query.question[0].name,
|
||||
@@ -570,8 +617,8 @@ class TCPRequestHandler(DNSBaseRequestHandler):
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling request from {peer}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class TCPDNSServer(socketserver.TCPServer):
|
||||
@@ -583,7 +630,6 @@ class TCPDNSServer(socketserver.TCPServer):
|
||||
self.tsig_view_map = tsig_view_map
|
||||
|
||||
|
||||
|
||||
class UDPDNSServer(socketserver.UDPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
@@ -593,12 +639,13 @@ class UDPDNSServer(socketserver.UDPServer):
|
||||
self.tsig_view_map = tsig_view_map
|
||||
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run a minimal AXFR DNS server using NetBox DNS plugin data"
|
||||
|
||||
def loadSettings(self):
|
||||
self.settings = settings.PLUGINS_CONFIG.get("netbox_plugin_bind_provisioner", None)
|
||||
self.settings = settings.PLUGINS_CONFIG.get(
|
||||
"netbox_plugin_bind_provisioner", None
|
||||
)
|
||||
if not self.settings:
|
||||
raise RuntimeError(
|
||||
"Command failed to initialize due to missing settings. Terminating Netbox."
|
||||
@@ -606,21 +653,10 @@ class Command(BaseCommand):
|
||||
|
||||
self.tsig_keys = self.settings.get("tsig_keys", None)
|
||||
if not self.tsig_keys:
|
||||
raise RuntimeError(
|
||||
"tsig_keys variable not set in plugin settings."
|
||||
)
|
||||
raise RuntimeError("tsig_keys variable not set in plugin settings.")
|
||||
|
||||
def loadCatalogZoneSettings(self):
|
||||
serial_file = self.settings.get("catalog_serial_file", None)
|
||||
if not serial_file:
|
||||
raise RuntimeError(
|
||||
"catalog_serial_file variable not set in plugin settings."
|
||||
)
|
||||
else:
|
||||
CatalogZoneAssembler.initCatalogZoneSerial(serial_file)
|
||||
|
||||
def loadTSIGKeySettings(self):
|
||||
# Load TSIG keys and map them to views
|
||||
def loadTSIGKeySettings(self):
|
||||
self.keyring = {}
|
||||
self.tsig_view_map = {}
|
||||
|
||||
@@ -653,7 +689,7 @@ class Command(BaseCommand):
|
||||
name=key_name_obj, secret=secret, algorithm=algorithm_str
|
||||
)
|
||||
self.tsig_view_map[key_name_str] = nb_view
|
||||
logger.debug(f"Loaded TSIG key: {key_name_str} (view: {nb_view.name})")
|
||||
logger.debug(f"Loaded TSIG key: {key_name_str} view: {nb_view.name}")
|
||||
|
||||
if not self.keyring:
|
||||
msg = "No TSIG keys found in database."
|
||||
@@ -661,28 +697,42 @@ class Command(BaseCommand):
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--port', type=int, default=5354, help='Port number to listen on')
|
||||
parser.add_argument('--address', type=str, default="0.0.0.0", help='IP to bind to')
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=5354, help="Port number to listen on"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--address", type=str, default="0.0.0.0", help="IP to bind to"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Load parameters
|
||||
port = options['port']
|
||||
address = options['address']
|
||||
port = options["port"]
|
||||
address = options["address"]
|
||||
CatalogZone.initSerial()
|
||||
# Initialize settings
|
||||
self.loadSettings()
|
||||
self.loadCatalogZoneSettings()
|
||||
# self.loadCatalogZoneSettings()
|
||||
self.loadTSIGKeySettings()
|
||||
# Following is temporary. Function and invocation can be deleted once v1.0 is reached.
|
||||
serial_file = self.settings.get("catalog_serial_file", None)
|
||||
if serial_file:
|
||||
CatalogZone.importLegacySerialFile(serial_file)
|
||||
|
||||
udp_server = UDPDNSServer((address, port), UDPRequestHandler, self.keyring, self.tsig_view_map)
|
||||
tcp_server = TCPDNSServer((address, port), TCPRequestHandler, self.keyring, self.tsig_view_map)
|
||||
udp_server = UDPDNSServer(
|
||||
(address, port), UDPRequestHandler, self.keyring, self.tsig_view_map
|
||||
)
|
||||
tcp_server = TCPDNSServer(
|
||||
(address, port), TCPRequestHandler, self.keyring, self.tsig_view_map
|
||||
)
|
||||
|
||||
def run_udp_server(server):
|
||||
logger.debug(f"SOA endpoint listening on {address} udp/{port}")
|
||||
server.serve_forever()
|
||||
|
||||
udp_thread = threading.Thread(target=run_udp_server, args=(udp_server,), daemon=True)
|
||||
udp_thread = threading.Thread(
|
||||
target=run_udp_server, args=(udp_server,), daemon=True
|
||||
)
|
||||
udp_thread.start()
|
||||
|
||||
logger.debug(f"AXFR endpoint listening on {address} tcp/{port}")
|
||||
tcp_server.serve_forever()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-23 14:36
|
||||
|
||||
import netbox.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("extras", "0133_make_cf_minmax_decimal"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="IntegerKeyValueSetting",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True, null=True)),
|
||||
("last_updated", models.DateTimeField(auto_now=True, null=True)),
|
||||
(
|
||||
"custom_field_data",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
encoder=utilities.json.CustomFieldJSONEncoder,
|
||||
),
|
||||
),
|
||||
("key", models.CharField(max_length=64)),
|
||||
("value", models.IntegerField()),
|
||||
(
|
||||
"tags",
|
||||
taggit.managers.TaggableManager(
|
||||
through="extras.TaggedItem", to="extras.Tag"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,24 @@
|
||||
from django.db import models
|
||||
import netbox.models
|
||||
import netbox_dns.models
|
||||
|
||||
|
||||
# class CatalogZoneSerial(models.Model):
|
||||
class IntegerKeyValueSetting(netbox.models.NetBoxModel):
|
||||
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=64)
|
||||
value = models.IntegerField()
|
||||
|
||||
def __str__(self):
|
||||
# return f'{self.view.name}: {self.serial}'
|
||||
return f"{self.key}: {str(self.value)}"
|
||||
|
||||
|
||||
# class TSIGKey(netbox.models.NetBoxModel):
|
||||
# view = models.ForeignKey(netbox_dns.models.View, on_delete=models.CASCADE)
|
||||
# name = models.CharField(max_length=256)
|
||||
# algorithm = models.CharField(max_length=25)
|
||||
# secret = models.CharField(max_length=50)
|
||||
#
|
||||
# def __str__(self):
|
||||
# return f'key "{self.name}" {{ algorithm {self.algorithm}; secret "{self.secret}"; }};'
|
||||
Reference in new issue
Block a user