🔐 Автообновление SSL: Certbot + Telegram + systemd timer

Это заметка-памятка: как я сделал обновление сертификатов Let’s Encrypt автоматическим, предсказуемым и наблюдаемым. Всё завязано на один bash-скрипт и нормальную демонизацию через systemd.

⚙️ certbot standalone
📨 2 сообщения в Telegram: старт + итог
🧯 nginx останавливается и поднимается обратно
🕒 systemd timer: 2 раза в месяц
Важное наблюдение из практики: даже если certbot обновил сертификат, браузер может продолжать ругаться, если nginx смотрит на не тот путь к fullchain.pem/privkey.pem. Это лечится правильными путями в конфиге и reload/restart.

🎯 Задача

  • Обновлять SSL-сертификаты Let’s Encrypt (Certbot) для нескольких доменов.
  • Работать без ручного вмешательства и «зависаний».
  • Присылать в Telegram не спам, а короткий отчёт (2 сообщения).
  • Запускаться автоматически 2 раза в месяц в боевом режиме: DRY_RUN=0.
1. stop nginx
2. certbot (groups)
3. start nginx
4. Telegram summary

🧩 Почему certbot standalone

В режиме --standalone certbot поднимает временный HTTP-сервер на 80 порту для прохождения HTTP-01 challenge. Поэтому на время обновления нужно освободить порт. Проще всего — кратко остановить nginx.

➕ Плюсы

  • Минимум зависимости от конфигов nginx (webroot не нужен).
  • Меньше «хрупких мест» и ручных условий.

➖ Минусы

  • Нужно освобождать 80 порт (стоп/старт nginx).
  • Короткий простой веба возможен (обычно секунды).
Если простой недопустим вообще — смотри альтернативы: webroot или DNS-01.

📦 Группировка доменов по сертификатам

Доменов много, но certbot удобно запускать группами: 1 строка = 1 сертификат. Это удобно и для диагностики, и для отчёта.

  • 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

🧠 Как работает скрипт

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 лучше cron: легко смотреть логи, статусы, следующие запуски, и меньше «скрытой магии».

📄 Листинг юнита 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 делает процесс регулярным и незаметным.
Если после успешного обновления браузер всё равно ругается — почти всегда виноват nginx, который смотрит на неправильные пути сертификатов. Это лечится за минуту.
 

🔔 P.S

  • Говорят, у nginx есть какой-то свой механизм мягкого обновления сертификатов, который работает значительно быстрее. Но я, пожалуй, не буду вносить изменения в скрипт, пока жаренный петух не клюнет в жопу. Работает - и так сойдет!