Запрос PHP к HTTPS API возвращает false, а curl_error() говорит о проблеме с сертификатом. Самый быстрый совет — поставить CURLOPT_SSL_VERIFYPEER в false — действительно может вернуть ответ, но цена такого ответа высока: клиент перестаёт доказывать, что подключился именно к серверу партнёра.
Давайте разберём узкий случай: PHP расширение cURL не может проверить серверный сертификат. Наша цель не в том, чтобы любой ценой получить HTTP-ответ. Нужно назвать причину, проверить её отдельно и оставить проверку цепочки и имени включённой.
Сначала сохраняю факт отказа
Текст ошибки сам по себе недостаточен. Он зависит от связки PHP, libcurl и TLS-библиотеки. Поэтому я сохраняю вместе номер ошибки, её текст, адрес без параметров и версию библиотеки. curl_error() и curl_errno() надо вызвать до curl_close(): после закрытия handle диагностировать уже нечего.
function getPartnerJson($url, $caFile)
{
if (!is_readable($caFile)) {
throw new RuntimeException('CA bundle is not readable: ' . $caFile);
}
$ch = curl_init($url);
$verbose = fopen('php://temp', 'w+');
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CAINFO => $caFile,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_VERBOSE => true,
CURLOPT_STDERR => $verbose,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
));
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
rewind($verbose);
$trace = stream_get_contents($verbose);
curl_close($ch);
fclose($verbose);
if ($body === false) {
throw new RuntimeException('cURL error ' . $errno . ': ' . $error);
}
return array('body' => $body, 'trace' => $trace);
}
В этом примере $caFile приходит из конфигурации приложения, а не из запроса пользователя. Временный verbose-след полезен на закрытом стенде: он помогает увидеть, какой CAfile пытается открыть библиотека и на каком этапе остановилась связь. Его нельзя без разбора отдавать в публичный ответ или журнал с токенами и заголовками.
Разделяю похожие симптомы
Фраза про certificate problem не означает автоматически старый bundle. Сначала я раскладываю наблюдение на несколько проверяемых веток. Код 60 в libcurl относится к неудачной проверке peer, а код 77 связан с чтением локального CA-файла. Однако один номер не заменяет текст ошибки и проверку конкретного окружения.
| Наблюдение | Что проверяю первым | Рабочее действие | Чего не делаю |
|---|---|---|---|
curl_errno() сообщает о peer verification | Имя из URL, цепочку сервера, доверенные корни локального bundle | Собираю цепочку и проверяю её с тем же CAfile | Не выключаю peer verification |
| Ошибка говорит о чтении CAfile | Существует ли файл, права чтения и все каталоги по пути | Исправляю путь или права service-user | Не подменяю ошибку пустым CAfile |
| В браузере работает, в PHP нет | Какие store и версии использует каждый клиент | Сравниваю PHP cURL и CLI отдельно | Не считаю браузер доказательством для PHP |
| На одном имени работает, на другом нет | SNI и имя из URL | Запускаю s_client с -servername | Не проверяю только IP-адрес |
Сравниваю PHP-клиент и командную строку
Команда curl -V полезна, но она описывает бинарник в shell. PHP-модуль может быть собран с другой версией libcurl или другой TLS-библиотекой. Поэтому в PHP я отдельно смотрю curl_version(); она возвращает версии cURL и SSL-библиотеки, связанные именно с расширением.
$version = curl_version();
printf("libcurl: %s\n", $version['version']);
printf("TLS library: %s\n", $version['ssl_version']);
printf("curl.cainfo: %s\n", ini_get('curl.cainfo') ?: '(not set)');
После этого можно повторить один и тот же безопасный запрос из shell, явно задав проверяемый файл. Вместо живого адреса ниже указан шаблон: подставляю только тот hostname, к которому действительно идёт приложение. Команда не является проверкой PHP, но быстро показывает, читает ли данный CA bundle иная связка curl/OpenSSL.
curl -v \
--cacert /opt/app/certs/ca-bundle.pem \
https://api.partner.example/
Если shell проходит, а PHP нет, я не переношу вывод в решение автоматически. Сначала сравниваю путь, права запуска, curl_version() и настройку curl.cainfo. Если оба клиента не доверяют цепочке, следующий вопрос относится уже к сертификатам сервера или составу нашего trust store.
Смотрю, что отдал сервер
Для HTTPS виртуального хоста важно послать Server Name Indication. Параметр -servername у openssl s_client добавляет имя в ClientHello. Без него сервер с несколькими сайтами может вернуть сертификат по умолчанию, и мы будем разбирать не тот объект.
openssl s_client \
-connect api.partner.example:443 \
-servername api.partner.example \
-showcerts \
</dev/null
У -showcerts есть важная граница: OpenSSL показывает список сертификатов, присланный сервером; это ещё не подтверждённая цепочка. Я выписываю subject и issuer каждого PEM-блока, смотрю, есть ли промежуточный сертификат, и только затем проверяю цепочку против конкретного CA bundle. Корневой CA обычно лежит у клиента, поэтому его отсутствие в выводе сервера само по себе не ошибка.
Подключаю CA bundle явным путём
Если проблема в неполном или устаревшем наборе доверенных корней, у исправления есть две границы. Для одного вызова я задаю CURLOPT_CAINFO абсолютным путём. Для всего PHP-окружения директива curl.cainfo задаёт значение по умолчанию для этой опции; PHP требует абсолютный путь. Эти способы не означают, что надо менять настройки OpenSSL stream wrapper: это другой клиентский путь.
; php.ini — абсолютный путь, доступный пользователю PHP-FPM/Apache
curl.cainfo="/opt/app/certs/ca-bundle-2018-08.pem"
; После изменения нужен обычный перезапуск процесса PHP,
; предусмотренный правилами конкретного окружения.
Сам файл беру из доверенного канала поставщика CA store или из пакета операционной системы по правилам проекта. Не собираю trust store из случайного сертификата, скопированного из браузера. Если партнёр использует собственный CA, добавляю именно его доверенный корень после подтверждения у владельца API, а не leaf-сертификат, который завтра может поменяться.
Короткий порядок проверки
- Воспроизвести ошибку на закрытом стенде и сохранить
curl_errno(),curl_error(), hostname и версии изcurl_version(). - Проверить, существует ли CAfile, читается ли он пользователем PHP и не указывает ли
curl.cainfoна другой файл. - Повторить запрос CLI с явным
--cacert, не смешивая результат CLI с результатом PHP. - Получить серверный список сертификатов через
openssl s_clientс правильным-servername. - Проверить, что hostname URL соответствует сертификату и что локальный trust store содержит доверенный корень для этой цепочки.
- Обновить или указать bundle, перезапустить нужный процесс и повторить тот же запрос при
CURLOPT_SSL_VERIFYPEER => true.
Где рецепт не даёт готового ответа
- Ошибка проверки может быть вызвана неверной датой на машине, отозванным сертификатом, неподходящим именем или политикой TLS-библиотеки. CA bundle закрывает только свою ветку.
- Если сервер не отдал нужный промежуточный сертификат, правильное исправление обычно находится у владельца сервера. Добавлять промежуточный сертификат в корневой trust store как постоянный обход не стоит.
- Внутренний сервис с частным CA требует управляемого распространения этого корня. Файл должен быть доступен процессу PHP, но не должен становиться редактируемым из веб-каталога.
- Параметры
CURLOPT_SSL_VERIFYPEERиCURLOPT_SSL_VERIFYHOSTостаются включёнными. Шифрование без проверки личности не подтверждает, кому отправлены данные.
Что считаю готовым
Исправление готово, когда тот же PHP-код с тем же URL завершает TLS-проверку при включённых peer и hostname checks, а путь к CA bundle понятен следующему разработчику. Если после этого ошибка остаётся, у нас уже есть не совет выключить защиту, а набор фактов для разговора с владельцем API: версия клиента, имя, серверная цепочка и локальный store.
Проверяемые источники
- PHP Manual: curl_error — текст последней ошибки текущего cURL-сеанса; его нужно читать до закрытия handle
- PHP Manual: curl_errno — числовой код последней ошибки cURL-сеанса
- PHP Manual: curl_version — версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль
- PHP Manual: cURL Runtime Configuration — директива curl.cainfo задаёт абсолютный путь по умолчанию для CURLOPT_CAINFO
- PHP Manual: cURL constants — значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST
- curl: SSL CA Certificates — проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения
- libcurl: error codes — значения ошибок зависят от версии; код 60 относится к неудачной проверке peer
- OpenSSL 1.0.2: s_client — диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath