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>
238 lines
9.7 KiB
Python
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())
|