Files
wildcard-lets-encrypt-cert-…/tests/test_cli.py
T
duffyduckandClaude Opus 5 fd76c18eb0 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>
2026-08-13 09:41:26 +02:00

163 lines
7.0 KiB
Python

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