🔐 Автообновление SSL: Certbot + Telegram + systemd timer
Это заметка-памятка: как я сделал обновление сертификатов Let’s Encrypt автоматическим, предсказуемым и наблюдаемым. Всё завязано на один bash-скрипт и нормальную демонизацию через systemd.
fullchain.pem/privkey.pem. Это лечится правильными путями в конфиге и reload/restart.🎯 Задача
- Обновлять SSL-сертификаты Let’s Encrypt (Certbot) для нескольких доменов.
- Работать без ручного вмешательства и «зависаний».
- Присылать в Telegram не спам, а короткий отчёт (2 сообщения).
- Запускаться автоматически 2 раза в месяц в боевом режиме:
DRY_RUN=0.
🧩 Почему certbot standalone
В режиме --standalone certbot поднимает временный HTTP-сервер на 80 порту для прохождения HTTP-01 challenge. Поэтому на время обновления нужно освободить порт. Проще всего — кратко остановить nginx.
➕ Плюсы
- Минимум зависимости от конфигов nginx (
webrootне нужен). - Меньше «хрупких мест» и ручных условий.
➖ Минусы
- Нужно освобождать 80 порт (стоп/старт nginx).
- Короткий простой веба возможен (обычно секунды).
📦 Группировка доменов по сертификатам
Доменов много, но certbot удобно запускать группами: 1 строка = 1 сертификат. Это удобно и для диагностики, и для отчёта.
ard-s.ru www.ard-s.ruasterisk.ard-s.ru www.asterisk.ard-s.ruficus.ard-s.ru www.ficus.ard-s.ruhypervisor.ard-s.ru www.hypervisor.ard-s.rumail.ard-s.rumikrotik.ard-s.ru www.mikrotik.ard-s.rupacs.ard-s.ru www.pacs.ard-s.rusecure.ard-s.rutest.ard-s.ru www.test.ard-s.ru
🧠 Как работает скрипт
1) Строгий режим bash
set -e,set -u,set -o pipefail
Идея простая: лучше громко упасть, чем тихо сделать ерунду.
2) Логи
Скрипт пишет в /var/log/certbot-update.log (плюс systemd-журнал).
3) Telegram-уведомления
- 🟡 старт
- 🟢/🟠 итоговый отчёт (сколько успешно, сколько с ошибкой, статус nginx)
printf) и curl --data-urlencode — иначе Telegram может показать «\\n» вместо новой строки.4) Критичный момент: set -e и $(...)
Если запускать certbot внутри подстановки $(...) при включённом set -e, bash может завершить скрипт раньше, чем мы успеем обработать код возврата. Поэтому в месте запуска certbot временно отключается set -e, а затем включается обратно.
5) Неприятная, но типовая проблема: nginx отдаёт старый сертификат
Certbot может обновить сертификат в /etc/letsencrypt/live/..., но если в nginx прописаны неверные пути к ssl_certificate/ssl_certificate_key, клиент продолжит видеть старый сертификат.
live/.../fullchain.pem и live/.../privkey.pem и сделать reload / restart nginx.📄 Листинг скрипта
nano /usr/local/bin/update_certificates.sh
#!/bin/bash # Обновление SSL-сертификатов certbot (standalone) для набора доменов. # Telegram: РОВНО 2 сообщения (старт + финальный отчет). # Логи: /var/log/certbot-update.log # # Запуск: # DRY_RUN=1 bash /usr/local/bin/update_certificates.sh # тестовый прогон # bash /usr/local/bin/update_certificates.sh # реальный прогон (DRY_RUN=0) set -euo pipefail LOG_FILE="/var/log/certbot-update.log" # --- Telegram --- # ВАЖНО: вставь СВЕЖИЙ токен (старый уже светился) BOT_TOKEN="ВСТАВЬ_НОВЫЙ_ТОКЕН_СЮДА" CHAT_ID="-1003310217653" # --- Наборы доменов (1 сертификат на строку) --- DOMAINS=( "ard-s.ru www.ard-s.ru" "asterisk.ard-s.ru www.asterisk.ard-s.ru" "ficus.ard-s.ru www.ficus.ard-s.ru" "hypervisor.ard-s.ru www.hypervisor.ard-s.ru" "mail.ard-s.ru" "mikrotik.ard-s.ru www.mikrotik.ard-s.ru" "pacs.ard-s.ru www.pacs.ard-s.ru" # фикс опечатки "secure.ard-s.ru" "test.ard-s.ru www.test.ard-s.ru" ) # 0 = реальный прогон, 1 = certbot --dry-run DRY_RUN="${DRY_RUN:-0}" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S %z')] $*" | tee -a "$LOG_FILE" } tg_send() { local action="$1" local status="$2" local message="${3:-}" local details="${4:-}" local emoji="⚪" local status_text="$status" case "$status" in start) emoji="🟡"; status_text="НАЧАЛО ОБНОВЛЕНИЯ" ;; success) emoji="🟢"; status_text="УСПЕШНО" ;; warning) emoji="🟠"; status_text="С ПРЕДУПРЕЖДЕНИЯМИ" ;; error) emoji="🔴"; status_text="ОШИБКА" ;; info) emoji="ℹ️"; status_text="ИНФОРМАЦИЯ" ;; esac export TZ='Europe/Moscow' local current_time current_time=$(date '+%d.%m.%Y %H:%M:%S') local hostname hostname=$(hostname) # Собираем текст с РЕАЛЬНЫМИ переводами строк local text text=$( printf "🔐 <b>Обновление SSL-сертификатов</b>\n\n" printf "%s <b>Статус:</b> %s\n" "$emoji" "$status_text" printf "📅 <b>Дата и время:</b> %s\n" "$current_time" printf "📝 <b>Действие:</b> %s\n" "$action" if [[ -n "$message" ]]; then printf "\n💬 <b>Сообщение:</b>\n<code>%s</code>\n" "$message" fi if [[ -n "$details" ]]; then # Ограничим размер, чтобы Telegram не отверг сообщение if (( ${#details} > 3000 )); then details="${details:0:2997}..." fi printf "\n📋 <b>Детали:</b>\n<pre>%s</pre>\n" "$details" fi printf "\n🖥 <b>Сервер:</b> %s\n" "$hostname" ) # Если токен не задан — не валим скрипт, но логируем if [[ -z "${BOT_TOKEN}" || "${BOT_TOKEN}" == "ВСТАВЬ_НОВЫЙ_ТОКЕН_СЮДА" ]]; then log "⚠️ BOT_TOKEN не задан/шаблонный — Telegram уведомление не отправлено." return 0 fi local url="https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" if command -v curl >/dev/null 2>&1; then curl -sS -X POST "$url" \ --connect-timeout 10 --max-time 20 \ --data-urlencode "chat_id=${CHAT_ID}" \ --data-urlencode "parse_mode=HTML" \ --data-urlencode "disable_web_page_preview=true" \ --data-urlencode "text=${text}" \ >/dev/null 2>&1 || log "⚠️ Не удалось отправить уведомление в Telegram (curl)." elif command -v wget >/dev/null 2>&1; then # У wget нет удобного urlencode — поэтому используем curl предпочтительно. # Но чтобы хоть как-то работало, отправим как есть (может сломаться на спецсимволах). wget -q --timeout=20 --tries=2 \ --post-data="chat_id=${CHAT_ID}&parse_mode=HTML&disable_web_page_preview=true&text=$(printf '%s' "$text")" \ "$url" -O /dev/null 2>&1 || log "⚠️ Не удалось отправить уведомление в Telegram (wget)." else log "⚠️ Нет curl/wget — Telegram уведомление невозможно." fi } build_d_args() { local domain_set="$1" local args=() local d for d in $domain_set; do args+=("-d" "$d") done printf '%q ' "${args[@]}" } # --- Управление nginx и trap --- nginx_was_running=0 nginx_started_by_script=0 restore_nginx() { # Поднимем nginx только если он был запущен до старта и мы его останавливали if [[ "$nginx_was_running" -eq 1 && "$nginx_started_by_script" -eq 0 ]]; then log "🚀 Возврат nginx (trap)..." systemctl start nginx >/dev/null 2>&1 || true fi } trap restore_nginx EXIT # --- Start --- JOB_START_EPOCH=$(date +%s) log "=== START ===" if systemctl is-active --quiet nginx; then nginx_was_running=1 else nginx_was_running=0 fi tg_send \ "Запуск автоматического обновления SSL-сертификатов" \ "start" \ "Наборов доменов: ${#DOMAINS[@]}. DRY_RUN=${DRY_RUN}" if [[ "$nginx_was_running" -eq 1 ]]; then log "🛑 Остановка nginx..." systemctl stop nginx else log "ℹ️ nginx уже остановлен — stop пропущен." fi # Проверка порта 80 — standalone должен поднять временный сервер if ss -lntp 2>/dev/null | grep -qE ':(80)\s'; then local80="$(ss -lntp 2>/dev/null | grep -E ':(80)\s' || true)" log "❌ Порт 80 занят. certbot --standalone не сможет стартовать." log "$local80" tg_send \ "Проверка перед certbot" \ "error" \ "Порт 80 занят — standalone не стартует" \ "$local80" exit 1 fi SUCCESSFUL="" FAILED="" TOTAL=${#DOMAINS[@]} PROCESSED=0 for domain_set in "${DOMAINS[@]}"; do PROCESSED=$((PROCESSED + 1)) log "🔄 [$PROCESSED/$TOTAL] certbot для: $domain_set" start_time=$(date +%s) # Ключевой фикс против преждевременного выхода из-за set -e внутри $(...) set +e if [[ "$DRY_RUN" == "1" ]]; then output=$(certbot certonly --standalone --dry-run \ $(build_d_args "$domain_set") \ --non-interactive --agree-tos --expand 2>&1) else output=$(certbot certonly --standalone \ $(build_d_args "$domain_set") \ --non-interactive --agree-tos --expand 2>&1) fi exit_code=$? set -e end_time=$(date +%s) duration=$((end_time - start_time)) if [[ $exit_code -eq 0 ]]; then log "✅ OK: $domain_set (${duration}s)" SUCCESSFUL+=$'\n'"$domain_set" else log "❌ FAIL: $domain_set (${duration}s)" log " certbot (первые 30 строк):" echo "$output" | head -n 30 | sed 's/^/ /' | tee -a "$LOG_FILE" >/dev/null FAILED+=$'\n'"$domain_set" fi log "---" sleep 2 done # Поднимаем nginx обратно, если он был запущен до старта NGINX_STATUS="⚪ nginx неизвестно" if [[ "$nginx_was_running" -eq 1 ]]; then log "🚀 Запуск nginx..." systemctl start nginx nginx_started_by_script=1 if systemctl is-active --quiet nginx; then log "✅ nginx запущен" NGINX_STATUS="🟢 nginx запущен" else log "❌ nginx не запустился!" NGINX_STATUS="🔴 nginx не запущен" fi else NGINX_STATUS="ℹ️ nginx был остановлен до старта" fi # Короткая выжимка certbot certificates CERT_INFO_SNIP="" set +e CERT_INFO_FULL=$(certbot certificates 2>/dev/null) set -e if [[ -n "${CERT_INFO_FULL:-}" ]]; then CERT_INFO_SNIP=$(echo "$CERT_INFO_FULL" | grep -E 'Certificate Name:|Domains:|Expiry Date:' | head -n 60) fi # Счётчики по наборам (а не по словам) ok_sets=0 bad_sets=0 if [[ -n "$SUCCESSFUL" ]]; then ok_sets=$(echo "$SUCCESSFUL" | sed '/^\s*$/d' | wc -l) fi if [[ -n "$FAILED" ]]; then bad_sets=$(echo "$FAILED" | sed '/^\s*$/d' | wc -l) fi end_epoch=$(date +%s) total_duration=$((end_epoch - JOB_START_EPOCH)) # Финальный отчёт — 2-е сообщение details=$( printf "⏱ Длительность: %s сек\n" "$total_duration" printf "%s\n" "$NGINX_STATUS" printf "DRY_RUN=%s\n\n" "$DRY_RUN" printf "📦 Наборов доменов: %s\n" "$TOTAL" printf "✅ Успешно: %s\n" "$ok_sets" printf "❌ Ошибки: %s\n\n" "$bad_sets" if [[ -n "$SUCCESSFUL" ]]; then printf "✅ Успешно:\n" echo "$SUCCESSFUL" | sed '/^\s*$/d' | sed 's/^/ • /' printf "\n" fi if [[ -n "$FAILED" ]]; then printf "❌ С ошибками:\n" echo "$FAILED" | sed '/^\s*$/d' | sed 's/^/ • /' printf "\n⚠️ Проверь: DNS A/AAAA, порт 80, firewall, и лог certbot.\n" fi if [[ -n "$CERT_INFO_SNIP" ]]; then printf "\ncertbot certificates (кратко):\n%s\n" "$CERT_INFO_SNIP" fi ) if [[ "$bad_sets" -eq 0 ]]; then tg_send \ "Завершение обновления SSL-сертификатов" \ "success" \ "Обновление завершено успешно" \ "$details" else tg_send \ "Завершение обновления SSL-сертификатов" \ "warning" \ "Обновление завершено с ошибками" \ "$details" fi log "=== DONE ===" # Код выхода if [[ "$bad_sets" -gt 0 ]]; then exit 1 else exit 0 fi
🧿 Демонизация: systemd service + timer
Чтобы не запускать руками, используется пара unit-файлов: certbot-update.service и certbot-update.timer.
systemd service
Type=oneshot— задача разовая, не висящий демон.Environment=DRY_RUN=0— принудительно боевой режим.ExecStart=/usr/local/bin/update_certificates.sh— запуск скрипта.
systemd timer
- Запуск 1-го и 15-го числа (ночью).
Persistent=true— догоняет пропущенные запуски.RandomizedDelaySec— при желании добавляет небольшой рандом.
📄 Листинг юнита systemd
nano /etc/systemd/system/certbot-update.service
GNU nano 8.4 /etc/systemd/system/certbot-update.service * [Unit] Description=Update SSL certificates (certbot standalone) + Telegram report Wants=network-online.target After=network-online.target [Service] Type=oneshot # Важно: принудительно боевой режим Environment=DRY_RUN=0 # Скрипт должен быть исполняемым ExecStart=/usr/local/bin/update_certificates.sh # На всякий случай: не зависать бесконечно TimeoutStartSec=30min # Логи будут в journald + твой /var/log/certbot-update.log # (скрипт сам пишет в /var/log/certbot-update.log)
📄 Таймер
nano /etc/systemd/system/certbot-update.timer
GNU nano 8.4 /etc/systemd/system/certbot-update.timer * [Unit] Description=Run certbot-update twice a month [Timer] OnCalendar=*-*-01 04:20:00 OnCalendar=*-*-15 04:20:00 # Если сервер был выключен в момент запуска — догонит после включения Persistent=true # Небольшой рандом, чтобы не долбить LE ровно в одно время (полезно, но не обязательно) RandomizedDelaySec=10min Unit=certbot-update.service [Install] WantedBy=timers.target
Активация и проверка:
# Перезагружаем systemd для загрузки новых юнитов
sudo systemctl daemon-reload
# Включаем и запускаем таймер
sudo systemctl enable --now certbot-update.timer
# Проверяем статус таймера
sudo systemctl status certbot-update.timer
# Смотрим список предстоящих запусков
sudo systemctl list-timers --all | grep certbot-update
# Проверяем лог последнего запуска
sudo journalctl -u certbot-update.service -n 50
✅ Итог
- Сертификаты обновляются автоматически.
- nginx корректно останавливается и поднимается обратно.
- Telegram даёт понятный отчёт: старт и итог.
- systemd timer делает процесс регулярным и незаметным.
🔔 P.S
- Говорят, у nginx есть какой-то свой механизм мягкого обновления сертификатов, который работает значительно быстрее. Но я, пожалуй, не буду вносить изменения в скрипт, пока жаренный петух не клюнет в жопу. Работает - и так сойдет!
