Files
duffyduckandClaude Opus 5 fd76c18eb0 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>
2026-08-13 09:41:26 +02:00

138 lines
4.7 KiB
Python

"""Configuration loading from environment / .env file."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
PRODUCTION_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
def _bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None or raw.strip() == "":
return default
return raw.strip().lower() in ("1", "true", "yes", "on", "y")
def _int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or raw.strip() == "":
return default
try:
return int(raw.strip())
except ValueError as exc:
raise ConfigError(f"{name} must be an integer, got {raw!r}") from exc
def _str(name: str, default: str = "") -> str:
raw = os.getenv(name)
return default if raw is None else raw.strip()
class ConfigError(Exception):
"""Raised when the configuration is incomplete or contradictory."""
@dataclass
class Config:
# Plesk
plesk_host: str
plesk_port: int
plesk_api_key: str
plesk_user: str
plesk_password: str
plesk_verify_tls: bool
plesk_timeout: int
# ACME
acme_email: str
acme_directory_url: str
acme_account_dir: Path
# Certificate
cert_password: str
cert_output_dir: Path
key_type: str
rsa_key_size: int
ec_curve: str
pfx_legacy_compat: bool
renew_days_before_expiry: int
# DNS
dns_ttl: int
dns_propagation_timeout: int
dns_propagation_interval: int
dns_resolvers: list[str] = field(default_factory=list)
@property
def plesk_url(self) -> str:
return f"https://{self.plesk_host}:{self.plesk_port}/enterprise/control/agent.php"
def validate(self) -> None:
if not self.plesk_host:
raise ConfigError("PLESK_HOST is not set (see .env.example).")
if not self.plesk_api_key and not (self.plesk_user and self.plesk_password):
raise ConfigError(
"Plesk credentials missing: set PLESK_API_KEY or PLESK_USER + PLESK_PASSWORD."
)
if not self.acme_email:
raise ConfigError("ACME_EMAIL is not set - Let's Encrypt requires a contact address.")
if not self.cert_password:
raise ConfigError("CERT_PASSWORD is not set - it is required for the .pfx file.")
if self.key_type not in ("rsa", "ec"):
raise ConfigError(f"KEY_TYPE must be 'rsa' or 'ec', got {self.key_type!r}.")
def load_config(env_file: str | os.PathLike[str] | None = None, staging: bool | None = None) -> Config:
"""Load the configuration from the environment, optionally seeded by a .env file."""
if env_file is not None:
load_dotenv(env_file, override=False)
else:
# Search upwards from the project root; harmless if no .env exists.
default_env = Path(__file__).resolve().parent.parent / ".env"
if default_env.is_file():
load_dotenv(default_env, override=False)
else:
load_dotenv(override=False)
use_staging = _bool("ACME_STAGING", False) if staging is None else staging
directory_url = _str("ACME_DIRECTORY_URL", PRODUCTION_DIRECTORY_URL) or PRODUCTION_DIRECTORY_URL
if use_staging:
directory_url = STAGING_DIRECTORY_URL
resolvers = [r.strip() for r in _str("DNS_RESOLVERS", "1.1.1.1,8.8.8.8").split(",") if r.strip()]
cfg = Config(
plesk_host=_str("PLESK_HOST"),
plesk_port=_int("PLESK_PORT", 8443),
plesk_api_key=_str("PLESK_API_KEY"),
plesk_user=_str("PLESK_USER"),
plesk_password=os.getenv("PLESK_PASSWORD", ""),
plesk_verify_tls=_bool("PLESK_VERIFY_TLS", False),
plesk_timeout=_int("PLESK_TIMEOUT", 60),
acme_email=_str("ACME_EMAIL"),
acme_directory_url=directory_url,
acme_account_dir=Path(_str("ACME_ACCOUNT_DIR", "./data") or "./data"),
cert_password=os.getenv("CERT_PASSWORD", ""),
cert_output_dir=Path(_str("CERT_OUTPUT_DIR", "./certs") or "./certs"),
key_type=_str("KEY_TYPE", "rsa").lower() or "rsa",
rsa_key_size=_int("RSA_KEY_SIZE", 4096),
ec_curve=_str("EC_CURVE", "secp256r1").lower() or "secp256r1",
pfx_legacy_compat=_bool("PFX_LEGACY_COMPAT", False),
renew_days_before_expiry=_int("RENEW_DAYS_BEFORE_EXPIRY", 30),
dns_ttl=_int("DNS_TTL", 300),
dns_propagation_timeout=_int("DNS_PROPAGATION_TIMEOUT", 600),
dns_propagation_interval=_int("DNS_PROPAGATION_INTERVAL", 15),
dns_resolvers=resolvers,
)
return cfg
def is_staging(cfg: Config) -> bool:
return cfg.acme_directory_url == STAGING_DIRECTORY_URL