"""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