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:
+230
@@ -0,0 +1,230 @@
|
||||
"""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("--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)
|
||||
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())
|
||||
Reference in New Issue
Block a user