new file: README.md

new file:   certbot_dns_netbox/__init__.py
new file:   certbot_dns_netbox/dns_netbox.py
new file:   certbot_dns_netbox/nbapi.py
new file:   pyproject.toml
This commit is contained in:
Sven Luthi committed 2026-10-26 17:47:10 +01:00
commit 91562ae538
5 files changed
+623

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# certbot-dns-netbox
Certbot DNS-01 authenticator plugin that updates the challenge TXT record in
[NetBox DNS](https://github.com/sys4/netbox-plugin-dns) (the `netbox-dns`
NetBox plugin) via the bundled `nbapi.py` API helper.
## How it works
For every domain in the certificate request certbot asks this plugin to set an
`_acme-challenge.<domain>` TXT record. The plugin works exclusively through
the netbox-dns **record API endpoint** — the zone endpoint is never queried,
and the plugin **never creates or deletes records**:
1. looks up the existing TXT records for the FQDN (records can be filtered by
`fqdn`, `zone` and `view` by name),
2. writes the challenge token into the existing record. If the record does
not exist yet, certbot aborts with an error: create the record once
beforehand (in the NetBox UI or your tooling) and rerun,
3. leaves the record in place after validation so it can be updated again on
the next renewal.
Because a single record holds a single token, a certificate request must not
combine a domain and its wildcard (`-d example.com -d '*.example.com'`): both
map to the same `_acme-challenge.example.com` record, and the second token
would overwrite the first. Issue those as separate certificates.
Connection settings are taken from the environment, exactly as `nbapi.py`
expects them:
| Variable | Purpose |
| --------------- | ------------------------------------------ |
| `NETBOX_URL` | Base URL of the NetBox instance |
| `NETBOX_BEARER` | NetBox API token (`Authorization: Bearer`) |
| `NETBOX_TOKEN` | NetBox API token (`Authorization: Token`) |
| `DEBUG` | Optional: verbose nbapi.py logging |
One of `NETBOX_BEARER` / `NETBOX_TOKEN` is required. The token's user needs
NetBox object permissions to view and change netbox-dns records only — no
zone or view permissions are required.
## Installation
Install into the same Python environment as certbot:
```
pip install ./certbot-dns-netbox
```
## Usage
```
export NETBOX_URL=https://netbox.example.com
export NETBOX_BEARER=<token>
certbot certonly \
--authenticator dns-netbox \
--dns-netbox-propagation-seconds 60 \
-d example.com -d '*.example.com'
```
### Options
| Option | Default | Description |
| ---------------------------------- | -------------- | ------------------------------------ |
| `--dns-netbox-propagation-seconds` | `30` | Seconds to wait for DNS propagation |
| `--dns-netbox-ttl` | keep current | TTL of the challenge TXT record (s) |
| `--dns-netbox-insecure` | off | Skip TLS verification towards NetBox |
The netbox-dns view to search is configured in code: `search_view` at the top
of `certbot_dns_netbox/dns_netbox.py`.
### Renewal configuration
certbot stores the authenticator in the renewal configuration, so renewals
need no extra flags:
```
# /etc/letsencrypt/renewal/example.com.conf
[renewalparams]
authenticator = dns-netbox
dns_netbox_ttl = 120
```
When certbot runs unattended (cron or systemd timer), make sure the `NETBOX_*`
environment variables are set, e.g. via a systemd `EnvironmentFile=` or a
wrapper script.
## Verification
```
certbot plugins --init dns-netbox # plugin is discovered and loads
certbot certonly --dry-run \
--authenticator dns-netbox \
-d sub.example.com # end-to-end against the staging ACME API
```
Watch the `_acme-challenge` TXT record in the `global` view: its value changes
to the challenge token during the run and remains in place afterwards. The
next renewal updates the same record again.
+3
View File
@@ -0,0 +1,3 @@
"""NetBox DNS authenticator plugin for Certbot."""
__version__ = "1.0.0"
+170
View File
@@ -0,0 +1,170 @@
"""NetBox DNS (netbox-plugin-dns) DNS-01 authenticator plugin for Certbot.
The TXT record required for the ACME dns-01 challenge is managed
exclusively through the netbox-dns record API endpoint, via the bundled
nbapi.py helper. The zone endpoint is never queried and the plugin never
creates or deletes records: an existing TXT record (created beforehand in
NetBox) is updated with the challenge token and left in place afterwards.
Connection settings are taken from the environment, exactly as nbapi.py
expects them:
* ``NETBOX_URL``: base URL of the NetBox instance
* ``NETBOX_BEARER`` or ``NETBOX_TOKEN``: NetBox API token
"""
import logging
import os
from typing import Callable, Optional
import requests
from certbot import errors
from certbot.plugins import dns_common
logger = logging.getLogger(__name__)
# netbox-dns view that contains the zone with the challenge TXT record.
search_view = "global"
class Authenticator(dns_common.DNSAuthenticator):
"""DNS-01 authenticator backed by the netbox-dns plugin."""
description = "Obtain certificates using a DNS TXT record (in netbox-dns)."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = None
@classmethod
def add_parser_arguments(
cls, add: Callable[..., None], default_propagation_seconds: int = 30
) -> None:
super().add_parser_arguments(
add, default_propagation_seconds=default_propagation_seconds
)
add(
"ttl",
type=int,
default=None,
help="TTL for the challenge TXT record, in seconds "
"(default: keep the record's current TTL).",
)
add(
"insecure",
action="store_true",
help="Do not verify the TLS certificate of the NetBox instance.",
)
def more_info(self) -> str:
return (
"This plugin updates an existing DNS TXT record in netbox-dns "
"with the token of the dns-01 challenge."
)
def _setup_credentials(self) -> None:
# nbapi.py reads its connection settings from the environment and
# terminates the process at import time when they are missing, so
# verify them here and fail with a proper certbot error instead.
if not os.environ.get("NETBOX_URL"):
raise errors.PluginError(
"Environment variable NETBOX_URL is not set (required by nbapi.py)."
)
if not (os.environ.get("NETBOX_BEARER") or os.environ.get("NETBOX_TOKEN")):
raise errors.PluginError(
"Environment variable NETBOX_BEARER or NETBOX_TOKEN must be set "
"(required by nbapi.py)."
)
self.client = _NetboxDnsClient(
ttl=self.conf("ttl"), insecure=self.conf("insecure")
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
try:
self.client.update_txt_record(validation_name, validation)
except errors.PluginError:
raise
except Exception as e:
raise errors.PluginError(
f"Failed to update TXT record {validation_name}: {e}"
) from e
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
# The TXT record is managed outside of this plugin and must remain in
# place so that it can be updated again on the next renewal; deleting
# it would leave nothing to update.
logger.debug(
"Leaving TXT record %s in place (this plugin only updates records).",
validation_name,
)
class _NetboxDnsClient:
"""netbox-dns client built on the bundled nbapi helper.
Only the record API endpoint is used, so the API token needs no
permissions on zones. Records are never created or deleted: the
challenge TXT record must already exist in NetBox and is updated in
place.
"""
def __init__(self, ttl: Optional[int] = None, insecure: bool = False):
# Deferred import: nbapi.py exits at import time when the NETBOX_*
# environment variables are missing. Authenticator._setup_credentials()
# performs the environment checks before this client is constructed.
from certbot_dns_netbox import nbapi
self._nbapi = nbapi
self._ttl = ttl
if insecure:
nbapi.disable_ssl_verify()
@staticmethod
def _fqdn(record_fqdn: str) -> str:
# netbox-dns stores record fqdns with a trailing dot.
return record_fqdn.rstrip(".").lower() + "."
def _get_txt_records(self, fqdn: str) -> list:
try:
return (
self._nbapi.get_dns_record(
{"fqdn": fqdn, "type": "TXT", "view": search_view},
multi_exp=True,
)
or []
)
except requests.exceptions.RequestException as e:
raise errors.PluginError(
f"Failed to query netbox-dns records for {fqdn}: {e}"
) from e
def update_txt_record(self, record_fqdn: str, validation: str) -> None:
"""Write the challenge token into the existing TXT record."""
fqdn = self._fqdn(record_fqdn)
records = self._get_txt_records(fqdn)
if any(record["value"] == validation for record in records):
logger.debug(
"TXT record for %s already has the expected value.", record_fqdn
)
return
existing = [record for record in records if not record.get("managed")]
if not existing:
raise errors.PluginError(
f"No updatable TXT record found for {record_fqdn} in view "
f"{search_view!r}. This plugin only updates existing records: "
"create the record once (type TXT) and rerun certbot."
)
patch = {"id": existing[0]["id"], "value": validation}
if self._ttl:
patch["ttl"] = self._ttl
self._nbapi.modify_dns_record(patch)
logger.info(
"Updated TXT record %s (id %s).",
existing[0]["fqdn"],
existing[0]["id"],
)
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
import os
import sys
import requests
import urllib3
import json
from typing import Union
base_url = os.environ.get("NETBOX_URL", None)
bearer = os.environ.get("NETBOX_BEARER", None)
token = os.environ.get("NETBOX_TOKEN", None)
os.environ.pop("http_proxy", None)
os.environ.pop("https_proxy", None)
DEBUG = bool(os.environ.get("DEBUG", False))
if not base_url:
print("❌ Missing required environment variable NETBOX_URL")
sys.exit(1)
if not bearer and not token:
print("❌ Missing required environment variables NETBOX_BEARER or NETBOX_TOKEN")
sys.exit(1)
# Clean URL
base_url = base_url.rstrip("/")
ssl_verify = True
timeout = 60
# Following for legacy code. Replace with direct header var access:
headers = {
"Authorization": f"Bearer {bearer}" if bearer else f"Token {token}",
"Content-Type": "application/json",
}
session = requests.Session()
session.headers.update(headers)
def _log_debug(msg: str):
if DEBUG:
print(msg)
def _raise_with_body(e, r, method="", path="", query=None, data=None):
"""Re-raise *e* enriched with the NetBox API response body and request
context when it is an HTTPError.
NetBox returns detailed validation errors in the response body (e.g.
{"mac_address": ["Enter a valid MAC address."]}), but requests'
raise_for_status() produces an HTTPError whose str() carries only the
status code and URL. This rebuilds the exception so str() includes
the API message and the request context (method, path, filters, data),
making failures debuggable without DEBUG mode.
Non-HTTPError exceptions (e.g. JSONDecodeError) are re-raised unchanged.
"""
if not isinstance(e, requests.exceptions.HTTPError):
raise e
try:
body = r.json()
except (ValueError, json.JSONDecodeError):
body = (r.text or "")[:1000]
if isinstance(body, dict):
parts = []
for k, v in body.items():
if isinstance(v, list):
parts.append(f"{k}: {', '.join(str(x) for x in v)}")
else:
parts.append(f"{k}: {v}")
body_str = "; ".join(parts) if parts else str(body)
else:
body_str = str(body)
req_context = ""
if method or path:
req_context = f" | Request: {method} {path}"
if query:
req_context += f" filters={query}"
if data:
req_context += f" data={data}"
raise requests.exceptions.HTTPError(
f"{e} | API: {body_str}{req_context}", response=r
)
def disable_ssl_verify():
global ssl_verify
ssl_verify = False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ==============================================================================
# ========== Get functions =====================================================
# ==============================================================================
def _get(
path: str, filters: Union[dict, list] = None, raw_text: bool = False
) -> Union[dict, str]:
# Disable object limit to ensure all matching objects are returned.
if isinstance(filters, dict):
if "limit" not in filters:
filters["limit"] = 0
elif isinstance(filters, list):
index = next((v for k, v in filters if k == "limit"), None)
if index is None:
filters.append(("limit", 0))
else:
raise Exception(
f"_get() called with unsupported filters type '{type(filters)}'"
)
path = path.strip("/")
url = f"{base_url}/api/{path}/"
try:
r = session.get(url, params=filters or {}, verify=ssl_verify, timeout=timeout)
r.raise_for_status()
return r.text if raw_text else r.json()
except (requests.exceptions.HTTPError, json.JSONDecodeError) as e:
_raise_with_body(e, r, method="GET", path=path, query=filters)
def get_resource(
path: str, filters: Union[dict, list], multi_exp: bool = False
) -> Union[dict, None]:
data = _get(path, filters)
count = data["count"]
# No Object found
if count == 0:
_log_debug(f"No object found using filter {filters}")
return None
# If multiple objects expected, return them all:
elif multi_exp:
return data["results"]
# If single object is expected, return it without list:
elif count == 1:
return data["results"][0]
# Single object expected, but multiple found, warn user and return nothing.
else:
_log_debug(
f"Error: Multiple objects found using filter {filters}. Refine your query!"
)
return None
# ==============================================================================
# ========== Update functions ==================================================
# ==============================================================================
def _patch(path: str, obj_id: int, data: dict) -> bool:
path = path.strip("/")
url = f"{base_url}/api/{path}/{obj_id}/"
try:
r = session.patch(url, json=data, verify=ssl_verify, timeout=timeout)
r.raise_for_status()
if r.status_code in (200, 204):
return True
else:
_log_debug("Update failed:")
_log_debug(r.text)
return False
except requests.exceptions.HTTPError as e:
_raise_with_body(e, r, method="PATCH", path=f"{path}/{obj_id}", data=data)
def _patch_resource(path: str, data: dict) -> Union[dict, None]:
obj_id = data.get("id", None)
# Dont execute if no ID is set
if not obj_id:
_log_debug("Error: No ID supplied when patching resource!")
return False
else:
return _patch(
path,
obj_id,
data,
)
def modify_resource(path: str, data: dict) -> Union[dict, None]:
return _patch_resource(path, data)
# ==============================================================================
# ========== Create functions ==================================================
# ==============================================================================
def _post(path: str, data: dict) -> Union[dict, list]:
path = path.strip("/")
url = f"{base_url}/api/{path}/"
try:
r = session.post(url, json=data, verify=ssl_verify, timeout=timeout)
r.raise_for_status()
return r.json()
except (requests.exceptions.HTTPError, json.JSONDecodeError) as e:
_raise_with_body(e, r, method="POST", path=path, data=data)
def _post_resource(path: str, data: dict) -> Union[dict, None]:
if data.get("id"):
_log_debug("Error: ID supplied when creating resource!")
return False
else:
return _post(
path,
data,
)
def create_resource(path: str, data: dict) -> Union[dict, None]:
return _post_resource(path, data)
# ==============================================================================
# ========== Delete functions ==================================================
# ==============================================================================
def _delete(path: str, obj_id: int) -> bool:
path = path.strip("/")
url = f"{base_url}/api/{path}/{obj_id}/"
try:
r = session.delete(url, verify=ssl_verify, timeout=timeout)
r.raise_for_status()
return True
except requests.exceptions.HTTPError as e:
_log_debug(f"HTTPError: {e}\nResponse body: {r.text}")
return False
def delete_resource(path: str, obj_id: int) -> bool:
return _delete(path, obj_id)
# ==============================================================================
# ========== Export functions ==================================================
# ==============================================================================
def export(path: str, filters: Union[dict, list], template: str) -> str:
if isinstance(filters, dict):
filters["export"] = template
elif isinstance(filters, list):
filters.append(("export", template))
else:
raise Exception(
f"export() called with unsupported filters type '{type(filters)}'"
)
return _get(path, filters, raw_text=True)
# ==============================================================================
# ========== Resource-specific wrappers (table-driven) =========================
# ==============================================================================
# Single source of truth mapping resource name -> NetBox API path. The loop
# below generates get_/modify_/create_/delete_ convenience wrappers for every
# entry, delegating to the generic core functions above. Adding a new resource
# is a one-line change to RESOURCES.
RESOURCES = {
"device": "/dcim/devices",
"device_interface": "/dcim/interfaces",
"device_role": "/dcim/device-roles",
"inventory_item": "/dcim/inventory-items",
"site": "/dcim/sites",
"location": "/dcim/locations",
"rack": "/dcim/racks",
"cable": "/dcim/cables",
"platform": "/dcim/platforms",
"tenant": "/tenancy/tenants",
"vm": "/virtualization/virtual-machines",
"vm_interface": "/virtualization/interfaces",
"virtual_disk": "/virtualization/virtual-disks",
"ipaddress": "/ipam/ip-addresses",
"vlan": "/ipam/vlans",
"dns_view": "/plugins/netbox-dns/views/",
"dns_zone": "/plugins/netbox-dns/zones/",
"dns_record": "/plugins/netbox-dns/records/"
}
def _make_getter(path: str):
def _wrapper(filters: Union[dict, list], multi_exp: bool = False):
return get_resource(path, filters, multi_exp)
return _wrapper
def _make_modifier(path: str):
def _wrapper(data: dict):
return _patch_resource(path, data)
return _wrapper
def _make_creator(path: str):
def _wrapper(data: dict):
return _post_resource(path, data)
return _wrapper
def _make_deleter(path: str):
def _wrapper(obj_id: int):
return _delete(path, obj_id)
return _wrapper
for _name, _path in RESOURCES.items():
for _verb, _factory in (
("get", _make_getter),
("modify", _make_modifier),
("create", _make_creator),
("delete", _make_deleter),
):
_fn = _factory(_path)
_fn.__name__ = f"{_verb}_{_name}"
_fn.__qualname__ = f"{_verb}_{_name}"
globals()[f"{_verb}_{_name}"] = _fn
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "certbot-dns-netbox"
version = "1.0.0"
description = "NetBox DNS (netbox-plugin-dns) DNS-01 authenticator plugin for Certbot"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "Apache-2.0" }
keywords = ["certbot", "acme", "letsencrypt", "netbox", "dns", "dns-01"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Plugins",
"Intended Audience :: System Administrators",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Security",
]
dependencies = [
"certbot>=2.7.0",
"requests>=2.20.0",
"urllib3>=1.26",
]
[project.entry-points."certbot.plugins"]
dns-netbox = "certbot_dns_netbox.dns_netbox:Authenticator"
[tool.setuptools]
packages = ["certbot_dns_netbox"]