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>
235 lines
9.6 KiB
Python
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())
|