diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..041b622 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,39 @@ +name: Publish Python package to PyPI + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: self-hosted + + steps: + - name: Check out repository + uses: https://github.com/actions/checkout@v4 + + - name: Verify tag matches package version + run: | + TAG="${GITHUB_REF_NAME}" + VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + if [ "$TAG" != "v$VERSION" ]; then + echo "::error::Tag ${TAG} does not match package version v${VERSION}" + exit 1 + fi + + - name: Install build tools + run: python3 -m pip install --upgrade build twine + + - name: Build package + run: python3 -m build + + - name: Check package + run: python3 -m twine check dist/* + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + TWINE_NON_INTERACTIVE: "1" + run: python3 -m twine upload dist/* diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..efaea9d --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,42 @@ +# This workflow will upload a Python Package to PyPI when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Create and Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build-and-publish: + name: Build and Publish + environment: pypi + permissions: + id-token: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + #- uses: actions/setup-python@v5 + # with: + # python-version: "3.x" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade build + + - name: Build release + run: | + python -m build --sdist --wheel + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d2f8d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +build/ +dist/ +*.egg-info/ +.venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a7a243a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sven Luthi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1ec516d..b6214b3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ -# 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 plugin) via the bundled `nbapi.py` API helper. +***DISCLAIMER: THIS PROJECT IS VIBE CODED USING AN LLM.*** + ## How it works For every domain in the certificate request certbot asks this plugin to set an @@ -13,16 +15,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 +107,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"] ) diff --git a/pyproject.toml b/pyproject.toml index d37fe6e..2f10d60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,9 @@ 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" } +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Sven Luthi", email = "dev@sven.luethi.co" }] keywords = ["certbot", "acme", "letsencrypt", "netbox", "dns", "dns-01"] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -16,6 +18,7 @@ classifiers = [ "Intended Audience :: System Administrators", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Topic :: Internet :: WWW/HTTP", "Topic :: Security", ] @@ -25,6 +28,10 @@ dependencies = [ "urllib3>=1.26", ] +[project.urls] +Homepage = "https://git.luethi.co/sven/certbot-dns-netbox" +Repository = "https://git.luethi.co/sven/certbot-dns-netbox.git" + [project.entry-points."certbot.plugins"] dns-netbox = "certbot_dns_netbox.dns_netbox:Authenticator"