После смены сертификата у партнёра старый PHP-процесс начинает возвращать ошибку проверки, хотя браузер на той же машине открывает сайт. Если быстро поставить CURLOPT_SSL_VERIFYPEER в false, запросы оживут, но приложение сможет принять сертификат подменённого узла. Цена обхода — не только предупреждение в коде, а потеря проверки личности удалённой стороны.
В полевом случае я не обновляю первый попавшийся файл. Сначала доказываю, какой именно libcurl и какой trust store использует PHP. Затем проверяю кандидатный bundle на отдельном стенде, меняю путь контролируемо и повторяю тот же запрос с включённой проверкой.
Браузер не является контрольным клиентом
Браузер может брать корни из системного хранилища и обновлять их по своим правилам. PHP extension cURL может быть собран с другой TLS-библиотекой и читать файл, заданный при сборке, в curl.cainfo или через CURLOPT_CAINFO. Поэтому фраза «в Chrome работает» полезна как симптом, но не отвечает, что должен сделать PHP-процесс.
Я начинаю с минимального отчёта, который можно получить в закрытой административной команде. В нём нет паролей и ответа API: только версии и признак, читается ли кандидатный файл. Путь лучше не выводить в публичную страницу; достаточно оставить его в приватном журнале релиза.
function tlsEnvironmentReport($bundle)
{
$version = curl_version();
return array(
'php' => PHP_VERSION,
'libcurl' => $version['version'],
'tls_library' => $version['ssl_version'],
'curl_cainfo_set' => ini_get('curl.cainfo') !== '',
'candidate_readable' => is_readable($bundle),
'candidate_size' => is_readable($bundle) ? filesize($bundle) : null,
);
}
Метод curl_version() возвращает данные о libcurl и SSL-библиотеке PHP-модуля. Этого достаточно, чтобы не сравнивать наугад PHP-FPM с командным curl. Если в отчёте bundle не читается, обновление сертификатов ещё не началось: сначала исправляю путь, владельца и права доступа для пользователя процесса.
Фиксирую точку, где выбирается CA file
В старом проекте CAfile иногда задают в трёх местах: значение по умолчанию curl.cainfo, явный CURLOPT_CAINFO в обёртке HTTP-клиента и настройки системы, с которыми собран libcurl. Я не меняю их одновременно. Иначе невозможно сказать, какая правка помогла и какое окружение останется на старом наборе после следующего деплоя.
| Где найдено доверие | Как проверяю | Безопасное действие | Почему не делать иначе |
|---|---|---|---|
Явный CURLOPT_CAINFO | Поиск в HTTP-обёртке и лог пути на закрытом стенде | Заменить версионный файл в конфигурации этого клиента | Изменение php.ini не влияет на явную опцию |
curl.cainfo | Сравнить ini_get() с загруженным php.ini | Указать абсолютный путь и штатно перезапустить PHP | Относительный путь зависит от окружения |
| Системный default libcurl | Verbose-след и документация сборки дистрибутива | Обновить системный пакет по процедуре платформы | Нельзя считать браузерный store тем же самым |
| Частный CA партнёра | Подтвердить root у владельца API | Добавить подтверждённый root в отдельный управляемый bundle | Не сохранять leaf из случайного TLS-ответа как корень |
Готовлю новый bundle как артефакт релиза
Кандидатный файл беру из официального источника CA store или из доверенного пакета операционной системы. Его имя содержит версию или дату поставки, например ca-bundle-2018-08.pem. Такой путь лучше безымянного cacert.pem: при следующем отказе видно, какой набор проверялся, и можно откатить конфигурацию на прежний файл без ручного редактирования содержимого.
Перед переключением я проверяю не только наличие PEM-маркеров. Беру leaf и intermediate конкретного тестового сервера, которые были собраны через openssl s_client с правильным SNI, и строю цепочку против кандидата. Это не доказывает, что bundle подходит для всего интернета, но доказывает нужный нам сценарий и не требует выдумывать результат команды.
# leaf.pem и intermediate.pem получены из тестового TLS-ответа.
# ca-bundle-2018-08.pem — кандидат из утверждённого источника.
openssl verify \
-purpose sslserver \
-CAfile /opt/app/certs/ca-bundle-2018-08.pem \
-untrusted ./intermediate.pem \
./leaf.pem
Если команда не строит цепочку, не объявляю новый bundle плохим без разбора. Возможно, сервер не прислал intermediate. Возможно, он использует частный CA, которого нет и не должно быть в публичном store. Возможно, для проверки был использован другой hostname без SNI. Каждая ветка требует собственного исправления; ни одна не требует отключить peer verification.
Переключаю PHP-клиент явно
Для независимой интеграции мне удобнее хранить абсолютный путь в конфигурации приложения и передавать его cURL. Тогда старый и новый bundle могут лежать рядом на время проверки, а код не читает путь из веб-запроса. Если в проекте выбран curl.cainfo, выполняю тот же принцип в php.ini и фиксирую перезапуск процесса в чек-листе релиза.
function partnerRequest($url, $bundle)
{
if (!is_readable($bundle)) {
throw new RuntimeException('Configured CA bundle is not readable');
}
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CAINFO => $bundle,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
));
$result = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
if ($result === false) {
throw new RuntimeException('Partner TLS request failed: ' . $errno . ' ' . $error);
}
return $result;
}
Значения timeout в примере — проектные, а не рецепт для всех API. Они нужны, чтобы демонстрационный запрос не висел бесконечно. Важнее другое: в рабочем коде нет ветки, где ошибка сертификата меняет CURLOPT_SSL_VERIFYPEER на false. Такой переключатель превращает сетевую аварию в скрытое изменение модели доверия.
Проверяю релиз тем же клиентом
После перезапуска я выполняю один заранее согласованный запрос с тем же PHP-SAPI, который обслуживает приложение. HTTP 200 сам по себе не является единственным критерием: сохраняю, что curl_exec() не вернул false, peer verification не была ослаблена и ответ соответствует контракту тестового endpoint. Для критичного API лучше выбрать безвредный health или read-only запрос, если владелец сервиса его предоставляет.
- Зафиксировать исходную ошибку, hostname, PHP-SAPI,
curl_version()и текущий источник CAfile. - Подготовить кандидатный bundle из утверждённого источника под отдельным версионным именем и проверить его чтение service-user.
- Снять leaf и intermediate тестового TLS-сервера через
openssl s_client -servernameи прогнатьopenssl verifyс кандидатом. - Переключить ровно один источник настройки: явный
CURLOPT_CAINFOлибоcurl.cainfo, а не всё сразу. - Штатно перезапустить PHP-процесс, если изменена глобальная конфигурация, и выполнить контролируемый PHP-запрос.
- Оставить в релизной заметке версию bundle, путь настройки, дату проверки и способ отката на предыдущий файл.
Что не является исправлением
- Не ставлю
CURLOPT_SSL_VERIFYPEER => falseи не понижаюCURLOPT_SSL_VERIFYHOST. Официальная документация libcurl прямо указывает, что отключение проверки делает соединение небезопасным. - Не добавляю в доверенные корни leaf-сертификат, который сервер прислал сегодня. Leaf и intermediate могут быть заменены; доверие к ним имеет другой смысл, чем доверие к CA.
- Не загружаю bundle из URL при каждом запуске приложения. Поставка файла должна проходить контролируемый релиз, иначе мы не знаем, какой root появился в доверии.
- Не смешиваю проблему устаревшего CA store с ошибкой имени. Если URL не покрыт сертификатом, новый bundle не изменит правильный отказ.
Ограничения и следующий шаг
CA bundle не вылечит неправильно настроенный TLS-сервер: недостающий intermediate должен поправить владелец сервера. Он также не заменяет обновление старой версии PHP или libcurl, если в ней есть известное ограничение. Но контролируемый bundle даёт короткий и проверяемый путь для обычного случая: PHP знает, какому набору CA доверять, файл читается, тестовая цепочка строится, а production-запрос проходит без снятия защиты.
Итог
Устаревший trust store — это конфигурационная проблема, а не приглашение выключить TLS-проверку. Если зафиксировать активный клиент, проверить кандидатный bundle против реальной цепочки и переключить путь как часть релиза, то ошибка становится воспроизводимой. В следующий раз команда увидит версию файла и проверку, а не загадочный false в настройках cURL.
Проверяемые источники
- PHP Manual: curl_version — версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль
- PHP Manual: cURL Runtime Configuration — директива curl.cainfo задаёт абсолютный путь по умолчанию для CURLOPT_CAINFO
- PHP Manual: curl_error — текст последней ошибки текущего cURL-сеанса; его нужно читать до закрытия handle
- PHP Manual: cURL constants — значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST
- curl: SSL CA Certificates — проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения
- libcurl: CURLOPT_CAINFO — путь к файлу доверенных CA для конкретного transfer
- libcurl: CURLOPT_SSL_VERIFYPEER — выключение проверки не загружает CA и делает TLS-соединение небезопасным
- OpenSSL 1.0.2: s_client — диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath
- OpenSSL 1.0.2: verify — проверка цепочки, различие trusted CAfile и untrusted промежуточных сертификатов