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:
41
README.md
41
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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("Content-Type", "application/json")
|
req.add_header("Accept", "application/json")
|
||||||
req.add_header("Content-Length", str(len(body)))
|
if data is not None:
|
||||||
req.add_header("Authorization", f"Basic {token}")
|
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:
|
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:
|
||||||
try:
|
self._raise_http_error(e, method, path)
|
||||||
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:
|
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"):
|
@staticmethod
|
||||||
err = data["error"]
|
def _raise_http_error(e, method, path):
|
||||||
msg = err.get("message") if isinstance(err, dict) else str(err)
|
try:
|
||||||
raise errors.PluginError(f"yeil dns-server error: {msg}")
|
body = e.read().decode("utf-8")
|
||||||
return data.get("result") if isinstance(data, dict) else None
|
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 ────────────────────────────────────────────────
|
# ── 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
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user