"""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 *.") 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())