{ "index": 8, "slug": "editorial-2027-10-mechanism-long-form-interview", "title": "TLS-сертификат: почему «curl работает» не закрывает проверку", "excerpt": "Один успешный запрос доказывает только один набор условий: маршрут, SNI, часы, trust store и политику конкретного клиента. Разбираем, как отделить эти проверки и исправить причину без отключения TLS.", "contentHtml": "

Симптом знакомый: curl -I https://api.example.test/health с ноутбука возвращает ответ, а приложение в контейнере получает ошибку сертификата. Иногда браузер открывает тот же адрес, но PHP cURL сообщает unable to get local issuer certificate. Цена неверного вывода — не только потерянное время. Если сделать запрос успешным через отключение проверки, сервис может отправить токен или платёжные данные узлу, чью личность он не подтвердил.

\n

Главный вопрос здесь не «работает ли curl», а «какой именно клиент, с каким trust store и каким именем прошёл какие проверки». Успешный запрос доказывает конкретный маршрут, часы, TLS-библиотеку, набор доверенных центров сертификации и политику одного процесса. Это не сертификат исправности браузера, другого контейнера или production-конфигурации. Ниже разберём границы доказательства и оставим runbook, который можно повторить в том же окружении, где живёт ошибка.

\n

Успешен не «curl», а конкретный verifier

\n

TLS не начинается с HTTP-статуса. Сначала клиент устанавливает соединение, договаривается о параметрах и получает от сервера сертификат или цепочку сертификатов. Затем он решает, можно ли доверять цепочке и подходит ли заявленная идентичность имени из URL. Только после успешного обычного handshake появляется защищённый канал для HTTP.

\n

Поэтому фраза «curl работает» слишком короткая. Она может означать: CLI использовал встроенный путь к CA bundle, попал на другой IP, получил сертификат для нужного виртуального хоста и проверил его по часам этой машины. PHP-процесс в контейнере может использовать другой libcurl, другой TLS backend, другой файл доверия, другой proxy и другое системное время. Оба результата будут честными, но отвечать на разные вопросы.

\n
\"Матрица
Один клиентский запрос проходит несколько условий. Исправлять нужно тот слой, который дал отказ, а не отключать всю проверку.
\n
URL и hostname\n  -> DNS и TCP-маршрут\n  -> ClientHello с SNI (если клиент его отправляет)\n  -> сертификат и цепочка от сервера\n  -> путь до доверенного CA + срок + политика\n  -> совпадение имени с сертификатом\n  -> обычный HTTP-запрос
\n

В TLS 1.3 серверное сообщение Certificate передаёт цепочку, когда аутентификация опирается на сертификат. Сам протокол описывает handshake и защищённый канал, но подробные правила проверки X.509 и сопоставления имени дополняются профилями и политикой клиента. Это важная граница: TLS сообщает, как обменяться сертификатом и ключами, но не выбирает за приложение доверенный корень.

\n

Пять проверок, которые нельзя смешивать

\n
Что доказывает диагностика и чего она не доказывает
СлойКто владеетУспех означаетЕщё нужно проверить
МаршрутDNS, сеть, proxyКлиент дошёл до некоторого TLS endpointЧто это нужный IP и proxy-путь
SNI и имяКлиент и TLS-серверВыбранный сертификат покрывает hostname из URLSAN, redirect и имя, которое видит приложение
ЦепочкаСервер и trust store клиентаИз leaf можно построить путь к доверенному CAВсе intermediate и состав доверия этого процесса
ВремяЧасы клиентаnow попадает в окно notBefore–notAfterUTC-время контейнера и узла
ПолитикаTLS backend и приложениеАлгоритмы, версии и режим проверки разрешеныНастройки конкретной сборки и требования endpoint
\n

Сертификат не является одним флагом «валиден». RFC 5280 описывает путь сертификации: сертификаты в цепочке должны связываться по issuer/subject, быть действительными в рассматриваемый момент и вести к trust anchor. Выбор trust anchor — локальная политика. Поэтому одинаковый PEM-файл на двух машинах не гарантирует одинаковый результат, если процессы читают разные файлы или один из них использует системное хранилище.

\n

Время — такой же вход, как URL. Поле notBefore задаёт начало, а notAfter — конец периода действия. Отставшие часы дают ошибку «ещё не действителен», спешащие — «истёк». Повторный запрос или новый DNS-ответ это не исправят. Снимайте время именно внутри контейнера или виртуальной машины, где запущен клиент.

\n

Имя сертификата и SNI — не одно и то же

\n

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

\n

Для HTTPS ожидаемое имя обычно берётся из hostname URL. Заголовок Host отправляется на HTTP-уровне позже. Если клиент подключился к IP и получил сертификат default-vhost, добавление другого Host не отменяет уже случившийся отказ TLS. Когда нужно проверить конкретный IP, сохраняйте hostname и меняйте только маршрут. Для этого подходит контролируемый тест вроде curl --resolve api.example.test:443:IP https://api.example.test/health, где IP заменяется на адрес из вашей инфраструктуры.

\n

RFC 9525 описывает service identity через reference identifier и presented identifier. Для DNS-имени это означает сравнение имени клиента с DNS-ID в subjectAltName; wildcard тоже подчиняется правилам сопоставления, а не произвольному поиску подстроки. Поэтому проверка «в сертификате где-то встречается нужное слово» недостаточна. Смотрите SAN и фактический hostname, а не только Common Name в старом просмотрщике.

\n

Почему разные клиенты дают разные ответы

\n

CLI cURL и PHP cURL могут выглядеть одинаково в логе, но иметь разную конфигурацию. Официальная документация cURL отмечает, что CA store зависит от сборки и TLS backend: в одних окружениях используется файл, в других — нативное хранилище ОС. PHP задаёт curl.cainfo как значение по умолчанию для CURLOPT_CAINFO, и для него нужен абсолютный путь. Это уже две точки расхождения до того, как приложение добавило собственные настройки.

\n

Проверка peer и проверка имени — отдельные операции. CURLOPT_SSL_VERIFYPEER => true проверяет, что сертификат можно связать с доверенным CA. CURLOPT_SSL_VERIFYHOST => 2 проверяет заявленное имя. Выключение первой операции не исправляет вторую; выключение обеих только убирает доказательство личности. Шифрование канала при этом может остаться, но оно не отвечает на вопрос, с тем ли endpoint вы говорите.

\n

Опция --cacert или CURLOPT_CAINFO — способ явно указать доверенный CA bundle для конкретного клиента. Это не команда «взять сертификат у сервера и доверять ему». Для публичного endpoint нужен поставляемый и проверенный набор доверенных CA; для частного CA — согласованный владельцем инфраструктуры сертификат корневого центра и контролируемая доставка. Если сервер не прислал intermediate, добавление leaf в общий bundle маскирует проблему вместо исправления серверной цепочки.

\n

Самодостаточный PHP-пример

\n

Ниже минимальный CLI-скрипт без фреймворка. Он принимает URL и, при необходимости, существующий абсолютный путь к CA bundle. В коде проверка peer и имени включена явно, а номер ошибки и текст сохраняются до закрытия дескриптора. Значения таймаутов здесь учебные: они не являются рекомендацией для каждого API.

\n
<?php\nfunction request($url, $caFile = null)\n{\n    if ($caFile !== null and !is_readable($caFile)) {\n        throw new RuntimeException('CA file is not readable: ' . $caFile);\n    }\n\n    $handle = curl_init($url);\n    if ($handle === false) {\n        throw new RuntimeException('curl_init failed');\n    }\n\n    $options = array(\n        CURLOPT_RETURNTRANSFER => true,\n        CURLOPT_SSL_VERIFYPEER => true,\n        CURLOPT_SSL_VERIFYHOST => 2,\n        CURLOPT_CONNECTTIMEOUT => 5,\n        CURLOPT_TIMEOUT => 15,\n    );\n\n    if ($caFile !== null) {\n        $options[CURLOPT_CAINFO] = $caFile;\n    }\n\n    curl_setopt_array($handle, $options);\n    $body = curl_exec($handle);\n    $errorNo = 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('cURL ' . $errorNo . ': ' . $error);\n    }\n\n    return array(\n        'httpStatus' => $info['http_code'],\n        'sslVerifyResult' => $info['ssl_verifyresult'],\n        'bodyBytes' => strlen($body),\n    );\n}\n\n$url = $argv[1] ?? 'https://example.com/';\n$caFile = $argv[2] ?? null;\nvar_export(request($url, $caFile));
\n

Запуск без второго аргумента использует CA store, который видит libcurl процесса:

\n
php tls-check.php https://api.example.test/health\nphp tls-check.php https://api.example.test/health /absolute/path/to/ca-bundle.pem
\n

Если доверие не строится, скрипт завершится исключением до HTTP-статуса. Если handshake прошёл, результат содержит HTTP-код и значение ssl_verifyresult, но это не заменяет проверку ответа API. Успешный 200 может означать только то, что TLS и HTTP для данного запроса завершились; он не доказывает правильность авторизации, данных или бизнес-операции.

\n

Пошаговый runbook без обхода проверки

\n
  1. Зафиксируйте наблюдение. Сохраните полный URL без секретных query-параметров, hostname, порт, текст ошибки, время UTC и место запуска. Запишите, это CLI, PHP-FPM, worker или другой SAPI, а также имя контейнера или образа.
  2. Сравните инструменты. Выполните curl --version, php --ini и php -i | grep -E 'curl.cainfo|openssl.cafile' в том же окружении. Версия CLI не является версией libcurl внутри PHP.
  3. Посмотрите handshake. Запустите curl -vS --connect-timeout 5 --max-time 15 https://api.example.test/health -o /dev/null. Не публикуйте в тикете cookies, authorization-заголовки и чувствительные URL. Вывод показывает путь диагностики, но не заменяет тест приложения.
  4. Проверьте серверную цепочку. Для наблюдения используйте openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts < /dev/null. Ключ -servername важен для виртуального хоста. Этот инструмент показывает выданные сертификаты; итоговое доверие всё равно оценивайте с тем CA bundle, который использует приложение.
  5. Разделите имя и маршрут. Сверьте hostname URL с SAN leaf-сертификата. Если подозреваете неправильный IP, повторите запрос через --resolve, сохранив исходное имя. Не заменяйте hostname на IP как «проверку»: это меняет проверяемую идентичность.
  6. Проверьте время и права. Снимите date -u внутри процесса или контейнера и убедитесь, что пользователь приложения может читать заявленный CA bundle. Ошибка доступа к файлу и отсутствие нужного корня выглядят по-разному на уровне причины, но обе проявляются до HTTP.
  7. Сделайте одно изменение. Если проблема в trust store, передайте подтверждённый CA bundle через CURLOPT_CAINFO или настройку curl.cainfo. Если проблема в intermediate, исправьте цепочку на TLS-сервере. Если проблема в SAN, перевыпустите сертификат с правильным именем. Не меняйте все слои одновременно.
  8. Повторите тем же SAPI. После изменения php.ini перезапустите PHP-FPM или другой долгоживущий процесс. Выполните безопасный health-запрос из того же контейнера и сохраните путь к bundle, версии и команду отката.
  9. Оставьте регрессию. Добавьте отдельные фикстуры для истёкшего сертификата, неизвестного issuer и неверного имени, если ваш тестовый стенд позволяет это сделать. Тест должен подтверждать отказ при включённой проверке, а не только успешный HTTP-ответ.
\n

Что считать исправлением, а что — маскировкой

\n

Исправление имеет владельца. Неполная цепочка — задача владельца TLS endpoint; отсутствующий корпоративный root — задача поставки trust store; неверный SAN или SNI — задача конфигурации имени и виртуального хоста; неверные часы — задача среды; запрещённый алгоритм или версия — задача совместимости и политики. Смена CA bundle не лечит все четыре случая.

\n

curl -k и CURLOPT_SSL_VERIFYPEER => false полезны только как короткий локальный эксперимент, когда нужно доказать, что дальше есть HTTP-ответ. Они не должны попадать в production-конфигурацию, общий helper или постоянный пример. Такой прогон отвечает на вопрос «можно ли продолжить без проверки», но не на вопрос «кому мы отправляем данные».

\n

Не скачивайте новый bundle по URL при каждом старте и не добавляйте в него любой сертификат, который встретился в handshake. CA bundle — часть поставки и политики доверия, а не кеш наблюдаемого ответа. Не считайте проверку сертификата доказательством прав доступа: после TLS остаются HTTP-аутентификация, авторизация, корректность endpoint и безопасность данных.

\n

Ограничения и критерий готовности

\n

Этот разбор не обещает диагностировать отзыв сертификата одинаково во всех TLS backend: поддержка CRL, OCSP и режима «best effort» зависит от клиента и политики. Он также не проверяет DNSSEC, корректность proxy, mutual TLS, pinning или авторизацию API. openssl s_client без явного набора параметров не является полным эквивалентом PHP cURL. Учебный скрипт не валидирует X.509 сам и не должен становиться security scanner.

\n

Считайте работу готовой, когда один и тот же PHP-SAPI в целевом окружении читает ожидаемый trust store, hostname совпадает с SAN, цепочка строится до согласованного CA, часы корректны, проверка peer и имени остаётся включённой, а безопасный запрос проходит без ошибки TLS. После этого отдельно проверяется HTTP-контракт. Вот почему «curl работает» — полезный сигнал, но не закрытие проверки: закрыть её можно только воспроизводимым результатом всех условий конкретного клиента.

\n

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

\n" }