diff --git a/letsencrypt_regru_api.py b/letsencrypt_regru_api.py index 86074ae..37d7f24 100644 --- a/letsencrypt_regru_api.py +++ b/letsencrypt_regru_api.py @@ -528,9 +528,13 @@ class NginxProxyManagerAPI: Ожидает, пока NPM распарсит загруженный сертификат и заполнит поля domain_names и expires_on. Возвращает актуальные данные сертификата или None по таймауту. + Если состояние стабилизировалось (3 одинаковых ответа подряд), выходит раньше + чтобы не ждать полный таймаут для custom-сертификатов, которые NPM не парсит. """ start = time.time() last: Optional[Dict] = None + stable_count = 0 + prev_state: Optional[Tuple[tuple, Optional[str]]] = None while time.time() - start < timeout_seconds: cert = self.get_certificate_by_id(cert_id) if cert: @@ -543,6 +547,17 @@ class NginxProxyManagerAPI: if domains and expires_on and (not created_on or expires_on != created_on): self.logger.info("NPM завершил парсинг сертификата") return cert + # Детекция стабильного состояния — если ответ не меняется 3 раза подряд, + # NPM скорее всего не будет парсить этот сертификат дальше + current_state = (tuple(sorted(domains)), expires_on) + if current_state == prev_state: + stable_count += 1 + if stable_count >= 3: + self.logger.debug(f"Состояние NPM стабилизировалось после {stable_count} проверок, выходим раньше") + return cert + else: + stable_count = 1 + prev_state = current_state time.sleep(interval_seconds) return last @@ -589,8 +604,123 @@ class NginxProxyManagerAPI: self.logger.debug(f"Сертификат для {domain} не найден") return None - def upload_certificate(self, domain: str, cert_path: str, key_path: str, - chain_path: Optional[str] = None) -> Optional[Dict]: + def _prepare_cert_files(self, cert_path: str, key_path: str) -> Tuple[ + Dict[str, Tuple[str, str, str]], str + ]: + """ + Подготовка файлов сертификата для multipart upload в NPM. + + Читает cert_path и key_path, определяет режим (fullchain / cert+chain), + валидирует PEM-формат, возвращает готовый files dict и прочитанный ключ. + + Args: + cert_path: Путь к файлу сертификата (fullchain.pem или cert.pem) + key_path: Путь к приватному ключу + + Returns: + Кортеж (files_dict, certificate_key_content) + + Raises: + FileNotFoundError: Если файл не найден + ValueError: Если PEM невалиден + """ + # Читаем приватный ключ + with open(key_path, 'r') as f: + certificate_key = f.read() + + # Определяем, какие файлы использовать. + # Приоритет: переданный cert_path (уважаем намерение вызывающего кода). + # Если передан fullchain.pem — используем его как certificate; + # если передан cert.pem — используем cert.pem + chain.pem (intermediate). + cert_dir = os.path.dirname(cert_path) + chain_only_path = os.path.join(cert_dir, "chain.pem") + is_fullchain = os.path.basename(cert_path) == 'fullchain.pem' + + if is_fullchain: + # Используем fullchain.pem как certificate — NPM web UI делает именно так + self.logger.info("Загружаем fullchain.pem как certificate (рекомендуемый режим)") + with open(cert_path, 'rb') as f: + certificate = f.read().decode('utf-8') + intermediate_certificate = "" + # Также передаём chain.pem отдельно, если есть + if os.path.exists(chain_only_path): + with open(chain_only_path, 'rb') as f: + intermediate_certificate = f.read().decode('utf-8') + self.logger.info(f"Загружено: fullchain.pem ({len(certificate)} байт), chain.pem ({len(intermediate_certificate)} байт)") + else: + self.logger.info(f"Загружено: fullchain.pem ({len(certificate)} байт)") + else: + # cert_path указывает на cert.pem — используем cert.pem + chain.pem + if os.path.exists(cert_path) and os.path.exists(chain_only_path): + self.logger.info("Загружаем cert.pem и chain.pem отдельно") + with open(cert_path, 'rb') as f: + certificate = f.read().decode('utf-8') + with open(chain_only_path, 'rb') as f: + intermediate_certificate = f.read().decode('utf-8') + self.logger.info(f"Загружено: cert.pem ({len(certificate)} байт), chain.pem ({len(intermediate_certificate)} байт)") + else: + # Fallback: загружаем cert_path как есть + self.logger.info(f"Загружаем {os.path.basename(cert_path)} как certificate") + with open(cert_path, 'rb') as f: + certificate = f.read().decode('utf-8') + intermediate_certificate = "" + self.logger.info(f"Загружено: {os.path.basename(cert_path)} ({len(certificate)} байт)") + + # Валидация PEM-формата + if not certificate.strip().startswith('-----BEGIN CERTIFICATE-----'): + raise ValueError("Основной сертификат не начинается с BEGIN CERTIFICATE") + + if intermediate_certificate and not intermediate_certificate.strip().startswith('-----BEGIN CERTIFICATE-----'): + raise ValueError("Промежуточный сертификат не начинается с BEGIN CERTIFICATE") + + # Диагностика + self.logger.debug(f"Основной сертификат начинается с: {certificate[:60]}...") + if intermediate_certificate: + self.logger.debug(f"Промежуточный сертификат начинается с: {intermediate_certificate[:60]}...") + + # Формируем multipart/form-data files словарь + cert_filename = os.path.basename(cert_path) + files: Dict[str, Tuple[str, str, str]] = { + 'certificate': (cert_filename, certificate, 'application/x-pem-file'), + 'certificate_key': ('privkey.pem', certificate_key, 'application/x-pem-file'), + } + if intermediate_certificate and intermediate_certificate.strip(): + files['intermediate_certificate'] = ('chain.pem', intermediate_certificate, 'application/x-pem-file') + + return files, certificate_key + + def _wait_and_log_parse(self, cert_id: int, operation: str = "загрузки") -> Optional[Dict]: + """ + Ожидание парсинга сертификата NPM с логированием результата. + + Args: + cert_id: ID сертификата в NPM + operation: Описание операции для лога ("загрузки" / "обновления") + + Returns: + Распарсенные данные или None + """ + try: + parsed = self.wait_for_certificate_parse(cert_id, timeout_seconds=30, interval_seconds=2) + if parsed: + domains = parsed.get('domain_names', []) + expires = parsed.get('expires_on') + created = parsed.get('created_on') + if not domains or (expires and created and expires == created): + self.logger.warning( + f"NPM не распарсил метаданные сертификата после {operation} " + f"(domain_names={domains}, expires=created_on). " + f"Сертификат загружен корректно, но в UI NPM дата истечения может отображаться неверно." + ) + self.logger.info( + f"Итоговые данные NPM после {operation}: домены={domains}, истекает={expires}" + ) + return parsed + except Exception as e: + self.logger.warning(f"Не удалось дождаться парсинга сертификата в NPM: {e}") + return None + + def upload_certificate(self, domain: str, cert_path: str, key_path: str) -> Optional[Dict]: """ Загрузка нового сертификата в NPM @@ -600,9 +730,8 @@ class NginxProxyManagerAPI: Args: domain: Основной домен - cert_path: Путь к файлу сертификата + cert_path: Путь к файлу сертификата (fullchain.pem или cert.pem) key_path: Путь к приватному ключу - chain_path: Путь к цепочке сертификатов (опционально) Returns: Данные созданного сертификата или None @@ -610,59 +739,7 @@ class NginxProxyManagerAPI: url = f"{self.host}/api/nginx/certificates" try: - # Читаем приватный ключ - with open(key_path, 'r') as f: - certificate_key = f.read() - - # Определяем, какие файлы использовать - cert_dir = os.path.dirname(cert_path) - cert_only_path = os.path.join(cert_dir, "cert.pem") - chain_only_path = os.path.join(cert_dir, "chain.pem") - - # Используем отдельные файлы cert.pem и chain.pem - # NPM лучше работает с разделенными файлами - if os.path.exists(cert_only_path) and os.path.exists(chain_only_path): - self.logger.info("Загружаем cert.pem и chain.pem отдельно") - with open(cert_only_path, 'rb') as f: - certificate = f.read().decode('utf-8') - with open(chain_only_path, 'rb') as f: - intermediate_certificate = f.read().decode('utf-8') - self.logger.info(f"Загружено: cert.pem ({len(certificate)} байт), chain.pem ({len(intermediate_certificate)} байт)") - else: - # Fallback: загружаем fullchain целиком - self.logger.info("cert.pem/chain.pem не найдены, загружаем fullchain.pem") - with open(cert_path, 'rb') as f: - certificate = f.read().decode('utf-8') - intermediate_certificate = "" - self.logger.info(f"Загружено: fullchain.pem ({len(certificate)} байт)") - - # Проверяем корректность сертификатов - if not certificate.strip().startswith('-----BEGIN CERTIFICATE-----'): - self.logger.error("Ошибка: Основной сертификат не начинается с BEGIN CERTIFICATE") - return None - - if intermediate_certificate and not intermediate_certificate.strip().startswith('-----BEGIN CERTIFICATE-----'): - self.logger.error("Ошибка: Промежуточный сертификат не начинается с BEGIN CERTIFICATE") - return None - - # Показываем первые строки для диагностики - self.logger.debug(f"Основной сертификат начинается с: {certificate[:60]}...") - if intermediate_certificate: - self.logger.debug(f"Промежуточный сертификат начинается с: {intermediate_certificate[:60]}...") - - # NPM Web UI использует multipart/form-data для загрузки custom сертификатов - # Загружаем cert.pem и chain.pem отдельно - files = { - 'certificate': ('cert.pem', certificate, 'application/x-pem-file'), - 'certificate_key': ('privkey.pem', certificate_key, 'application/x-pem-file'), - } - - # Добавляем intermediate_certificate если есть - if intermediate_certificate and intermediate_certificate.strip(): - files['intermediate_certificate'] = ('chain.pem', intermediate_certificate, 'application/x-pem-file') - self.logger.info(f"Загружаем cert ({len(certificate)} байт) + chain ({len(intermediate_certificate)} байт) + privkey") - else: - self.logger.info(f"Загружаем cert ({len(certificate)} байт) + privkey (без chain)") + files, _cert_key = self._prepare_cert_files(cert_path, key_path) # Дополнительные поля формы (только разрешенные NPM поля) data = { @@ -671,7 +748,6 @@ class NginxProxyManagerAPI: } self.logger.debug("NPM будет автоматически извлекать домены и дату истечения из сертификата") - self.logger.debug(f"Uploading certificate as multipart/form-data") self.logger.debug(f"Files: {list(files.keys())}") self.logger.debug(f"Data: {data}") @@ -702,24 +778,15 @@ class NginxProxyManagerAPI: self.logger.debug(f"NPM meta: {json.dumps(meta, indent=2, ensure_ascii=False)}") # После загрузки подождём, пока NPM распарсит сертификат (domain_names, expires_on) - try: - parsed = self.wait_for_certificate_parse(cert_id, timeout_seconds=12, interval_seconds=1) - if parsed: - self.logger.info( - f"Итоговые данные NPM: домены={parsed.get('domain_names', [])}, истекает={parsed.get('expires_on')}" - ) - return parsed - except Exception as e: - self.logger.warning(f"Не удалось дождаться парсинга сертификата в NPM: {e}") - - return result + parsed = self._wait_and_log_parse(cert_id, "загрузки") + return parsed if parsed else result else: self.logger.error("Не удалось получить ID созданного сертификата") self.logger.error(f"Ответ NPM: {result}") return None - except FileNotFoundError as e: - self.logger.error(f"Файл сертификата не найден: {e}") + except (FileNotFoundError, ValueError) as e: + self.logger.error(f"Ошибка подготовки файлов сертификата: {e}") return None except requests.exceptions.RequestException as e: self.logger.error(f"Ошибка при загрузке сертификата в NPM: {e}") @@ -727,16 +794,14 @@ class NginxProxyManagerAPI: self.logger.error(f"Ответ сервера: {e.response.text}") return None - def update_certificate(self, cert_id: int, cert_path: str, key_path: str, - chain_path: Optional[str] = None) -> bool: + def update_certificate(self, cert_id: int, cert_path: str, key_path: str) -> bool: """ Обновление существующего сертификата Args: cert_id: ID сертификата в NPM - cert_path: Путь к файлу сертификата + cert_path: Путь к файлу сертификата (fullchain.pem или cert.pem) key_path: Путь к приватному ключу - chain_path: Путь к цепочке сертификатов (опционально) Returns: True если успешно @@ -744,38 +809,7 @@ class NginxProxyManagerAPI: url = f"{self.host}/api/nginx/certificates/{cert_id}" try: - # Читаем приватный ключ - with open(key_path, 'r') as f: - certificate_key = f.read() - - # Определяем, какие файлы использовать - cert_dir = os.path.dirname(cert_path) - cert_only_path = os.path.join(cert_dir, "cert.pem") - chain_only_path = os.path.join(cert_dir, "chain.pem") - - # Используем cert.pem и chain.pem при обновлении, при отсутствии – fullchain - files: Dict[str, Tuple[str, str, str]] - if os.path.exists(cert_only_path) and os.path.exists(chain_only_path): - self.logger.info("Обновление: используем cert.pem и chain.pem") - with open(cert_only_path, 'rb') as f: - certificate = f.read().decode('utf-8') - with open(chain_only_path, 'rb') as f: - intermediate_certificate = f.read().decode('utf-8') - self.logger.debug(f"Загружено: cert.pem ({len(certificate)} байт), chain.pem ({len(intermediate_certificate)} байт)") - files = { - 'certificate': ('cert.pem', certificate, 'application/x-pem-file'), - 'certificate_key': ('privkey.pem', certificate_key, 'application/x-pem-file'), - 'intermediate_certificate': ('chain.pem', intermediate_certificate, 'application/x-pem-file'), - } - else: - self.logger.info("Обновление: cert/chain не найдены, используем fullchain.pem") - with open(cert_path, 'rb') as f: - certificate = f.read().decode('utf-8') - self.logger.debug(f"Загружено: fullchain.pem ({len(certificate)} байт)") - files = { - 'certificate': ('fullchain.pem', certificate, 'application/x-pem-file'), - 'certificate_key': ('privkey.pem', certificate_key, 'application/x-pem-file'), - } + files, _cert_key = self._prepare_cert_files(cert_path, key_path) # Дополнительные поля формы data = { @@ -787,19 +821,11 @@ class NginxProxyManagerAPI: response.raise_for_status() self.logger.info("Сертификат успешно обновлен в NPM") - # Дождаться, пока NPM обновит метаданные - try: - parsed = self.wait_for_certificate_parse(cert_id, timeout_seconds=12, interval_seconds=1) - if parsed: - self.logger.info( - f"Итоговые данные NPM после обновления: домены={parsed.get('domain_names', [])}, истекает={parsed.get('expires_on')}" - ) - except Exception as e: - self.logger.warning(f"Не удалось дождаться обновления метаданных сертификата: {e}") + self._wait_and_log_parse(cert_id, "обновления") return True - except FileNotFoundError as e: - self.logger.error(f"Файл сертификата не найден: {e}") + except (FileNotFoundError, ValueError) as e: + self.logger.error(f"Ошибка подготовки файлов сертификата: {e}") return False except requests.exceptions.RequestException as e: self.logger.error(f"Ошибка при обновлении сертификата в NPM: {e}") @@ -874,11 +900,11 @@ class NginxProxyManagerAPI: # Обновляем существующий сертификат cert_id = existing_cert.get("id") self.logger.info(f"Обновление существующего сертификата (ID: {cert_id})") - return self.update_certificate(cert_id, final_cert_path, key_path, final_chain_path) + return self.update_certificate(cert_id, final_cert_path, key_path) else: # Создаем новый сертификат self.logger.info("Создание нового сертификата в NPM") - result = self.upload_certificate(domain, final_cert_path, key_path, final_chain_path) + result = self.upload_certificate(domain, final_cert_path, key_path) return result is not None