Улучшена обработка загрузки и обновления сертификатов: добавлена детекция стабильного состояния, улучшена подготовка файлов сертификата для загрузки.

This commit is contained in:
Dmitriy Fofanov
2026-02-25 09:31:23 +03:00
parent 6717d23199
commit fa8fbc6323
+146 -120
View File
@@ -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