From 62b809db95f9577aedd350ea0d650749afc9c9d4 Mon Sep 17 00:00:00 2001 From: Sven Luthi Date: Mon, 26 Oct 2026 18:37:14 +0100 Subject: [PATCH] modified: README.md modified: certbot_dns_netbox/dns_netbox.py --- README.md | 37 +++++++++++------- certbot_dns_netbox/dns_netbox.py | 66 ++++++++++++++++++++------------ 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1ec516d..350dccb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 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 `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. +2. writes each challenge token into one of the pre-provisioned records, + claiming one record per token, +3. leaves the records in place after validation so they 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. +### Provisioning + +The challenge TXT records must be created beforehand in netbox-dns, in the +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` expects them: @@ -94,6 +105,6 @@ certbot certonly --dry-run \ -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. +Watch the `_acme-challenge` TXT records in the `global` view: their values +change to the challenge tokens during the run and remain in place afterwards. +The next renewal updates the same records again. diff --git a/certbot_dns_netbox/dns_netbox.py b/certbot_dns_netbox/dns_netbox.py index 616d61d..ad227a1 100644 --- a/certbot_dns_netbox/dns_netbox.py +++ b/certbot_dns_netbox/dns_netbox.py @@ -1,10 +1,11 @@ """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. +The TXT records for the ACME dns-01 challenge are 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: the challenge TXT records (created beforehand in +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 expects them: @@ -59,8 +60,8 @@ class Authenticator(dns_common.DNSAuthenticator): 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." + "This plugin updates pre-provisioned DNS TXT records in " + "netbox-dns with the tokens of the dns-01 challenges." ) def _setup_credentials(self) -> None: @@ -106,8 +107,8 @@ class _NetboxDnsClient: 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. + challenge TXT records must be provisioned beforehand in NetBox, one + per challenge token, and are updated in place. """ def __init__(self, ttl: Optional[int] = None, insecure: bool = False): @@ -118,6 +119,10 @@ class _NetboxDnsClient: self._nbapi = nbapi 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: nbapi.disable_ssl_verify() @@ -141,30 +146,43 @@ class _NetboxDnsClient: ) from e 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) 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 + for record in records: + if record["value"] == validation and not record.get("managed"): + self._claimed.add(record["id"]) + 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: + existing = sorted( + (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( - 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." + f"Not enough TXT records for {record_fqdn} in view " + f"{search_view!r}: {len(existing)} record(s) exist, but all of " + "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: patch["ttl"] = self._ttl self._nbapi.modify_dns_record(patch) + self._claimed.add(target["id"]) logger.info( - "Updated TXT record %s (id %s).", - existing[0]["fqdn"], - existing[0]["id"], + "Updated TXT record %s (id %s).", target["fqdn"], target["id"] )