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