modified: README.md
modified: certbot_dns_netbox/dns_netbox.py
This commit is contained in:
1 parent
91562ae538
commit
62b809db95
2 files changed
+66
-37
No files matched your search
@@ -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.
|
||||||
@@ -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"],
|
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user