{ "index": 339, "slug": "editorial-2018-08-practice-tls-ca", "title": "PHP cURL: как исправить ошибку проверки TLS без отключения защиты", "excerpt": "PHP cURL возвращает ошибку сертификата, хотя адрес открывается в браузере. Разбираем CA bundle, цепочку, SNI и hostname, а затем проверяем исправление тем же запросом.", "contentHtml": "

PHP cURL возвращает false, а в ошибке, например, написано SSL certificate problem. В браузере тот же адрес открывается. Ошибка часто заканчивается быстрым обходом: разработчик выставляет CURLOPT_SSL_VERIFYPEER в false и получает ответ от API. Это не исправление. Клиент перестаёт проверять, кому он отправляет токен, персональные данные или платёжные параметры. Цена ошибки — не только сбой запроса, но и возможность незаметно установить TLS-соединение с чужим сервером.

\n

Правильный путь начинается с факта отказа. Нужно выяснить, не читается ли локальный CA bundle — набор доверенных сертификатов, не отсутствует ли промежуточный сертификат, не выбран ли другой виртуальный хост по SNI и подходит ли имя из URL сертификату. Эти причины дают похожие сообщения, но требуют разных владельцев и действий.

\n

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

\n

HTTPS-клиент проверяет не просто строку в адресе. Он получает сертификат сервера, строит цепочку до доверенного корня из локального хранилища и сверяет имя хоста с сертификатом. До этого сервер может выбрать сертификат по SNI (Server Name Indication) — имени, которое клиент передаёт в начале TLS-диалога. При включённой проверке ошибка в любом звене останавливает запрос.

\n

CA bundle отвечает на вопрос «каким центрам сертификации доверяет этот процесс?». Серверная цепочка отвечает на вопрос «прислал ли сервер сертификаты, нужные для построения пути?». Имя хоста (hostname) отвечает на вопрос «выдан ли сертификат именно этому имени?». SNI помогает серверу выбрать нужный виртуальный хост. Нельзя исправить одну проблему настройкой, предназначенной для другой.

\n

Сначала фиксирую отказ в PHP

\n

Диагностика должна выполняться тем же PHP-процессом и с тем же URL, который использует приложение. Браузер и команда curl в командной строке могут работать с другими версиями TLS-библиотеки, другими хранилищами и другим пользователем ОС. Это полезные контрольные точки, но не доказательство для PHP.

\n
<?php\n\n$url = 'https://api.partner.example/v1/ping';\n$caFile = '/opt/app/certs/ca-bundle.pem';\n\nif (!is_readable($caFile)) {\n    throw new RuntimeException('CA bundle is not readable: ' . $caFile);\n}\n\n$handle = curl_init($url);\nif ($handle === false) {\n    throw new RuntimeException('Unable to initialize cURL');\n}\n\n$verbose = fopen('php://temp', 'w+');\nif ($verbose === false) {\n    curl_close($handle);\n    throw new RuntimeException('Unable to create cURL trace stream');\n}\n\n$settingsApplied = curl_setopt_array($handle, [\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_CAINFO => $caFile,\n    CURLOPT_SSL_VERIFYPEER => true,\n    CURLOPT_SSL_VERIFYHOST => 2,\n    CURLOPT_VERBOSE => true,\n    CURLOPT_STDERR => $verbose,\n    CURLOPT_CONNECTTIMEOUT => 5,\n    CURLOPT_TIMEOUT => 15,\n]);\nif (!$settingsApplied) {\n    curl_close($handle);\n    fclose($verbose);\n    throw new RuntimeException('Unable to configure cURL');\n}\n\n$body = curl_exec($handle);\n$errno = curl_errno($handle);\n$error = curl_error($handle);\nrewind($verbose);\n$trace = stream_get_contents($verbose);\n\ncurl_close($handle);\nfclose($verbose);\n\nif ($body === false) {\n    throw new RuntimeException('cURL error ' . $errno . ': ' . $error);\n}\n\necho $body;
\n

Вызовы curl_errno() и curl_error() стоят до curl_close(). Номер и текст нужно сохранять вместе с именем хоста, временем, версией PHP cURL и идентификатором запроса. Verbose-трасса подходит для закрытого стенда. В рабочий лог её можно писать только после удаления токенов, заголовков авторизации и тела запроса.

\n

Путь к CA bundle должен приходить из конфигурации окружения. Не принимайте его из HTTP-параметра. Файл должен быть доступен пользователю PHP-FPM или Apache и не должен находиться в каталоге, который раздаёт веб-сервер.

\n

Разделяю симптом, причину, проверку и действие

\n
Типовые ветки диагностики ошибки TLS
СимптомПричинаПроверкаДействие
Ошибка о невозможности проверить peer, часто код 60Нет доверенного корня, неполная цепочка или неверное имяСнять сертификаты с SNI, проверить путь через тот же CA bundle и сверить имя хостаИсправить bundle, серверную цепочку или адрес; оставить проверки включёнными
Ошибка чтения CA-файла, часто код 77Файл отсутствует, путь относительный или пользователь процесса не имеет доступаis_readable(), абсолютный путь и права каждого каталогаИсправить доставку файла и права процесса PHP
CLI проходит, PHP отказываетРазные libcurl, TLS-библиотеки, хранилища CA или пользователь ОСcurl_version() в PHP, curl -V в командной строке, явный --cacertСравнить окружения и задать CA bundle именно PHP
Браузер проходит, PHP отказываетБраузер использует системное или собственное хранилищеПовторить запрос из PHP с явным CURLOPT_CAINFOНе считать браузер контрольным результатом; исправить PHP-окружение
Сертификаты меняются при запуске с разным именемРазные виртуальные хосты и SNIopenssl s_client с именем хоста из URL и без подмены на IPИсправить URL, DNS или конфигурацию TLS на сервере
\n

Сравниваю версии и хранилища

\n

Сначала смотрю, чем собран PHP-модуль. Это отделяет ошибку приложения от различий окружения.

\n
$version = curl_version();\n\nprintf('libcurl: %s\\n', $version['version']);\nprintf('TLS library: %s\\n', $version['ssl_version']);\nprintf('curl.cainfo: %s\\n', ini_get('curl.cainfo') ?: '(not set)');
\n

Затем выполняю учебную CLI-команду с тестовым адресом и явным файлом. Адрес api.partner.example не является производственным сервером. В реальной проверке его заменяют точным именем хоста из конфигурации приложения.

\n
curl -v \\\n  --cacert /opt/app/certs/ca-bundle.pem \\\n  https://api.partner.example/v1/ping
\n

Если CLI проходит, а PHP нет, сравниваю не только файл. Проверяю пользователя процесса, права на каталог, значение curl.cainfo, версию curl_version() и наличие прокси. Если оба клиента отказывают, перехожу к серверной цепочке и имени. Повторный запуск с отключённой проверкой не добавляет диагностического факта.

\n

Проверяю серверную цепочку и SNI

\n

Для виртуального хоста передаю серверу имя из URL. Команда ниже — учебный пример для закрытого стенда. Она показывает сертификаты, которые сервер прислал в ответ. Она не заменяет проверку цепочки.

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

Из вывода выписываю конечный сертификат и каждый промежуточный сертификат (intermediate). Корневой сертификат обычно хранится у клиента и не обязан приходить от сервера. Если сервер не прислал нужный промежуточный сертификат, исправление принадлежит владельцу HTTPS-сервера. Не добавляю промежуточный сертификат в хранилище доверия как постоянный обход: это смешивает две разные роли.

\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

Успешная команда подтверждает построение цепочки для выбранного набора доверия. Она не подтверждает, что сертификат подходит имени хоста. Это условие проверяю отдельно тем же URL из PHP. IP-адрес не заменяет имя: сертификат должен содержать этот IP как IP-адрес, а не только DNS-имя.

\n

Исправляю только подтверждённую ветку

\n

Для одного вызова указываю абсолютный путь через CURLOPT_CAINFO. Для всего PHP-окружения можно задать абсолютный путь в curl.cainfo. После изменения конфигурации перезапускаю тот процесс PHP, который реально выполняет запрос. Изменение CLI-конфигурации не меняет настройки PHP-FPM автоматически.

\n
; php.ini. Пример, не универсальное имя каталога.\ncurl.cainfo='/opt/app/certs/ca-bundle.pem'
\n

Bundle беру из управляемого источника: пакета операционной системы, согласованного хранилища проекта или официального канала поставщика. Если API использует частный CA, добавляю доверенный корень по процедуре владельца сервиса. Не копирую leaf-сертификат из браузера и не собираю хранилище из случайных файлов. Leaf может быть перевыпущен, а доверие должно принадлежать контролируемому CA.

\n
Схема диагностики ошибки TLS в PHP cURL: ошибка, версия клиента, CA bundle, серверная цепочка с SNI, исправление и повторная проверка
Сначала фиксируем отказ и отделяем окружение от сервера. Только после этого меняем CA bundle или исправляем цепочку.
\n

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

\n
  1. Повторить тот же запрос в PHP на закрытом стенде и сохранить код, текст ошибки, имя хоста, версии и безопасную трассу.
  2. Проверить абсолютный путь к CA bundle, чтение файла пользователем PHP и значение curl.cainfo.
  3. Сравнить PHP cURL с CLI через curl_version(), curl -V и явный --cacert.
  4. Получить серверный список сертификатов через openssl s_client с -servername из URL.
  5. Разделить сертификаты в формате PEM (блоки BEGIN CERTIFICATE/END CERTIFICATE) и проверить цепочку через openssl verify, не смешивая -CAfile и -untrusted.
  6. Проверить имя хоста в URL и Subject Alternative Name (SAN) сертификата. Не подставлять IP как диагностический shortcut.
  7. Исправить только подтверждённую причину: путь или права, клиентский bundle, серверную цепочку, DNS или сертификат.
  8. Перезапустить нужный PHP-процесс и повторить исходный запрос при CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.
\n

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

\n

CA bundle не исправит неверную дату на машине, отозванный сертификат, несовместимую политику TLS или неправильное имя хоста. Если корень неизвестен и его нельзя добавить через управляемый процесс, запрос должен оставаться отклонённым. Это честный результат, а не повод включать CURLOPT_SSL_VERIFYPEER => false.

\n

Проверка сертификата не превращает API в надёжный сервис. После TLS остаются ошибки DNS, прокси, HTTP, авторизации и формата ответа. Здесь рассматривается только граница установления доверенного TLS-соединения. Учебные команды и адреса из статьи не доказывают доступность какого-либо production-сервиса.

\n

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

\n

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

\n

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

" }