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>
138 lines
4.7 KiB
Python
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
|