Улучшена обработка загрузки и обновления сертификатов: добавлена детекция стабильного состояния, улучшена подготовка файлов сертификата для загрузки.
This commit is contained in:
+146
-120
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user