Files
wildcard-lets-encrypt-cert-…/app/main.py
T
duffyduckandClaude Opus 5 efdaefc39e Nameserver ohne Route ueberspringen statt abzustuerzen
Die Nameserver von fon-aria.de haben AAAA-Records, der Container hat aber
kein IPv6. dns.query.udp() ist deshalb mit OSError [Errno 99] "Cannot
assign requested address" durchgeschlagen und hat den ganzen Lauf
abgebrochen - nach dem Anlegen der Challenge-Records.

Adressen, zu denen es keine Route gibt, werden jetzt vorher aussortiert
(UDP-connect, verschickt nichts) und waehrend des Wartens zusaetzlich
verworfen, falls doch eine ausfaellt. Bleibt keine uebrig, gibt es eine
klare Meldung statt eines Tracebacks. Bei fon-aria.de bleiben so die
beiden IPv4-Adressen derselben Nameserver uebrig.

- OSError wird in main() sauber abgefangen
- tests/test_dns.py deckt die Faelle ab; im Container gegen die echte
  Zone gegengeprueft

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

238 lines
9.7 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 OSError as exc:
log.error("Network error: %s", exc)
return 1
except KeyboardInterrupt: # pragma: no cover
log.error("Aborted.")
return 130
if __name__ == "__main__":
sys.exit(main())