modified: README.md

modified:   certbot_dns_netbox/dns_netbox.py
This commit is contained in:
Sven Luthi committed 2026-10-26 18:37:14 +01:00
1 parent 91562ae538
commit 62b809db95
2 files changed
+66 -37

No files matched your search

+24 -13
View File
@@ -1,6 +1,6 @@
# certbot-dns-netbox # certbot-dns-netbox
Certbot DNS-01 authenticator plugin that updates the challenge TXT record in Certbot DNS-01 authenticator plugin that updates the challenge TXT records in
[NetBox DNS](https://github.com/sys4/netbox-plugin-dns) (the `netbox-dns` [NetBox DNS](https://github.com/sys4/netbox-plugin-dns) (the `netbox-dns`
NetBox plugin) via the bundled `nbapi.py` API helper. NetBox plugin) via the bundled `nbapi.py` API helper.
@@ -13,16 +13,27 @@ and the plugin **never creates or deletes records**:
1. looks up the existing TXT records for the FQDN (records can be filtered by 1. looks up the existing TXT records for the FQDN (records can be filtered by
`fqdn`, `zone` and `view` by name), `fqdn`, `zone` and `view` by name),
2. writes the challenge token into the existing record. If the record does 2. writes each challenge token into one of the pre-provisioned records,
not exist yet, certbot aborts with an error: create the record once claiming one record per token,
beforehand (in the NetBox UI or your tooling) and rerun, 3. leaves the records in place after validation so they can be updated again
3. leaves the record in place after validation so it can be updated again on on the next renewal.
the next renewal.
Because a single record holds a single token, a certificate request must not ### Provisioning
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 The challenge TXT records must be created beforehand in netbox-dns, in the
would overwrite the first. Issue those as separate certificates. view configured via `search_view` (`certbot_dns_netbox/dns_netbox.py`,
default: `global`). Each challenge token needs its own record:
| Certificate request | TXT records needed |
| ------------------------------------------------------ | ------------------ |
| `-d example.com` or `-d '*.example.com'` | 1 |
| `-d example.com -d '*.example.com'` (apex + wildcard) | 2 |
All records for a challenge share the same name (e.g. name
`_acme-challenge.unifi` in zone `ewcs.ch`); netbox-dns allows multiple TXT
records with different values for the same name. If a certificate request
needs more records than exist, certbot aborts with an error. Renewals simply
rewrite the values, so the records never need to be touched again.
Connection settings are taken from the environment, exactly as `nbapi.py` Connection settings are taken from the environment, exactly as `nbapi.py`
expects them: expects them:
@@ -94,6 +105,6 @@ certbot certonly --dry-run \
-d sub.example.com # end-to-end against the staging ACME API -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 Watch the `_acme-challenge` TXT records in the `global` view: their values
to the challenge token during the run and remains in place afterwards. The change to the challenge tokens during the run and remain in place afterwards.
next renewal updates the same record again. The next renewal updates the same records again.
+42 -24
View File
@@ -1,10 +1,11 @@
"""NetBox DNS (netbox-plugin-dns) DNS-01 authenticator plugin for Certbot. """NetBox DNS (netbox-plugin-dns) DNS-01 authenticator plugin for Certbot.
The TXT record required for the ACME dns-01 challenge is managed The TXT records for the ACME dns-01 challenge are managed exclusively
exclusively through the netbox-dns record API endpoint, via the bundled through the netbox-dns record API endpoint, via the bundled nbapi.py
nbapi.py helper. The zone endpoint is never queried and the plugin never helper. The zone endpoint is never queried and the plugin never creates
creates or deletes records: an existing TXT record (created beforehand in or deletes records: the challenge TXT records (created beforehand in
NetBox) is updated with the challenge token and left in place afterwards. NetBox, one per challenge token) are updated with the challenge tokens
and left in place afterwards.
Connection settings are taken from the environment, exactly as nbapi.py Connection settings are taken from the environment, exactly as nbapi.py
expects them: expects them:
@@ -59,8 +60,8 @@ class Authenticator(dns_common.DNSAuthenticator):
def more_info(self) -> str: def more_info(self) -> str:
return ( return (
"This plugin updates an existing DNS TXT record in netbox-dns " "This plugin updates pre-provisioned DNS TXT records in "
"with the token of the dns-01 challenge." "netbox-dns with the tokens of the dns-01 challenges."
) )
def _setup_credentials(self) -> None: def _setup_credentials(self) -> None:
@@ -106,8 +107,8 @@ class _NetboxDnsClient:
Only the record API endpoint is used, so the API token needs no Only the record API endpoint is used, so the API token needs no
permissions on zones. Records are never created or deleted: the permissions on zones. Records are never created or deleted: the
challenge TXT record must already exist in NetBox and is updated in challenge TXT records must be provisioned beforehand in NetBox, one
place. per challenge token, and are updated in place.
""" """
def __init__(self, ttl: Optional[int] = None, insecure: bool = False): def __init__(self, ttl: Optional[int] = None, insecure: bool = False):
@@ -118,6 +119,10 @@ class _NetboxDnsClient:
self._nbapi = nbapi self._nbapi = nbapi
self._ttl = ttl self._ttl = ttl
# record ids claimed by this certbot run; each challenge token gets
# its own pre-provisioned record so multiple tokens for the same
# name (e.g. apex and wildcard) do not overwrite each other.
self._claimed: set = set()
if insecure: if insecure:
nbapi.disable_ssl_verify() nbapi.disable_ssl_verify()
@@ -141,30 +146,43 @@ class _NetboxDnsClient:
) from e ) from e
def update_txt_record(self, record_fqdn: str, validation: str) -> None: def update_txt_record(self, record_fqdn: str, validation: str) -> None:
"""Write the challenge token into the existing TXT record.""" """Write the challenge token into one of the pre-provisioned records.
Each challenge token claims its own record so that several tokens
for the same name (e.g. apex and wildcard on one certificate) stay
live simultaneously.
"""
fqdn = self._fqdn(record_fqdn) fqdn = self._fqdn(record_fqdn)
records = self._get_txt_records(fqdn) records = self._get_txt_records(fqdn)
if any(record["value"] == validation for record in records): for record in records:
logger.debug( if record["value"] == validation and not record.get("managed"):
"TXT record for %s already has the expected value.", record_fqdn self._claimed.add(record["id"])
) logger.debug(
return "TXT record for %s already has the expected value.",
record_fqdn,
)
return
existing = [record for record in records if not record.get("managed")] existing = sorted(
if not existing: (record for record in records if not record.get("managed")),
key=lambda record: record["id"],
)
free = [record for record in existing if record["id"] not in self._claimed]
if not free:
raise errors.PluginError( raise errors.PluginError(
f"No updatable TXT record found for {record_fqdn} in view " f"Not enough TXT records for {record_fqdn} in view "
f"{search_view!r}. This plugin only updates existing records: " f"{search_view!r}: {len(existing)} record(s) exist, but all of "
"create the record once (type TXT) and rerun certbot." "them are already in use by this certificate request. Add "
"another TXT record with the same name and rerun certbot."
) )
patch = {"id": existing[0]["id"], "value": validation} target = free[0]
patch = {"id": target["id"], "value": validation}
if self._ttl: if self._ttl:
patch["ttl"] = self._ttl patch["ttl"] = self._ttl
self._nbapi.modify_dns_record(patch) self._nbapi.modify_dns_record(patch)
self._claimed.add(target["id"])
logger.info( logger.info(
"Updated TXT record %s (id %s).", "Updated TXT record %s (id %s).", target["fqdn"], target["id"]
existing[0]["fqdn"],
existing[0]["id"],
) )