Добавлены улучшения для управления сертификатами и синхронизации Wiki

- Обновлены скрипты для поддержки групп сертификатов (SAN) в конфигурации.
- Добавлен новый скрипт для запуска GitHub Actions workflows.
- Обновлены Makefile и документация для отражения новых возможностей.
- Улучшена обработка доменов в коде и конфигурации.
This commit is contained in:
Dmitriy Fofanov
2026-02-24 23:07:36 +03:00
parent 3aacf626a3
commit 90966663bf
7 changed files with 390 additions and 73 deletions
+5 -1
View File
@@ -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"],
+6
View File
@@ -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
+91 -30
View File
@@ -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. Протестируйте исполняемые файлы"
+2 -2
View File
@@ -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",
+143 -37
View File
@@ -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
+137
View File
@@ -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()
+6 -3
View File
@@ -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