diff --git a/.github/workflows/deploy-service.yml b/.github/workflows/deploy-service.yml index a932143..4a234c0 100644 --- a/.github/workflows/deploy-service.yml +++ b/.github/workflows/deploy-service.yml @@ -134,6 +134,8 @@ jobs: domains_raw = os.environ.get("CFG_DOMAINS", "").strip() if domains_raw.startswith("["): + # JSON array: каждый элемент может содержать запятые для SAN групп + # Пример: ["*.dfv24.com,dfv24.com", "github.dfv24.com"] domains = json.loads(domains_raw) else: domains = [item.strip() for item in domains_raw.split(",") if item.strip()] @@ -141,7 +143,9 @@ jobs: if not domains: raise RuntimeError("CFG_DOMAINS must contain at least one domain") - domain = os.environ.get("CFG_DOMAIN", "").strip() or domains[0] + # primary domain: первый домен из первой SAN группы + first_group_domains = [d.strip() for d in domains[0].split(",") if d.strip()] + domain = os.environ.get("CFG_DOMAIN", "").strip() or first_group_domains[0] config = { "regru_username": os.environ["CFG_REGRU_USERNAME"], diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml index aeff6e7..d1317cd 100644 --- a/.github/workflows/wiki-sync.yml +++ b/.github/workflows/wiki-sync.yml @@ -1,6 +1,12 @@ name: "Синхронизация Wiki" on: + workflow_dispatch: + inputs: + ref: + description: "Branch or tag to run wiki sync from" + required: false + default: "master" push: branches: - master diff --git a/Makefile b/Makefile index 5b07aa5..2c0baad 100644 --- a/Makefile +++ b/Makefile @@ -3,29 +3,36 @@ # Let's Encrypt с DNS-валидацией через API reg.ru # ============================================================================== -# Переменные -INSTALL_DIR = /opt/letsencrypt-regru +# Переменные (переопределяемые через окружение или аргументы make) +INSTALL_DIR ?= /opt/letsencrypt-regru SCRIPT_NAME = letsencrypt_regru_api.py CONFIG_EXAMPLE = config.json.example SERVICE_NAME = letsencrypt-regru SERVICE_FILE = $(SERVICE_NAME).service TIMER_FILE = $(SERVICE_NAME).timer -CONFIG_DIR = /etc/letsencrypt -CONFIG_FILE = $(CONFIG_DIR)/regru_config.json -LOG_DIR = /var/log +CONFIG_DIR ?= /etc/letsencrypt +CONFIG_FILE ?= $(CONFIG_DIR)/regru_config.json +LOG_DIR ?= /var/log LOG_FILE = $(LOG_DIR)/letsencrypt_regru.log CRON_LOG = $(LOG_DIR)/letsencrypt_cron.log -SYSTEMD_DIR = /etc/systemd/system -PYTHON = python3 +SYSTEMD_DIR ?= /etc/systemd/system +PYTHON ?= python3 # Цвета для вывода RED = \033[0;31m GREEN = \033[0;32m YELLOW = \033[1;33m BLUE = \033[0;34m +CYAN = \033[0;36m NC = \033[0m # No Color -.PHONY: help install uninstall status check-root setup-dirs install-script install-service install-cron clean build build-linux build-windows build-all package-linux package-windows release +.PHONY: help install uninstall status check-root setup-dirs install-script \ + install-service install-cron install-dependencies install-pyinstaller \ + check-config test-run test-cert logs run obtain renew \ + remove-service remove-cron remove-files \ + clean clean-build build build-linux build-windows build-all \ + package-linux package-windows release test-build build-info \ + wiki-sync ci-release deploy-service # Переменные для сборки PYINSTALLER = pyinstaller @@ -34,6 +41,19 @@ DIST_DIR = dist BUILD_DIR_PY = build SPEC_FILE = $(APP_NAME).spec +# Переменные для запуска Gitea Actions workflows +DFGIT_URL ?= http://dfgit +WORKFLOW_DISPATCH_SCRIPT ?= scripts/wiki_sync_dispatch.py + +WIKI_SYNC_WORKFLOW ?= wiki-sync.yml +WIKI_SYNC_REF ?= master + +RELEASE_WORKFLOW ?= release.yml +RELEASE_REF ?= master + +DEPLOY_SERVICE_WORKFLOW ?= deploy-service.yml +DEPLOY_SERVICE_REF ?= master + # ============================================================================== # Помощь # ============================================================================== @@ -64,6 +84,12 @@ help: @echo " $(YELLOW)make release$(NC) - Полный цикл релиза (build + package)" @echo " $(YELLOW)make clean-build$(NC) - Очистить артефакты сборки" @echo "" + @echo "$(GREEN)CI / Wiki:$(NC)" + @echo "" + @echo " $(YELLOW)make wiki-sync$(NC) - Запустить workflow wiki-sync.yml на dfgit" + @echo " $(YELLOW)make ci-release$(NC) - Запустить workflow release.yml на dfgit (нужен TAG или VERSION)" + @echo " $(YELLOW)make deploy-service$(NC) - Запустить workflow deploy-service.yml на dfgit" + @echo "" @echo " $(YELLOW)make help$(NC) - Показать эту справку" @echo "" @@ -91,7 +117,9 @@ install: check-root @$(MAKE) install-dependencies @$(MAKE) install-script @$(MAKE) install-service - @$(MAKE) install-cron + @echo "" + @echo "$(YELLOW)ℹ️ Для дополнительного резервирования через cron (опционально):$(NC)" + @echo " $(CYAN)sudo make install-cron$(NC)" @echo "" @echo "$(GREEN)╔════════════════════════════════════════════════════════════════╗$(NC)" @echo "$(GREEN)║ ✓ Установка завершена успешно! ║$(NC)" @@ -184,14 +212,16 @@ install-service: @echo "$(YELLOW)→ Создание systemd service...$(NC)" @echo "[Unit]" > $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "Description=Let's Encrypt Certificate Manager with reg.ru DNS" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) - @echo "After=network.target" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) + @echo "After=network-online.target" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) + @echo "Wants=network-online.target" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "[Service]" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "Type=oneshot" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) - @echo "ExecStart=$(PYTHON) $(INSTALL_DIR)/$(SCRIPT_NAME) -c $(CONFIG_FILE)" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) + @echo "ExecStart=$(PYTHON) $(INSTALL_DIR)/$(SCRIPT_NAME) -c $(CONFIG_FILE) --auto" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "StandardOutput=journal" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "StandardError=journal" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "User=root" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) + @echo "SyslogIdentifier=$(SERVICE_NAME)" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "[Install]" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @echo "WantedBy=multi-user.target" >> $(SYSTEMD_DIR)/$(SERVICE_FILE) @@ -199,13 +229,14 @@ install-service: @echo "" @echo "$(YELLOW)→ Создание systemd timer...$(NC)" @echo "[Unit]" > $(SYSTEMD_DIR)/$(TIMER_FILE) - @echo "Description=Daily Let's Encrypt Certificate Check and Renewal" >> $(SYSTEMD_DIR)/$(TIMER_FILE) + @echo "Description=Let's Encrypt Certificate Auto-Renewal Timer" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "Requires=$(SERVICE_FILE)" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "[Timer]" >> $(SYSTEMD_DIR)/$(TIMER_FILE) - @echo "OnCalendar=daily" >> $(SYSTEMD_DIR)/$(TIMER_FILE) - @echo "Persistent=true" >> $(SYSTEMD_DIR)/$(TIMER_FILE) + @echo "OnBootSec=15min" >> $(SYSTEMD_DIR)/$(TIMER_FILE) + @echo "OnUnitActiveSec=12h" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "RandomizedDelaySec=1h" >> $(SYSTEMD_DIR)/$(TIMER_FILE) + @echo "Persistent=true" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "[Install]" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @echo "WantedBy=timers.target" >> $(SYSTEMD_DIR)/$(TIMER_FILE) @@ -353,7 +384,7 @@ check-config: echo "$(RED)✗ Конфигурация не найдена: $(CONFIG_FILE)$(NC)"; \ echo "$(YELLOW)Совет: Скопируйте config.json.example в $(CONFIG_FILE)$(NC)"; \ echo " $(CYAN)sudo cp config.json.example $(CONFIG_FILE)$(NC)"; \ - echo " $(CYAN)sudo chmod 644 $(CONFIG_FILE)$(NC)"; \ + echo " $(CYAN)sudo chmod 600 $(CONFIG_FILE)$(NC)"; \ exit 1; \ fi @echo "$(GREEN)✓ Конфигурация найдена$(NC)" @@ -413,6 +444,33 @@ logs: @echo "$(YELLOW)→ Логи systemd:$(NC)" @journalctl -u $(SERVICE_FILE) -n 50 --no-pager 2>/dev/null || echo "$(YELLOW)Логи systemd не найдены$(NC)" +# Запуск workflow синхронизации wiki на dfgit +wiki-sync: + @echo "$(BLUE)╔════════════════════════════════════════════════════════════════╗$(NC)" + @echo "$(BLUE)║ Запуск wiki-sync workflow на dfgit ║$(NC)" + @echo "$(BLUE)╚════════════════════════════════════════════════════════════════╝$(NC)" + @echo "" + @$(PYTHON) $(WORKFLOW_DISPATCH_SCRIPT) --workflow $(WIKI_SYNC_WORKFLOW) --ref $(WIKI_SYNC_REF) --input ref=$(WIKI_SYNC_REF) + +ci-release: + @echo "$(BLUE)╔════════════════════════════════════════════════════════════════╗$(NC)" + @echo "$(BLUE)║ Запуск release workflow на dfgit ║$(NC)" + @echo "$(BLUE)╚════════════════════════════════════════════════════════════════╝$(NC)" + @echo "" +ifndef TAG + ifndef VERSION + $(error Укажите TAG=vX.Y.Z или VERSION=X.Y.Z. Пример: make ci-release TAG=v1.0.0) + endif +endif + @$(PYTHON) $(WORKFLOW_DISPATCH_SCRIPT) --workflow $(RELEASE_WORKFLOW) --ref $(RELEASE_REF) --input tag=$(if $(TAG),$(TAG),v$(VERSION)) --require-input tag + +deploy-service: + @echo "$(BLUE)╔════════════════════════════════════════════════════════════════╗$(NC)" + @echo "$(BLUE)║ Запуск deploy-service workflow на dfgit ║$(NC)" + @echo "$(BLUE)╚════════════════════════════════════════════════════════════════╝$(NC)" + @echo "" + @$(PYTHON) $(WORKFLOW_DISPATCH_SCRIPT) --workflow $(DEPLOY_SERVICE_WORKFLOW) --ref $(if $(REF),$(REF),$(DEPLOY_SERVICE_REF)) --input ref=$(if $(REF),$(REF),$(DEPLOY_SERVICE_REF)) + # Ручной запуск обновления run: check-root @echo "$(YELLOW)→ Запуск обновления сертификата...$(NC)" @@ -496,9 +554,13 @@ build: $(MAKE) install-pyinstaller; \ fi @echo "$(YELLOW)→ Компиляция $(SCRIPT_NAME) в исполняемый файл...$(NC)" - @$(PYINSTALLER) --onefile \ + @UNAME=$$(uname -s 2>/dev/null || echo "Windows"); \ + SEPARATOR=":"; \ + case "$$UNAME" in MINGW*|MSYS*|CYGWIN*|Windows*) SEPARATOR=";";; esac; \ + echo "$(YELLOW)→ Используем разделитель add-data: $$SEPARATOR$(NC)"; \ + $(PYINSTALLER) --onefile \ --name $(APP_NAME) \ - --add-data "README.md:." \ + --add-data "README.md$${SEPARATOR}." \ --hidden-import requests \ --hidden-import certbot \ --hidden-import cryptography \ @@ -545,7 +607,6 @@ build-linux: --hidden-import certbot \ --hidden-import cryptography \ --collect-all certbot \ - --target-arch x86_64 \ --noconfirm \ $(SCRIPT_NAME) @echo "" @@ -609,7 +670,8 @@ build-all: @$(MAKE) build-linux @echo "" @UNAME=$$(uname -s 2>/dev/null || echo "Windows"); \ - if [ "$$UNAME" != "Windows" ] && [ "$$UNAME" != "MINGW"* ] && [ "$$UNAME" != "MSYS"* ]; then \ + case "$$UNAME" in Windows*|MINGW*|MSYS*|CYGWIN*) IS_WIN=1;; *) IS_WIN=0;; esac; \ + if [ "$$IS_WIN" = "0" ]; then \ echo "$(YELLOW)Пропускаем Windows сборку (текущая ОС: $$UNAME)$(NC)"; \ echo "$(YELLOW)Используйте Windows для создания .exe файла$(NC)"; \ else \ @@ -665,15 +727,14 @@ package-windows: build-windows @cp $(DIST_DIR)/$(APP_NAME).exe $(DIST_DIR)/package/ @cp README.md $(DIST_DIR)/package/ 2>/dev/null || true @if [ -f "config.json.example" ]; then cp config.json.example $(DIST_DIR)/package/; fi - @echo "$(YELLOW)→ Создание архива (tar.gz)...$(NC)" - @cd $(DIST_DIR)/package && tar -czf ../$(APP_NAME)-windows-x86_64.tar.gz * + @echo "$(YELLOW)→ Создание архива (zip)...$(NC)" + @cd $(DIST_DIR)/package && $(PYTHON) -m zipfile -c ../$(APP_NAME)-windows-x86_64.zip . \ + || (cd $(DIST_DIR)/package && zip -r ../$(APP_NAME)-windows-x86_64.zip *) \ + || (cd $(DIST_DIR)/package && tar -czf ../$(APP_NAME)-windows-x86_64.tar.gz *) @rm -rf $(DIST_DIR)/package @echo "" - @echo "$(GREEN)✓ Пакет создан:$(NC) $(DIST_DIR)/$(APP_NAME)-windows-x86_64.tar.gz" - @ls -lh $(DIST_DIR)/$(APP_NAME)-windows-x86_64.tar.gz - @echo "" - @echo "$(YELLOW)Содержимое пакета:$(NC)" - @tar -tzf $(DIST_DIR)/$(APP_NAME)-windows-x86_64.tar.gz | head -10 + @echo "$(GREEN)✓ Пакет создан:$(NC)" + @ls -lh $(DIST_DIR)/$(APP_NAME)-windows-x86_64.zip 2>/dev/null || ls -lh $(DIST_DIR)/$(APP_NAME)-windows-x86_64.tar.gz 2>/dev/null # Полный цикл релиза release: @@ -692,12 +753,12 @@ release: @echo "$(GREEN)╚════════════════════════════════════════════════════════════════╝$(NC)" @echo "" @echo "$(YELLOW)Артефакты релиза:$(NC)" - @ls -lh $(DIST_DIR)/*.tar.gz $(DIST_DIR)/*.zip 2>/dev/null || dir $(DIST_DIR)\*.zip 2>/dev/null || echo " Проверьте $(DIST_DIR)/" + @ls -lh $(DIST_DIR)/$(APP_NAME)-*.tar.gz $(DIST_DIR)/$(APP_NAME)-*.zip 2>/dev/null || dir $(DIST_DIR)\$(APP_NAME)-*.* 2>/dev/null || echo " Проверьте $(DIST_DIR)/" @echo "" @echo "$(YELLOW)Контрольные суммы SHA256:$(NC)" - @cd $(DIST_DIR) && sha256sum *.tar.gz *.zip 2>/dev/null || \ - cd $(DIST_DIR) && certutil -hashfile $(APP_NAME)-windows-x86_64.zip SHA256 2>/dev/null || \ - echo " Используйте sha256sum или certutil для проверки контрольных сумм" + @cd $(DIST_DIR) && sha256sum $(APP_NAME)-*.tar.gz $(APP_NAME)-*.zip 2>/dev/null || \ + $(PYTHON) -c "import hashlib,glob,os; [print(hashlib.sha256(open(f,'rb').read()).hexdigest()+' '+os.path.basename(f)) for f in sorted(glob.glob('$(DIST_DIR)/$(APP_NAME)-*.*'))]" 2>/dev/null || \ + echo " Используйте sha256sum или Python для проверки контрольных сумм" @echo "" @echo "$(YELLOW)Следующие шаги:$(NC)" @echo " 1. Протестируйте исполняемые файлы" diff --git a/config.json.example b/config.json.example index f8f563e..9d6477e 100644 --- a/config.json.example +++ b/config.json.example @@ -3,8 +3,8 @@ "regru_password": "your_password", "domain": "dfv24.com", "domains": [ - "dfv24.com", - "pages.github.dfv24.com" + "*.dfv24.com,dfv24.com", + "github.dfv24.com" ], "wildcard": true, "email": "admin@dfv24.com", diff --git a/letsencrypt_regru_api.py b/letsencrypt_regru_api.py index 0461c8a..fb9ac5b 100644 --- a/letsencrypt_regru_api.py +++ b/letsencrypt_regru_api.py @@ -63,8 +63,9 @@ DEFAULT_CONFIG = { # Параметры домена "domain": "example.com", - "domains": [], # Список доменов для управления из одного конфига - "wildcard": True, # Создавать wildcard сертификат (*.domain.com) + "domains": [], # Группы SAN-доменов: ["*.dfv24.com,dfv24.com", "github.dfv24.com"] + # Запятая внутри строки = один сертификат с несколькими SAN + "wildcard": True, # Создавать wildcard сертификат (*.domain.com) если domains пуст # Email для уведомлений Let's Encrypt "email": "admin@example.com", @@ -1461,10 +1462,16 @@ class LetsEncryptManager: self.logger.error("Выполните: sudo pkill -9 certbot") return False - # Формируем список доменов - domains = [self.domain] - if self.config.get("wildcard", False): - domains.append(f"*.{self.domain}") + # Формируем список доменов (SAN группа) + # Если в конфигурации передан cert_domains (список SAN), используем его. + # Иначе — обратная совместимость: domain + wildcard. + cert_domains = self.config.get("cert_domains") + if cert_domains and isinstance(cert_domains, list): + domains = list(cert_domains) + else: + domains = [self.domain] + if self.config.get("wildcard", False): + domains.append(f"*.{self.domain}") domain_args = [] for d in domains: @@ -1654,15 +1661,19 @@ class LetsEncryptManager: cleanup_hook_script.close() os.chmod(cleanup_hook_script.name, 0o755) + # Используем cert_name — первый домен из SAN группы (или self.domain) + cert_domains = self.config.get("cert_domains") + cert_name = cert_domains[0] if cert_domains and isinstance(cert_domains, list) else self.domain + cmd = [ "certbot", "renew", - "--cert-name", self.domain, + "--cert-name", cert_name, "--manual-auth-hook", auth_hook_script.name, "--manual-cleanup-hook", cleanup_hook_script.name, "--non-interactive", ] - self.logger.info(f"Домен: {self.domain}") + self.logger.info(f"Домен (cert-name): {cert_name}") self.logger.info(f"Конфигурация: {config_path}") self.logger.info(f"Auth hook: {sys.executable} {os.path.abspath(__file__)} --config {config_path} --auth-hook") self.logger.info(f"Cleanup hook: {sys.executable} {os.path.abspath(__file__)} --config {config_path} --cleanup-hook") @@ -1830,11 +1841,12 @@ def load_config(config_file: Optional[str] = None) -> Dict: def get_configured_domains(config: Dict) -> List[str]: """ - Возвращает список доменов из конфигурации (с обратной совместимостью). + Возвращает плоский список доменов из конфигурации (с обратной совместимостью). Поддерживаемые варианты: - domain: "example.com" - domains: ["example.com", "example.org"] + - domains: ["*.dfv24.com,dfv24.com", "github.dfv24.com"] - оба варианта одновременно """ domains: List[str] = [] @@ -1850,7 +1862,11 @@ def get_configured_domains(config: Dict) -> List[str]: if isinstance(configured_domains, list): for item in configured_domains: if isinstance(item, str) and item.strip(): - domains.append(item.strip()) + # Разбиваем элемент по запятой — каждый sub-домен добавляется отдельно + for sub in item.split(","): + sub = sub.strip() + if sub: + domains.append(sub) # Удаляем дубликаты с сохранением порядка unique_domains: List[str] = [] @@ -1867,6 +1883,68 @@ def get_configured_domains(config: Dict) -> List[str]: return unique_domains +def parse_cert_groups(config: Dict) -> List[List[str]]: + """ + Разбирает конфигурацию и возвращает список «групп сертификатов». + + Каждый элемент domains может содержать несколько доменов через запятую — + они образуют ОДИН сертификат (SAN). Разные элементы массива — разные + сертификаты. + + Примеры: + domains: ["*.dfv24.com,dfv24.com", "github.dfv24.com"] + → [["*.dfv24.com", "dfv24.com"], ["github.dfv24.com"]] + + domains: ["example.com"] + → [["example.com"]] (+ wildcard если включён) + + Если конфигурация задана в «старом» формате (domain + wildcard), + функция возвращает одну группу с учётом wildcard. + + Returns: + Список списков доменов. Каждый внутренний список — SAN-домены + одного сертификата. + """ + configured_domains_raw = config.get("domains", []) + if isinstance(configured_domains_raw, str): + configured_domains_raw = [configured_domains_raw] + + groups: List[List[str]] = [] + + if isinstance(configured_domains_raw, list) and configured_domains_raw: + for item in configured_domains_raw: + if not isinstance(item, str) or not item.strip(): + continue + # Разбиваем элемент по запятой → SAN-группа одного сертификата + san_list = [d.strip() for d in item.split(",") if d.strip()] + if san_list: + groups.append(san_list) + + # Если groups пусты, используем «старый» формат: domain + wildcard + if not groups: + primary = config.get("domain", "").strip() + if not primary: + raise ValueError("В конфигурации не указан ни один домен (domain/domains)") + group = [primary] + if config.get("wildcard", False): + group.append(f"*.{primary}") + groups.append(group) + else: + # Если в группе ровно один «чистый» домен и включён wildcard, + # автоматически добавляем *.domain (обратная совместимость). + # Если пользователь явно указал SAN-ы через запятую — оставляем как есть. + wildcard = config.get("wildcard", False) + expanded: List[List[str]] = [] + for group in groups: + if wildcard and len(group) == 1 and not group[0].startswith("*."): + expanded.append([group[0], f"*.{group[0]}"]) + else: + expanded.append(group) + groups = expanded + + return groups + + def get_domain_config(base_config: Dict, domain: str) -> Dict: """Создаёт копию конфигурации для конкретного домена.""" domain_config = base_config.copy() @@ -1874,6 +1952,23 @@ def get_domain_config(base_config: Dict, domain: str) -> Dict: return domain_config +def get_cert_group_config(base_config: Dict, cert_group: List[str]) -> Dict: + """ + Создаёт копию конфигурации для группы SAN-доменов (один сертификат). + + Args: + base_config: Базовая конфигурация + cert_group: Список доменов для одного сертификата (SAN) + + Returns: + Конфигурация с domain = первый домен, cert_domains = все домены группы + """ + group_config = base_config.copy() + group_config["domain"] = cert_group[0] # primary domain (cert-name) + group_config["cert_domains"] = cert_group # все SAN домены + return group_config + + def extract_registrable_domain(target_domain: str, configured_domains: List[str]) -> Tuple[str, str]: """ Определяет зарегистрированный домен и префикс поддомена для DNS API (reg.ru). @@ -2189,6 +2284,10 @@ def main(): except ValueError as e: logger.error(str(e)) return 1 + + # Разбиваем домены на группы сертификатов (SAN) + # Каждая группа — один сертификат с несколькими доменами + cert_groups = parse_cert_groups(config) # Тестирование DNS записей (полный цикл как при создании SSL) if args.test_dns: @@ -2354,13 +2453,14 @@ def main(): test_gen = TestCertificateGenerator(logger) all_success = True - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("-" * 80) - logger.info(f"ГЕНЕРАЦИЯ ТЕСТОВОГО СЕРТИФИКАТА ДЛЯ: {domain}") + logger.info(f"ГЕНЕРАЦИЯ ТЕСТОВОГО СЕРТИФИКАТА ДЛЯ: {', '.join(cert_group)}") logger.info("-" * 80) success = test_gen.generate_self_signed_certificate( - domain=domain, + domain=primary_domain, wildcard=config.get("wildcard", False), output_dir=config["cert_dir"], validity_days=90 @@ -2385,11 +2485,11 @@ def main(): ) if npm_api.login(): - cert_dir = os.path.join(config["cert_dir"], domain) + cert_dir = os.path.join(config["cert_dir"], primary_domain) cert_path = os.path.join(cert_dir, "fullchain.pem") key_path = os.path.join(cert_dir, "privkey.pem") - existing = npm_api.find_certificate_by_domain(domain) + existing = npm_api.find_certificate_by_domain(primary_domain) if existing: cert_id = existing.get("id") @@ -2400,7 +2500,7 @@ def main(): logger.warning("⚠️ Не удалось обновить сертификат в NPM") else: logger.info("Загрузка нового тестового сертификата в NPM") - if npm_api.upload_certificate(domain, cert_path, key_path): + if npm_api.upload_certificate(primary_domain, cert_path, key_path): logger.info("✅ Тестовый сертификат успешно загружен в NPM") else: logger.warning("⚠️ Не удалось загрузить сертификат в NPM") @@ -2541,16 +2641,17 @@ def main(): if args.info: any_error = False - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("=" * 80) - logger.info(f"ИНФОРМАЦИЯ О СЕРТИФИКАТЕ: {domain}") + logger.info(f"ИНФОРМАЦИЯ О СЕРТИФИКАТЕ: {', '.join(cert_group)}") logger.info("=" * 80) - manager = LetsEncryptManager(get_domain_config(config, domain), api, logger) + manager = LetsEncryptManager(get_cert_group_config(config, cert_group), api, logger) days_left = manager.check_certificate_expiry() if days_left is None: - logger.error(f"Сертификат не найден для домена: {domain}") + logger.error(f"Сертификат не найден для домена: {primary_domain}") any_error = True continue @@ -2588,15 +2689,16 @@ def main(): return 1 if any_error else 0 elif args.check: - # Только проверка срока действия по всем доменам + # Только проверка срока действия по всем группам сертификатов final_code = 0 - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("=" * 80) - logger.info(f"ПРОВЕРКА СЕРТИФИКАТА: {domain}") + logger.info(f"ПРОВЕРКА СЕРТИФИКАТА: {', '.join(cert_group)}") logger.info("=" * 80) - manager = LetsEncryptManager(get_domain_config(config, domain), api, logger) + manager = LetsEncryptManager(get_cert_group_config(config, cert_group), api, logger) days_left = manager.check_certificate_expiry() is_staging = manager.is_staging_certificate() @@ -2652,12 +2754,13 @@ def main(): all_success = True - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("=" * 80) - logger.info(f"ПОЛУЧЕНИЕ STAGING СЕРТИФИКАТА: {domain}") + logger.info(f"ПОЛУЧЕНИЕ STAGING СЕРТИФИКАТА: {', '.join(cert_group)}") logger.info("=" * 80) - manager = LetsEncryptManager(get_domain_config(config, domain), api, logger) + manager = LetsEncryptManager(get_cert_group_config(config, cert_group), api, logger) success = manager.obtain_certificate(staging=True) if success: @@ -2666,7 +2769,7 @@ def main(): logger.info("✅ ТЕСТОВЫЙ СЕРТИФИКАТ УСПЕШНО ПОЛУЧЕН") logger.info("=" * 80) logger.info("") - logger.info(f"📂 Расположение: /etc/letsencrypt/live/{domain}/") + logger.info(f"📂 Расположение: /etc/letsencrypt/live/{primary_domain}/") logger.info("") logger.info("🔄 Следующие шаги:") logger.info(" 1. ✅ Проверьте что процесс прошел успешно") @@ -2692,12 +2795,13 @@ def main(): # Принудительное получение новых сертификатов all_success = True - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("=" * 80) - logger.info(f"ПОЛУЧЕНИЕ PRODUCTION СЕРТИФИКАТА: {domain}") + logger.info(f"ПОЛУЧЕНИЕ PRODUCTION СЕРТИФИКАТА: {', '.join(cert_group)}") logger.info("=" * 80) - manager = LetsEncryptManager(get_domain_config(config, domain), api, logger) + manager = LetsEncryptManager(get_cert_group_config(config, cert_group), api, logger) success = manager.obtain_certificate(staging=False) if success: manager.display_certificate_info() @@ -2728,12 +2832,13 @@ def main(): # Обновление существующих сертификатов all_success = True - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("=" * 80) - logger.info(f"ОБНОВЛЕНИЕ СЕРТИФИКАТА: {domain}") + logger.info(f"ОБНОВЛЕНИЕ СЕРТИФИКАТА: {', '.join(cert_group)}") logger.info("=" * 80) - manager = LetsEncryptManager(get_domain_config(config, domain), api, logger) + manager = LetsEncryptManager(get_cert_group_config(config, cert_group), api, logger) success = manager.renew_certificate() if success: manager.display_certificate_info() @@ -3098,12 +3203,13 @@ def main(): any_changed = False all_success = True - for domain in configured_domains: + for cert_group in cert_groups: + primary_domain = cert_group[0] logger.info("=" * 60) - logger.info(f"ДОМЕН: {domain}") + logger.info(f"ГРУППА СЕРТИФИКАТА: {', '.join(cert_group)}") logger.info("=" * 60) - manager = LetsEncryptManager(get_domain_config(config, domain), api, logger) + manager = LetsEncryptManager(get_cert_group_config(config, cert_group), api, logger) days_left = manager.check_certificate_expiry() success = True diff --git a/scripts/wiki_sync_dispatch.py b/scripts/wiki_sync_dispatch.py new file mode 100644 index 0000000..2beb538 --- /dev/null +++ b/scripts/wiki_sync_dispatch.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from subprocess import PIPE, run + + +def get_env(name: str, default: str = "") -> str: + value = os.getenv(name, default) + return value.strip() if isinstance(value, str) else default + + +def fail(message: str, code: int = 1) -> None: + print(f"✗ {message}") + sys.exit(code) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Dispatch Gitea Actions workflow") + parser.add_argument("--workflow", default="wiki-sync.yml", help="Workflow file name (e.g. wiki-sync.yml)") + parser.add_argument("--ref", default="master", help="Git ref for workflow dispatch") + parser.add_argument( + "--input", + action="append", + default=[], + help="Workflow input in key=value format. Can be provided multiple times.", + ) + parser.add_argument( + "--require-input", + action="append", + default=[], + help="Required input key(s). Fails if key is missing or empty.", + ) + return parser.parse_args() + + +def parse_inputs(items: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + for item in items: + if "=" not in item: + fail(f"Неверный формат --input '{item}'. Используйте key=value") + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + fail(f"Пустой ключ в --input '{item}'") + result[key] = value + return result + + +def get_repo_path() -> str: + result = run(["git", "config", "--get", "remote.origin.url"], stdout=PIPE, stderr=PIPE, text=True) + origin_url = (result.stdout or "").strip() + if result.returncode != 0 or not origin_url: + fail("remote.origin.url не найден. Проверьте, что команда запущена внутри git-репозитория.") + + repo_path = re.sub(r"^(https?://[^/]+/|git@[^:]+:)", "", origin_url) + repo_path = re.sub(r"\.git$", "", repo_path) + if not repo_path: + fail(f"Не удалось вычислить owner/repo из remote.origin.url: {origin_url}") + return repo_path + + +def main() -> None: + args = parse_args() + + token = get_env("DFGIT_TOKEN") or get_env("GIT_TOKEN") + if not token: + fail("не задан DFGIT_TOKEN (или GIT_TOKEN).") + + dfgit_url = get_env("DFGIT_URL", "http://dfgit").rstrip("/") + workflow = args.workflow.strip() or "wiki-sync.yml" + ref = args.ref.strip() or "master" + inputs = parse_inputs(args.input) + + for required_key in args.require_input: + required_key = required_key.strip() + if required_key and not inputs.get(required_key, "").strip(): + fail( + f"Обязательный input '{required_key}' не задан. " + f"Передайте --input {required_key}=..." + ) + + repo_path = get_repo_path() + dispatch_url = f"{dfgit_url}/api/v1/repos/{repo_path}/actions/workflows/{workflow}/dispatches" + + payload_data: dict[str, object] = {"ref": ref} + if inputs: + payload_data["inputs"] = inputs + + payload = json.dumps(payload_data).encode("utf-8") + + print(f"→ Репозиторий: {repo_path}") + print(f"→ Workflow: {workflow}, ref: {ref}") + if inputs: + print(f"→ Inputs: {json.dumps(inputs, ensure_ascii=False)}") + print(f"→ Запрос: {dispatch_url}") + + request = urllib.request.Request( + dispatch_url, + data=payload, + method="POST", + headers={ + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + status = response.getcode() + body = response.read().decode("utf-8", errors="replace").strip() + except urllib.error.HTTPError as error: + status = error.code + body = error.read().decode("utf-8", errors="replace").strip() + except urllib.error.URLError as error: + fail(f"Ошибка сети при обращении к dfgit: {error}") + + if status in (200, 201, 204): + print("✓ Workflow успешно запущен") + print("Проверьте статус в Actions на сервере dfgit.") + return + + print(f"✗ Ошибка запуска workflow (HTTP {status})") + if body: + print("Ответ API:") + print(body) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/secrets_description.md b/secrets_description.md index d281e11..17d6a0e 100644 --- a/secrets_description.md +++ b/secrets_description.md @@ -43,11 +43,14 @@ This document provides a detailed description of the secrets required for deploy - **Example**: `admin@dfv24.com` ### CFG_DOMAINS -- **Description**: The domains to be managed. Can be provided as a JSON array or CSV. -- **Example**: `["dfv24.com", "pages.github.dfv24.com"]` +- **Description**: Certificate groups (SAN). Each array element = one certificate. Comma-separated domains within one element form a single multi-SAN certificate. +- **Format**: JSON array — `["*.dfv24.com,dfv24.com", "github.dfv24.com"]` +- **Result**: 2 certificates: + 1. `*.dfv24.com` + `dfv24.com` (one cert, two SANs) + 2. `github.dfv24.com` (separate cert) ### CFG_DOMAIN -- **Description**: The primary domain. If empty, the first domain from `CFG_DOMAINS` is used. +- **Description**: The primary domain. If empty, the first domain from the first group in `CFG_DOMAINS` is used. - **Example**: `dfv24.com` ### CFG_WILDCARD