Три просроченных сертификата за квартал: автоматизируем SSL для 45 сайтов

Задача клиента: сертификаты истекают незаметно

В марте 2026 года к нам обратилось digital-агентство WebCraft из Казани. Ребята делали и поддерживали сайты для малого и среднего бизнеса: 45 клиентских площадок на 3 серверах (2 с nginx, 1 с Apache) плюс ещё 8 сайтов в Kubernetes-кластере.

За последний квартал у них три раза рвануло на просроченных SSL-сертификатах:

  • Инцидент 1: Интернет-магазин мебели — сертификат умер ночью, утром покупатели уткнулись в ERR_CERT_DATE_INVALID. Продажи стояли 4 часа, пока не пришёл администратор
  • Инцидент 2: Корпоративный сайт строительной компании — Chrome намертво заблокировал переход. Владелец решил, что сайт взломали, и звонил в панике полдня
  • Инцидент 3: API-сервер мобильного приложения — 15 000 пользователей разом остались без работающего приложения, потому что pinned certificate не совпал с обновлённым
«Каждый инцидент — это потерянные клиенты и нервный звонок от заказчика. Мы не можем вручную следить за 45 сертификатами, нужна автоматизация» — технический директор WebCraft.

Корень проблемы был простой и грустный. Часть сертификатов — от Let's Encrypt на 90 дней, часть — коммерческие на год. Обновляли вручную, сроки фиксировали в Google-таблице, которую никто не открывал месяцами. Мы из АйТи Фреш предложили закрыть вопрос раз и навсегда: автообновление всего что есть плюс мониторинг с алертами.

Инвентаризация сертификатов

Начали с инвентаризации — нужно было понять, что вообще происходит на серверах клиента:

#!/bin/bash
# ssl-inventory.sh — инвентаризация всех SSL-сертификатов

echo "=== Инвентаризация SSL-сертификатов ==="
echo ""

# Сертификаты certbot
echo "--- Let's Encrypt (certbot) ---"
sudo certbot certificates 2>/dev/null | grep -E '(Certificate Name|Domains|Expiry Date)'

# Сертификаты в nginx
echo ""
echo "--- nginx конфиги ---"
for conf in /etc/nginx/sites-enabled/*; do
    CERT=$(grep -oP 'ssl_certificate\s+\K[^;]+' "$conf" 2>/dev/null | head -1)
    SERVER=$(grep -oP 'server_name\s+\K[^;]+' "$conf" 2>/dev/null | head -1)
    if [ -n "$CERT" ] && [ -f "$CERT" ]; then
        EXPIRY=$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2)
        DAYS=$(( ($(date -d "$EXPIRY" +%s) - $(date +%s)) / 86400 ))
        ISSUER=$(openssl x509 -issuer -noout -in "$CERT" | grep -oP 'O=\K[^,/]+')
        if [ $DAYS -lt 30 ]; then
            STATUS="⚠ СКОРО ИСТЕКАЕТ"
        elif [ $DAYS -lt 0 ]; then
            STATUS="❌ ИСТЁК"
        else
            STATUS="✓ OK"
        fi
        printf "%-35s %-20s %3d дней  %-15s %s\n" "$SERVER" "$ISSUER" "$DAYS" "$STATUS" "$CERT"
    fi
done

# Результат:
# mebel-store.ru               Let's Encrypt   12 дней  ⚠ СКОРО ИСТЕКАЕТ  /etc/letsencrypt/live/mebel-store.ru/fullchain.pem
# stroydom-kazan.ru            Comodo           340 дней ✓ OK              /etc/ssl/certs/stroydom.pem
# api.mobile-app.ru            Let's Encrypt   -3 дней  ❌ ИСТЁК           /etc/letsencrypt/live/api.mobile-app.ru/fullchain.pem
# ...

Картина получилась интересная: 32 сертификата Let's Encrypt (5 из них истекали уже через 2 недели), 8 коммерческих от Comodo и DigiCert, и 5 самоподписанных на внутренних сервисах. Certbot стоял, автообновление было настроено — но cron-задача тихо падала из-за ошибки прав доступа. Месяцами.

Корневые причины проблем

Разобрались, почему сертификаты продолжали истекать. Причин оказалось четыре:

  • Сломанный cron certbot/etc/cron.d/certbot запускал обновление от имени пользователя www-data, у которого не было прав на перезапуск nginx
  • HTTP challenge невозможен для некоторых доменов — 6 доменов сидели за Cloudflare CDN, и HTTP-01 challenge там просто не проходил
  • Коммерческие сертификаты — обновлялись только руками, а сроки постоянно терялись
  • Нет мониторинга — никто не получал ни одного уведомления о приближающемся истечении

Настройка certbot с автоматическим обновлением

Все 45 сайтов перевели на Let's Encrypt и настроили нормальное автообновление.

Установка certbot и плагинов

# Установка certbot с плагинами для nginx и Cloudflare DNS
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx python3-certbot-dns-cloudflare

# Проверка версии
certbot --version
# certbot 2.9.0

# Для Apache-сервера (третий сервер клиента)
sudo apt-get install -y python3-certbot-apache

Получение сертификатов через HTTP-01 challenge

Домены без Cloudflare CDN — стандартный HTTP-01 challenge, ничего хитрого:

# Получение сертификата для одного домена
sudo certbot --nginx -d mebel-store.ru -d www.mebel-store.ru \
  --non-interactive --agree-tos --email admin@webcraft.ru \
  --redirect --staple-ocsp

# Массовое получение сертификатов
# Список доменов (по одному на строку, домен + www)
cat > /tmp/domains.txt <<'EOF'
mebel-store.ru,www.mebel-store.ru
kazan-flowers.ru,www.kazan-flowers.ru
auto-parts-kzn.ru,www.auto-parts-kzn.ru
beauty-salon-kazan.ru,www.beauty-salon-kazan.ru
restoran-kazanochka.ru,www.restoran-kazanochka.ru
EOF

# Скрипт массового получения
while IFS= read -r domains; do
    DOMAIN_ARGS=$(echo "$domains" | tr ',' ' ' | sed 's/[^ ]* */-d &/g')
    echo "Получаем сертификат для: $domains"
    sudo certbot --nginx $DOMAIN_ARGS \
      --non-interactive --agree-tos --email admin@webcraft.ru \
      --redirect --staple-ocsp \
      --keep-until-expiring
    echo "---"
done < /tmp/domains.txt

Wildcard-сертификаты через Cloudflare DNS challenge

А вот с 6 доменами за Cloudflare CDN пришлось повозиться. HTTP challenge там не работает в принципе — запись A смотрит на Cloudflare proxy, а не на наш сервер. Решили через DNS-01 challenge и Cloudflare API:

# Создаём файл с API-токеном Cloudflare
sudo mkdir -p /etc/letsencrypt/cloudflare
sudo tee /etc/letsencrypt/cloudflare/credentials.ini > /dev/null <<'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
sudo chmod 600 /etc/letsencrypt/cloudflare/credentials.ini

# Получаем wildcard-сертификат
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
  --dns-cloudflare-propagation-seconds 30 \
  -d "stroydom-kazan.ru" \
  -d "*.stroydom-kazan.ru" \
  --non-interactive --agree-tos --email admin@webcraft.ru

# Проверяем полученный сертификат
sudo openssl x509 -in /etc/letsencrypt/live/stroydom-kazan.ru/fullchain.pem \
  -noout -subject -dates -ext subjectAltName
# subject= CN = stroydom-kazan.ru
# notBefore=Mar 31 00:00:00 2026 GMT
# notAfter=Jun 29 00:00:00 2026 GMT
# X509v3 Subject Alternative Name:
#     DNS:stroydom-kazan.ru, DNS:*.stroydom-kazan.ru

Wildcard-сертификат закрывает сразу все поддомены: www, api, admin, cdn — один файл вместо четырёх отдельных.

Настройка автообновления и reload hooks

Момент, который часто упускают: certbot должен не просто обновить сертификат, но и перезагрузить веб-сервер — иначе nginx продолжает раздавать старый:

# Создаём deploy hooks для nginx и Apache
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy

# Hook для nginx
sudo tee /etc/letsencrypt/renewal-hooks/deploy/01-reload-nginx.sh > /dev/null <<'HOOK'
#!/bin/bash
# Перезагружаем nginx после обновления сертификата
if systemctl is-active --quiet nginx; then
    systemctl reload nginx
    logger "certbot: nginx перезагружен после обновления сертификата $RENEWED_DOMAINS"
fi
HOOK
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/01-reload-nginx.sh

# Hook для Apache (на третьем сервере)
sudo tee /etc/letsencrypt/renewal-hooks/deploy/01-reload-apache.sh > /dev/null <<'HOOK'
#!/bin/bash
if systemctl is-active --quiet apache2; then
    systemctl reload apache2
    logger "certbot: apache2 перезагружен после обновления сертификата $RENEWED_DOMAINS"
fi
HOOK
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/01-reload-apache.sh

# Hook для уведомления в Telegram
sudo tee /etc/letsencrypt/renewal-hooks/deploy/99-notify.sh > /dev/null <<'HOOK'
#!/bin/bash
BOT_TOKEN="YOUR_BOT_TOKEN"
CHAT_ID="YOUR_CHAT_ID"
MSG="SSL обновлён: $RENEWED_DOMAINS на $(hostname) ($(date '+%Y-%m-%d %H:%M'))"
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
  -d chat_id="${CHAT_ID}" -d text="${MSG}" > /dev/null
HOOK
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/99-notify.sh

# Настраиваем systemd timer вместо cron (надёжнее)
sudo systemctl enable certbot.timer
sudo systemctl start certbot.timer

# Проверяем расписание
systemctl list-timers certbot.timer
# NEXT                         LEFT          LAST   PASSED  UNIT          ACTIVATES
# Tue 2026-04-01 03:25:00 MSK  23h left      Mon... 1h ago  certbot.timer certbot.service

# Тестовый запуск (dry run)
sudo certbot renew --dry-run
# Processing /etc/letsencrypt/renewal/mebel-store.ru.conf
# Simulating renewal of an existing certificate for mebel-store.ru and www.mebel-store.ru
# ...
# Congratulations, all simulated renewals succeeded:
#   /etc/letsencrypt/live/mebel-store.ru/fullchain.pem (success)
#   /etc/letsencrypt/live/stroydom-kazan.ru/fullchain.pem (success)
#   ... (45 сертификатов)

Мониторинг SSL-сертификатов

Автообновление — хорошо. Но что если certbot не сможет обновить сертификат? DNS лёг, API Cloudflare вернул ошибку, закончилось место на диске — вариантов масса. Нужен независимый мониторинг, который предупредит заранее, а не после того как всё упало.

Скрипт проверки сроков SSL

Написали скрипт с двойной проверкой: снаружи — через реальное TLS-подключение, и изнутри — по файлам на диске:

#!/bin/bash
# /opt/scripts/check-ssl-expiry.sh
# Проверяет SSL-сертификаты всех доменов и алертит при приближении срока

DOMAINS=(
    "mebel-store.ru"
    "stroydom-kazan.ru"
    "kazan-flowers.ru"
    "auto-parts-kzn.ru"
    "beauty-salon-kazan.ru"
    "restoran-kazanochka.ru"
    # ... все 45 доменов
)

WARN_DAYS=21    # Предупреждение за 21 день
CRIT_DAYS=7     # Критический алерт за 7 дней
BOT_TOKEN="YOUR_BOT_TOKEN"
CHAT_ID="YOUR_CHAT_ID"
LOGFILE="/var/log/ssl-check.log"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] SSL check started" >> "$LOGFILE"

for domain in "${DOMAINS[@]}"; do
    # Получаем дату истечения через TLS-подключение
    EXPIRY=$(echo | openssl s_client -servername "$domain" -connect "$domain":443 2>/dev/null \
             | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

    if [ -z "$EXPIRY" ]; then
        MSG="SSL CHECK FAILED: $domain — не удалось подключиться"
        echo "[$(date)] $MSG" >> "$LOGFILE"
        curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
          -d chat_id="${CHAT_ID}" -d text="$MSG" > /dev/null
        continue
    fi

    EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
    NOW_EPOCH=$(date +%s)
    DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

    if [ $DAYS_LEFT -lt 0 ]; then
        MSG="SSL EXPIRED: $domain истёк $((DAYS_LEFT * -1)) дней назад!"
        curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
          -d chat_id="${CHAT_ID}" -d text="$MSG" > /dev/null
    elif [ $DAYS_LEFT -lt $CRIT_DAYS ]; then
        MSG="SSL CRITICAL: $domain истекает через $DAYS_LEFT дней ($EXPIRY)"
        curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
          -d chat_id="${CHAT_ID}" -d text="$MSG" > /dev/null
    elif [ $DAYS_LEFT -lt $WARN_DAYS ]; then
        MSG="SSL WARNING: $domain истекает через $DAYS_LEFT дней ($EXPIRY)"
        curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
          -d chat_id="${CHAT_ID}" -d text="$MSG" > /dev/null
    fi

    echo "[$(date)] $domain: $DAYS_LEFT дней" >> "$LOGFILE"
done

echo "[$(date '+%Y-%m-%d %H:%M:%S')] SSL check completed" >> "$LOGFILE"
# Добавляем в cron (ежедневная проверка в 9:00)
echo '0 9 * * * root /opt/scripts/check-ssl-expiry.sh' | sudo tee /etc/cron.d/ssl-check
sudo chmod 644 /etc/cron.d/ssl-check

Мониторинг в Prometheus и Grafana

Для полноценного мониторинга развернули blackbox_exporter — компонент Prometheus, который проверяет внешние эндпоинты и умеет снимать SSL-метрики:

# Установка blackbox_exporter
wget https://github.com/prometheus/blackbox_exporter/releases/download/v0.25.0/blackbox_exporter-0.25.0.linux-amd64.tar.gz
tar xvf blackbox_exporter-0.25.0.linux-amd64.tar.gz
sudo mv blackbox_exporter-0.25.0.linux-amd64/blackbox_exporter /usr/local/bin/

# Конфигурация blackbox_exporter
sudo tee /etc/blackbox_exporter/config.yml > /dev/null <<'EOF'
modules:
  http_2xx_ssl:
    prober: http
    timeout: 10s
    http:
      valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
      valid_status_codes: [200, 301, 302]
      method: GET
      tls_config:
        insecure_skip_verify: false
      fail_if_ssl: false
      fail_if_not_ssl: true
EOF

# Systemd unit
sudo tee /etc/systemd/system/blackbox-exporter.service > /dev/null <<'EOF'
[Unit]
Description=Blackbox Exporter
After=network.target

[Service]
ExecStart=/usr/local/bin/blackbox_exporter --config.file=/etc/blackbox_exporter/config.yml
Restart=always
User=nobody

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now blackbox-exporter

Конфигурация Prometheus для сбора SSL-метрик:

# Добавляем в /etc/prometheus/prometheus.yml

  - job_name: 'ssl-monitoring'
    metrics_path: /probe
    params:
      module: [http_2xx_ssl]
    static_configs:
      - targets:
          - https://mebel-store.ru
          - https://stroydom-kazan.ru
          - https://kazan-flowers.ru
          - https://auto-parts-kzn.ru
          - https://beauty-salon-kazan.ru
          # ... все 45 доменов
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: localhost:9115  # blackbox_exporter
# Алерты для SSL в Prometheus
# /etc/prometheus/rules/ssl_alerts.yml
groups:
  - name: ssl
    rules:
      - alert: SSLCertExpiringSoon
        expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 21
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "SSL сертификат {{ $labels.instance }} истекает менее чем через 21 день"
          description: "Осталось {{ $value | humanizeDuration }}"

      - alert: SSLCertExpireCritical
        expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 7
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "SSL сертификат {{ $labels.instance }} истекает менее чем через 7 дней!"

      - alert: SSLCertExpired
        expr: probe_ssl_earliest_cert_expiry - time() < 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "SSL сертификат {{ $labels.instance }} ИСТЁК!"

      - alert: SSLProbeFailure
        expr: probe_success{job="ssl-monitoring"} == 0
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Не удалось подключиться к {{ $labels.instance }} по HTTPS"

Cert-manager для Kubernetes-сайтов

8 сайтов клиента жили в Kubernetes-кластере — там своя история. Развернули cert-manager, контроллер который сам получает и обновляет сертификаты прямо внутри кластера без какого-либо ручного вмешательства.

Установка cert-manager через Helm

# Установка cert-manager
helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true \
  --version v1.14.0

# Проверяем установку
kubectl -n cert-manager get pods
# NAME                                      READY   STATUS    RESTARTS
# cert-manager-7d8f6b5c4-x2k9l             1/1     Running   0
# cert-manager-cainjector-5b8f6b5c4-m4n7p  1/1     Running   0
# cert-manager-webhook-6b8f6b5c4-p9r2s     1/1     Running   0

ClusterIssuer для Let's Encrypt и Cloudflare DNS

Создаём два ClusterIssuer — staging для тестов и production для боевой работы:

# Секрет с API-токеном Cloudflare
kubectl -n cert-manager create secret generic cloudflare-api-token \
  --from-literal=api-token=YOUR_CLOUDFLARE_API_TOKEN

# ClusterIssuer для production
cat <<'EOF' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@webcraft.ru
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      # HTTP-01 challenge для обычных доменов
      - http01:
          ingress:
            class: nginx
      # DNS-01 challenge для wildcard (Cloudflare)
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
        selector:
          dnsZones:
            - "stroydom-kazan.ru"
            - "webcraft-agency.ru"
EOF

# Проверяем статус
kubectl get clusterissuer
# NAME               READY   AGE
# letsencrypt-prod   True    30s

Дальше всё просто: добавляем аннотацию к Ingress-ресурсу, и cert-manager сам разберётся с получением сертификата:

# Пример Ingress с автоматическим SSL
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mebel-store-ingress
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  tls:
    - hosts:
        - mebel-store.ru
        - www.mebel-store.ru
      secretName: mebel-store-tls
  rules:
    - host: mebel-store.ru
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: mebel-store-svc
                port:
                  number: 80

# Проверяем статус сертификата
kubectl get certificate
# NAME              READY   SECRET            AGE
# mebel-store-tls   True    mebel-store-tls   2m

kubectl describe certificate mebel-store-tls
# Events:
#   Normal  Issuing    2m    cert-manager  Issuing certificate
#   Normal  Generated  2m    cert-manager  Generated new private key
#   Normal  Requested  2m    cert-manager  Created CertificateRequest
#   Normal  Issuing    90s   cert-manager  The certificate has been issued successfully

Замена коммерческих сертификатов на Let's Encrypt

8 коммерческих сертификатов обходились клиенту примерно в 40 000 руб/год. Перевели всё на Let's Encrypt.

Процесс замены коммерческого сертификата

По каждому домену с коммерческим сертификатом прошли по одной схеме:

# 1. Получаем новый сертификат Let's Encrypt
sudo certbot certonly --nginx \
  -d stroydom-kazan.ru -d www.stroydom-kazan.ru \
  --non-interactive --agree-tos --email admin@webcraft.ru

# 2. Обновляем конфигурацию nginx
# Было:
# ssl_certificate /etc/ssl/certs/stroydom-kazan.ru.crt;
# ssl_certificate_key /etc/ssl/private/stroydom-kazan.ru.key;

# Стало:
# ssl_certificate /etc/letsencrypt/live/stroydom-kazan.ru/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/stroydom-kazan.ru/privkey.pem;

sudo sed -i 's|ssl_certificate /etc/ssl/certs/stroydom-kazan.ru.crt;|ssl_certificate /etc/letsencrypt/live/stroydom-kazan.ru/fullchain.pem;|' /etc/nginx/sites-enabled/stroydom-kazan.conf
sudo sed -i 's|ssl_certificate_key /etc/ssl/private/stroydom-kazan.ru.key;|ssl_certificate_key /etc/letsencrypt/live/stroydom-kazan.ru/privkey.pem;|' /etc/nginx/sites-enabled/stroydom-kazan.conf

# 3. Проверяем конфигурацию и перезагружаем
sudo nginx -t
# nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx

# 4. Проверяем новый сертификат
echo | openssl s_client -connect stroydom-kazan.ru:443 -servername stroydom-kazan.ru 2>/dev/null | openssl x509 -noout -issuer -dates
# issuer= /C=US/O=Let's Encrypt/CN=E6
# notBefore=Mar 31 00:00:00 2026 GMT
# notAfter=Jun 29 00:00:00 2026 GMT

OCSP Stapling и оптимизация SSL

Заодно причесали SSL-конфигурацию nginx на всех сайтах — там тоже было что улучшить:

# /etc/nginx/snippets/ssl-params.conf — общие параметры SSL

# Протоколы и шифры
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;

# SSL session cache
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;

# OCSP Stapling (ускоряет TLS handshake)
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 1.1.1.1 valid=300s;
resolver_timeout 5s;

# DH params (для TLS 1.2)
ssl_dhparam /etc/nginx/ssl/dhparam.pem;

# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
# Генерация DH-параметров (один раз)
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

# Подключаем в каждый server-блок
# server {
#     listen 443 ssl http2;
#     include snippets/ssl-params.conf;
#     ssl_certificate /etc/letsencrypt/live/domain.ru/fullchain.pem;
#     ssl_certificate_key /etc/letsencrypt/live/domain.ru/privkey.pem;
#     ...
# }

# Проверяем OCSP stapling
openssl s_client -connect mebel-store.ru:443 -status /dev/null | grep -A 3 'OCSP Response'
# OCSP Response Status: successful (0x0)
# OCSP Response Data:
#     Response Status: good

Дашборд SSL-мониторинга в Grafana

Сделали единый дашборд в Grafana — все 45 сайтов на одном экране, без переключений.

PromQL-запросы для дашборда

Основные панели дашборда:

# Панель 1: Дней до истечения (таблица)
# PromQL:
(probe_ssl_earliest_cert_expiry - time()) / 86400

# Панель 2: Сертификаты, истекающие в ближайшие 30 дней (список)
# PromQL:
(probe_ssl_earliest_cert_expiry - time()) / 86400 < 30

# Панель 3: Статус HTTPS-проверки (StatusMap)
# PromQL:
probe_success{job="ssl-monitoring"}

# Панель 4: Версия TLS
# PromQL:
probe_tls_version_info{job="ssl-monitoring"}

# Панель 5: Время TLS handshake
# PromQL:
probe_http_duration_seconds{phase="tls",job="ssl-monitoring"}

Логика цветов простая: зелёный — больше 30 дней до истечения, жёлтый — меньше 21 дня, красный — меньше 7. Технический директор WebCraft рассказал, что стал открывать дашборд каждое утро — просто чтобы убедиться, что всё зелёное.

Ежедневный отчёт в Telegram

Помимо алертов настроили ежедневный сводный отчёт — чтобы утром в почте была полная картина:

#!/bin/bash
# /opt/scripts/ssl-daily-report.sh
# Ежедневный отчёт по SSL-сертификатам

DOMAINS_FILE="/opt/scripts/domains.list"
BOT_TOKEN="YOUR_BOT_TOKEN"
CHAT_ID="YOUR_CHAT_ID"

TOTAL=0
OK=0
WARN=0
CRIT=0
EXPIRED=0
DETAILS=""

while IFS= read -r domain; do
    [ -z "$domain" ] && continue
    TOTAL=$((TOTAL + 1))

    EXPIRY_EPOCH=$(echo | openssl s_client -servername "$domain" -connect "$domain":443 2>/dev/null \
      | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 | xargs -I{} date -d {} +%s 2>/dev/null)

    if [ -z "$EXPIRY_EPOCH" ]; then
        CRIT=$((CRIT + 1))
        DETAILS="${DETAILS}\n  $domain — НЕДОСТУПЕН"
        continue
    fi

    DAYS=$(( (EXPIRY_EPOCH - $(date +%s)) / 86400 ))

    if [ $DAYS -lt 0 ]; then
        EXPIRED=$((EXPIRED + 1))
        DETAILS="${DETAILS}\n  $domain — ИСТЁК ($DAYS дн.)"
    elif [ $DAYS -lt 7 ]; then
        CRIT=$((CRIT + 1))
        DETAILS="${DETAILS}\n  $domain — $DAYS дн."
    elif [ $DAYS -lt 21 ]; then
        WARN=$((WARN + 1))
        DETAILS="${DETAILS}\n  $domain — $DAYS дн."
    else
        OK=$((OK + 1))
    fi
done < "$DOMAINS_FILE"

MSG="SSL-отчёт $(date '+%d.%m.%Y'):
  Всего: $TOTAL
  OK (>21д): $OK
  Warning (<21д): $WARN
  Critical (<7д): $CRIT
  Истекших: $EXPIRED"

if [ $WARN -gt 0 ] || [ $CRIT -gt 0 ] || [ $EXPIRED -gt 0 ]; then
    MSG="${MSG}\n\nТребуют внимания:${DETAILS}"
fi

curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
  -d chat_id="${CHAT_ID}" \
  -d text="$(echo -e "$MSG")" > /dev/null
# Cron: ежедневно в 9:00
echo '0 9 * * * root /opt/scripts/ssl-daily-report.sh' | sudo tee /etc/cron.d/ssl-report

Результаты внедрения

Проект закрыли за 4 рабочих дня. Вот что получилось в цифрах — результаты автоматизации SSL, которую сделала команда АйТи Фреш для агентства WebCraft:

МетрикаДо автоматизацииПосле автоматизации
Просроченных сертификатов за квартал3 инцидента0 инцидентов
Время на управление сертификатами~4 часа/месяц (ручное)0 (полная автоматизация)
Расходы на коммерческие SSL~40 000 руб/год0 руб (Let's Encrypt)
Время реакции на проблему1–4 часа (утренний звонок клиента)5 минут (алерт в Telegram)
Покрытие мониторингом0%100% (45 доменов + 8 в K8s)
OCSP StaplingНе настроен100% сайтов

За три месяца certbot автоматически обновил 135 сертификатов — 45 доменов, три обновления за 90-дневный цикл. Ни одного инцидента. Мониторинг дважды поймал проблемы до того, как они превратились в аварию: первый раз DNS-провайдер лёг в момент DNS challenge, второй — Cloudflare API вернул 429, то есть rate limit. В обоих случаях алерт пришёл за 14 дней до истечения сертификата. Четырнадцать дней — это вполне комфортное время, чтобы разобраться без паники.

«Раньше SSL-сертификаты были постоянной головной болью. Сейчас я вообще о них не думаю — получаю отчёт в Telegram каждое утро, и там всегда зелёный статус. 40 000 рублей экономии в год — приятный бонус» — технический директор WebCraft.

Часто задаваемые вопросы

Если коротко — ничем существенным. Let's Encrypt выпускает стандартные DV (Domain Validation) сертификаты, которые нормально воспринимаются всеми браузерами без каких-либо предупреждений. Есть три отличия от коммерческих: срок действия 90 дней вместо года — но это закрывается автообновлением; нет OV/EV-валидации с верификацией организации; нет финансовой гарантии. На практике для 95% сайтов Let's Encrypt — совершенно рабочий выбор. OV и EV сертификаты реально нужны только банкам и крупным корпорациям, которым важна строчка с названием компании в адресной строке.

DNS-01 challenge — это метод подтверждения владения доменом, при котором certbot создаёт TXT-запись в DNS-зоне. Мы используем его в двух ситуациях. Первая — wildcard-сертификаты вида *.domain.ru: HTTP-01 их просто не поддерживает, без вариантов. Вторая — когда сервер недоступен снаружи: стоит за CDN или закрыт файрволом. Для работы DNS challenge нужен API-доступ к вашему DNS-провайдеру. Certbot из коробки поддерживает Cloudflare, Route53, Google DNS, DigitalOcean и ещё несколько десятков через плагины.

Из командной строки проще всего так: echo | openssl s_client -servername domain.ru -connect domain.ru:443 2>/dev/null | openssl x509 -noout -dates. Вывод покажет notBefore и notAfter — дату начала и конца действия. Если сертификат лежит файлом, то: openssl x509 -enddate -noout -in /path/to/cert.pem. В браузере — кликните на замок в адресной строке, выберите «Сертификат», вкладка «Срок действия». Три способа, выбирайте удобный.

Wildcard-сертификат *.domain.ru закрывает все поддомены первого уровня: www, api, admin, mail — любые. Но есть важный нюанс, на котором спотыкаются: он НЕ покрывает сам корневой домен domain.ru и поддомены второго уровня вроде sub.api.domain.ru. Поэтому мы всегда запрашиваем оба варианта сразу: -d domain.ru -d *.domain.ru. Один certbot-запрос — и всё закрыто.

Вот что чаще всего ломается и как это чинить. Порт 80 закрыт — HTTP-01 challenge без него не работает, смотрите правила файрвола. DNS не обновляется — при DNS challenge первым делом проверяйте API-токен и права на запись в зону. Rate limit Let's Encrypt — лимит 50 сертификатов на домен в неделю, превысить несложно при массовом перевыпуске. Нет прав на перезапуск nginx — deploy hook обязан запускаться от root, иначе nginx не перечитает новый сертификат. И универсальный совет: перед боевым обновлением запускайте certbot renew --dry-run — покажет ошибку, не трогая реальные сертификаты.

Нужна помощь с настройкой?

Специалисты АйТи Фреш помогут с внедрением и настройкой — 15+ лет опыта, обслуживание от 15 000 ₽/мес

📞 Связаться с нами
#SSL сертификат автоматизация#Let's Encrypt certbot#wildcard сертификат DNS challenge#мониторинг SSL сертификатов#Zabbix проверка сертификатов#cert-manager Kubernetes#Cloudflare API certbot#автообновление сертификатов