new file: .gitea/workflows/publish.yml
Publish Python package to PyPI / publish (push) Successful in 29s
Publish Python package to PyPI / publish (push) Successful in 29s
new file: .github/workflows/python-publish.yml new file: .gitignore new file: LICENSE modified: README.md modified: certbot_dns_netbox/dns_netbox.py modified: pyproject.toml
This commit is contained in:
1 parent
91562ae538
commit
f1e7737a07
7 files changed
+189
-40
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
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: Create virtual environment
|
||||||
|
run: python3 -m venv .venv
|
||||||
|
|
||||||
|
- name: Install build tools
|
||||||
|
run: .venv/bin/pip install --upgrade build twine
|
||||||
|
|
||||||
|
- name: Verify tag matches package version
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
VERSION="$(grep -m1 '^version = ' pyproject.toml | cut -d '"' -f2)"
|
||||||
|
if [ "$TAG" != "v$VERSION" ]; then
|
||||||
|
echo "::error::Tag ${TAG} does not match package version v${VERSION}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: .venv/bin/python3 -m build
|
||||||
|
|
||||||
|
- name: Check package
|
||||||
|
run: .venv/bin/python3 -m twine check dist/*
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: __token__
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
TWINE_NON_INTERACTIVE: "1"
|
||||||
|
run: .venv/bin/python3 -m twine upload dist/*
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
@@ -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.
|
||||||
@@ -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 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.
|
||||||
|
|
||||||
|
***DISCLAIMER: THIS PROJECT IS VIBE CODED USING AN LLM.***
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
For every domain in the certificate request certbot asks this plugin to set an
|
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
|
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 +107,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"],
|
|
||||||
)
|
)
|
||||||
+9
-2
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61"]
|
requires = ["setuptools>=77"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
@@ -8,7 +8,9 @@ version = "1.0.0"
|
|||||||
description = "NetBox DNS (netbox-plugin-dns) DNS-01 authenticator plugin for Certbot"
|
description = "NetBox DNS (netbox-plugin-dns) DNS-01 authenticator plugin for Certbot"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
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"]
|
keywords = ["certbot", "acme", "letsencrypt", "netbox", "dns", "dns-01"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -16,6 +18,7 @@ classifiers = [
|
|||||||
"Intended Audience :: System Administrators",
|
"Intended Audience :: System Administrators",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
"Topic :: Security",
|
"Topic :: Security",
|
||||||
]
|
]
|
||||||
@@ -25,6 +28,10 @@ dependencies = [
|
|||||||
"urllib3>=1.26",
|
"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"]
|
[project.entry-points."certbot.plugins"]
|
||||||
dns-netbox = "certbot_dns_netbox.dns_netbox:Authenticator"
|
dns-netbox = "certbot_dns_netbox.dns_netbox:Authenticator"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user