{ "index": 365, "slug": "ошибка-php-ssl-certificate-error-unable-to-get-local-issuer-certificate", "title": "PHP: как исправить unable to get local issuer certificate без отключения TLS", "excerpt": "Ошибка cURL 60 возникает, когда PHP не может построить доверенную цепочку сертификата. Разбираем CA bundle, SNI, различия CLI и FPM и проверяем исправление с включённой TLS-валидацией.", "contentHtml": "

PHP отправляет запрос к HTTPS API и возвращает SSL certificate problem: unable to get local issuer certificate. В браузере тот же адрес открывается. Команда меняет одну строку на CURLOPT_SSL_VERIFYPEER => false, получает ответ и считает проблему закрытой. На этом шаге клиент перестаёт проверять, кому он передаёт данные. Цена ошибки — утечка токена, ответа API или персональных данных через подменённый узел.

\n

Тезис статьи простой: ошибка не означает, что «на сервере нет SSL-сертификата». Она означает, что конкретная связка PHP, libcurl и TLS-библиотеки не смогла доказать доверие к сертификату. Причина может быть в локальном CA bundle, неполной цепочке на удалённом сервере, неверном имени, SNI или другом php.ini. Исправление должно сохранить проверку узла и имени.

\n

Что именно проверяет PHP

\n

При HTTPS-клиент строит цепочку от сертификата сайта к доверенному корню. Сервер обычно присылает конечный сертификат и промежуточные сертификаты. Корневые центры сертификации клиент берёт из локального хранилища. Если цепочка не строится, клиент останавливает соединение до HTTP-запроса.

\n

Здесь работают несколько независимых условий. Имя в URL должно совпасть с именем в сертификате. Сервер должен выбрать правильный виртуальный хост по SNI. Переданные промежуточные сертификаты должны связать конечный сертификат с корнем. Локальный CA bundle должен содержать нужный доверенный корень и быть доступен пользователю PHP.

\n

Браузер не заменяет проверку PHP. Браузер может использовать системное хранилище, собственный набор CA, кеш промежуточного сертификата или другой прокси-маршрут. CLI-cURL и PHP-FPM тоже могут использовать разные версии libcurl, OpenSSL и разные файлы конфигурации.

\n
\"Схема
Диагностика разделяет три объекта: сертификаты, которые прислал сервер, доверенное хранилище клиента и окружение, из которого выполняется PHP-код.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
cURL error 60 и unable to get local issuer certificateЦепочка не дошла до доверенного корняСравнить серверные сертификаты и CA bundle процессаИсправить цепочку сервера или указать актуальный bundle
Ошибка только в PHP-FPMFPM загрузил другой php.ini, путь или библиотекуСнять phpinfo() в том же окружении и вызвать curl_version()Менять конфигурацию FPM, а не только CLI
CLI проходит с --cacert, PHP нетPHP не видит этот файл или не имеет прав чтенияПроверить ini_get('curl.cainfo'), is_readable() и праваЗадать абсолютный путь в нужной конфигурации
Один hostname проходит, другой нетДругой сертификат по SNI или имя не входит в SANЗапустить openssl s_client с именем из URLИсправить URL, DNS или TLS-конфигурацию сервера
После добавления CA ошибка не исчезлаСервер не прислал intermediate, истёк сертификат или неверны часыПроверить дату, issuer, срок действия и полный вывод TLSПередать исправление владельцу сервера или окружения
Работает только с verify_peer=falseПроверка отключена, но доверие не настроеноВернуть проверки и повторить тест с явным CA bundleНе использовать обход в production
\n

Сначала фиксирую точный отказ

\n

Учебный код ниже использует домен api.example.test. Он показывает способ диагностики и не утверждает, что такой запрос выполнен. Путь к CA bundle приходит из конфигурации приложения, а не из HTTP-параметра пользователя.

\n
<?php\n\nfunction requestApi($url, $caFile)\n{\n    if (!is_readable($caFile)) {\n        throw new RuntimeException('CA bundle is not readable');\n    }\n\n    $handle = curl_init($url);\n    curl_setopt_array($handle, array(\n        CURLOPT_RETURNTRANSFER => true,\n        CURLOPT_CAINFO => $caFile,\n        CURLOPT_SSL_VERIFYPEER => true,\n        CURLOPT_SSL_VERIFYHOST => 2,\n        CURLOPT_CONNECTTIMEOUT => 5,\n        CURLOPT_TIMEOUT => 15,\n    ));\n\n    $body = curl_exec($handle);\n    $errno = curl_errno($handle);\n    $error = curl_error($handle);\n    $info = curl_getinfo($handle);\n    curl_close($handle);\n\n    if ($body === false) {\n        throw new RuntimeException($errno . ': ' . $error);\n    }\n\n    return array('body' => $body, 'info' => $info);\n}
\n

Снимайте curl_errno(), curl_error() и curl_getinfo() до закрытия handle. В журнале достаточно оставить код ошибки, hostname без секретных query-параметров, версию TLS-библиотеки и время. Не записывайте Authorization, cookie и полный ответ API.

\n

Код 60 обычно указывает на неудачную проверку сертификата, но номер не выбирает причину сам. Код 77 чаще связан с чтением локального CA-файла. Точный текст, версия клиента и окружение важнее одной цифры.

\n

Сравниваю CLI и PHP-FPM

\n

Сначала выясните, какой PHP выполняет проблемный код. php --ini показывает CLI-конфигурацию. Она не доказывает, какой файл загрузил Apache или PHP-FPM. В веб-контуре смотрите загруженный файл через безопасную диагностическую страницу или временный закрытый endpoint. После проверки удалите его.

\n
<?php\n\n$version = curl_version();\nvar_dump(array(\n    'loaded_ini' => php_ini_loaded_file(),\n    'curl_cainfo' => ini_get('curl.cainfo'),\n    'openssl_cafile' => ini_get('openssl.cafile'),\n    'curl' => $version['version'],\n    'ssl' => $version['ssl_version'],\n));
\n

Этот фрагмент учебный. Не публикуйте его без ограничения доступа: сведения о путях и версиях раскрывают устройство окружения. Если CLI и FPM показывают разные значения, проверяйте именно тот процесс, который делает запрос. Изменение CLI не исправляет PHP-FPM.

\n

Для cURL настройка curl.cainfo задаёт значение по умолчанию для CURLOPT_CAINFO. PHP требует абсолютный путь. Явный CURLOPT_CAINFO имеет смысл для одного клиента, когда приложение должно использовать версионный файл рядом с конфигурацией. Не задавайте путь из пользовательского ввода.

\n

Проверяю сервер с правильным SNI

\n

На одном IP-адресе могут работать несколько HTTPS-сайтов. Клиент передаёт имя в ClientHello через SNI, и сервер выбирает сертификат виртуального хоста. Если проверять IP или забыть имя, можно получить сертификат сайта по умолчанию и сделать неверный вывод.

\n
openssl s_client \\\n  -connect api.example.test:443 \\\n  -servername api.example.test \\\n  -showcerts \\\n  </dev/null
\n

Параметр -showcerts показывает список сертификатов, который прислал сервер. Это не готовое доказательство доверенной цепочки. Просмотрите subject и issuer каждого PEM-блока. Убедитесь, что в списке есть нужные промежуточные сертификаты. Отсутствие корневого CA в ответе обычно нормально: корень чаще лежит у клиента.

\n

Проверяйте командой имя из фактического URL приложения. Не подставляйте IP вместо hostname. Заголовок HTTP Host не исправит TLS-сертификат, выбранный до отправки HTTP-запроса.

\n

Разделяю серверную цепочку и локальное доверие

\n

Если сервер прислал конечный и промежуточный сертификаты, отдельно проверьте цепочку. Учебная команда предполагает, что leaf.pem и intermediate.pem получены из тестового ответа, а ca-bundle.pem — проверенный файл доверенных корней.

\n
openssl verify \\\n  -purpose sslserver \\\n  -CAfile ./ca-bundle.pem \\\n  -untrusted ./intermediate.pem \\\n  ./leaf.pem
\n

-CAfile задаёт доверенные корни, а -untrusted помогает построить цепочку через промежуточные сертификаты. Не переносите intermediate в список корней только ради зелёного результата. Если сервер не отправляет обязательный intermediate, исправление обычно должен внести владелец сервера.

\n

Если chain проходит, это ещё не отменяет проверку имени. В PHP оставьте CURLOPT_SSL_VERIFYHOST => 2 и используйте тот же hostname. Сертификат может быть подписан доверенным CA, но не предназначаться для адреса из URL.

\n

Подключаю CA bundle безопасно

\n

Публичный сервис обычно использует системный trust store или CA bundle из доверенного пакета. Локальная разработка на Windows может требовать отдельный PEM-файл. Внутренний сервис с частным CA требует подтверждённого корня от владельца сервиса и управляемой доставки этого корня. Не копируйте leaf-сертификат из браузера в проект: он может скоро смениться.

\n
; php.ini, пример с абсолютным путём\ncurl.cainfo=\"C:\\php\\extras\\ssl\\cacert.pem\"\n\n; Для stream wrapper это отдельный клиентский путь\nopenssl.cafile=\"C:\\php\\extras\\ssl\\cacert.pem\"
\n

Эти директивы не начинают действовать во всех уже запущенных процессах автоматически. Перезапустите Apache, PHP-FPM или другой процесс по правилам окружения. Затем снова проверьте фактический загруженный конфигурационный файл.

\n

Для одного вызова задайте путь явно:

\n
$handle = curl_init('https://api.example.test/health');\ncurl_setopt_array($handle, array(\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_CAINFO => __DIR__ . '/certs/ca-bundle.pem',\n    CURLOPT_SSL_VERIFYPEER => true,\n    CURLOPT_SSL_VERIFYHOST => 2,\n));
\n

В production путь должен быть доступен пользователю PHP, но не должен быть редактируемым из web-каталога. Храните bundle как конфигурационный артефакт с понятным происхождением и процедурой обновления. Не скачивайте доверенный файл по тому же неподтверждённому соединению, которое пытаетесь исправить.

\n

Порядок действий

\n
  1. Сохраните точный текст ошибки, код cURL, hostname, время и окружение, в котором запрос падает.
  2. Проверьте, какой php.ini загрузил этот процесс, и снимите curl_version(), curl.cainfo и openssl.cafile.
  3. Убедитесь, что CA bundle существует, читается пользователем PHP и задан абсолютным путём.
  4. Повторите проверку из CLI с явным --cacert; не принимайте результат CLI за результат FPM.
  5. Получите серверную цепочку через openssl s_client с -servername, равным hostname из URL.
  6. Отдельно проверьте leaf и intermediate против CA bundle. Не объявляйте промежуточный сертификат доверенным корнем.
  7. Исправьте источник проблемы: путь или права в PHP, состав доверенного хранилища, серверную цепочку, hostname, SNI или часы системы.
  8. Перезапустите нужный процесс и повторите тот же PHP-запрос с CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.
\n

Ограничения и отрицательный путь

\n

CA bundle не исправит неверные системные часы, истёкший или отозванный сертификат, неподходящее имя, несовместимую TLS-политику и неверный прокси. При HTTPS до прокси проверяется ещё один TLS-канал. Для него может потребоваться отдельное доверие. Разбирайте такие ошибки по слоям.

\n

Самоподписанный сертификат не становится безопасным от того, что его добавили случайным файлом. Для внутреннего сервиса установите частный корень через управляемый процесс и ограничьте область доверия. Не добавляйте сертификаты партнёра в глобальное хранилище без согласования.

\n

Не используйте CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 или curl -k как постоянный фикс. Такой тест может подтвердить, что сеть и HTTP доступны, но он не подтверждает личность сервера. Даже во временной локальной диагностике зафиксируйте возврат проверок и не переносите обход в production.

\n

Проверяемый критерий готовности

\n

Исправление готово, когда тот же PHP-процесс выполняет запрос к тому же hostname с включёнными peer- и hostname-проверками, а путь к доверенному хранилищу известен и читается его пользователем. В журнале остаются код, текст и версия клиента без секретов. Если запрос по-прежнему падает, у вас есть проверяемый набор фактов: серверная цепочка с SNI, имя сертификата, активный php.ini, версия TLS-библиотеки и состояние CA bundle. Это уже основание для адресного исправления, а не повод отключать TLS.

\n

Проверяемые источники

\n" }