Wildcard-Zertifikate ueber Plesk-DNS und Let's Encrypt

Legt den A/AAAA-Record fuer einen Namen in Plesk an bzw. aktualisiert ihn und
holt anschliessend per DNS-01-Challenge ein Wildcard-Zertifikat von Let's
Encrypt. Alle Bestandteile werden einzeln abgelegt, zusaetzlich als
kombinierte bundle.pem und als passwortgeschuetzte cert.pfx.

- Plesk-Anbindung ueber die XML-API (Zone finden, Records lesen/anlegen/loeschen)
- ACME-Order mit dns-01, Account-Key wird wiederverwendet
- Propagations-Check gegen die autoritativen Nameserver der Zone, bricht vor
  der Validierung ab statt einen Fehlversuch bei Let's Encrypt zu verbrennen
- Renewal-Check: laeuft idempotent, taugt so direkt fuer den Cron
- Docker-Container, Konfiguration ueber .env, Ausgabe im Projektverzeichnis
- Tests ohne echten Plesk-Server bzw. ohne Zertifikatsausstellung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-13 09:41:26 +02:00
co-authored by Claude Opus 5
commit fd76c18eb0
17 changed files with 2080 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
"""Check the acme-library API usage against the real Let's Encrypt staging directory.
Fetches the directory and builds the client; account registration is stubbed out so
nothing is created on the CA side.
"""
import os
import pathlib
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
os.environ.update(
PLESK_HOST="p", PLESK_API_KEY="k", ACME_EMAIL="a@b.de", CERT_PASSWORD="x",
ACME_ACCOUNT_DIR=tempfile.mkdtemp(), ACME_STAGING="true",
)
import acme, josepy
from app.config import load_config
from app import acme_client
from acme import challenges, messages
print("acme:", getattr(acme, "__version__", "?"), "josepy:", getattr(josepy, "__version__", "?"))
registered = []
acme_client.AcmeManager._register = lambda self, c: registered.append(c)
cfg = load_config(env_file="/dev/null")
m = acme_client.AcmeManager(cfg)
print("directory newOrder:", m.client.directory["newOrder"])
assert registered, "register hook not called"
assert m.account_key_file.is_file() and oct(m.account_key_file.stat().st_mode)[-3:] == "600"
# reload must reuse the very same account key
m2 = acme_client.AcmeManager(cfg)
assert m2.account_key.thumbprint() == m.account_key.thumbprint()
print("account key reused ok")
# dns-01 challenge helpers used in obtain_certificate()
chall = challenges.DNS01(token=b"0123456789abcdef0123456789abcdef")
validation = chall.validation(m.account_key)
name = chall.validation_domain_name("example.com")
assert name == "_acme-challenge.example.com", name
assert isinstance(validation, str) and len(validation) == 43, validation
resp = chall.response(m.account_key)
assert isinstance(resp, challenges.DNS01Response)
print("dns-01 helpers ok:", name, validation)
# the API surface obtain_certificate() relies on
for attr in ("new_order", "answer_challenge", "poll_and_finalize"):
assert hasattr(m.client, attr), attr
assert hasattr(messages, "STATUS_VALID")
assert hasattr(messages.NewRegistration, "from_data")
print("client API surface ok")
print("\nACME API CHECK PASSED")
+162
View File
@@ -0,0 +1,162 @@
"""End-to-end wiring test of app.main with a stateful fake Plesk and a fake CA."""
import datetime
import os
import pathlib
import sys
import tempfile
import xml.etree.ElementTree as ET
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
OUT = tempfile.mkdtemp()
os.environ.update(
PLESK_HOST="plesk.example.com", PLESK_API_KEY="dummy", ACME_EMAIL="a@b.de",
CERT_PASSWORD="secret123", CERT_OUTPUT_DIR=OUT, ACME_ACCOUNT_DIR=tempfile.mkdtemp(),
RSA_KEY_SIZE="2048",
)
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from app import main as appmain
from app import plesk as pleskmod
# ---------------------------------------------------------------- fake Plesk
class FakeZone:
def __init__(self):
self.next_id = 100
self.records = [] # dicts: id, type, host, value
def add(self, rtype, host, value, zone="example.com"):
self.next_id += 1
fqdn = zone if host in ("", None) else f"{host}.{zone}"
self.records.append({"id": str(self.next_id), "type": rtype, "host": fqdn + ".", "value": value})
return str(self.next_id)
ZONE = FakeZone()
ZONE.add("NS", "", "ns1.example.com.")
ZONE.add("A", "vpn", "203.0.113.1")
def fake_request(self, body):
tag = body.tag
if tag == "site":
return ET.fromstring(
'<packet><site><get><result><status>ok</status><id>3</id>'
'<data><gen_info><name>example.com</name></gen_info></data></result></get></site></packet>'
)
if tag == "webspace":
return ET.fromstring('<packet><webspace><get></get></webspace></packet>')
if body.find("get_rec") is not None:
parts = "".join(
f'<result><status>ok</status><id>{r["id"]}</id><data><site-id>3</site-id>'
f'<type>{r["type"]}</type><host>{r["host"]}</host><value>{r["value"]}</value></data></result>'
for r in ZONE.records
)
return ET.fromstring(f"<packet><dns><get_rec>{parts}</get_rec></dns></packet>")
if (add := body.find("add_rec")) is not None:
rid = ZONE.add(add.findtext("type"), add.findtext("host") or "", add.findtext("value"))
return ET.fromstring(
f"<packet><dns><add_rec><result><status>ok</status><id>{rid}</id></result></add_rec></dns></packet>"
)
if (dele := body.find("del_rec")) is not None:
rid = dele.findtext("./filter/id")
before = len(ZONE.records)
ZONE.records[:] = [r for r in ZONE.records if r["id"] != rid]
assert len(ZONE.records) == before - 1, f"record {rid} not found"
return ET.fromstring(
"<packet><dns><del_rec><result><status>ok</status></result></del_rec></dns></packet>"
)
raise AssertionError("unexpected " + ET.tostring(body, encoding="unicode"))
pleskmod.PleskClient._request = fake_request
# ------------------------------------------------------------------ fake CA
issued = {}
class FakeAcme:
def __init__(self, cfg):
self.cfg = cfg
def obtain_certificate(self, domains, csr_pem, solver):
csr = x509.load_pem_x509_csr(csr_pem)
issued["domains"] = domains
# One challenge per identifier - wildcard and base name share the record name
# but carry different values, so both must be present at the same time.
expected = {}
for d in domains:
base = d[2:] if d.startswith("*.") else d
name = f"_acme-challenge.{base}"
value = "val-" + d.replace("*.", "wild-").replace(".", "")
expected.setdefault(name, set()).add(value)
solver.add_txt(name, value)
solver.wait_for_propagation(expected)
issued["expected"] = expected
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
now = datetime.datetime.now(datetime.timezone.utc)
ca = (x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Fake CA")]))
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Fake CA")]))
.public_key(ca_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now).not_valid_after(now + datetime.timedelta(days=3000))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256()))
leaf = (x509.CertificateBuilder().subject_name(csr.subject).issuer_name(ca.subject)
.public_key(csr.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now).not_valid_after(now + datetime.timedelta(days=90))
.add_extension(
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value, False)
.sign(ca_key, hashes.SHA256()))
return "".join(c.public_bytes(serialization.Encoding.PEM).decode() for c in (leaf, ca))
appmain.AcmeManager = FakeAcme
appmain.authoritative_servers = lambda zone, resolvers: ["192.0.2.53"]
appmain.wait_for_txt = lambda expected, servers, timeout, interval: True
# -------------------------------------------------------------------- tests
print("--- run 1: new record + new certificate")
rc = appmain.main(["vpn.example.com", "203.0.113.9", "--env", "/dev/null"])
assert rc == 0, rc
assert issued["domains"] == ["vpn.example.com", "*.vpn.example.com"]
a_records = [r for r in ZONE.records if r["type"] == "A"]
assert len(a_records) == 1 and a_records[0]["value"] == "203.0.113.9", a_records
assert not [r for r in ZONE.records if r["type"] == "TXT"], "challenge records not cleaned up"
assert (Path(OUT) / "vpn.example.com" / "cert.pfx").is_file()
print("\n--- run 2: nothing to do (cert still valid)")
issued.clear()
rc = appmain.main(["vpn.example.com", "203.0.113.9", "--env", "/dev/null"])
assert rc == 0 and not issued, "should not have issued again"
print("\n--- run 3: --force")
rc = appmain.main(["vpn.example.com", "203.0.113.9", "--force", "--env", "/dev/null"])
assert rc == 0 and issued
print("\n--- run 4: --no-wildcard --san + wildcard input normalised")
issued.clear()
rc = appmain.main(["*.vpn.example.com", "--no-wildcard", "--san", "alt.example.com",
"--env", "/dev/null"])
assert rc == 0 and issued["domains"] == ["vpn.example.com", "alt.example.com"], issued
print("\n--- run 5: --dns-only")
issued.clear()
rc = appmain.main(["www.example.com", "203.0.113.55", "--dns-only", "--env", "/dev/null"])
assert rc == 0 and not issued
assert any(r["host"] == "www.example.com." and r["value"] == "203.0.113.55" for r in ZONE.records)
print("\n--- error paths")
assert appmain.main(["vpn.example.com", "not-an-ip", "--env", "/dev/null"]) == 2
assert appmain.main(["vpn.otherdomain.tld", "203.0.113.1", "--env", "/dev/null"]) == 1
os.environ["PLESK_API_KEY"] = ""
os.environ["PLESK_HOST"] = ""
assert appmain.main(["vpn.example.com", "--env", "/dev/null"]) == 2
print("\nALL CLI TESTS PASSED")
+204
View File
@@ -0,0 +1,204 @@
"""Smoke test: no network, no Plesk. Exercises parsing, DNS logic and file output."""
import datetime
import os
import pathlib
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
os.environ.update(
PLESK_HOST="plesk.example.com", PLESK_API_KEY="dummy", ACME_EMAIL="a@b.de",
CERT_PASSWORD="secret123", KEY_TYPE="ec", CERT_OUTPUT_DIR=tempfile.mkdtemp(),
ACME_ACCOUNT_DIR=tempfile.mkdtemp(),
)
from app.config import load_config
from app.plesk import PleskClient, relative_host, normalise_name, PleskError
from app.acme_client import certificate_domains, csr_for, parse_chain, build_csr
from app.certfiles import write_certificate_files, certificate_info, format_info, renewal_needed
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import NameOID
cfg = load_config(env_file="/dev/null")
cfg.validate()
print("config ok:", cfg.plesk_url)
# ---- host helpers ---------------------------------------------------------
assert relative_host("vpn.example.com", "example.com") == "vpn"
assert relative_host("example.com", "example.com") == ""
assert relative_host("_acme-challenge.a.b.example.com", "example.com") == "_acme-challenge.a.b"
assert normalise_name("VPN.Example.COM.") == "vpn.example.com"
try:
relative_host("other.de", "example.com")
raise SystemExit("FAIL: expected PleskError")
except PleskError:
pass
print("host helpers ok")
# ---- Plesk XML parsing with a stubbed transport ---------------------------
SITES = """<?xml version="1.0"?><packet><site><get>
<result><status>ok</status><id>3</id><data><gen_info><name>example.com</name>
<ascii-name>example.com</ascii-name></gen_info></data></result>
<result><status>ok</status><id>7</id><data><gen_info><name>sub.example.com</name></gen_info></data></result>
</get></site></packet>"""
WEBSPACES = """<?xml version="1.0"?><packet><webspace><get>
<result><status>ok</status><id>3</id><data><gen_info><name>example.com</name></gen_info></data></result>
</get></webspace></packet>"""
RECORDS = """<?xml version="1.0"?><packet><dns><get_rec>
<result><status>ok</status><id>101</id><data><site-id>3</site-id><type>A</type>
<host>vpn.example.com.</host><value>10.0.0.1</value></data></result>
<result><status>ok</status><id>102</id><data><site-id>3</site-id><type>NS</type>
<host>example.com.</host><value>ns1.example.com.</value></data></result>
<result><status>ok</status><id>103</id><data><site-id>3</site-id><type>TXT</type>
<host>_acme-challenge.vpn.example.com.</host><value>"oldvalue"</value></data></result>
</get_rec></dns></packet>"""
ADD_OK = """<?xml version="1.0"?><packet><dns><add_rec><result><status>ok</status>
<id>555</id></result></add_rec></dns></packet>"""
DEL_OK = """<?xml version="1.0"?><packet><dns><del_rec><result><status>ok</status>
<id>101</id></result></del_rec></dns></packet>"""
calls = []
class StubPlesk(PleskClient):
def _request(self, body):
payload = ET.tostring(body, encoding="unicode")
calls.append(payload)
if body.tag == "site":
return ET.fromstring(SITES)
if body.tag == "webspace":
return ET.fromstring(WEBSPACES)
if body.find("get_rec") is not None:
return ET.fromstring(RECORDS)
if body.find("add_rec") is not None:
return ET.fromstring(ADD_OK)
if body.find("del_rec") is not None:
return ET.fromstring(DEL_OK)
raise AssertionError("unexpected request " + payload)
p = StubPlesk(cfg)
assert p.list_zones() == {"example.com": "3", "sub.example.com": "7"}, p.list_zones()
assert p.find_zone("vpn.example.com") == ("example.com", "3")
assert p.find_zone("x.sub.example.com") == ("sub.example.com", "7")
recs = p.get_records("3")
assert [r.type for r in recs] == ["A", "NS", "TXT"]
assert recs[2].value == "oldvalue", recs[2].value # quotes stripped
assert p.nameservers("example.com") == ["ns1.example.com"]
calls.clear()
assert p.ensure_address_record("vpn.example.com", "10.0.0.1") == "unchanged"
assert not any("add_rec" in c for c in calls)
calls.clear()
assert p.ensure_address_record("vpn.example.com", "10.0.0.2") == "updated"
assert any("del_rec" in c for c in calls) and any("add_rec" in c for c in calls)
add = [c for c in calls if "add_rec" in c][0]
assert "<host>vpn</host>" in add and "<value>10.0.0.2</value>" in add and "<type>A</type>" in add, add
calls.clear()
assert p.ensure_address_record("new.example.com", "2001:db8::1") == "created"
add = [c for c in calls if "add_rec" in c][0]
assert "<type>AAAA</type>" in add, add
calls.clear()
assert p.ensure_address_record("example.com", "10.0.0.9") == "created"
add = [c for c in calls if "add_rec" in c][0]
assert "<host>example.com.</host>" in add, add # apex -> absolute name
assert p.zone_has_txt("_acme-challenge.vpn.example.com", "oldvalue") is True
assert p.zone_has_txt("_acme-challenge.vpn.example.com", "nope") is False
assert p.delete_txt_records("_acme-challenge.vpn.example.com") == 1
print("plesk client ok")
# ---- domains / CSR --------------------------------------------------------
domains = certificate_domains("vpn.example.com", True, ["extra.example.com", "vpn.example.com"])
assert domains == ["vpn.example.com", "*.vpn.example.com", "extra.example.com"], domains
key, csr_pem = csr_for(cfg, domains)
csr = x509.load_pem_x509_csr(csr_pem)
sans = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.DNSName)
assert sans == domains, sans
assert csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == "vpn.example.com"
print("csr ok:", sans)
# ---- fake chain -> file output -------------------------------------------
def selfsigned(cn, subject_key, issuer_name=None, issuer_key=None, sans=None, days=90, ca=False):
now = datetime.datetime.now(datetime.timezone.utc)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
issuer = issuer_name or subject
b = (x509.CertificateBuilder().subject_name(subject).issuer_name(issuer)
.public_key(subject_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days)))
if sans:
b = b.add_extension(x509.SubjectAlternativeName([x509.DNSName(d) for d in sans]), critical=False)
if ca:
b = b.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
return b.sign(issuer_key or subject_key, hashes.SHA256())
from cryptography.hazmat.primitives.asymmetric import rsa
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_cert = selfsigned("Test CA R1", ca_key, ca=True, days=3000)
leaf = selfsigned("vpn.example.com", key, ca_cert.subject, ca_key, sans=domains)
chain = [leaf, ca_cert]
# round-trip through the PEM parser used for the ACME answer
pem = b"".join(c.public_bytes(serialization.Encoding.PEM) for c in chain).decode()
assert len(parse_chain(pem)) == 2
paths = write_certificate_files(cfg, "vpn.example.com", key, csr_pem, chain, domains)
out = Path(os.environ["CERT_OUTPUT_DIR"]) / "vpn.example.com"
expected_files = {
"privkey.pem", "privkey-traditional.pem", "privkey-encrypted.pem", "pubkey.pem",
"csr.pem", "cert.pem", "cert.crt", "cert.der", "chain.pem", "chain-01.pem",
"fullchain.pem", "bundle.pem", "cert.pfx", "cert-info.json", "cert-info.txt",
}
present = {f.name for f in out.iterdir()}
assert expected_files <= present, expected_files - present
assert (out / "privkey.pem").stat().st_mode & 0o777 == 0o600
assert (out / "cert.pfx").stat().st_mode & 0o777 == 0o600
print("files ok:", sorted(present))
# PFX must open with the configured password
from cryptography.hazmat.primitives.serialization import pkcs12
k2, c2, cas = pkcs12.load_key_and_certificates((out / "cert.pfx").read_bytes(), b"secret123")
assert c2.subject == leaf.subject and len(cas) == 1
try:
pkcs12.load_key_and_certificates((out / "cert.pfx").read_bytes(), b"wrong")
raise SystemExit("FAIL: wrong pfx password accepted")
except ValueError:
pass
# encrypted private key too
serialization.load_pem_private_key((out / "privkey-encrypted.pem").read_bytes(), b"secret123")
# bundle = key + leaf + chain
bundle = (out / "bundle.pem").read_text()
assert bundle.count("BEGIN CERTIFICATE") == 2 and "PRIVATE KEY" in bundle
assert (out / "fullchain.pem").read_text().count("BEGIN CERTIFICATE") == 2
assert (out / "chain.pem").read_text().count("BEGIN CERTIFICATE") == 1
print("pfx/bundle ok")
info = certificate_info(chain, key, domains)
assert info["leaf"]["subject_alternative_names"] == domains
assert info["key"]["algorithm"] == "EC"
print(format_info(info))
needed, reason = renewal_needed(cfg, "vpn.example.com", domains)
assert needed is False, reason
needed, reason = renewal_needed(cfg, "vpn.example.com", domains + ["other.example.com"])
assert needed is True and "missing names" in reason, reason
needed, reason = renewal_needed(cfg, "unknown.example.com", domains)
assert needed is True and "no certificate" in reason
print("renewal check ok")
# ---- legacy pfx path ------------------------------------------------------
cfg.pfx_legacy_compat = True
write_certificate_files(cfg, "legacy.example.com", key, csr_pem, chain, domains)
legacy = Path(os.environ["CERT_OUTPUT_DIR"]) / "legacy.example.com" / "cert.pfx"
pkcs12.load_key_and_certificates(legacy.read_bytes(), b"secret123")
print("legacy pfx ok")
print("\nALL SMOKE TESTS PASSED")