Ночной запуск резервного копирования падает с сообщением "SSL handshake failed", днём та же команда из интерактивной сессии работает безукоризненно. Знакомая картина для многих администраторов. Cron живёт в аскетичном окружении, и именно там всплывают проблемы с цепочками сертификатов, устаревшими CA bundle и несогласованным SNI. В этой статье разбирается практический маршрут диагностики, в центре которого стоит утилита openssl s_client с правильно выбранным параметром -servername.
Почему cron ломается там, где shell работает
Планировщик cron запускает задачи с минимальным набором переменных окружения. В типичном окружении cron есть лишь PATH=/usr/bin:/bin, HOME, LOGNAME и SHELL=/bin/sh. Многие инструменты, работающие с TLS, зависят от переменных, которые в этом окружении отсутствуют. Классические примеры: SSL_CERT_FILE и SSL_CERT_DIR для OpenSSL, CURL_CA_BUNDLE для curl, REQUESTS_CA_BUNDLE для Python-библиотеки requests, NODE_EXTRA_CA_CERTS для Node.js. Скрипт, который в интерактивной сессии находил корневые сертификаты через переменную из профиля пользователя, под cron остаётся с пустым указателем и пытается использовать встроенные пути.
Проверить окружение cron проще всего прямым способом. Добавьте в crontab временную строку:
\\\`bash
# Выгрузка окружения cron в файл для сравнения с интерактивной сессией
* env > /tmp/cron-env.txt 2>&1
\\\`
Через минуту в /tmp/cron-env.txt можно увидеть полный список. Сравнение с выводом env из обычной сессии почти всегда подсвечивает виновника ещё до запуска openssl. Вторая частая причина расхождений кроется в разных бинарниках. В cron PATH указывает на /usr/bin, а в пользовательской сессии может быть /usr/local/bin, где собрана более новая версия того же curl. Команда which в контексте cron показывает, какой именно бинарник получает задача.
Базовый прогон openssl s_client и построчный разбор вывода
Утилита s_client это швейцарский нож TLS-диагностики. Начинать стоит с самого простого вызова:
\\\`bash
# Подключение к серверу с выводом сертификата и цепочки
openssl s_client -connect api.example.com:443 -showcerts < /dev/null
\\\`
Перенаправление из /dev/null закрывает stdin сразу после установления соединения, иначе команда будет ждать ввода. Типичный фрагмент вывода выглядит так:
\\\`text
CONNECTED(00000003)
depth=2 C = US, O = DigiCert Inc, CN = DigiCert Global Root G2
verify return:1
depth=1 C = US, O = DigiCert Inc, CN = RapidSSL TLS RSA CA G1
verify return:1
depth=0 CN = api.example.com
verify return:1
---
Certificate chain
0 s:CN = api.example.com
i:C = US, O = DigiCert Inc, CN = RapidSSL TLS RSA CA G1
1 s:C = US, O = DigiCert Inc, CN = RapidSSL TLS RSA CA G1
i:C = US, O = DigiCert Inc, CN = DigiCert Global Root G2
---
Verification: OK
\\\`
Разбор по строкам. Строка CONNECTED говорит лишь об установленном TCP-соединении, TLS ещё не проверялся. Тройка строк depth показывает обход цепочки от корня к конечному сертификату: depth=2 это корневой CA, depth=1 промежуточный, depth=0 серверный. Блок "Certificate chain" перечисляет то, что реально прислал сервер по протоколу. Номер 0 всегда сертификат самого сервера, далее промежуточные. Финальная строка "Verification: OK" является главным вердиктом. Если вместо неё стоит код ошибки, дальше разбираемся с ним.
Параметр servername и роль SNI в ошибках handshake
На одном IP-адресе нередко живут десятки виртуальных хостов с разными сертификатами. Клиент сообщает желаемое имя через расширение SNI (Server Name Indication) в первом же сообщении ClientHello. Если клиент не передаёт SNI или передаёт неверное имя, сервер отвечает сертификатом по умолчанию, и валидация имени хоста проваливается, а иногда обрывается и сам handshake.
\\\`bash
# Сравнение ответа сервера с SNI и без него
openssl s_client -connect 203.0.113.15:443 -servername api.example.com < /dev/null 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect 203.0.113.15:443 < /dev/null 2>/dev/null | openssl x509 -noout -subject
\\\`
Если первая команда возвращает "subject=CN = api.example.com", а вторая "subject=CN = default-hosting-certificate.local", загадка решена: сервер обслуживает несколько имён, и без корректного SNI клиент получает чужой сертификат. Для cron-скриптов это типично при обращении к IP-адресу вместо домена или при использовании старой библиотеки, не отправляющей SNI вовсе. Проверить версию OpenSSL на клиенте помогает команда openssl version -a, которая заодно показывает каталог OPENSSLDIR, где лежит системный CA bundle.
Следующий слой диагностики затрагивает версии протоколов и коды проверки verify return.
Принудительное указание версии протокола помогает отделить проблемы неготовности сервера от проблем цепочки:
\\\`bash
# Проверка TLS 1.2 принудительно
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 < /dev/null
# Проверка TLS 1.3
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 < /dev/null
\\\`
Ошибка "sslv3 alert handshake failure" после ClientHello означает, что клиент и сервер не договорились о версии или наборе шифров. Серверы с жёсткой политикой часто режут всё ниже TLS 1.2. Параметры -cipher и -ciphersuites позволяют проверить конкретные наборы шифров отдельно для TLS 1.2 и 1.3.
Отдельного внимания заслуживают коды verify. Их полный список выводится с расшифровкой прямо в выводе s_client. Наиболее частые в продакшене:
\\\`text
verify error:num=10:certificate has expired
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
verify error:num=19:self signed certificate in certificate chain
\\\`
Код 10 говорит об истёкшем сроке. Проверить даты удалённого сертификата без установления полноценного соединения вручную можно так:
\\\`bash
# Извлечение сроков действия сертификата с удалённого сервера
openssl s_client -connect api.example.com:443 -servername api.example.com < /dev/null 2>/dev/null | openssl x509 -noout -dates
\\\`
Код 20 означает, что клиент не нашёл издателя в локальном хранилище: почти наверняка проблема CA bundle. Код 21 похож, но указывает, что сервер вообще не прислал промежуточный сертификат, и клиенту нечем достроить цепочку. Код 19 сигнализирует о самоподписанном сертификате где-то в цепочке, типично для внутренних сервисов.
Цепочки сертификатов и почему сервер обязан слать промежуточные
TLS-сервер по стандарту отправляет весь набор сертификатов, кроме корневого. Браузеры прощают пропущенный промежуточный, потому что умеют скачивать его по ссылке AIA (Authority Information Access) или хранят популярные промежуточные в кэше. Консольные утилиты и cron-скрипты такой роскоши лишены. Результат изящен в своей несправедливости: в браузере замок зелёный, а curl из cron падает.
Диагностика проста. Считаем количество сертификатов в ответе сервера:
\\\`bash
# Подсчёт сертификатов в присланной сервером цепочке
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts < /dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE"
\\\`
Если число равно 1, сервер шлёт только конечный сертификат, и строгие клиенты споткнутся. Тест строится на принципе одной переменной: тот же запрос с браузера работает, с curl нет, значит дело в недостающем звене. Исправляется на стороне сервера сборкой полного файла цепочки, для nginx это директива ssl_certificate с файлом, где сначала идёт серверный сертификат, затем промежуточный по порядку.
CA bundle в минимальных окружениях cron и контейнерах
На полноценной системе корневые сертификаты лежат в /etc/ssl/certs/ca-certificates.crt (Debian, Ubuntu) или /etc/pki/tls/certs/ca-bundle.crt (RHEL, CentOS, Rocky). В контейнерах на базе alpine или scratch, в минимальных виртуальных машинах и в chroot-окружениях для cron этих файлов может не быть вовсе. Проверка их свежести тоже полезна, устаревший bundle не знает о новых корнях, которые начали использовать после ротации крупных удостоверяющих центров.
Команды для проверки и подстановки хранилища при диагностике:
\\\`bash
# Явное указание файла доверенных корней при тесте
openssl s_client -connect api.example.com:443 -servername api.example.com -CAfile /etc/ssl/certs/ca-certificates.crt < /dev/null
# Подсчёт корней в bundle
grep -c "BEGIN CERTIFICATE" /etc/ssl/certs/ca-certificates.crt
# Поиск конкретного корня внутри bundle
openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -i "DigiCert Global Root G2"
\\\`
Если с явным -CAfile проверка зелёная, а без него код 20, системный путь по умолчанию битый или пустой. Обновление делается штатно: apt install --reinstall ca-certificates и update-ca-certificates в Debian-подобных системах, yum update ca-certificates в семействе RHEL. В cron-задачах надёжная привычка это задавать переменные окружения явно прямо в crontab, где их можно объявлять построчно до записей расписания:
\\\`bash
# Явные переменные TLS для всех задач в этом crontab
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15 3 * /usr/local/bin/nightly-upload.sh >> /var/log/nightly-upload.log 2>&1
\\\`
Пошаговый план администратора при ошибке handshake в cron
Когда задача упала ночью, действовать стоит методично, меняя по одной переменной за шаг:
- Воспроизвести падение вручную с окружением cron, запустив задачу через env -i с минимальным набором переменных до интерактивной shell-сессии;
- Прогнать openssl s_client с -connect, -servername и -showcerts, зафиксировать строку Verification и код verify;
- Сопоставить код с причиной: 10 проверять даты, 20 чинить CA bundle, 21 чинить цепочку на сервере, 19 договариваться о доверии к внутреннему CA;
- Проверить согласованность протоколов через -tls1_2 и -tls1_3, исключив обрыв на этапе ClientHello;
- Прописать недостающие переменные окружения в crontab, обновить bundle, перезапустить задачу и убедиться в успехе.
Пункт первый заслуживает пояснения. Команда env -i /bin/sh -c '/usr/local/bin/nightly-upload.sh' стартует скрипт в пустом окружении и воспроизводит условия cron почти один в один. Многие администраторы замечали, что после такого прогона половина загадок рассеивается без всякого openssl.
Профилактика рецидивов и мониторинг сроков сертификатов
Грамотная профилактика дешевле ночной диагностики. Минимальный набор мер включает мониторинг сроков действия сертификатов, регулярное обновление ca-certificates, явные переменные окружения в каждом crontab и периодический контрольный прогон s_client. Срок действия сертификата удобно проверять из той же cron, превратив проблему в собственное лекарство:
\\\`bash
#!/bin/bash
# Проверка: за сколько дней истекает сертификат сервиса
HOST="api.example.com"
WARN_DAYS=14
EXPIRY=\$(openssl s_client -connect \${HOST}:443 -servername \${HOST} < /dev/null 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
EXPIRY_TS=\$(date -d "\${EXPIRY}" +%s)
NOW_TS=\$(date +%s)
DAYS_LEFT=\$(( (EXPIRY_TS - NOW_TS) / 86400 ))
if [ "\${DAYS_LEFT}" -lt "\${WARN_DAYS}" ]; then
echo "Сертификат \${HOST} истекает через \${DAYS_LEFT} дн."
fi
\\\`
Такой скрипт под cron раз в сутки поймает приближающееся истечение за две недели. Дополнительно полезно раз в неделю прогонять полную проверку цепочки по списку критичных хостов и складывать результаты в лог с ротацией. Пиннинг версий утилит в контейнерах, единый источник CA bundle для всех сервисов и запрет на обращение к сервисам по голому IP вместо доменного имени снимают остальные типичные грабли. TLS-диагностика перестаёт быть ночным приключением, когда у администратора под рукой есть openssl s_client, понимание кодов verify и привычка думать об окружении cron раньше, чем о коварстве сертификатов.