From 2ccd6d9f14fb0e33bc25f402ee3fae86881110ce Mon Sep 17 00:00:00 2001 From: eskimo Date: Sun, 3 May 2026 15:58:24 -0400 Subject: [PATCH] Initial fork of certbot-dns-servfail; talks to the yeil dns-server RPC DNS-01 authenticator that walks up the labels of the validation name, calls findzone on the dns-server RPC to locate the registered parent zone, then addrecord/deleterecord around the TXT challenge. Auth is HTTP Basic with the shared rpc key (matches the protocol the yeil DNS web app uses in dns/src/lib/rpc.ts). --- README.md | 58 ++++++++++++ certbot_dns_yeil/__init__.py | 0 certbot_dns_yeil/dns_yeil.py | 179 +++++++++++++++++++++++++++++++++++ setup.py | 20 ++++ 4 files changed, 257 insertions(+) create mode 100644 README.md create mode 100644 certbot_dns_yeil/__init__.py create mode 100644 certbot_dns_yeil/dns_yeil.py create mode 100644 setup.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..02e41c6 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# certbot-dns-yeil + +yeil DNS Authenticator plugin for [Certbot](https://certbot.eff.org/). + +Talks to the yeil dns-server's RPC over HTTP Basic auth — the same RPC the +yeil DNS web app uses. Use this on hosts that can reach the dns-server +directly (NetBird-attached, typically). For internet-only clients, expose +an HTTP API in front of the RPC and write a separate plugin against it. + +Wildcard certs require DNS-01, so this plugin (or another DNS authenticator) +is needed for `*.example.com`. + +## Installation + +```sh +pip install git+https://git.eskimo.dev/Yeil/certbot-dns-yeil.git +``` + +## Configuration + +The plugin reads `yeil_rpc_url` and `yeil_rpc_key` from a credentials INI. + +```ini +dns_yeil_rpc_url = http://100.123.x.x:6969 +dns_yeil_rpc_key = the-rpc-key-from-dns-server-config +``` + +`chmod 600` it. + +`yeil_rpc_url` is the URL of any one of the dns-server NSes — they share +the underlying Postgres so writes propagate either way. + +## Usage + +```sh +certbot certonly \ + --authenticator dns-yeil \ + --dns-yeil-credentials /etc/letsencrypt/yeil.ini \ + -d smtp.yeil.org \ + --preferred-challenges dns +``` + +For wildcards: + +```sh +certbot certonly \ + --authenticator dns-yeil \ + --dns-yeil-credentials /etc/letsencrypt/yeil.ini \ + -d yeil.org -d '*.yeil.org' +``` + +## How it works + +For each requested name, the plugin walks up the labels and calls the +dns-server's `findzone` RPC until it finds the registered zone. It then +creates a TXT record at `_acme-challenge.` via `addrecord`, waits +for propagation, and on cleanup calls `deleterecord` with the saved +record id. diff --git a/certbot_dns_yeil/__init__.py b/certbot_dns_yeil/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/certbot_dns_yeil/dns_yeil.py b/certbot_dns_yeil/dns_yeil.py new file mode 100644 index 0000000..706c011 --- /dev/null +++ b/certbot_dns_yeil/dns_yeil.py @@ -0,0 +1,179 @@ +"""DNS-01 authenticator plugin for Certbot using the yeil dns-server RPC. + +Talks to the dns-server (ShakeStation fork) over HTTP Basic auth. The RPC +shape is `{ method, params }` POST, returning `{ result } | { result: null, +error: { message } }`. Same protocol the yeil DNS web app uses; see +yeil/dns/src/lib/rpc.ts. +""" + +import base64 +import json +import logging +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from certbot import errors, interfaces +from certbot.plugins import dns_common +from zope.interface import implementer + +logger = logging.getLogger(__name__) + + +@implementer(interfaces.IAuthenticator) +@implementer(interfaces.IPluginFactory) +class Authenticator(dns_common.DNSAuthenticator): + description = "Obtain certificates via DNS-01 using the yeil dns-server RPC." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.credentials = None + # (domain, validation_name, validation) -> (zone_id, record_id) + self._records = {} + + @classmethod + def add_parser_arguments(cls, add): + super(Authenticator, cls).add_parser_arguments( + add, default_propagation_seconds=20 + ) + add("credentials", help="Path to your yeil credentials INI file.") + + def more_info(self): + return ( + "Configures Certbot to perform DNS-01 challenges by adding TXT " + "records via the yeil dns-server RPC." + ) + + def _setup_credentials(self): + self.credentials = self._configure_credentials( + "credentials", + "yeil dns-server credentials INI file", + { + "rpc_url": "Base URL of the dns-server RPC (e.g. http://100.123.x.x:6969)", + "rpc_key": "Shared RPC key (config.rpc.key on the dns-server)", + }, + ) + + # ── RPC ──────────────────────────────────────────────────────────── + + def _rpc(self, method, params=None): + """POST {method, params} to the dns-server RPC and return result. + + Raises errors.PluginError on transport or RPC error. + """ + url = self.credentials.conf("rpc_url") + key = self.credentials.conf("rpc_key") + token = base64.b64encode(f"x:{key}".encode("utf-8")).decode("ascii") + body = json.dumps({"method": method, "params": params or {}}).encode("utf-8") + + req = Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Content-Length", str(len(body))) + req.add_header("Authorization", f"Basic {token}") + + try: + with urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + try: + err_body = json.loads(e.read().decode("utf-8")) + msg = err_body.get("error", {}).get("message") or e.reason + except Exception: + msg = e.reason + raise errors.PluginError(f"yeil dns-server error ({e.code}): {msg}") + except URLError as e: + raise errors.PluginError(f"yeil dns-server unreachable: {e}") + + if isinstance(data, dict) and data.get("error"): + err = data["error"] + msg = err.get("message") if isinstance(err, dict) else str(err) + raise errors.PluginError(f"yeil dns-server error: {msg}") + return data.get("result") if isinstance(data, dict) else None + + # ── Zone resolution ──────────────────────────────────────────────── + + def _find_zone_id(self, fqdn): + """Walk up the labels of fqdn, calling findzone, until one matches. + + Returns (zone_id, zone_name). Raises PluginError if nothing found. + """ + labels = fqdn.rstrip(".").split(".") + # Need at least a domain.tld pair to be a candidate zone. + while len(labels) >= 2: + candidate = ".".join(labels) + try: + result = self._rpc("findzone", {"domain": candidate}) + except errors.PluginError as e: + # A "zone not found" response surfaces as a PluginError; that's + # the signal to walk up. Anything else (auth, network) we'd + # rather re-raise after exhausting candidates, so swallow here. + logger.debug("findzone(%s) miss: %s", candidate, e) + result = None + if isinstance(result, dict) and result.get("id"): + return result["id"], candidate + labels = labels[1:] + raise errors.PluginError( + f"No registered zone found in dns-server for any suffix of {fqdn}" + ) + + @staticmethod + def _relative_name(validation_name, zone_name): + """`_acme-challenge.smtp.yeil.org` in zone `yeil.org` -> `_acme-challenge.smtp`. + + If validation_name equals the zone, returns "@" (the apex). + """ + v = validation_name.rstrip(".") + z = zone_name.rstrip(".") + if v == z: + return "@" + suffix = "." + z + if v.endswith(suffix): + return v[: -len(suffix)] + # Shouldn't happen if _find_zone_id picked the zone from this fqdn. + raise errors.PluginError( + f"validation_name {v} is not within zone {z}" + ) + + # ── certbot hooks ────────────────────────────────────────────────── + + def _perform(self, domain, validation_name, validation): + zone_id, zone_name = self._find_zone_id(validation_name) + rel_name = self._relative_name(validation_name, zone_name) + result = self._rpc( + "addrecord", + { + "zone": zone_id, + "name": rel_name, + "type": "TXT", + "content": validation, + "ttl": 60, + }, + ) + record_id = ( + result.get("id") if isinstance(result, dict) else None + ) + if not record_id: + raise errors.PluginError( + "dns-server addrecord did not return a record id" + ) + self._records[(domain, validation_name, validation)] = (zone_id, record_id) + + def _cleanup(self, domain, validation_name, validation): + entry = self._records.pop((domain, validation_name, validation), None) + if not entry: + logger.warning( + "No stored record for %s; skipping cleanup", validation_name + ) + return + zone_id, record_id = entry + try: + self._rpc( + "deleterecord", + {"zone": zone_id, "record": record_id}, + ) + except errors.PluginError as e: + # Don't fail the renewal because of a stale TXT we couldn't + # delete; log and move on. Operator can prune by hand. + logger.warning( + "Failed to clean up TXT record %s (zone=%s record=%s): %s", + validation_name, zone_id, record_id, e, + ) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1a49972 --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup, find_packages + +setup( + name="certbot-dns-yeil", + version="1.0.0", + description="yeil DNS Authenticator plugin for Certbot", + url="https://git.eskimo.dev/Yeil/certbot-dns-yeil", + author="yeil", + license="MIT", + packages=find_packages(), + install_requires=[ + "certbot>=1.1.0", + "zope.interface", + ], + entry_points={ + "certbot.plugins": [ + "dns-yeil = certbot_dns_yeil.dns_yeil:Authenticator", + ], + }, +)