Files
wildcard-lets-encrypt-cert-…/app/main.py
T
duffyduckandClaude Opus 5 611593b5c0 Zone gezielt bei Plesk erfragen statt alle Domains aufzulisten
Ein Name wie endian.fon-aria.de ist oft nur ein Record in der Zone
fon-aria.de und existiert weder als Domain noch als Abo. Ausserdem darf
nicht jeder API-User alle Domains des Servers auflisten - dann brach das
Tool mit "No domains found on the Plesk server" ab, obwohl die Zone da war.

Die Zone wird jetzt Label fuer Label von unten nach oben gezielt abgefragt
(endian.fon-aria.de -> fon-aria.de), jeweils per site.get und webspace.get
mit Namensfilter. Das Auflisten aller Domains dient nur noch der
Fehlermeldung, die jetzt auch zeigt, welche Namen probiert wurden und was
Plesk dazu gesagt hat.

- neue Option --zone, um die Zone bei Bedarf fest vorzugeben
- get_rec meldet jetzt als Warnung, wenn Plesk keine Records liefert
- run.sh baut das Image immer (Cache), damit kein alter Stand haengen bleibt
- Tests bilden den Fall nach: Zone nur als Abo auffindbar, Auflisten verboten

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:14:51 +02:00

235 lines
9.6 KiB
Python

"""CLI entry point: update DNS in Plesk, then issue a wildcard Let's Encrypt certificate."""
from __future__ import annotations
import argparse
import ipaddress
import logging
import sys
from . import __version__
from .acme_client import AcmeFailure, AcmeManager, certificate_domains, csr_for, parse_chain
from .certfiles import certificate_info, format_info, renewal_needed, write_certificate_files
from .config import Config, ConfigError, is_staging, load_config
from .dnsutil import authoritative_servers, wait_for_txt
from .plesk import PleskClient, PleskError, normalise_name
log = logging.getLogger("wildcard-cert")
class PleskDnsSolver:
"""Puts the ACME dns-01 TXT records into Plesk and removes them afterwards."""
def __init__(self, plesk: PleskClient, cfg: Config, keep_records: bool = False,
ignore_propagation_timeout: bool = False):
self.plesk = plesk
self.cfg = cfg
self.keep_records = keep_records
self.ignore_propagation_timeout = ignore_propagation_timeout
self.created: list[tuple[str, str, str]] = [] # (record_id, name, value)
self.zones: set[str] = set()
self._purged: set[str] = set()
def add_txt(self, name: str, value: str) -> None:
name = normalise_name(name)
# Leftovers from an aborted earlier run would only confuse the validation.
# Only once per name: a wildcard order puts two values on the same record
# name, and the second one must not wipe the first.
if name not in self._purged:
self._purged.add(name)
stale = self.plesk.delete_txt_records(name)
if stale:
log.info("Removed %d stale TXT record(s) for %s", stale, name)
record_id, zone = self.plesk.add_txt_record(name, value)
self.created.append((record_id, name, value))
self.zones.add(zone)
def wait_for_propagation(self, expected: dict[str, set[str]]) -> None:
for name, values in expected.items():
for value in values:
if not self.plesk.zone_has_txt(name, value):
raise PleskError(
f"Plesk does not report the TXT record {name} after adding it. "
"Is the DNS zone managed by this Plesk server?"
)
log.info("Plesk confirms all %d challenge record(s).", sum(len(v) for v in expected.values()))
servers: list[str] = []
for zone in self.zones:
servers.extend(authoritative_servers(zone, self.cfg.dns_resolvers))
servers = list(dict.fromkeys(servers)) or list(self.cfg.dns_resolvers)
ok = wait_for_txt(
expected,
servers,
self.cfg.dns_propagation_timeout,
self.cfg.dns_propagation_interval,
)
if not ok and not self.ignore_propagation_timeout:
raise AcmeFailure(
"The _acme-challenge TXT records did not show up on the authoritative "
"nameservers within DNS_PROPAGATION_TIMEOUT. Aborting before Let's Encrypt "
"counts a failed validation. Use --ignore-propagation-timeout to try anyway."
)
def cleanup(self) -> None:
if self.keep_records:
log.warning("--keep-txt: leaving %d challenge record(s) in place.", len(self.created))
return
for record_id, name, _value in self.created:
try:
self.plesk.delete_record(record_id)
except PleskError as exc:
log.warning("Could not remove challenge record %s (%s): %s", record_id, name, exc)
self.created.clear()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="wildcard-cert",
description="Create/update a DNS record in Plesk and issue a wildcard "
"Let's Encrypt certificate (dns-01) for it.",
)
parser.add_argument("fqdn", help="DNS name, e.g. vpn.example.com")
parser.add_argument("ip", nargs="?", help="IPv4/IPv6 address for the A/AAAA record")
parser.add_argument("--ip", dest="ip_opt", help="alternative to the positional IP argument")
parser.add_argument("--san", action="append", default=[],
help="additional SAN (repeatable)")
parser.add_argument("--zone",
help="name the Plesk DNS zone explicitly instead of detecting it "
"(e.g. --zone example.com for host.example.com)")
parser.add_argument("--no-wildcard", action="store_true",
help="only the plain name, without *.<fqdn>")
parser.add_argument("--skip-dns", action="store_true",
help="do not touch the A/AAAA record (challenge records are still needed)")
parser.add_argument("--dns-only", action="store_true",
help="only create/update the A/AAAA record, no certificate")
parser.add_argument("--staging", action="store_true",
help="use the Let's Encrypt staging environment")
parser.add_argument("--force", action="store_true",
help="issue even if the existing certificate is still valid")
parser.add_argument("--keep-txt", action="store_true",
help="keep the _acme-challenge records (debugging)")
parser.add_argument("--ignore-propagation-timeout", action="store_true",
help="continue even if the TXT records are not visible in time")
parser.add_argument("--env", help="path to an .env file")
parser.add_argument("-v", "--verbose", action="store_true", help="debug output")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
return parser
def setup_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S",
)
for noisy in ("urllib3", "requests", "acme.client", "josepy"):
logging.getLogger(noisy).setLevel(logging.DEBUG if verbose else logging.WARNING)
def run(args: argparse.Namespace) -> int:
cfg = load_config(args.env, staging=True if args.staging else None)
cfg.validate()
fqdn = normalise_name(args.fqdn)
if fqdn.startswith("*."):
fqdn = fqdn[2:]
ip = args.ip_opt or args.ip
if ip:
try:
ipaddress.ip_address(ip)
except ValueError:
raise ConfigError(f"{ip!r} is not a valid IP address.") from None
domains = certificate_domains(fqdn, not args.no_wildcard, args.san)
log.info("=" * 62)
log.info("Domain : %s", fqdn)
log.info("Certificate : %s", ", ".join(domains))
log.info("IP address : %s", ip or "(unchanged)")
log.info("ACME : %s%s", cfg.acme_directory_url, " [STAGING]" if is_staging(cfg) else "")
log.info("Output : %s", cfg.cert_output_dir / fqdn)
log.info("=" * 62)
plesk = PleskClient(cfg)
plesk.zone_hint = args.zone
zone, domain_id = plesk.find_zone(fqdn)
log.info("Plesk DNS zone: %s (domain id %s)", zone, domain_id)
# -- 1. A/AAAA record --------------------------------------------------
if ip and not args.skip_dns:
action = plesk.ensure_address_record(fqdn, ip)
log.info("DNS record %s: %s", fqdn, action)
elif not ip:
log.info("No IP given - skipping the address record.")
if args.dns_only:
log.info("--dns-only: done.")
return 0
# -- 2. renewal check --------------------------------------------------
needed, reason = renewal_needed(cfg, fqdn, domains)
if not needed and not args.force:
log.info("Certificate %s (%s). Nothing to do - use --force to renew anyway.", reason, fqdn)
return 0
log.info("Issuing certificate: %s", reason if needed else "forced")
# -- 3. key + CSR ------------------------------------------------------
private_key, csr_pem = csr_for(cfg, domains)
# -- 4. ACME order with dns-01 ----------------------------------------
manager = AcmeManager(cfg)
solver = PleskDnsSolver(
plesk, cfg,
keep_records=args.keep_txt,
ignore_propagation_timeout=args.ignore_propagation_timeout,
)
try:
fullchain_pem = manager.obtain_certificate(domains, csr_pem, solver)
finally:
# obtain_certificate() cleans up itself; this catches anything that blew up
# before it got that far. cleanup() is idempotent.
solver.cleanup()
chain = parse_chain(fullchain_pem)
# -- 5. write every artefact ------------------------------------------
paths = write_certificate_files(cfg, fqdn, private_key, csr_pem, chain, domains)
width = max(len(label) for label in paths)
print()
print("Files written")
print("=" * 62)
for label, path in paths.items():
print(f" {label:<{width}} : {path}")
print()
print(format_info(certificate_info(chain, private_key, domains)))
print()
if is_staging(cfg):
print("NOTE: staging certificate - not trusted by browsers.")
print("PFX password: the value of CERT_PASSWORD from your .env")
return 0
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
setup_logging(args.verbose)
try:
return run(args)
except ConfigError as exc:
log.error("Configuration error: %s", exc)
return 2
except PleskError as exc:
log.error("Plesk error: %s", exc)
return 1
except AcmeFailure as exc:
log.error("%s", exc)
return 1
except KeyboardInterrupt: # pragma: no cover
log.error("Aborted.")
return 130
if __name__ == "__main__":
sys.exit(main())