Добавлены улучшения для управления сертификатами и синхронизации Wiki
- Обновлены скрипты для поддержки групп сертификатов (SAN) в конфигурации. - Добавлен новый скрипт для запуска GitHub Actions workflows. - Обновлены Makefile и документация для отражения новых возможностей. - Улучшена обработка доменов в коде и конфигурации.
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
@@ -2190,6 +2285,10 @@ def main():
|
||||
logger.error(str(e))
|
||||
return 1
|
||||
|
||||
# Разбиваем домены на группы сертификатов (SAN)
|
||||
# Каждая группа — один сертификат с несколькими доменами
|
||||
cert_groups = parse_cert_groups(config)
|
||||
|
||||
# Тестирование DNS записей (полный цикл как при создании SSL)
|
||||
if args.test_dns:
|
||||
logger.info("=" * 80)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user