From 3a3a5be790b54eaa270a046802e3ce2ec844e2f8 Mon Sep 17 00:00:00 2001 From: Dmitriy Fofanov Date: Wed, 25 Feb 2026 08:49:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B2=D0=B5=D0=BB=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20DNS=20=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-service.yml | 6 +- README.md | 6 +- config.json.example | 6 +- letsencrypt_regru.ps1 | 6 +- letsencrypt_regru.sh | 6 +- letsencrypt_regru_api.py | 143 ++++++++++++++++++++++----- wiki/Configuration.md | 6 +- wiki/Installation.md | 8 +- 8 files changed, 142 insertions(+), 45 deletions(-) diff --git a/.github/workflows/deploy-service.yml b/.github/workflows/deploy-service.yml index c3d64b9..1e1e495 100644 --- a/.github/workflows/deploy-service.yml +++ b/.github/workflows/deploy-service.yml @@ -154,9 +154,9 @@ jobs: "email": os.environ["CFG_EMAIL"], "cert_dir": os.environ.get("CFG_CERT_DIR") or "/etc/letsencrypt/live", "log_file": os.environ.get("CFG_LOG_FILE") or "/var/log/letsencrypt_regru.log", - "dns_propagation_wait": to_int(os.environ.get("CFG_DNS_PROPAGATION_WAIT"), 60), - "dns_check_attempts": to_int(os.environ.get("CFG_DNS_CHECK_ATTEMPTS"), 10), - "dns_check_interval": to_int(os.environ.get("CFG_DNS_CHECK_INTERVAL"), 10), + "dns_propagation_wait": to_int(os.environ.get("CFG_DNS_PROPAGATION_WAIT"), 180), + "dns_check_attempts": to_int(os.environ.get("CFG_DNS_CHECK_ATTEMPTS"), 20), + "dns_check_interval": to_int(os.environ.get("CFG_DNS_CHECK_INTERVAL"), 15), "renewal_days": to_int(os.environ.get("CFG_RENEWAL_DAYS"), 30), "npm_enabled": to_bool(os.environ.get("CFG_NPM_ENABLED"), True), "npm_host": os.environ.get("CFG_NPM_HOST") or "", diff --git a/README.md b/README.md index beb7c2d..6b76cbb 100644 --- a/README.md +++ b/README.md @@ -711,9 +711,9 @@ sudo nano /etc/letsencrypt/regru_config.json "email": "admin@example.com", "cert_dir": "/etc/letsencrypt/live", "log_file": "/var/log/letsencrypt_regru.log", - "dns_propagation_wait": 60, - "dns_check_attempts": 10, - "dns_check_interval": 10 + "dns_propagation_wait": 180, + "dns_check_attempts": 20, + "dns_check_interval": 15 } ``` diff --git a/config.json.example b/config.json.example index cbc3820..593516b 100644 --- a/config.json.example +++ b/config.json.example @@ -10,9 +10,9 @@ "email": "admin@example.com", "cert_dir": "/etc/letsencrypt/live", "log_file": "/var/log/letsencrypt_regru.log", - "dns_propagation_wait": 60, - "dns_check_attempts": 10, - "dns_check_interval": 10, + "dns_propagation_wait": 180, + "dns_check_attempts": 20, + "dns_check_interval": 15, "renewal_days": 30, "npm_enabled": true, "npm_host": "http://192.0.2.1:81", diff --git a/letsencrypt_regru.ps1 b/letsencrypt_regru.ps1 index cc0f9fb..f3c1f67 100644 --- a/letsencrypt_regru.ps1 +++ b/letsencrypt_regru.ps1 @@ -141,9 +141,9 @@ function Create-SampleConfig { domain = "example.com" wildcard = $true email = "admin@example.com" - dns_propagation_wait = 60 - dns_check_attempts = 10 - dns_check_interval = 10 + dns_propagation_wait = 180 + dns_check_attempts = 20 + dns_check_interval = 15 } $sampleConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath diff --git a/letsencrypt_regru.sh b/letsencrypt_regru.sh index 1891a56..07cdd75 100644 --- a/letsencrypt_regru.sh +++ b/letsencrypt_regru.sh @@ -340,9 +340,9 @@ create_config() { "email": "${email}", "cert_dir": "${CERT_DIR}", "log_file": "${LOG_DIR}/letsencrypt_regru.log", - "dns_propagation_wait": 60, - "dns_check_attempts": 10, - "dns_check_interval": 10, + "dns_propagation_wait": 180, + "dns_check_attempts": 20, + "dns_check_interval": 15, "renewal_days": 30, "npm_enabled": ${npm_enabled}, "npm_host": "${npm_host}", diff --git a/letsencrypt_regru_api.py b/letsencrypt_regru_api.py index 9a9b8ad..86074ae 100644 --- a/letsencrypt_regru_api.py +++ b/letsencrypt_regru_api.py @@ -75,9 +75,9 @@ DEFAULT_CONFIG = { "log_file": "/var/log/letsencrypt_regru.log", # Параметры DNS - "dns_propagation_wait": 60, # Время ожидания распространения DNS (секунды) - "dns_check_attempts": 10, # Количество попыток проверки DNS - "dns_check_interval": 10, # Интервал между проверками DNS (секунды) + "dns_propagation_wait": 180, # Время ожидания распространения DNS (секунды) + "dns_check_attempts": 20, # Количество попыток проверки DNS + "dns_check_interval": 15, # Интервал между проверками DNS (секунды) # Параметры обновления сертификата "renewal_days": 30, # За сколько дней до истечения обновлять (по умолчанию 30) @@ -259,34 +259,42 @@ class RegRuAPI: self.logger.error(f"Ошибка HTTP запроса: {e}") raise - def get_zone_records(self, domain: str) -> List[Dict]: + def get_zone_records(self, domain: str, retries: int = 3) -> List[Dict]: """ - Получение DNS записей домена + Получение DNS записей домена (с повторами при пустом ответе) Args: domain: Доменное имя + retries: Количество повторных попыток при пустом ответе Returns: Список DNS записей """ self.logger.info(f"Получение DNS записей для домена: {domain}") - # Задержка перед запросом (защита от rate limit) - time.sleep(1) + for attempt in range(retries): + # Задержка перед запросом (защита от rate limit) + time.sleep(2 if attempt > 0 else 1) + + params = { + "domain_name": domain, + } + + try: + result = self._make_request("zone/get_resource_records", params) + + if "answer" in result and "records" in result["answer"]: + records = result["answer"]["records"] + self.logger.info(f"Получено {len(records)} DNS записей") + return records + except Exception as e: + self.logger.warning(f"Ошибка при получении DNS записей (попытка {attempt + 1}/{retries}): {e}") + + if attempt < retries - 1: + self.logger.info(f"DNS записи не получены, повтор ({attempt + 2}/{retries})...") - params = { - "domain_name": domain, - } - - result = self._make_request("zone/get_resource_records", params) - - if "answer" in result and "records" in result["answer"]: - records = result["answer"]["records"] - self.logger.info(f"Получено {len(records)} DNS записей") - return records - else: - self.logger.warning("DNS записи не найдены") - return [] + self.logger.warning("DNS записи не найдены после всех попыток") + return [] def add_txt_record(self, domain: str, subdomain: str, txt_value: str) -> bool: """ @@ -1354,7 +1362,7 @@ class LetsEncryptManager: self.logger.info("✅ TXT запись успешно добавлена в API reg.ru") # Ждем распространения DNS - wait_time = self.config.get("dns_propagation_wait", 60) + wait_time = self.config.get("dns_propagation_wait", 120) self.logger.info("") self.logger.info("⏳ Ожидание распространения DNS...") self.logger.info(f" Время ожидания: {wait_time} секунд") @@ -1394,7 +1402,10 @@ class LetsEncryptManager: def dns_cleanup_hook(self, validation_domain: str, validation_token: str) -> bool: """ - Обработчик очистки DNS challenge - удаление TXT записи + Обработчик очистки DNS challenge - удаление ВСЕХ TXT записей для данного subdomain. + + Удаляет ВСЕ _acme-challenge TXT записи (не только с совпадающим токеном), + чтобы не оставлять stale записей от параллельных wildcard/apex challenge'ей. Args: validation_domain: Домен валидации (например, example.com или *.example.com) @@ -1414,7 +1425,36 @@ class LetsEncryptManager: self.logger.info(f"Домен: {registrable_domain}, Поддомен: {subdomain}") - return self.api.remove_txt_record(registrable_domain, subdomain, validation_token) + # Удаляем ВСЕ TXT записи с этим subdomain (а не только совпадающие по значению) + try: + records = self.api.get_zone_records(registrable_domain) + matching = [ + r for r in records + if r.get("rectype") == "TXT" + and r.get("subdomain", "").lower() == subdomain.lower() + ] + if not matching: + self.logger.info("TXT записи для удаления не найдены") + return True + + self.logger.info(f"Найдено {len(matching)} TXT записей для удаления") + for record in matching: + record_id = record.get("id") + record_text = record.get("text", "")[:30] + try: + self.api._make_request("zone/remove_record", { + "domain_name": registrable_domain, + "record_id": record_id, + }) + self.logger.info(f"Удалена TXT запись ID={record_id} ({record_text}...)") + time.sleep(1) + except Exception as e: + self.logger.warning(f"Не удалось удалить запись {record_id}: {e}") + return True + except Exception as e: + self.logger.warning(f"Ошибка при очистке TXT записей: {e}") + # Fallback: пытаемся удалить конкретную запись по значению + return self.api.remove_txt_record(registrable_domain, subdomain, validation_token) def _get_authoritative_nameservers(self, domain: str) -> List[str]: """Возвращает список authoritative NS для домена.""" @@ -1563,6 +1603,59 @@ class LetsEncryptManager: """ return self.verify_dns_record_external(self.domain, subdomain, expected_value) + def _clean_stale_acme_records(self, domains: List[str]) -> None: + """ + Удаляет ВСЕ stale _acme-challenge TXT записи для указанных доменов + ПЕРЕД запуском certbot. Это предотвращает ситуацию, когда старые + токены от предыдущих неудачных попыток мешают валидации. + """ + configured_domains = get_configured_domains(self.config) + + # Собираем уникальные (registrable_domain, subdomain) пары + pairs_seen = set() + pairs = [] + for domain in domains: + registrable_domain, subdomain_prefix = extract_registrable_domain( + domain, configured_domains + ) + subdomain = f"_acme-challenge.{subdomain_prefix}" if subdomain_prefix else "_acme-challenge" + key = (registrable_domain.lower(), subdomain.lower()) + if key not in pairs_seen: + pairs_seen.add(key) + pairs.append((registrable_domain, subdomain)) + + # Для каждого уникального registrable_domain — одна загрузка записей + zone_cache: Dict[str, List[Dict]] = {} + for registrable_domain, subdomain in pairs: + rd_lower = registrable_domain.lower() + if rd_lower not in zone_cache: + try: + zone_cache[rd_lower] = self.api.get_zone_records(registrable_domain) + except Exception as e: + self.logger.warning(f"Не удалось получить DNS записи {registrable_domain}: {e}") + zone_cache[rd_lower] = [] + + records = zone_cache[rd_lower] + stale_ids = [ + r.get("id") for r in records + if r.get("rectype") == "TXT" + and r.get("subdomain", "").lower() == subdomain.lower() + and r.get("id") + ] + + if stale_ids: + self.logger.info(f"Удаление {len(stale_ids)} stale _acme-challenge TXT записей " + f"({subdomain}.{registrable_domain})") + for record_id in stale_ids: + try: + self.api._make_request("zone/remove_record", { + "domain_name": registrable_domain, + "record_id": record_id, + }) + time.sleep(1) + except Exception as e: + self.logger.warning(f"Не удалось удалить запись {record_id}: {e}") + def obtain_certificate(self, staging: bool = False) -> bool: """ Получение нового сертификата @@ -1617,6 +1710,10 @@ class LetsEncryptManager: for d in domains: domain_args.extend(["-d", d]) + # Удаляем stale _acme-challenge TXT записи от предыдущих неудачных попыток + self.logger.info("Очистка stale ACME TXT записей перед запуском certbot...") + self._clean_stale_acme_records(domains) + # Создаём временные wrapper скрипты для hooks import tempfile diff --git a/wiki/Configuration.md b/wiki/Configuration.md index bec1d58..8002b6a 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -25,9 +25,9 @@ "email": "admin@example.com", "cert_dir": "/etc/letsencrypt/live", "log_file": "/var/log/letsencrypt_regru.log", - "dns_propagation_wait": 60, - "dns_check_attempts": 10, - "dns_check_interval": 10, + "dns_propagation_wait": 180, + "dns_check_attempts": 20, + "dns_check_interval": 15, "renewal_days": 30, "npm_enabled": true, "npm_host": "http://192.0.2.1:81", diff --git a/wiki/Installation.md b/wiki/Installation.md index a9c2028..1d2d248 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -286,9 +286,9 @@ sudo systemctl restart letsencrypt-regru.timer "email": "admin@example.com", "cert_dir": "/etc/letsencrypt/live", "log_file": "/var/log/letsencrypt-regru/letsencrypt_regru.log", - "dns_propagation_wait": 60, - "dns_check_attempts": 10, - "dns_check_interval": 10, + "dns_propagation_wait": 180, + "dns_check_attempts": 20, + "dns_check_interval": 15, "renewal_days": 30, "npm_enabled": true, "npm_host": "http://10.10.10.14:81", @@ -399,7 +399,7 @@ letsencrypt-regru --obtain -v **DNS не обновляется** ```bash ## Увеличьте время ожидания в config.json -"dns_propagation_wait": 120, +"dns_propagation_wait": 180, "dns_check_attempts": 20 ```