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
+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")