diff --git a/README.md b/README.md index 02e41c6..d64f8e5 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ 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. +Authenticates against `dns.yeil.app`'s public API with an email and an +app password, then adds/removes TXT records to satisfy ACME DNS-01 +challenges. Works for any yeil user with an owned DNS zone — the +certbot host just needs HTTPS reachability to `dns.yeil.app`. -Wildcard certs require DNS-01, so this plugin (or another DNS authenticator) -is needed for `*.example.com`. +Wildcard certs require DNS-01, so this plugin (or another DNS +authenticator) is needed for `*.example.com`. ## Installation @@ -18,17 +18,21 @@ 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. +Create an app password at `https://account.yeil.app/security` and +drop it into 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 +dns_yeil_email = you@yourdomain.com +dns_yeil_app_password = abcd-efgh-ijkl-mnop ``` `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. +Optional override if you're testing against a non-production host: + +```ini +dns_yeil_base_url = https://dns.staging.example +``` ## Usage @@ -51,8 +55,13 @@ certbot certonly \ ## 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. +The plugin logs in once per run (`POST /api/v1/auth/login`) and caches +the returned Bearer token. For each requested name it asks the API +which zone the account owns that covers the FQDN +(`GET /api/v1/zones?suffix_of=`), creates a TXT at +`_acme-challenge.` (`POST /api/v1/zones/{id}/records`), waits for +propagation, and on cleanup deletes the record by id +(`DELETE /api/v1/zones/{id}/records/{recordId}`). + +The token is a real yeil session — revoking the app password (or +hitting `/logout`) invalidates it cleanly. diff --git a/certbot_dns_yeil/dns_yeil.py b/certbot_dns_yeil/dns_yeil.py index 706c011..a1cfd80 100644 --- a/certbot_dns_yeil/dns_yeil.py +++ b/certbot_dns_yeil/dns_yeil.py @@ -1,12 +1,14 @@ -"""DNS-01 authenticator plugin for Certbot using the yeil dns-server RPC. +"""DNS-01 authenticator plugin for Certbot using the yeil public API. -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. +Authenticates against dns.yeil.app/api/v1/auth/login with an +email + app password, caches the Bearer token for the run, then +adds/removes TXT records via the public records API. Any yeil user +with an app password and an owned DNS zone can use it. + +The certbot host only needs HTTPS reachability to dns.yeil.app; no +NetBird or shared admin key. """ -import base64 import json import logging from urllib.error import HTTPError, URLError @@ -18,15 +20,24 @@ from zope.interface import implementer logger = logging.getLogger(__name__) +DEFAULT_BASE_URL = "https://dns.yeil.app" +HTTP_TIMEOUT = 30 + @implementer(interfaces.IAuthenticator) @implementer(interfaces.IPluginFactory) class Authenticator(dns_common.DNSAuthenticator): - description = "Obtain certificates via DNS-01 using the yeil dns-server RPC." + description = ( + "Obtain certificates via DNS-01 using the yeil public DNS API." + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.credentials = None + # Bearer token cached for the lifetime of this plugin instance. + # The login route mints a 30-day session; we only need it for + # the duration of one certbot run. + self._token = None # (domain, validation_name, validation) -> (zone_id, record_id) self._records = {} @@ -40,80 +51,102 @@ class Authenticator(dns_common.DNSAuthenticator): def more_info(self): return ( "Configures Certbot to perform DNS-01 challenges by adding TXT " - "records via the yeil dns-server RPC." + "records via the yeil public DNS API at dns.yeil.app." ) def _setup_credentials(self): self.credentials = self._configure_credentials( "credentials", - "yeil dns-server credentials INI file", + "yeil API 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)", + "email": "yeil account email (e.g. you@yourdomain.com)", + "app_password": "yeil app password (create one in account.yeil.app/security)", }, ) - # ── RPC ──────────────────────────────────────────────────────────── + # ── HTTP ─────────────────────────────────────────────────────────── - def _rpc(self, method, params=None): - """POST {method, params} to the dns-server RPC and return result. + def _base_url(self): + url = self.credentials.conf("base_url") or DEFAULT_BASE_URL + return url.rstrip("/") - Raises errors.PluginError on transport or RPC error. + def _request(self, method, path, body=None, auth=True): + """Send a JSON request and return the parsed JSON response. + + Raises PluginError on transport or non-2xx HTTP responses. + Returns None for 204 No Content. """ - 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") + url = f"{self._base_url()}{path}" + data = json.dumps(body).encode("utf-8") if body is not None else None - 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}") + req = Request(url, data=data, method=method) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json") + req.add_header("Content-Length", str(len(data))) + if auth: + if not self._token: + self._login() + req.add_header("Authorization", f"Bearer {self._token}") try: - with urlopen(req, timeout=30) as resp: - data = json.loads(resp.read().decode("utf-8")) + with urlopen(req, timeout=HTTP_TIMEOUT) as resp: + if resp.status == 204: + return None + payload = resp.read().decode("utf-8") + return json.loads(payload) if payload else None 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}") + self._raise_http_error(e, method, path) except URLError as e: - raise errors.PluginError(f"yeil dns-server unreachable: {e}") + raise errors.PluginError( + f"yeil dns API unreachable ({method} {path}): {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 + @staticmethod + def _raise_http_error(e, method, path): + try: + body = e.read().decode("utf-8") + parsed = json.loads(body) + msg = parsed.get("message") or parsed.get("error") or e.reason + except Exception: + msg = e.reason + raise errors.PluginError( + f"yeil dns API error ({method} {path}, {e.code}): {msg}" + ) + + def _login(self): + email = self.credentials.conf("email") + password = self.credentials.conf("app_password") + result = self._request( + "POST", + "/api/v1/auth/login", + body={"email": email, "password": password}, + auth=False, + ) + if not isinstance(result, dict) or "token" not in result: + raise errors.PluginError( + "yeil dns API login returned no token" + ) + self._token = result["token"] # ── Zone resolution ──────────────────────────────────────────────── - def _find_zone_id(self, fqdn): - """Walk up the labels of fqdn, calling findzone, until one matches. + def _find_zone(self, fqdn): + """Resolve the longest-suffix zone owned by the caller. - Returns (zone_id, zone_name). Raises PluginError if nothing found. + Returns (zone_id, zone_name). Raises PluginError if none. """ - 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}" + from urllib.parse import quote + + result = self._request( + "GET", + f"/api/v1/zones?suffix_of={quote(fqdn, safe='')}", ) + if not isinstance(result, dict) or "id" not in result: + raise errors.PluginError( + f"yeil dns API: no owned zone covers {fqdn}" + ) + return result["id"], result["zoneName"] @staticmethod def _relative_name(validation_name, zone_name): @@ -128,7 +161,6 @@ class Authenticator(dns_common.DNSAuthenticator): 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}" ) @@ -136,12 +168,12 @@ class Authenticator(dns_common.DNSAuthenticator): # ── certbot hooks ────────────────────────────────────────────────── def _perform(self, domain, validation_name, validation): - zone_id, zone_name = self._find_zone_id(validation_name) + zone_id, zone_name = self._find_zone(validation_name) rel_name = self._relative_name(validation_name, zone_name) - result = self._rpc( - "addrecord", - { - "zone": zone_id, + result = self._request( + "POST", + f"/api/v1/zones/{zone_id}/records", + body={ "name": rel_name, "type": "TXT", "content": validation, @@ -153,9 +185,12 @@ class Authenticator(dns_common.DNSAuthenticator): ) if not record_id: raise errors.PluginError( - "dns-server addrecord did not return a record id" + "yeil dns API: addrecord did not return a record id" ) - self._records[(domain, validation_name, validation)] = (zone_id, 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) @@ -166,9 +201,9 @@ class Authenticator(dns_common.DNSAuthenticator): return zone_id, record_id = entry try: - self._rpc( - "deleterecord", - {"zone": zone_id, "record": record_id}, + self._request( + "DELETE", + f"/api/v1/zones/{zone_id}/records/{record_id}", ) except errors.PluginError as e: # Don't fail the renewal because of a stale TXT we couldn't diff --git a/setup.py b/setup.py index 1a49972..cbe3e37 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="certbot-dns-yeil", - version="1.0.0", + version="2.0.0", description="yeil DNS Authenticator plugin for Certbot", url="https://git.eskimo.dev/Yeil/certbot-dns-yeil", author="yeil",