{ "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. Цена неверного вывода — не только потерянное время. Если сделать запрос успешным через отключение проверки, сервис может отправить токен или платёжные данные узлу, чью личность он не подтвердил.
Главный вопрос здесь не «работает ли curl», а «какой именно клиент, с каким trust store и каким именем прошёл какие проверки». Успешный запрос доказывает конкретный маршрут, часы, TLS-библиотеку, набор доверенных центров сертификации и политику одного процесса. Это не сертификат исправности браузера, другого контейнера или production-конфигурации. Ниже разберём границы доказательства и оставим runbook, который можно повторить в том же окружении, где живёт ошибка.
\nTLS не начинается с HTTP-статуса. Сначала клиент устанавливает соединение, договаривается о параметрах и получает от сервера сертификат или цепочку сертификатов. Затем он решает, можно ли доверять цепочке и подходит ли заявленная идентичность имени из URL. Только после успешного обычного handshake появляется защищённый канал для HTTP.
\nПоэтому фраза «curl работает» слишком короткая. Она может означать: CLI использовал встроенный путь к CA bundle, попал на другой IP, получил сертификат для нужного виртуального хоста и проверил его по часам этой машины. PHP-процесс в контейнере может использовать другой libcurl, другой TLS backend, другой файл доверия, другой proxy и другое системное время. Оба результата будут честными, но отвечать на разные вопросы.
\nURL и hostname\n -> DNS и TCP-маршрут\n -> ClientHello с SNI (если клиент его отправляет)\n -> сертификат и цепочка от сервера\n -> путь до доверенного CA + срок + политика\n -> совпадение имени с сертификатом\n -> обычный HTTP-запрос\nВ TLS 1.3 серверное сообщение Certificate передаёт цепочку, когда аутентификация опирается на сертификат. Сам протокол описывает handshake и защищённый канал, но подробные правила проверки X.509 и сопоставления имени дополняются профилями и политикой клиента. Это важная граница: TLS сообщает, как обменяться сертификатом и ключами, но не выбирает за приложение доверенный корень.
| Слой | Кто владеет | Успех означает | Ещё нужно проверить |
|---|---|---|---|
| Маршрут | DNS, сеть, proxy | Клиент дошёл до некоторого TLS endpoint | Что это нужный IP и proxy-путь |
| SNI и имя | Клиент и TLS-сервер | Выбранный сертификат покрывает hostname из URL | SAN, redirect и имя, которое видит приложение |
| Цепочка | Сервер и trust store клиента | Из leaf можно построить путь к доверенному CA | Все intermediate и состав доверия этого процесса |
| Время | Часы клиента | now попадает в окно notBefore–notAfter | UTC-время контейнера и узла |
| Политика | TLS backend и приложение | Алгоритмы, версии и режим проверки разрешены | Настройки конкретной сборки и требования endpoint |
Сертификат не является одним флагом «валиден». RFC 5280 описывает путь сертификации: сертификаты в цепочке должны связываться по issuer/subject, быть действительными в рассматриваемый момент и вести к trust anchor. Выбор trust anchor — локальная политика. Поэтому одинаковый PEM-файл на двух машинах не гарантирует одинаковый результат, если процессы читают разные файлы или один из них использует системное хранилище.
\nВремя — такой же вход, как URL. Поле notBefore задаёт начало, а notAfter — конец периода действия. Отставшие часы дают ошибку «ещё не действителен», спешащие — «истёк». Повторный запрос или новый DNS-ответ это не исправят. Снимайте время именно внутри контейнера или виртуальной машины, где запущен клиент.
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 заменяется на адрес из вашей инфраструктуры.
RFC 9525 описывает service identity через reference identifier и presented identifier. Для DNS-имени это означает сравнение имени клиента с DNS-ID в subjectAltName; wildcard тоже подчиняется правилам сопоставления, а не произвольному поиску подстроки. Поэтому проверка «в сертификате где-то встречается нужное слово» недостаточна. Смотрите SAN и фактический hostname, а не только Common Name в старом просмотрщике.
CLI cURL и PHP cURL могут выглядеть одинаково в логе, но иметь разную конфигурацию. Официальная документация cURL отмечает, что CA store зависит от сборки и TLS backend: в одних окружениях используется файл, в других — нативное хранилище ОС. PHP задаёт curl.cainfo как значение по умолчанию для CURLOPT_CAINFO, и для него нужен абсолютный путь. Это уже две точки расхождения до того, как приложение добавило собственные настройки.
Проверка peer и проверка имени — отдельные операции. CURLOPT_SSL_VERIFYPEER => true проверяет, что сертификат можно связать с доверенным CA. CURLOPT_SSL_VERIFYHOST => 2 проверяет заявленное имя. Выключение первой операции не исправляет вторую; выключение обеих только убирает доказательство личности. Шифрование канала при этом может остаться, но оно не отвечает на вопрос, с тем ли endpoint вы говорите.
Опция --cacert или CURLOPT_CAINFO — способ явно указать доверенный CA bundle для конкретного клиента. Это не команда «взять сертификат у сервера и доверять ему». Для публичного endpoint нужен поставляемый и проверенный набор доверенных CA; для частного CA — согласованный владельцем инфраструктуры сертификат корневого центра и контролируемая доставка. Если сервер не прислал intermediate, добавление leaf в общий bundle маскирует проблему вместо исправления серверной цепочки.
Ниже минимальный 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 процесса:
\nphp 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 для данного запроса завершились; он не доказывает правильность авторизации, данных или бизнес-операции.
curl --version, php --ini и php -i | grep -E 'curl.cainfo|openssl.cafile' в том же окружении. Версия CLI не является версией libcurl внутри PHP.curl -vS --connect-timeout 5 --max-time 15 https://api.example.test/health -o /dev/null. Не публикуйте в тикете cookies, authorization-заголовки и чувствительные URL. Вывод показывает путь диагностики, но не заменяет тест приложения.openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts < /dev/null. Ключ -servername важен для виртуального хоста. Этот инструмент показывает выданные сертификаты; итоговое доверие всё равно оценивайте с тем CA bundle, который использует приложение.--resolve, сохранив исходное имя. Не заменяйте hostname на IP как «проверку»: это меняет проверяемую идентичность.date -u внутри процесса или контейнера и убедитесь, что пользователь приложения может читать заявленный CA bundle. Ошибка доступа к файлу и отсутствие нужного корня выглядят по-разному на уровне причины, но обе проявляются до HTTP.CURLOPT_CAINFO или настройку curl.cainfo. Если проблема в intermediate, исправьте цепочку на TLS-сервере. Если проблема в SAN, перевыпустите сертификат с правильным именем. Не меняйте все слои одновременно.php.ini перезапустите PHP-FPM или другой долгоживущий процесс. Выполните безопасный health-запрос из того же контейнера и сохраните путь к bundle, версии и команду отката.Исправление имеет владельца. Неполная цепочка — задача владельца TLS endpoint; отсутствующий корпоративный root — задача поставки trust store; неверный SAN или SNI — задача конфигурации имени и виртуального хоста; неверные часы — задача среды; запрещённый алгоритм или версия — задача совместимости и политики. Смена CA bundle не лечит все четыре случая.
\ncurl -k и CURLOPT_SSL_VERIFYPEER => false полезны только как короткий локальный эксперимент, когда нужно доказать, что дальше есть HTTP-ответ. Они не должны попадать в production-конфигурацию, общий helper или постоянный пример. Такой прогон отвечает на вопрос «можно ли продолжить без проверки», но не на вопрос «кому мы отправляем данные».
Не скачивайте новый bundle по URL при каждом старте и не добавляйте в него любой сертификат, который встретился в handshake. CA bundle — часть поставки и политики доверия, а не кеш наблюдаемого ответа. Не считайте проверку сертификата доказательством прав доступа: после TLS остаются HTTP-аутентификация, авторизация, корректность endpoint и безопасность данных.
\nЭтот разбор не обещает диагностировать отзыв сертификата одинаково во всех TLS backend: поддержка CRL, OCSP и режима «best effort» зависит от клиента и политики. Он также не проверяет DNSSEC, корректность proxy, mutual TLS, pinning или авторизацию API. openssl s_client без явного набора параметров не является полным эквивалентом PHP cURL. Учебный скрипт не валидирует X.509 сам и не должен становиться security scanner.
Считайте работу готовой, когда один и тот же PHP-SAPI в целевом окружении читает ожидаемый trust store, hostname совпадает с SAN, цепочка строится до согласованного CA, часы корректны, проверка peer и имени остаётся включённой, а безопасный запрос проходит без ошибки TLS. После этого отдельно проверяется HTTP-контракт. Вот почему «curl работает» — полезный сигнал, но не закрытие проверки: закрыть её можно только воспроизводимым результатом всех условий конкретного клиента.
\nCertificate и границы ответственности TLS.--cacert, --insecure и различия native/file-based хранилищ.curl.cainfo и требование абсолютного пути.