v2: authenticate via app password + use dns.yeil.app public API

Replaces direct dns-server RPC calls (admin shared key, NetBird-only
reachability) with calls to the public /api/v1 surface. The plugin
now logs in with an email + app password, caches the returned Bearer
for the run, then findZone/addRecord/deleteRecord through HTTPS.
Any yeil user with an owned DNS zone can use it from anywhere with
internet access — no more shared key, no NetBird requirement.

INI shape:
  dns_yeil_email = you@yourdomain.com
  dns_yeil_app_password = abcd-efgh-ijkl-mnop
  # dns_yeil_base_url = https://dns.yeil.app  (optional override)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
eskimo
2026-05-11 14:46:22 -04:00
parent 2ccd6d9f14
commit 456f034efb
3 changed files with 129 additions and 85 deletions

View File

@@ -2,13 +2,13 @@
yeil DNS Authenticator plugin for [Certbot](https://certbot.eff.org/). 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 Authenticates against `dns.yeil.app`'s public API with an email and an
yeil DNS web app uses. Use this on hosts that can reach the dns-server app password, then adds/removes TXT records to satisfy ACME DNS-01
directly (NetBird-attached, typically). For internet-only clients, expose challenges. Works for any yeil user with an owned DNS zone — the
an HTTP API in front of the RPC and write a separate plugin against it. certbot host just needs HTTPS reachability to `dns.yeil.app`.
Wildcard certs require DNS-01, so this plugin (or another DNS authenticator) Wildcard certs require DNS-01, so this plugin (or another DNS
is needed for `*.example.com`. authenticator) is needed for `*.example.com`.
## Installation ## Installation
@@ -18,17 +18,21 @@ pip install git+https://git.eskimo.dev/Yeil/certbot-dns-yeil.git
## Configuration ## 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 ```ini
dns_yeil_rpc_url = http://100.123.x.x:6969 dns_yeil_email = you@yourdomain.com
dns_yeil_rpc_key = the-rpc-key-from-dns-server-config dns_yeil_app_password = abcd-efgh-ijkl-mnop
``` ```
`chmod 600` it. `chmod 600` it.
`yeil_rpc_url` is the URL of any one of the dns-server NSes — they share Optional override if you're testing against a non-production host:
the underlying Postgres so writes propagate either way.
```ini
dns_yeil_base_url = https://dns.staging.example
```
## Usage ## Usage
@@ -51,8 +55,13 @@ certbot certonly \
## How it works ## How it works
For each requested name, the plugin walks up the labels and calls the The plugin logs in once per run (`POST /api/v1/auth/login`) and caches
dns-server's `findzone` RPC until it finds the registered zone. It then the returned Bearer token. For each requested name it asks the API
creates a TXT record at `_acme-challenge.<rel>` via `addrecord`, waits which zone the account owns that covers the FQDN
for propagation, and on cleanup calls `deleterecord` with the saved (`GET /api/v1/zones?suffix_of=<fqdn>`), creates a TXT at
record id. `_acme-challenge.<rel>` (`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.

View File

@@ -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 Authenticates against dns.yeil.app/api/v1/auth/login with an
shape is `{ method, params }` POST, returning `{ result } | { result: null, email + app password, caches the Bearer token for the run, then
error: { message } }`. Same protocol the yeil DNS web app uses; see adds/removes TXT records via the public records API. Any yeil user
yeil/dns/src/lib/rpc.ts. 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 json
import logging import logging
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
@@ -18,15 +20,24 @@ from zope.interface import implementer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "https://dns.yeil.app"
HTTP_TIMEOUT = 30
@implementer(interfaces.IAuthenticator) @implementer(interfaces.IAuthenticator)
@implementer(interfaces.IPluginFactory) @implementer(interfaces.IPluginFactory)
class Authenticator(dns_common.DNSAuthenticator): 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.credentials = None 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) # (domain, validation_name, validation) -> (zone_id, record_id)
self._records = {} self._records = {}
@@ -40,80 +51,102 @@ class Authenticator(dns_common.DNSAuthenticator):
def more_info(self): def more_info(self):
return ( return (
"Configures Certbot to perform DNS-01 challenges by adding TXT " "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): def _setup_credentials(self):
self.credentials = self._configure_credentials( self.credentials = self._configure_credentials(
"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)", "email": "yeil account email (e.g. you@yourdomain.com)",
"rpc_key": "Shared RPC key (config.rpc.key on the dns-server)", "app_password": "yeil app password (create one in account.yeil.app/security)",
}, },
) )
# ── RPC ──────────────────────────────────────────────────────────── # ── HTTP ───────────────────────────────────────────────────────────
def _rpc(self, method, params=None): def _base_url(self):
"""POST {method, params} to the dns-server RPC and return result. 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") url = f"{self._base_url()}{path}"
key = self.credentials.conf("rpc_key") data = json.dumps(body).encode("utf-8") if body is not None else None
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 = 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-Type", "application/json")
req.add_header("Content-Length", str(len(body))) req.add_header("Content-Length", str(len(data)))
req.add_header("Authorization", f"Basic {token}") if auth:
if not self._token:
self._login()
req.add_header("Authorization", f"Bearer {self._token}")
try: try:
with urlopen(req, timeout=30) as resp: with urlopen(req, timeout=HTTP_TIMEOUT) as resp:
data = json.loads(resp.read().decode("utf-8")) if resp.status == 204:
return None
payload = resp.read().decode("utf-8")
return json.loads(payload) if payload else None
except HTTPError as e: except HTTPError as e:
self._raise_http_error(e, method, path)
except URLError as e:
raise errors.PluginError(
f"yeil dns API unreachable ({method} {path}): {e}"
)
@staticmethod
def _raise_http_error(e, method, path):
try: try:
err_body = json.loads(e.read().decode("utf-8")) body = e.read().decode("utf-8")
msg = err_body.get("error", {}).get("message") or e.reason parsed = json.loads(body)
msg = parsed.get("message") or parsed.get("error") or e.reason
except Exception: except Exception:
msg = e.reason msg = e.reason
raise errors.PluginError(f"yeil dns-server error ({e.code}): {msg}") raise errors.PluginError(
except URLError as e: f"yeil dns API error ({method} {path}, {e.code}): {msg}"
raise errors.PluginError(f"yeil dns-server unreachable: {e}") )
if isinstance(data, dict) and data.get("error"): def _login(self):
err = data["error"] email = self.credentials.conf("email")
msg = err.get("message") if isinstance(err, dict) else str(err) password = self.credentials.conf("app_password")
raise errors.PluginError(f"yeil dns-server error: {msg}") result = self._request(
return data.get("result") if isinstance(data, dict) else None "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 ──────────────────────────────────────────────── # ── Zone resolution ────────────────────────────────────────────────
def _find_zone_id(self, fqdn): def _find_zone(self, fqdn):
"""Walk up the labels of fqdn, calling findzone, until one matches. """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(".") from urllib.parse import quote
# Need at least a domain.tld pair to be a candidate zone.
while len(labels) >= 2: result = self._request(
candidate = ".".join(labels) "GET",
try: f"/api/v1/zones?suffix_of={quote(fqdn, safe='')}",
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}"
) )
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 @staticmethod
def _relative_name(validation_name, zone_name): def _relative_name(validation_name, zone_name):
@@ -128,7 +161,6 @@ class Authenticator(dns_common.DNSAuthenticator):
suffix = "." + z suffix = "." + z
if v.endswith(suffix): if v.endswith(suffix):
return v[: -len(suffix)] return v[: -len(suffix)]
# Shouldn't happen if _find_zone_id picked the zone from this fqdn.
raise errors.PluginError( raise errors.PluginError(
f"validation_name {v} is not within zone {z}" f"validation_name {v} is not within zone {z}"
) )
@@ -136,12 +168,12 @@ class Authenticator(dns_common.DNSAuthenticator):
# ── certbot hooks ────────────────────────────────────────────────── # ── certbot hooks ──────────────────────────────────────────────────
def _perform(self, domain, validation_name, validation): 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) rel_name = self._relative_name(validation_name, zone_name)
result = self._rpc( result = self._request(
"addrecord", "POST",
{ f"/api/v1/zones/{zone_id}/records",
"zone": zone_id, body={
"name": rel_name, "name": rel_name,
"type": "TXT", "type": "TXT",
"content": validation, "content": validation,
@@ -153,9 +185,12 @@ class Authenticator(dns_common.DNSAuthenticator):
) )
if not record_id: if not record_id:
raise errors.PluginError( 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): def _cleanup(self, domain, validation_name, validation):
entry = self._records.pop((domain, validation_name, validation), None) entry = self._records.pop((domain, validation_name, validation), None)
@@ -166,9 +201,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return return
zone_id, record_id = entry zone_id, record_id = entry
try: try:
self._rpc( self._request(
"deleterecord", "DELETE",
{"zone": zone_id, "record": record_id}, f"/api/v1/zones/{zone_id}/records/{record_id}",
) )
except errors.PluginError as e: except errors.PluginError as e:
# Don't fail the renewal because of a stale TXT we couldn't # Don't fail the renewal because of a stale TXT we couldn't

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="certbot-dns-yeil", name="certbot-dns-yeil",
version="1.0.0", version="2.0.0",
description="yeil DNS Authenticator plugin for Certbot", description="yeil DNS Authenticator plugin for Certbot",
url="https://git.eskimo.dev/Yeil/certbot-dns-yeil", url="https://git.eskimo.dev/Yeil/certbot-dns-yeil",
author="yeil", author="yeil",