new file: README.md
new file: certbot_dns_netbox/__init__.py new file: certbot_dns_netbox/dns_netbox.py new file: certbot_dns_netbox/nbapi.py new file: pyproject.toml
This commit is contained in:
commit
91562ae538
5 files changed
+623
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""NetBox DNS authenticator plugin for Certbot."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,170 @@
|
||||
"""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.
|
||||
|
||||
Connection settings are taken from the environment, exactly as nbapi.py
|
||||
expects them:
|
||||
|
||||
* ``NETBOX_URL``: base URL of the NetBox instance
|
||||
* ``NETBOX_BEARER`` or ``NETBOX_TOKEN``: NetBox API token
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from certbot import errors
|
||||
from certbot.plugins import dns_common
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# netbox-dns view that contains the zone with the challenge TXT record.
|
||||
search_view = "global"
|
||||
|
||||
|
||||
class Authenticator(dns_common.DNSAuthenticator):
|
||||
"""DNS-01 authenticator backed by the netbox-dns plugin."""
|
||||
|
||||
description = "Obtain certificates using a DNS TXT record (in netbox-dns)."
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.client = None
|
||||
|
||||
@classmethod
|
||||
def add_parser_arguments(
|
||||
cls, add: Callable[..., None], default_propagation_seconds: int = 30
|
||||
) -> None:
|
||||
super().add_parser_arguments(
|
||||
add, default_propagation_seconds=default_propagation_seconds
|
||||
)
|
||||
add(
|
||||
"ttl",
|
||||
type=int,
|
||||
default=None,
|
||||
help="TTL for the challenge TXT record, in seconds "
|
||||
"(default: keep the record's current TTL).",
|
||||
)
|
||||
add(
|
||||
"insecure",
|
||||
action="store_true",
|
||||
help="Do not verify the TLS certificate of the NetBox instance.",
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
def _setup_credentials(self) -> None:
|
||||
# nbapi.py reads its connection settings from the environment and
|
||||
# terminates the process at import time when they are missing, so
|
||||
# verify them here and fail with a proper certbot error instead.
|
||||
if not os.environ.get("NETBOX_URL"):
|
||||
raise errors.PluginError(
|
||||
"Environment variable NETBOX_URL is not set (required by nbapi.py)."
|
||||
)
|
||||
if not (os.environ.get("NETBOX_BEARER") or os.environ.get("NETBOX_TOKEN")):
|
||||
raise errors.PluginError(
|
||||
"Environment variable NETBOX_BEARER or NETBOX_TOKEN must be set "
|
||||
"(required by nbapi.py)."
|
||||
)
|
||||
|
||||
self.client = _NetboxDnsClient(
|
||||
ttl=self.conf("ttl"), insecure=self.conf("insecure")
|
||||
)
|
||||
|
||||
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
|
||||
try:
|
||||
self.client.update_txt_record(validation_name, validation)
|
||||
except errors.PluginError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise errors.PluginError(
|
||||
f"Failed to update TXT record {validation_name}: {e}"
|
||||
) from e
|
||||
|
||||
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
|
||||
# The TXT record is managed outside of this plugin and must remain in
|
||||
# place so that it can be updated again on the next renewal; deleting
|
||||
# it would leave nothing to update.
|
||||
logger.debug(
|
||||
"Leaving TXT record %s in place (this plugin only updates records).",
|
||||
validation_name,
|
||||
)
|
||||
|
||||
|
||||
class _NetboxDnsClient:
|
||||
"""netbox-dns client built on the bundled nbapi helper.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: Optional[int] = None, insecure: bool = False):
|
||||
# Deferred import: nbapi.py exits at import time when the NETBOX_*
|
||||
# environment variables are missing. Authenticator._setup_credentials()
|
||||
# performs the environment checks before this client is constructed.
|
||||
from certbot_dns_netbox import nbapi
|
||||
|
||||
self._nbapi = nbapi
|
||||
self._ttl = ttl
|
||||
if insecure:
|
||||
nbapi.disable_ssl_verify()
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(record_fqdn: str) -> str:
|
||||
# netbox-dns stores record fqdns with a trailing dot.
|
||||
return record_fqdn.rstrip(".").lower() + "."
|
||||
|
||||
def _get_txt_records(self, fqdn: str) -> list:
|
||||
try:
|
||||
return (
|
||||
self._nbapi.get_dns_record(
|
||||
{"fqdn": fqdn, "type": "TXT", "view": search_view},
|
||||
multi_exp=True,
|
||||
)
|
||||
or []
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise errors.PluginError(
|
||||
f"Failed to query netbox-dns records for {fqdn}: {e}"
|
||||
) from e
|
||||
|
||||
def update_txt_record(self, record_fqdn: str, validation: str) -> None:
|
||||
"""Write the challenge token into the existing TXT record."""
|
||||
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
|
||||
|
||||
existing = [record for record in records if not record.get("managed")]
|
||||
if not existing:
|
||||
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."
|
||||
)
|
||||
|
||||
patch = {"id": existing[0]["id"], "value": validation}
|
||||
if self._ttl:
|
||||
patch["ttl"] = self._ttl
|
||||
self._nbapi.modify_dns_record(patch)
|
||||
logger.info(
|
||||
"Updated TXT record %s (id %s).",
|
||||
existing[0]["fqdn"],
|
||||
existing[0]["id"],
|
||||
)
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import urllib3
|
||||
import json
|
||||
from typing import Union
|
||||
|
||||
base_url = os.environ.get("NETBOX_URL", None)
|
||||
bearer = os.environ.get("NETBOX_BEARER", None)
|
||||
token = os.environ.get("NETBOX_TOKEN", None)
|
||||
os.environ.pop("http_proxy", None)
|
||||
os.environ.pop("https_proxy", None)
|
||||
|
||||
DEBUG = bool(os.environ.get("DEBUG", False))
|
||||
|
||||
if not base_url:
|
||||
print("❌ Missing required environment variable NETBOX_URL")
|
||||
sys.exit(1)
|
||||
if not bearer and not token:
|
||||
print("❌ Missing required environment variables NETBOX_BEARER or NETBOX_TOKEN")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Clean URL
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
ssl_verify = True
|
||||
timeout = 60
|
||||
|
||||
# Following for legacy code. Replace with direct header var access:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {bearer}" if bearer else f"Token {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update(headers)
|
||||
|
||||
|
||||
def _log_debug(msg: str):
|
||||
if DEBUG:
|
||||
print(msg)
|
||||
|
||||
|
||||
def _raise_with_body(e, r, method="", path="", query=None, data=None):
|
||||
"""Re-raise *e* enriched with the NetBox API response body and request
|
||||
context when it is an HTTPError.
|
||||
|
||||
NetBox returns detailed validation errors in the response body (e.g.
|
||||
{"mac_address": ["Enter a valid MAC address."]}), but requests'
|
||||
raise_for_status() produces an HTTPError whose str() carries only the
|
||||
status code and URL. This rebuilds the exception so str() includes
|
||||
the API message and the request context (method, path, filters, data),
|
||||
making failures debuggable without DEBUG mode.
|
||||
Non-HTTPError exceptions (e.g. JSONDecodeError) are re-raised unchanged.
|
||||
"""
|
||||
if not isinstance(e, requests.exceptions.HTTPError):
|
||||
raise e
|
||||
|
||||
try:
|
||||
body = r.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
body = (r.text or "")[:1000]
|
||||
|
||||
if isinstance(body, dict):
|
||||
parts = []
|
||||
for k, v in body.items():
|
||||
if isinstance(v, list):
|
||||
parts.append(f"{k}: {', '.join(str(x) for x in v)}")
|
||||
else:
|
||||
parts.append(f"{k}: {v}")
|
||||
body_str = "; ".join(parts) if parts else str(body)
|
||||
else:
|
||||
body_str = str(body)
|
||||
|
||||
req_context = ""
|
||||
if method or path:
|
||||
req_context = f" | Request: {method} {path}"
|
||||
if query:
|
||||
req_context += f" filters={query}"
|
||||
if data:
|
||||
req_context += f" data={data}"
|
||||
|
||||
raise requests.exceptions.HTTPError(
|
||||
f"{e} | API: {body_str}{req_context}", response=r
|
||||
)
|
||||
|
||||
|
||||
def disable_ssl_verify():
|
||||
global ssl_verify
|
||||
ssl_verify = False
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ========== Get functions =====================================================
|
||||
# ==============================================================================
|
||||
def _get(
|
||||
path: str, filters: Union[dict, list] = None, raw_text: bool = False
|
||||
) -> Union[dict, str]:
|
||||
# Disable object limit to ensure all matching objects are returned.
|
||||
if isinstance(filters, dict):
|
||||
if "limit" not in filters:
|
||||
filters["limit"] = 0
|
||||
elif isinstance(filters, list):
|
||||
index = next((v for k, v in filters if k == "limit"), None)
|
||||
if index is None:
|
||||
filters.append(("limit", 0))
|
||||
else:
|
||||
raise Exception(
|
||||
f"_get() called with unsupported filters type '{type(filters)}'"
|
||||
)
|
||||
|
||||
path = path.strip("/")
|
||||
url = f"{base_url}/api/{path}/"
|
||||
try:
|
||||
r = session.get(url, params=filters or {}, verify=ssl_verify, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.text if raw_text else r.json()
|
||||
except (requests.exceptions.HTTPError, json.JSONDecodeError) as e:
|
||||
_raise_with_body(e, r, method="GET", path=path, query=filters)
|
||||
|
||||
|
||||
def get_resource(
|
||||
path: str, filters: Union[dict, list], multi_exp: bool = False
|
||||
) -> Union[dict, None]:
|
||||
data = _get(path, filters)
|
||||
count = data["count"]
|
||||
# No Object found
|
||||
if count == 0:
|
||||
_log_debug(f"No object found using filter {filters}")
|
||||
return None
|
||||
|
||||
# If multiple objects expected, return them all:
|
||||
elif multi_exp:
|
||||
return data["results"]
|
||||
|
||||
# If single object is expected, return it without list:
|
||||
elif count == 1:
|
||||
return data["results"][0]
|
||||
|
||||
# Single object expected, but multiple found, warn user and return nothing.
|
||||
else:
|
||||
_log_debug(
|
||||
f"Error: Multiple objects found using filter {filters}. Refine your query!"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ========== Update functions ==================================================
|
||||
# ==============================================================================
|
||||
def _patch(path: str, obj_id: int, data: dict) -> bool:
|
||||
path = path.strip("/")
|
||||
url = f"{base_url}/api/{path}/{obj_id}/"
|
||||
try:
|
||||
r = session.patch(url, json=data, verify=ssl_verify, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
if r.status_code in (200, 204):
|
||||
return True
|
||||
else:
|
||||
_log_debug("Update failed:")
|
||||
_log_debug(r.text)
|
||||
return False
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
_raise_with_body(e, r, method="PATCH", path=f"{path}/{obj_id}", data=data)
|
||||
|
||||
|
||||
def _patch_resource(path: str, data: dict) -> Union[dict, None]:
|
||||
obj_id = data.get("id", None)
|
||||
# Dont execute if no ID is set
|
||||
if not obj_id:
|
||||
_log_debug("Error: No ID supplied when patching resource!")
|
||||
return False
|
||||
else:
|
||||
return _patch(
|
||||
path,
|
||||
obj_id,
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
def modify_resource(path: str, data: dict) -> Union[dict, None]:
|
||||
return _patch_resource(path, data)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ========== Create functions ==================================================
|
||||
# ==============================================================================
|
||||
def _post(path: str, data: dict) -> Union[dict, list]:
|
||||
path = path.strip("/")
|
||||
url = f"{base_url}/api/{path}/"
|
||||
try:
|
||||
r = session.post(url, json=data, verify=ssl_verify, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except (requests.exceptions.HTTPError, json.JSONDecodeError) as e:
|
||||
_raise_with_body(e, r, method="POST", path=path, data=data)
|
||||
|
||||
|
||||
def _post_resource(path: str, data: dict) -> Union[dict, None]:
|
||||
if data.get("id"):
|
||||
_log_debug("Error: ID supplied when creating resource!")
|
||||
return False
|
||||
else:
|
||||
return _post(
|
||||
path,
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
def create_resource(path: str, data: dict) -> Union[dict, None]:
|
||||
return _post_resource(path, data)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ========== Delete functions ==================================================
|
||||
# ==============================================================================
|
||||
def _delete(path: str, obj_id: int) -> bool:
|
||||
path = path.strip("/")
|
||||
url = f"{base_url}/api/{path}/{obj_id}/"
|
||||
try:
|
||||
r = session.delete(url, verify=ssl_verify, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except requests.exceptions.HTTPError as e:
|
||||
_log_debug(f"HTTPError: {e}\nResponse body: {r.text}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_resource(path: str, obj_id: int) -> bool:
|
||||
return _delete(path, obj_id)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ========== Export functions ==================================================
|
||||
# ==============================================================================
|
||||
def export(path: str, filters: Union[dict, list], template: str) -> str:
|
||||
if isinstance(filters, dict):
|
||||
filters["export"] = template
|
||||
elif isinstance(filters, list):
|
||||
filters.append(("export", template))
|
||||
else:
|
||||
raise Exception(
|
||||
f"export() called with unsupported filters type '{type(filters)}'"
|
||||
)
|
||||
return _get(path, filters, raw_text=True)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ========== Resource-specific wrappers (table-driven) =========================
|
||||
# ==============================================================================
|
||||
# Single source of truth mapping resource name -> NetBox API path. The loop
|
||||
# below generates get_/modify_/create_/delete_ convenience wrappers for every
|
||||
# entry, delegating to the generic core functions above. Adding a new resource
|
||||
# is a one-line change to RESOURCES.
|
||||
RESOURCES = {
|
||||
"device": "/dcim/devices",
|
||||
"device_interface": "/dcim/interfaces",
|
||||
"device_role": "/dcim/device-roles",
|
||||
"inventory_item": "/dcim/inventory-items",
|
||||
"site": "/dcim/sites",
|
||||
"location": "/dcim/locations",
|
||||
"rack": "/dcim/racks",
|
||||
"cable": "/dcim/cables",
|
||||
"platform": "/dcim/platforms",
|
||||
"tenant": "/tenancy/tenants",
|
||||
"vm": "/virtualization/virtual-machines",
|
||||
"vm_interface": "/virtualization/interfaces",
|
||||
"virtual_disk": "/virtualization/virtual-disks",
|
||||
"ipaddress": "/ipam/ip-addresses",
|
||||
"vlan": "/ipam/vlans",
|
||||
"dns_view": "/plugins/netbox-dns/views/",
|
||||
"dns_zone": "/plugins/netbox-dns/zones/",
|
||||
"dns_record": "/plugins/netbox-dns/records/"
|
||||
}
|
||||
|
||||
|
||||
def _make_getter(path: str):
|
||||
def _wrapper(filters: Union[dict, list], multi_exp: bool = False):
|
||||
return get_resource(path, filters, multi_exp)
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def _make_modifier(path: str):
|
||||
def _wrapper(data: dict):
|
||||
return _patch_resource(path, data)
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def _make_creator(path: str):
|
||||
def _wrapper(data: dict):
|
||||
return _post_resource(path, data)
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def _make_deleter(path: str):
|
||||
def _wrapper(obj_id: int):
|
||||
return _delete(path, obj_id)
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
for _name, _path in RESOURCES.items():
|
||||
for _verb, _factory in (
|
||||
("get", _make_getter),
|
||||
("modify", _make_modifier),
|
||||
("create", _make_creator),
|
||||
("delete", _make_deleter),
|
||||
):
|
||||
_fn = _factory(_path)
|
||||
_fn.__name__ = f"{_verb}_{_name}"
|
||||
_fn.__qualname__ = f"{_verb}_{_name}"
|
||||
globals()[f"{_verb}_{_name}"] = _fn
|
||||
Reference in new issue
Block a user