diff --git a/editorial/production/README.md b/editorial/production/README.md
index e8d1394..6baa40b 100644
--- a/editorial/production/README.md
+++ b/editorial/production/README.md
@@ -1,6 +1,6 @@
# Производство редакционных партий
-На 31 июля 2026 года строгий аудит проходит 18 из 358 созданных материалов. Остальные 340 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
+На 31 июля 2026 года строгий аудит проходит 24 из 358 созданных материалов. Остальные 334 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
diff --git a/editorial/reviews/2018-07-draft.md b/editorial/reviews/2018-07-draft.md
new file mode 100644
index 0000000..41f3238
--- /dev/null
+++ b/editorial/reviews/2018-07-draft.md
@@ -0,0 +1,144 @@
+# Июль 2018 — тройное ревью: таймауты веб-интеграции
+
+Статус: принят в публикационный слой 31 июля 2026 года. Пакет не
+перезаписывает web/data/articles.json:
+web/data/editorial-revisions.mjs сопоставляет три ревизии только
+по стабильным slug.
+
+Партия:
+
+- editorial-2018-07-practice-http-timeouts
+- editorial-2018-07-mechanism-http-timeouts
+- editorial-2018-07-field-http-timeouts
+
+## Карта покрытия
+
+| Перспектива | Главный вопрос | Основной текст без источников | Артефакт |
+| --- | --- | ---: | --- |
+| Практика | Как задать бюджет одного PHP cURL-вызова | 7 963 знака | конфигурация cURL, временная шкала и правило повтора |
+| Механизм | Чем отличаются connect, total и low speed | 8 813 знаков | матрица стадий, временные поля cURL и условия повторов |
+| Поле | Как разобрать зависание по наблюдаемым стадиям | 9 588 знаков | trace, локальный тестовый сервер и дерево диагностики |
+
+У каждой ревизии есть 8, 9 и 9 заголовков второго уровня соответственно,
+одна доступная таблица с thead и scope, один рисунок
+с непустым alt и подписью, 2–3 блока кода, нумерованный порядок
+действий, ограничения и точный заголовок
+<h2>Проверяемые источники</h2>.
+
+## 1. Факты и техника — пройдено
+
+### Границы cURL
+
+- Официальная документация libcurl сверена для
+ CURLOPT_CONNECTTIMEOUT,
+ CURLOPT_TIMEOUT,
+ LOW_SPEED_LIMIT,
+ LOW_SPEED_TIME
+ и curl_easy_getinfo.
+- Текст не называет общий CURLOPT_TIMEOUT «read timeout».
+ В нём он ограничивает весь перенос; пара LOW_SPEED_LIMIT и LOW_SPEED_TIME
+ описана как граница средней скорости ниже порога за период.
+- Указано, что connect phase включает DNS и протокольные переговоры до
+ установленного соединения, а connect timeout находится внутри общего
+ timeout. Бюджеты не складываются.
+- CURLE_OPERATION_TIMEDOUT (28)
+ используется только как итог достижения условия таймаута. Статьи требуют
+ записывать пороги, HTTP-код и временные отметки, а не угадывают по одному
+ коду ошибочную стадию.
+
+### Повторы и HTTP
+
+- Для исторической точности сверены
+ RFC 7231, раздел 4.2.2,
+ действовавший в 2018 году, и актуальный
+ RFC 9110, раздел 9.2.2.
+- Ни одна статья не советует «просто повторить запрос». GET и HEAD получают
+ только один ограниченный повтор в остатке бюджета. Изменение состояния
+ требует постоянного ключа операции и документированной дедупликации у
+ партнёра либо проверки статуса. Без этого результат обозначается
+ неопределённым.
+- Примеры с локальной задержкой помечены как учебные. В текст не добавлены
+ вымышленные метрики, пользователи, результаты инструментов или состояние
+ внешнего сервиса.
+
+### Исполнимость кода
+
+- Примеры используют синхронный PHP cURL, доступный для эпохи 2018 года:
+ curl_exec, curl_getinfo,
+ CURLOPT_CONNECTTIMEOUT, CURLOPT_TIMEOUT и
+ базовые временные поля.
+- Код читает диагностику до curl_close, отделяет transport error
+ от HTTP-кода и не записывает в обязательный trace токен, тело запроса или
+ ответ партнёра.
+
+Вердикт первого прохода: утверждения о cURL, HTTP и повторах имеют первичную
+опору; ложных обещаний и небезопасного автоматического повтора нет.
+
+## 2. Редактура и голос — пройдено
+
+- Первый абзац каждой статьи называет симптом и цену ошибки: занятый пул PHP,
+ шестидесятисекундное ожидание или неизвестное состояние синхронизации.
+- Каждая статья держит один главный вопрос. Практика строит бюджет, механизм
+ разделяет настройки, поле ведёт расследование по trace. Три текста не
+ пересказывают друг друга.
+- Речь соответствует М1 2018 года: PHP, cURL, HTTP, DNS, TLS, журнал и
+ локальный тест. Автор ведёт читателя через «сначала проверяем — затем
+ меняем», без позднего жаргона SLO, распределённой трассировки или
+ неподтверждённых показателей.
+- Технический ход сохраняется в каждом разделе: симптом → граница →
+ проверка → действие → ограничение. Общие вводные и слова без проверяемого
+ объекта не использованы.
+- В конце есть критерий результата: trace указывает на стадию, одно
+ ограничение меняется после проверки, а безопасный повтор определяется
+ контрактом операции.
+
+Вердикт второго прохода: объём находится в интервале 5 000–15 000 знаков,
+голос не опережает 2018 год, а плотность текста не держится на общих оценках.
+
+## 3. Визуал и выпуск — пройдено для черновика
+
+- Повторный независимый strict-проход остановил практическую статью: первые
+ 800 знаков содержали конкретный сценарий и цену, но не явный маркер
+ симптома. Первое предложение исправлено на Симптом: карточка
+ товара...; сценарий, причина и цена ошибки сохранены.
+- Созданы три разные SVG: временной бюджет запроса, границы таймаутов и
+ дерево полевого разбора. Каждая схема передаёт решение из соответствующей
+ статьи, а не повторяет заголовок.
+- Все SVG прошли xmllint --noout. Затем они были реально
+ растрированы через sharp в исходном размере и на ширине
+ 343 px. Проверены границы canvas, контраст и читаемость ключевых заголовков.
+- В первой визуальной версии длинные строки в схеме механизма заходили на
+ цветные плашки, а нижние решения в схеме полевого разбора не помещались
+ в карточки. Перед этим вердиктом подписи разбиты по строкам, карточки
+ увеличены; повторный рендер не показывает обрезанных элементов.
+- На узкой ширине схема остаётся внутри контейнера. Её подробный смысл
+ продублирован непустыми alt и figcaption;
+ таблицы имеют существующий контейнер прокрутки
+ .table-scroll в общих стилях.
+- После интеграции основной редактор повторно прогнал strict audit всех трёх
+ slug и production build. Сборка прошла и сгенерировала 374 статические
+ страницы.
+
+## Реально выполненные проверки
+
+1. node --check web/scripts/upgrade-2018-07.mjs — успешно.
+2. node web/scripts/upgrade-2018-07.mjs --print-revisions —
+ успешно; в stdout выводится только JSON трёх ревизий, stderr пуст.
+3. Безопасный import выполнен отдельной командой; модуль экспортирует
+ revisions, не печатает Usage и не меняет exit code.
+4. Структурная проверка экспортированных HTML подтвердила для всех трёх
+ ревизий объём, число h2, figure, table, thead, code,
+ нумерованный список, единый раздел источников, внешние ссылки и
+ непустой alt.
+5. xmllint --noout для трёх SVG — успешно.
+6. Растровый рендер SVG через sharp проверен в исходном
+ размере и на ширине 343 px; после исправления нет визуального выхода за
+ canvas.
+7. git status --short по зоне задачи подтвердил, что до
+ добавления этого ревью появились только четыре разрешённых июльских
+ файла; web/data/articles.json и редакционные общие файлы
+ этой партией не менялись.
+
+Итоговый вердикт: пакет принят к публикации после независимого
+интеграционного ревью. Commit и push — отдельный выпускной шаг основного
+редактора.
diff --git a/editorial/reviews/2018-08-draft.md b/editorial/reviews/2018-08-draft.md
new file mode 100644
index 0000000..20ede26
--- /dev/null
+++ b/editorial/reviews/2018-08-draft.md
@@ -0,0 +1,130 @@
+# 2018-08 · TLS и CA bundle в PHP · тройное ревью
+
+Статус: **принят в публикационный слой 31 июля 2026 года**. Пакет не
+перезаписывает `web/data/articles.json`: `web/data/editorial-revisions.mjs`
+накладывает его только по стабильным slug.
+
+## Состав партии
+
+| Ревизия | Основной текст | Смысловая задача | Визуал | Источники |
+| --- | ---: | --- | --- | ---: |
+| `editorial-2018-08-practice-tls-ca` | 7 816 знаков | Диагностика certificate verification в PHP cURL без снятия защиты | `tls-ca-diagnostic-2018.svg` | 8 |
+| `editorial-2018-08-mechanism-tls-ca` | 7 641 знак | Разделение server chain, CA store, hostname check и SNI | `tls-ca-chain-sni-2018.svg` | 7 |
+| `editorial-2018-08-field-tls-ca` | 8 358 знаков | Контролируемая замена устаревшего trust store | `tls-ca-refresh-plan-2018.svg` | 9 |
+
+Во всех трёх ревизиях: 8–9 смысловых `
` до раздела источников, одна
+доступная таблица с `` и `scope="col"`, один SVG с содержательным `alt`
+и `figcaption`, несколько примеров кода, нумерованный порядок действий,
+ограничения и точный заголовок `
Проверяемые источники
`.
+
+## Проход 1 — факты и техника
+
+### Что сверено
+
+- [Документация cURL о CA certificates](https://curl.se/docs/sslcerts.html)
+ подтверждает, что peer verification включена по умолчанию и custom CA store
+ задаётся отдельно; ни одна статья не предлагает `CURLOPT_SSL_VERIFYPEER=false`
+ как решение.
+- [Документация libcurl о CURLOPT_SSL_VERIFYPEER](https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html)
+ и [CURLOPT_SSL_VERIFYHOST](https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html)
+ использована для разделения проверки цепочки и имени хоста. В каждом PHP
+ примере установлены `CURLOPT_SSL_VERIFYPEER => true` и
+ `CURLOPT_SSL_VERIFYHOST => 2`.
+- [OpenSSL 1.0.2 s_client](https://docs.openssl.org/1.0.2/man1/s_client/)
+ использован для фактов о `-servername`, `-showcerts`, `-CAfile` и `-CApath`.
+ Текст прямо оговаривает, что `-showcerts` показывает присланный сервером
+ список, а не уже проверенную цепочку.
+- [OpenSSL 1.0.2 verify](https://docs.openssl.org/1.0.2/man1/verify/)
+ подтверждает различие `-CAfile` (trusted certificates) и `-untrusted`
+ (intermediate certificates). Команда в статье разделяет leaf, intermediate
+ и trust anchor, а не превращает intermediate в корневое доверие.
+- [PHP curl_version](https://www.php.net/manual/en/function.curl-version.php),
+ [curl_error](https://www.php.net/manual/en/function.curl-error.php) и
+ [cURL runtime configuration](https://www.php.net/manual/en/curl.configuration.php)
+ сверены для диагностики PHP-модуля и абсолютного `curl.cainfo`.
+
+### Технические решения ревью
+
+- Команды используют `api.partner.example` как шаблон. В статьях нет
+ выдуманного вывода `curl`, `s_client` или `openssl verify` и нет утверждения,
+ что один код ошибки всегда означает единственную причину.
+- Код сохраняет `curl_errno()` и `curl_error()` до `curl_close()`, а verbose
+ trace отмечен как материал закрытого стенда, не публичный лог.
+- В статье о механизме цепочка проверяется отдельно от HTTP и отдельно от
+ hostname check. Это не смешивает SNI с проверкой имени.
+- В полевой статье новый bundle — версионный конфигурационный артефакт;
+ кандидат проверяется до переключения, а final check выполняется тем же
+ PHP-клиентом.
+
+### Выполненные проверки
+
+```text
+node --check web/scripts/upgrade-2018-08.mjs
+node web/scripts/upgrade-2018-08.mjs --print-revisions | JSON.parse(...)
+dynamic import: revisions export есть, process.exitCode не изменился
+структурная проверка: 3/3 ревизии, 5 000–15 000 знаков, h2/figure/table/code/ol/sources
+negative scan: нет CURLOPT_SSL_VERIFYPEER => false и VERIFYHOST => 0/1
+```
+
+Вердикт прохода: **пройдено**. Версионная оговорка сохранена: формулировки
+ошибок и детали TLS backend зависят от связки PHP/libcurl/OpenSSL; инструкции
+собирают её версию до вывода.
+
+## Проход 2 — редактура и голос
+
+Период — М1, 2018: практик PHP-интеграций расширяет вертикаль от cURL-ошибки к
+серверной цепочке и конфигурации окружения. Словарь ограничен cURL, OpenSSL,
+CA bundle, SNI, hostname, intermediate и конкретными параметрами API. В текст
+не добавлены поздние рамки вроде SLO, Kubernetes, distributed tracing или
+организационной стратегии.
+
+| Проверка | Результат |
+| --- | --- |
+| Симптом и цена ошибки в первых 1–2 предложениях | Есть во всех трёх статьях: ложное исправление создаёт риск принятия подменённого узла |
+| Один главный вопрос на текст | Диагностика / механизм / controlled refresh разделены, примеры не дублируют друг друга |
+| Прагматичный маршрут | Каждый раздел ведёт от наблюдения к проверке и действию; финал возвращает критерий готовности |
+| Объём без источников | 7 816 / 7 641 / 8 358, в диапазоне 5 000–15 000 |
+| Шаблонные вводные | Сканер не нашёл «В современном мире», «очень важно», «следует отметить», «просто нужно», «нужно понимать, что» |
+| Границы применимости | Явно названы частный CA, missing intermediate, неверное имя, дата/отзыв/политика и разные TLS backend |
+
+Во время этой вычитки сокращены универсальные формулировки: вместо «обновите
+сертификат» указано, какой объект обновляется и чем он проверяется; вместо
+«TLS не работает» названы конкретные ветки — CAfile, server chain, SNI и
+hostname. Тон оставлен спокойным и предметным: «давайте разберём», «сначала
+проверяю», «не смешиваю причины».
+
+Вердикт прохода: **пройдено**.
+
+## Проход 3 — визуал и выпуск
+
+### Визуал
+
+- `xmllint --noout` успешно проверил все три SVG.
+- Каждая схема открыта в браузере при ширине 1 280 px. Видимые размеры страницы
+ не выходили за viewport; у SVG есть ``, `` и 20–26 текстовых
+ узлов для пояснения собственной схемы.
+- После первого визуального просмотра исправлены переноса длинных названий в
+ `tls-ca-diagnostic-2018.svg` и `tls-ca-refresh-plan-2018.svg`; во второй
+ схеме сокращена подпись стрелки и перенесена строка, чтобы текст не заходил
+ на соседний блок.
+- В HTML статей используются существующие обёртки
+ `
`: stylesheet блога даёт им горизонтальную
+ прокрутку, а таблицам — `min-width`. Полный mobile-render статьи намеренно
+ не запускался: по условиям пакета ревизии не добавлены в data archive. На
+ уровне разметки каждая таблица подготовлена для этого стиля.
+
+### Выпускной контракт
+
+- Скрипт экспортирует ровно три объекта как `revisions`.
+- При `node web/scripts/upgrade-2018-08.mjs --print-revisions` stdout проходит
+ JSON.parse и содержит только три заданных slug.
+- При import скрипт не печатает Usage и не меняет `process.exitCode`.
+- Новые файлы ограничены этим review, одним скриптом и тремя SVG; `articles.json`,
+ стандарт, очередь, registry, audit-скрипт, Git index/commit/push не менялись.
+
+Вердикт прохода: **принято к публикации**. Основной редактор повторно прогнал
+strict audit всех трёх slug и production build после интеграции: сборка прошла
+и сгенерировала 374 статические страницы. Повторный live mobile-render в этой
+среде заблокирован политикой браузера; для таблиц применён уже проверенный
+общий контракт `
` + `min-width`, а отдельные SVG
+прошли XML и авторскую визуальную вычитку.
diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs
index 510ca92..1ac61aa 100644
--- a/web/data/editorial-revisions.mjs
+++ b/web/data/editorial-revisions.mjs
@@ -1,10 +1,14 @@
import { revisions as march2018Revisions } from '../scripts/upgrade-2018-03.mjs';
import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs';
import { revisions as june2018Revisions } from '../scripts/upgrade-2018-06.mjs';
+import { revisions as july2018Revisions } from '../scripts/upgrade-2018-07.mjs';
+import { revisions as august2018Revisions } from '../scripts/upgrade-2018-08.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
...march2018Revisions,
...may2018Revisions,
...june2018Revisions,
+ ...july2018Revisions,
+ ...august2018Revisions,
];
diff --git a/web/public/assets/editorial/2018/http-timeout-budget-2018.svg b/web/public/assets/editorial/2018/http-timeout-budget-2018.svg
new file mode 100644
index 0000000..815774e
--- /dev/null
+++ b/web/public/assets/editorial/2018/http-timeout-budget-2018.svg
@@ -0,0 +1,66 @@
+
diff --git a/web/public/assets/editorial/2018/http-timeout-investigation-2018.svg b/web/public/assets/editorial/2018/http-timeout-investigation-2018.svg
new file mode 100644
index 0000000..ec8d025
--- /dev/null
+++ b/web/public/assets/editorial/2018/http-timeout-investigation-2018.svg
@@ -0,0 +1,60 @@
+
diff --git a/web/public/assets/editorial/2018/http-timeout-layers-2018.svg b/web/public/assets/editorial/2018/http-timeout-layers-2018.svg
new file mode 100644
index 0000000..3d35823
--- /dev/null
+++ b/web/public/assets/editorial/2018/http-timeout-layers-2018.svg
@@ -0,0 +1,38 @@
+
diff --git a/web/public/assets/editorial/2018/tls-ca-chain-sni-2018.svg b/web/public/assets/editorial/2018/tls-ca-chain-sni-2018.svg
new file mode 100644
index 0000000..100ec41
--- /dev/null
+++ b/web/public/assets/editorial/2018/tls-ca-chain-sni-2018.svg
@@ -0,0 +1,53 @@
+
diff --git a/web/public/assets/editorial/2018/tls-ca-diagnostic-2018.svg b/web/public/assets/editorial/2018/tls-ca-diagnostic-2018.svg
new file mode 100644
index 0000000..50a6792
--- /dev/null
+++ b/web/public/assets/editorial/2018/tls-ca-diagnostic-2018.svg
@@ -0,0 +1,82 @@
+
diff --git a/web/public/assets/editorial/2018/tls-ca-refresh-plan-2018.svg b/web/public/assets/editorial/2018/tls-ca-refresh-plan-2018.svg
new file mode 100644
index 0000000..849248f
--- /dev/null
+++ b/web/public/assets/editorial/2018/tls-ca-refresh-plan-2018.svg
@@ -0,0 +1,67 @@
+
diff --git a/web/scripts/upgrade-2018-07.mjs b/web/scripts/upgrade-2018-07.mjs
new file mode 100644
index 0000000..f79caf4
--- /dev/null
+++ b/web/scripts/upgrade-2018-07.mjs
@@ -0,0 +1,498 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const escapeHtml = (value) => String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+const paragraph = (content) => '
/g) || []).length < 5) {
+ throw new Error(meta.slug + ': at least five h2 headings are required');
+ }
+
+ for (const fragment of ['
', '
', '
', '']) {
+ if (!bodyHtml.includes(fragment)) {
+ throw new Error(meta.slug + ': missing ' + fragment);
+ }
+ }
+
+ if (!contentHtml.includes('
Проверяемые источники
') || sources.length < 2) {
+ throw new Error(meta.slug + ': primary source section is incomplete');
+ }
+
+ return {
+ ...meta,
+ contentHtml,
+ };
+}
+
+const sources = {
+ connectTimeout: {
+ label: 'libcurl: CURLOPT_CONNECTTIMEOUT',
+ url: 'https://curl.se/libcurl/c/CURLOPT_CONNECTTIMEOUT.html',
+ note: 'состав фазы соединения, включение DNS и протокольных переговоров, соотношение с общим таймаутом',
+ },
+ timeout: {
+ label: 'libcurl: CURLOPT_TIMEOUT',
+ url: 'https://curl.se/libcurl/c/CURLOPT_TIMEOUT.html',
+ note: 'жёсткий предел всего переноса от начала до конца и включение connect timeout в этот предел',
+ },
+ lowSpeedLimit: {
+ label: 'libcurl: CURLOPT_LOW_SPEED_LIMIT',
+ url: 'https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_LIMIT.html',
+ note: 'средняя скорость, ниже которой перенос считается слишком медленным совместно с LOW_SPEED_TIME',
+ },
+ lowSpeedTime: {
+ label: 'libcurl: CURLOPT_LOW_SPEED_TIME',
+ url: 'https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_TIME.html',
+ note: 'длительность низкой скорости и завершение переноса с ошибкой таймаута',
+ },
+ errors: {
+ label: 'libcurl: Error Codes',
+ url: 'https://curl.se/libcurl/c/libcurl-errors.html',
+ note: 'значение CURLE_OPERATION_TIMEDOUT (28): достигнуто одно из условий таймаута',
+ },
+ getinfo: {
+ label: 'libcurl: curl_easy_getinfo и временные отметки',
+ url: 'https://curl.se/libcurl/c/curl_easy_getinfo.html',
+ note: 'смысл NAMELOOKUP, CONNECT, APPCONNECT, STARTTRANSFER и TOTAL после переноса',
+ },
+ rfc7231: {
+ label: 'RFC 7231, раздел 4.2.2 — идемпотентные методы',
+ url: 'https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.2',
+ note: 'документ, действовавший в 2018 году; определяет идемпотентность и повтор после сбоя связи до чтения ответа',
+ },
+ rfc9110: {
+ label: 'RFC 9110, раздел 9.2.2 — идемпотентные методы',
+ url: 'https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2',
+ note: 'актуальная редакция HTTP Semantics; запрещает угадывать безопасный автоматический повтор неидемпотентного запроса',
+ },
+};
+
+const practiceArticle = createRevision(
+ {
+ slug: 'editorial-2018-07-practice-http-timeouts',
+ title: 'PHP cURL. Как дать веб-интеграции ограниченный бюджет времени',
+ categories: ['PHP', 'cURL', 'Интеграции'],
+ cover: '/assets/editorial/2018/http-timeout-budget-2018.svg',
+ excerpt: 'У одного запроса к партнёру должен быть общий предел, короткая граница соединения и понятное правило повтора. Разбираем это на PHP cURL без бесконечного ожидания и двойных операций.',
+ readingMinutes: 11,
+ },
+ [
+ paragraph('Симптом: карточка товара ждёт цену от партнёра, а PHP-процесс висит до лимита веб-сервера. В это время заняты рабочие процессы, пользователь получает пустой блок, а следующий разработчик увеличивает общий таймаут до минуты. Цена такой правки — больше занятых процессов и та же неизвестная причина сбоя.'),
+ paragraph('В этой заметке разберём один узкий вопрос: как задать бюджеты ожидания для PHP cURL-запроса, записать результат и решить, когда его вообще можно повторить. Значения ниже учебные. Их нельзя переносить в другой API без размера ответа, ожидаемой нагрузки и договора с партнёром.'),
+ heading('Сначала задаю время, которое можно отдать партнёру'),
+ paragraph('Таймаут — это не число, которое берут из чужого примера. Сначала у сценария появляется предел. Допустим, страница готова ждать внешний остаток восемь секунд. Внутри этих восьми секунд соединению дадим две секунды. Оставшееся время занимает ожидание первого байта и получение тела. Если партнёр не уложился, текущая страница завершает свой путь, а не держит PHP бесконечно.'),
+ paragraph('Такой расчёт не доказывает, что восемь секунд хороши для всех. Он делает решение проверяемым: в журнале можно увидеть, на какой части пути ушло время, и поменять конкретную границу. Общий предел особенно важен, потому что без него успешно открытое соединение всё ещё может ждать ответ или тело сколько угодно долго.'),
+ figure(
+ '/assets/editorial/2018/http-timeout-budget-2018.svg',
+ 'Шкала HTTP-запроса с отдельной границей соединения и общим пределом всего переноса.',
+ 'Connect timeout ограничивает начальную фазу, общий timeout охватывает её вместе с ожиданием и телом ответа.',
+ ),
+ heading('Разделяю три разных вопроса'),
+ paragraph('У cURL есть несколько настроек, которые часто называют одним словом timeout. У них разная работа. Если смешать их в одну цифру, из лога с ошибкой 28 нельзя понять, был ли недоступен адрес, долго ли отвечал сервер или ответ передавался слишком медленно. Поэтому у каждого ограничения должна быть собственная причина появления.'),
+ dataTable(
+ ['Вопрос', 'Настройка', 'Что ограничивает', 'Что проверять при срабатывании'],
+ [
+ ['Успели ли открыть соединение?', 'CURLOPT_CONNECTTIMEOUT', 'DNS, TCP и переговоры протокола до установленного соединения', 'Адрес, DNS, сеть, TLS и короткий путь до партнёра'],
+ ['Успел ли закончиться весь запрос?', 'CURLOPT_TIMEOUT', 'Весь перенос от старта до конца, включая соединение', 'Бюджет сценария, ожидание первого байта и размер тела'],
+ ['Не течёт ли ответ слишком медленно?', 'CURLOPT_LOW_SPEED_LIMIT + CURLOPT_LOW_SPEED_TIME', 'Среднюю скорость ниже порога в течение заданного времени', 'Размер ответа, прокси-буферизацию и реальную скорость передачи'],
+ ['Можно ли попробовать ещё раз?', 'Код приложения', 'Бизнес-операцию, метод и оставшееся время', 'Идемпотентность и состояние операции у партнёра'],
+ ],
+ ),
+ paragraph('Официальная документация libcurl прямо включает DNS и все переговоры до установленного соединения в connect phase. Она также говорит, что этот короткий предел находится внутри общего timeout. Поэтому connect timeout не складывают с total timeout: при двух и восьми секундах максимум всего вызова всё равно восемь, а не десять.'),
+ heading('Минимальная настройка PHP cURL'),
+ paragraph('Ниже функция не пытается решить бизнес-логику за приложение. Она возвращает тело, ошибку, HTTP-код и времена. Важная деталь: HTTP-код читаем отдельно от ошибки cURL. Сервер может ответить 500 быстро; это HTTP-ответ, а не таймаут транспорта. При сетевом обрыве HTTP-код обычно останется нулём.'),
+ codeBlock([
+ ' true,',
+ ' CURLOPT_HTTPHEADER => array(',
+ ' "Accept: application/json",',
+ ' "X-Request-Id: " . $requestId,',
+ ' ),',
+ ' CURLOPT_CONNECTTIMEOUT => 2,',
+ ' CURLOPT_TIMEOUT => 8,',
+ ' CURLOPT_LOW_SPEED_LIMIT => 100,',
+ ' CURLOPT_LOW_SPEED_TIME => 3,',
+ ' ));',
+ '',
+ ' $body = curl_exec($curl);',
+ ' $result = array(',
+ ' "body" => $body,',
+ ' "curl_errno" => curl_errno($curl),',
+ ' "curl_error" => curl_error($curl),',
+ ' "http_code" => curl_getinfo($curl, CURLINFO_HTTP_CODE),',
+ ' "name_lookup" => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),',
+ ' "connect" => curl_getinfo($curl, CURLINFO_CONNECT_TIME),',
+ ' "start_transfer" => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),',
+ ' "total" => curl_getinfo($curl, CURLINFO_TOTAL_TIME),',
+ ' );',
+ '',
+ ' curl_close($curl);',
+ ' return $result;',
+ '}',
+ ].join('\n')),
+ paragraph('Пара low speed limit и low speed time нужна не как красивое третье число. Она подходит для ответа, который уже начался, но его средняя скорость долго остаётся ниже порога. В примере граница равна 100 байтам в секунду в течение трёх секунд. Для короткого JSON это может быть разумным сигналом зависания; для выгрузки большого файла такой порог нужно выбирать отдельно.'),
+ heading('Не называю общий timeout read timeout'),
+ paragraph('У простого вызова libcurl нет одной настройки, которая буквально означает «не ждать чтения N секунд». CURLOPT_TIMEOUT ограничивает весь перенос. Пара low speed ограничивает среднюю скорость передачи за период. Это близкий практический контроль для зависшего тела, но не тот же самый механизм. В тексте, логе и настройках лучше называть вещи своими именами — иначе следующая проверка окажется неверной.'),
+ paragraph('Если start_transfer близок к восьми секундам, ответ долго не начинался: смотреть нужно очередь и обработку у партнёра после успешного соединения. Если первый байт пришёл быстро, а total упёрся в потолок, граница уже в теле ответа, сжатии или канале. Если connect близок к двум секундам и HTTP-код ноль, повышать время ожидания ответа бессмысленно: сначала проверяют адрес и соединение.'),
+ heading('Повторяю только чтение или известную операцию'),
+ paragraph('Ошибка timeout не говорит, что партнёр ничего не сделал. Запрос мог дойти до сервера, операция могла выполниться, а ответ потеряться. Поэтому нельзя после любого POST просто вызвать ту же функцию ещё раз: заказ, платёж или заявка могут появиться дважды. HTTP различает методы по предполагаемому эффекту; в документе, действовавшем в 2018 году, GET, HEAD, PUT и DELETE имеют идемпотентную семантику, но конкретный API всё равно может иметь побочные действия вокруг них.'),
+ paragraph('Для чтения можно оставить один контролируемый повтор, если ещё хватает времени на полезный ответ. Для изменения состояния нужен договор с партнёром: постоянный ключ операции, поиск состояния по нему или другой способ доказать, что первый вызов не был применён. Пока такого договора нет, результат таймаута следует считать неопределённым и передать на проверку, а не создавать второй объект.'),
+ codeBlock([
+ '= 2;',
+ '}',
+ '',
+ '// Для POST эта функция всегда вернёт false.',
+ '// Повтор POST требует отдельного контракта ключа операции у партнёра.',
+ ].join('\n')),
+ heading('Порядок ввода в проект'),
+ orderedList([
+ 'Назвать пользовательский сценарий и записать его внешний бюджет: сколько секунд можно ждать именно этому экрану или задаче.',
+ 'Поставить общий timeout меньше лимита PHP и короткий connect timeout внутри него; не суммировать их.',
+ 'Вернуть из клиента ошибку cURL, HTTP-код и несколько временных отметок, не записывая в журнал тело с персональными данными.',
+ 'На тестовом адресе проверить отдельно: недоступный хост, задержку до первого байта и медленное тело ответа.',
+ 'Для каждого метода зафиксировать правило повтора: GET и HEAD могут иметь один контролируемый повтор, изменение состояния — только после договора о ключе операции или проверке статуса.',
+ 'После первых журналов менять одну границу и повторять тот же сценарий, а не поднимать все таймауты одновременно.',
+ ]),
+ heading('Ограничения примера'),
+ paragraph('Числа два, восемь, сто и три не являются нормативом. На выбор влияют число параллельных PHP-процессов, размер ожидаемого тела, повторное использование соединения, прокси и пользовательский сценарий. CURLINFO_TOTAL_TIME показывает длительность завершившегося переноса; он не заменяет отдельный замер очереди веб-сервера до входа в PHP. Если приложение идёт через несколько прокси, у каждого может быть свой предел ожидания, который тоже нужно знать.'),
+ heading('Итог'),
+ paragraph('Рабочая настройка начинается не с увеличения одного timeout. У запроса есть общий бюджет, короткая граница соединения и при необходимости контроль слишком медленной передачи. В журнале остаются error, HTTP-код и времена. После этого видно, где искать проблему и имеет ли право код сделать ещё одну попытку.'),
+ ],
+ [
+ sources.connectTimeout,
+ sources.timeout,
+ sources.lowSpeedLimit,
+ sources.lowSpeedTime,
+ sources.getinfo,
+ sources.rfc7231,
+ sources.rfc9110,
+ ],
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2018-07-mechanism-http-timeouts',
+ title: 'PHP cURL. Почему connect, общий timeout и медленный ответ нельзя смешивать',
+ categories: ['PHP', 'cURL', 'HTTP'],
+ cover: '/assets/editorial/2018/http-timeout-layers-2018.svg',
+ excerpt: 'Один error 28 не рассказывает, что именно не успело: DNS и TLS, ожидание первого байта или тело ответа. Разбираем границы опций libcurl и безопасные условия для повтора.',
+ readingMinutes: 11,
+ },
+ [
+ paragraph('В журнале появляется curl_errno = 28, и его называют read timeout. После этого общий предел увеличивают с десяти до шестидесяти секунд. Ошибка остаётся, но PHP-процессы ждут в шесть раз дольше — цена неверного названия уже измеряется занятым пулом и медленным экраном.'),
+ paragraph('Разберём механизм по частям: что cURL считает соединением, где находится общий предел, чем на самом деле служит low speed и почему error 28 не даёт разрешение повторить запрос. Это уровень PHP 2018: обычный синхронный вызов curl_exec, без поздних терминов и без обещания, что одна настройка исправит партнёра.'),
+ heading('Connect phase начинается раньше TCP'),
+ paragraph('В документации libcurl connect phase начинается с разрешения имени. В неё входят DNS, TCP и последующие протокольные переговоры, пока не появится установленное соединение с удалённой стороной. Для HTTPS сюда попадёт и TLS-рукопожатие. Поэтому медленный DNS легко проявится как превышение connect timeout, хотя сам TCP-пакет ещё не посылался.'),
+ paragraph('Это полезная граница расследования. Если cURL не успел пройти connect phase, у приложения ещё нет HTTP-ответа партнёра. Проверяют URL, DNS, маршрут, сертификат и доступность конечного узла. Не стоит сразу искать медленный SQL на стороне API: до его обработчика запрос мог вообще не дойти.'),
+ figure(
+ '/assets/editorial/2018/http-timeout-layers-2018.svg',
+ 'Три условия остановки HTTP-вызова libcurl: connect phase, общий перенос и низкая скорость ответа.',
+ 'Connect timeout охватывает начальную фазу, total timeout — весь перенос, low speed — среднюю скорость ниже выбранного порога.',
+ ),
+ heading('Общий timeout накрывает соединение'),
+ paragraph('Общий CURLOPT_TIMEOUT ограничивает весь перенос от старта до конца. Он не запускается после успешного соединения, а действует с самого начала. Документация libcurl приводит прямой пример: если connect timeout равен четырём секундам, а общий — двум, весь вызов остановится не позже двух. Из двух ограничений побеждает более ранняя граница.'),
+ paragraph('Из этого следует простой контракт конфигурации: connect timeout всегда меньше или равен общему, а общий соответствует бюджету сценария. Если экран может ждать восемь секунд, ставить connect восемь и total восемь обычно не помогает отличить «не установили связь» от «партнёр долго отвечает». Короткая отдельная граница нужна для первой стадии, а не для сложения секунд.'),
+ dataTable(
+ ['Стадия', 'Что завершилось', 'Полезные поля после curl_exec', 'Следующая проверка'],
+ [
+ ['До соединения', 'Нет подтверждённого соединения с узлом', 'name_lookup, connect, app_connect, HTTP-код 0', 'DNS, маршрут, TLS, адрес партнёра'],
+ ['После соединения, до ответа', 'Соединение есть, первый байт не пришёл', 'connect мал, start_transfer близок к total', 'Очередь или обработчик на стороне партнёра'],
+ ['После первого байта', 'Ответ начал идти, но не завершился', 'start_transfer мал, total близок к пределу', 'Размер ответа, прокси, скорость и формат выгрузки'],
+ ['HTTP-ошибка', 'Сервер успел прислать статус', 'HTTP-код не ноль, cURL может не иметь транспортной ошибки', 'Контракт конкретного статуса, а не таймауты'],
+ ],
+ ),
+ heading('Read timeout в этой конфигурации не отдельная ручка'),
+ paragraph('В PHP cURL часто ищут настройку «таймаут чтения». Для простого вызова её лучше не выдумывать. CURLOPT_TIMEOUT — общий жёсткий потолок. CURLOPT_LOW_SPEED_LIMIT вместе с CURLOPT_LOW_SPEED_TIME говорит другое: средняя скорость переноса была ниже заданного числа байтов в секунду в течение выбранного времени, поэтому перенос остановлен.'),
+ paragraph('Этот контроль полезен для ситуации «ответ начал идти, затем почти застыл». Но он может оборвать и легитимно медленную выгрузку, если порог выбран без знания размера и канала. Он не является проверкой того, что партнёр вообще не начал работу. На длинных отчётах лучше менять контракт — отдавать задачу асинхронно или получать результат частями, а не ставить слишком большой общий предел на страницу.'),
+ codeBlock([
+ ' true,',
+ ' CURLOPT_CONNECTTIMEOUT => 2,',
+ ' CURLOPT_TIMEOUT => 8,',
+ ' CURLOPT_LOW_SPEED_LIMIT => 100,',
+ ' CURLOPT_LOW_SPEED_TIME => 3,',
+ '));',
+ '',
+ '$body = curl_exec($curl);',
+ '$errno = curl_errno($curl);',
+ '$error = curl_error($curl);',
+ 'curl_close($curl);',
+ '',
+ '// errno 28 означает только достижение одного из условий таймаута.',
+ '// По одному errno нельзя определить стадию без сохранённых времён.',
+ ].join('\n')),
+ heading('Сохраняю временную шкалу, а не одно название ошибки'),
+ paragraph('После завершения переноса cURL даёт накопленные времена. Они не являются отдельными независимыми интервалами: каждое значение отсчитывается от начала запроса. Например, CONNECT_TIME — время от старта до подключения, а STARTTRANSFER_TIME — время от старта до первого полученного байта. Чтобы увидеть длительность участка, соседние накопленные отметки вычитают.'),
+ paragraph('Для HTTPS пригодится CURLINFO_APPCONNECT_TIME. На обычном HTTP он может быть нулевым, и это не ошибка. В PHP старого проекта стоит проверять доступность нужных констант на установленной версии расширения, а не копировать набор полей из новой документации. Базовые NAMELOOKUP, CONNECT, STARTTRANSFER и TOTAL существовали задолго до 2018 года и дают достаточно материала для первой диагностики.'),
+ codeBlock([
+ ' curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),',
+ ' "connect" => curl_getinfo($curl, CURLINFO_CONNECT_TIME),',
+ ' "app_connect" => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),',
+ ' "start_transfer" => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),',
+ ' "total" => curl_getinfo($curl, CURLINFO_TOTAL_TIME),',
+ ' );',
+ '}',
+ '',
+ 'function difference($later, $earlier) {',
+ ' return max(0, $later - $earlier);',
+ '}',
+ ].join('\n')),
+ paragraph('Если в логе connect = 0.18, start_transfer = 7.91 и total = 8.00, это не результат замера реального сервиса, а пример формы вывода: связь установилась быстро, а первый байт не пришёл до общего предела. Другой рисунок — start_transfer = 0.30 и total = 8.00 — указывает уже на передачу тела. Эти два случая нельзя лечить одной и той же настройкой.'),
+ heading('Error 28 описывает итог, а не причину'),
+ paragraph('Официальный список ошибок libcurl определяет CURLE_OPERATION_TIMEDOUT как достижение указанного условия таймаута. Он не записывает в код 28 отдельную метку «DNS», «сервер» или «тело ответа». Поэтому в одно событие диагностики кладут URL без секретной строки запроса, метод, пороги, error, HTTP-код, длину полученного тела и временную шкалу. Без сохранённых порогов даже хорошие времена нельзя сопоставить с решением клиента.'),
+ paragraph('Не стоит логировать пароль, токен или полный JSON только ради расследования. В большинстве случаев достаточно пути API, корреляционного идентификатора, кода ошибки, HTTP-кода и чисел времени. Если нужен фрагмент тела для контракта ошибки, его согласуют отдельно и маскируют. Диагностика не должна превращать таймаут в утечку данных.'),
+ heading('Повтор относится к операции, а не к error 28'),
+ paragraph('Документ HTTP, действовавший в 2018 году, различает идемпотентные методы: повтор одинакового запроса должен иметь тот же предполагаемый эффект, что и один вызов. Он объясняет, почему после сбоя связи до чтения ответа клиент может повторить такую операцию. Актуальный RFC 9110 добавляет важную границу: клиент не должен автоматически повторять неидемпотентный запрос, если не знает, что семантика конкретной операции идемпотентна или что первый запрос точно не был применён.'),
+ paragraph('Метод сам по себе не заменяет договор API. POST /orders без ключа операции не стоит повторять. POST с постоянным внешним идентификатором можно повторять только если партнёр документировал дедупликацию по этому идентификатору и приложение сохраняет один и тот же ключ на все попытки. После timeout с неизвестным состоянием безопасный путь часто состоит из проверки статуса операции, а не из второго создания.'),
+ heading('Последовательность проверки'),
+ orderedList([
+ 'Зафиксировать для одного маршрута общий бюджет и короткий connect timeout; указать их рядом с вызовом или в конфигурации.',
+ 'В тестовой среде отдельно вызвать несуществующий адрес, сервер с задержкой до первого байта и сервер с медленным телом.',
+ 'После каждого вызова записать cURL error, HTTP-код и накопленные времена NAMELOOKUP, CONNECT, STARTTRANSFER, TOTAL.',
+ 'Сопоставить рисунок времён с одной стадией, а не менять сразу DNS, timeout и повтор.',
+ 'Проверить метод и контракт операции до добавления повтора; для создания сущностей описать ключ операции или проверку статуса.',
+ 'Только затем менять конкретный предел и повторять тот же сценарий на тестовом адресе.',
+ ]),
+ heading('Ограничения разбора'),
+ paragraph('Эти поля показывают путь со стороны клиента. Они не раскрывают внутренние очереди партнёра, его базу данных или работу промежуточного прокси. Повторно используемое соединение может сделать connect time маленьким, хотя новый запрос всё равно будет ждать обработчик. При параллельных вызовах или очереди в multi-интерфейсе смысл общего timeout тоже требует отдельной проверки по версии libcurl. Здесь разбирается обычный синхронный PHP-вызов.'),
+ heading('Итог'),
+ paragraph('Connect timeout, общий timeout и low speed отвечают на три разных наблюдения. Ошибка 28 говорит лишь, что одно из условий сработало. Когда рядом лежат пороги, HTTP-код и временная шкала, запрос перестаёт быть «зависшим вообще»: можно назвать стадию, проверить конкретную границу и не повторить небезопасную операцию.'),
+ ],
+ [
+ sources.connectTimeout,
+ sources.timeout,
+ sources.lowSpeedLimit,
+ sources.lowSpeedTime,
+ sources.errors,
+ sources.getinfo,
+ sources.rfc7231,
+ sources.rfc9110,
+ ],
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2018-07-field-http-timeouts',
+ title: 'PHP cURL. Как разобрать зависшую интеграцию по стадиям запроса',
+ categories: ['PHP', 'cURL', 'Практика'],
+ cover: '/assets/editorial/2018/http-timeout-investigation-2018.svg',
+ excerpt: 'Полевой разбор начинается не с повтора запроса: сохраняем error, HTTP-код и стадии cURL, отличаем отсутствие соединения от позднего первого байта и решаем судьбу неопределённой операции.',
+ readingMinutes: 12,
+ },
+ [
+ paragraph('Ночная синхронизация сообщает только «таймаут партнёра», а утром неизвестно: DNS не ответил, TLS не установился, партнёр не начал ответ или тело выгрузки шло слишком долго. Если в такой момент запустить задачу повторно, можно одновременно нагрузить недоступный узел и дважды отправить изменение. Цена ошибки — не только сорванная выгрузка, но и неизвестное состояние данных.'),
+ paragraph('Ниже учебный полевой маршрут для PHP 2018: как добавить к одному cURL-вызову наблюдаемые стадии, воспроизвести три типа задержки на локальном стенде и принять решение без догадки. Числа и строки лога здесь являются форматом примера, а не заявлением о результатах чужого сервиса.'),
+ heading('Сначала фиксирую то, что клиент действительно видел'),
+ paragraph('В журнал нельзя писать только фразу «curl timeout». Нужны как минимум метод, безопасный идентификатор операции, cURL error, HTTP-код, пороги клиента и временные отметки. HTTP-код ноль означает, что cURL не получил HTTP-статус от целевого сервера; ненулевой код отделяет транспортную проблему от ответа, который успел сформироваться. Полный URL с токеном, тело запроса и ответ партнёра в общий лог не кладём.'),
+ paragraph('Для одной операции нужен постоянный идентификатор. Он помогает сопоставить клиентский лог с логом партнёра и не должен быть случайно создан заново при повторе. Это ещё не идемпотентность: ключ становится защитой только тогда, когда API партнёра принимает его и документированно связывает с одной операцией.'),
+ figure(
+ '/assets/editorial/2018/http-timeout-investigation-2018.svg',
+ 'Дерево разбора HTTP-вызова: нет HTTP-кода, поздний первый байт или медленное тело ответа приводят к разным проверкам.',
+ 'Временные отметки ограничивают место поиска. Повтор не выполняется автоматически для операции с неизвестным состоянием.',
+ ),
+ heading('Собираю одну строку диагностики после curl_exec'),
+ paragraph('Код ниже рассчитан на обычный PHP cURL. Сначала завершается curl_exec, затем до curl_close читаются error и сведения о переносе. Время start_transfer означает момент первого полученного байта, а не момент, когда JSON уже разобран приложением. Если API возвращает большой ответ, обработка JSON и запись в базу находятся уже за пределами этой шкалы и их измеряют отдельно.'),
+ codeBlock([
+ ' $operationId,',
+ ' "curl_errno" => curl_errno($curl),',
+ ' "curl_error" => curl_error($curl),',
+ ' "http_code" => curl_getinfo($curl, CURLINFO_HTTP_CODE),',
+ ' "name_lookup" => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),',
+ ' "connect" => curl_getinfo($curl, CURLINFO_CONNECT_TIME),',
+ ' "app_connect" => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),',
+ ' "start_transfer" => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),',
+ ' "total" => curl_getinfo($curl, CURLINFO_TOTAL_TIME),',
+ ' "connect_limit" => $limits["connect"],',
+ ' "total_limit" => $limits["total"],',
+ ' );',
+ '}',
+ '',
+ '$limits = array("connect" => 2, "total" => 8);',
+ '$curl = curl_init($partnerUrl);',
+ 'curl_setopt_array($curl, array(',
+ ' CURLOPT_RETURNTRANSFER => true,',
+ ' CURLOPT_CONNECTTIMEOUT => $limits["connect"],',
+ ' CURLOPT_TIMEOUT => $limits["total"],',
+ '));',
+ '',
+ '$body = curl_exec($curl);',
+ '$trace = collectPartnerTrace($curl, $operationId, $limits);',
+ 'curl_close($curl);',
+ '',
+ '// Запишите $trace в свой журнал с маскировкой чувствительных полей.',
+ ].join('\n')),
+ paragraph('Лог удобнее хранить как поля, а не как склеенную строку. Тогда можно отфильтровать все события с HTTP-кодом ноль и увидеть, какие из них упёрлись в connect limit. Но даже без отдельной системы метрик эти поля дают материал для ручного разбора нескольких случаев. Главное — сохранять их и при успехе, и при ошибке: иначе невозможно сравнить нормальный путь с проблемным.'),
+ heading('Делаю задержки воспроизводимыми на локальном стенде'),
+ paragraph('Нельзя ждать настоящего сбоя партнёра, чтобы проверить обработчик. В каталоге для теста можно запустить встроенный сервер PHP и создать два маршрута: один задерживает первый байт, другой пишет начало ответа, затем ждёт перед хвостом. Это не модель всего интернета; она нужна, чтобы увидеть разницу между start_transfer и total своим клиентом.'),
+ codeBlock([
+ ' true));',
+ ' return;',
+ '}',
+ '',
+ 'if ($path === "/slow-body") {',
+ ' echo "{\\"items\\":[";',
+ ' flush();',
+ ' usleep(4000000);',
+ ' echo "1]}";',
+ ' return;',
+ '}',
+ '',
+ 'echo json_encode(array("ok" => true));',
+ ].join('\n')),
+ paragraph('Запуск php -S 127.0.0.1:8080 router.php даёт адрес для клиента из предыдущего раздела. Для /slow-first-byte общий предел меньше четырёх секунд должен остановить вызов до ответа. Для /slow-body первый байт может появиться рано, а общий предел — позже. Поведение flush() зависит от SAPI и прокси, поэтому этот маршрут проверяют именно на своём локальном запуске, а не используют как доказательство поведения production-прокси.'),
+ heading('Читаю временную шкалу как стадии, а не как независимые числа'),
+ paragraph('Все времена cURL накопительные. Нельзя сложить name_lookup, connect и start_transfer: каждый отсчитывается от начала. Для приближённой длительности DNS смотрят name lookup. Для пути от DNS до TCP и TLS сравнивают более позднюю отметку с name lookup. Для ожидания приложения после установленного соединения сопоставляют start transfer с connect или app connect.'),
+ dataTable(
+ ['Наблюдение в trace', 'Что клиент может утверждать', 'Что не следует утверждать', 'Следующий шаг'],
+ [
+ ['HTTP-код 0, connect близок к лимиту', 'Клиент не получил HTTP-ответ и долго устанавливал соединение', 'Что SQL партнёра медленный', 'Проверить DNS, маршрут, доступность и TLS'],
+ ['connect мал, start_transfer близок к total', 'Связь установилась, но первый байт не пришёл вовремя', 'Что тело ответа слишком большое', 'Передать партнёру ID операции и его время ожидания'],
+ ['start_transfer мал, total близок к total limit', 'Ответ начался, но перенос не завершился в бюджете', 'Что проблема именно в DNS', 'Проверить размер, буферизацию и скорость тела'],
+ ['HTTP-код 500 или 429', 'Сервер успел ответить статусом', 'Что это транспортный timeout', 'Обработать статус по контракту API и его условиям повторов'],
+ ],
+ ),
+ paragraph('В HTTPS app_connect помогает отделить завершение TLS от последующего ожидания ответа. На HTTP это поле может быть нулём. Нулевое поле нельзя интерпретировать как «TLS занял ноль секунд» без знания схемы URL. В trace полезно положить также схему и безопасно нормализованный host, но не секретные параметры запроса.'),
+ heading('Разделяю проверку с партнёром и изменение клиента'),
+ paragraph('Когда trace указывает на поздний первый байт, партнёру передают ID операции, время старта, URL-путь, пороги и фактические накопленные времена. Фраза «у вас тормозит» не помогает найти запрос. Когда проблема до соединения, сначала проверяют адрес, DNS и сертификаты со стороны клиента. Когда задерживается тело, сравнивают ожидаемый размер ответа с тем, что реально нужно экрану: возможно, вместо большого списка нужен фильтр или отдельная выгрузка.'),
+ paragraph('До любого увеличения timeout сначала повторяют один и тот же тестовый сценарий и смотрят, изменилась ли именно нужная стадия. Если общий предел подняли, а start transfer всё так же приходит поздно, клиент просто дольше скрывает внешний сбой. Если уменьшили объём ответа и total стал меньше при таком же start transfer, улучшили передачу, но не обработчик партнёра.'),
+ heading('Не запускаю повтор для неизвестного изменения'),
+ paragraph('После timeout у POST клиент не знает, создал ли партнёр заявку до обрыва ответа. HTTP-стандарт различает идемпотентные методы потому, что одинаковый запрос с таким эффектом можно повторять после сбоя связи до чтения ответа. Но даже в 2018 году это не было разрешением считать любой вызов безопасным: бизнес-операция и её контракт важнее удобства очередного запуска.'),
+ paragraph('Практическая развилка короткая. GET и HEAD можно повторить ограниченное число раз, если хватает общего бюджета. Изменение состояния повторяют только с постоянным ключом операции и документированной дедупликацией у партнёра или после запроса статуса по уже сохранённому ключу. Если ни одного условия нет, запись помечают как неопределённую и разбирают её отдельно. Так медленный ответ не превращается в два одинаковых действия.'),
+ codeBlock([
+ '', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+ return '
',
+ ];
+
+ for (const fragment of requiredFragments) {
+ if (!contentHtml.includes(fragment)) {
+ throw new Error(meta.slug + ': missing required fragment ' + fragment);
+ }
+ }
+
+ if ((contentHtml.match(/
/g) || []).length < 6) {
+ throw new Error(meta.slug + ': fewer than six sections');
+ }
+
+ if (sources.length < 2) {
+ throw new Error(meta.slug + ': at least two primary sources are required');
+ }
+
+ return { ...meta, contentHtml, bodyLength };
+}
+
+const phpCurlError = {
+ title: 'PHP Manual: curl_error',
+ url: 'https://www.php.net/manual/en/function.curl-error.php',
+ note: 'текст последней ошибки текущего cURL-сеанса; его нужно читать до закрытия handle',
+};
+
+const phpCurlErrno = {
+ title: 'PHP Manual: curl_errno',
+ url: 'https://www.php.net/manual/en/function.curl-errno.php',
+ note: 'числовой код последней ошибки cURL-сеанса',
+};
+
+const phpCurlVersion = {
+ title: 'PHP Manual: curl_version',
+ url: 'https://www.php.net/manual/en/function.curl-version.php',
+ note: 'версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль',
+};
+
+const phpCurlConfiguration = {
+ title: 'PHP Manual: cURL Runtime Configuration',
+ url: 'https://www.php.net/manual/en/curl.configuration.php',
+ note: 'директива curl.cainfo задаёт абсолютный путь по умолчанию для CURLOPT_CAINFO',
+};
+
+const phpCurlConstants = {
+ title: 'PHP Manual: cURL constants',
+ url: 'https://www.php.net/manual/en/curl.constants.php',
+ note: 'значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST',
+};
+
+const curlCertificates = {
+ title: 'curl: SSL CA Certificates',
+ url: 'https://curl.se/docs/sslcerts.html',
+ note: 'проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения',
+};
+
+const curlCaInfo = {
+ title: 'libcurl: CURLOPT_CAINFO',
+ url: 'https://curl.se/libcurl/c/CURLOPT_CAINFO.html',
+ note: 'путь к файлу доверенных CA для конкретного transfer',
+};
+
+const curlVerifyPeer = {
+ title: 'libcurl: CURLOPT_SSL_VERIFYPEER',
+ url: 'https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html',
+ note: 'выключение проверки не загружает CA и делает TLS-соединение небезопасным',
+};
+
+const curlVerifyHost = {
+ title: 'libcurl: CURLOPT_SSL_VERIFYHOST',
+ url: 'https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html',
+ note: 'проверка имени хоста в сертификате является отдельным условием',
+};
+
+const curlErrors = {
+ title: 'libcurl: error codes',
+ url: 'https://curl.se/libcurl/c/libcurl-errors.html',
+ note: 'значения ошибок зависят от версии; код 60 относится к неудачной проверке peer',
+};
+
+const opensslSClient = {
+ title: 'OpenSSL 1.0.2: s_client',
+ url: 'https://docs.openssl.org/1.0.2/man1/s_client/',
+ note: 'диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath',
+};
+
+const opensslVerify = {
+ title: 'OpenSSL 1.0.2: verify',
+ url: 'https://docs.openssl.org/1.0.2/man1/verify/',
+ note: 'проверка цепочки, различие trusted CAfile и untrusted промежуточных сертификатов',
+};
+
+const practiceArticle = createRevision(
+ {
+ slug: 'editorial-2018-08-practice-tls-ca',
+ title: 'PHP cURL. Как разобрать SSL certificate problem и не отключить проверку',
+ categories: ['PHP', 'cURL', 'Безопасность', 'Практика'],
+ cover: '/assets/editorial/2018/tls-ca-diagnostic-2018.svg',
+ excerpt: 'Разбираем отказ проверки TLS в PHP по шагам: сохраняем код ошибки, сравниваем клиент с командной строкой, смотрим цепочку и указываем корректный CA bundle.',
+ readingMinutes: 10,
+ },
+ [
+ paragraph('Запрос PHP к HTTPS API возвращает false, а curl_error() говорит о проблеме с сертификатом. Самый быстрый совет — поставить CURLOPT_SSL_VERIFYPEER в false — действительно может вернуть ответ, но цена такого ответа высока: клиент перестаёт доказывать, что подключился именно к серверу партнёра.'),
+ paragraph('Давайте разберём узкий случай: PHP расширение cURL не может проверить серверный сертификат. Наша цель не в том, чтобы любой ценой получить HTTP-ответ. Нужно назвать причину, проверить её отдельно и оставить проверку цепочки и имени включённой.'),
+ heading('Сначала сохраняю факт отказа'),
+ paragraph('Текст ошибки сам по себе недостаточен. Он зависит от связки PHP, libcurl и TLS-библиотеки. Поэтому я сохраняю вместе номер ошибки, её текст, адрес без параметров и версию библиотеки. curl_error() и curl_errno() надо вызвать до curl_close(): после закрытия handle диагностировать уже нечего.'),
+ codeBlock(String.raw`
+function getPartnerJson($url, $caFile)
+{
+ if (!is_readable($caFile)) {
+ throw new RuntimeException('CA bundle is not readable: ' . $caFile);
+ }
+
+ $ch = curl_init($url);
+ $verbose = fopen('php://temp', 'w+');
+
+ curl_setopt_array($ch, array(
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CAINFO => $caFile,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ CURLOPT_VERBOSE => true,
+ CURLOPT_STDERR => $verbose,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_TIMEOUT => 15,
+ ));
+
+ $body = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $error = curl_error($ch);
+ rewind($verbose);
+ $trace = stream_get_contents($verbose);
+
+ curl_close($ch);
+ fclose($verbose);
+
+ if ($body === false) {
+ throw new RuntimeException('cURL error ' . $errno . ': ' . $error);
+ }
+
+ return array('body' => $body, 'trace' => $trace);
+}
+`),
+ paragraph('В этом примере $caFile приходит из конфигурации приложения, а не из запроса пользователя. Временный verbose-след полезен на закрытом стенде: он помогает увидеть, какой CAfile пытается открыть библиотека и на каком этапе остановилась связь. Его нельзя без разбора отдавать в публичный ответ или журнал с токенами и заголовками.'),
+ heading('Разделяю похожие симптомы'),
+ paragraph('Фраза про certificate problem не означает автоматически старый bundle. Сначала я раскладываю наблюдение на несколько проверяемых веток. Код 60 в libcurl относится к неудачной проверке peer, а код 77 связан с чтением локального CA-файла. Однако один номер не заменяет текст ошибки и проверку конкретного окружения.'),
+ dataTable(
+ ['Наблюдение', 'Что проверяю первым', 'Рабочее действие', 'Чего не делаю'],
+ [
+ ['curl_errno() сообщает о peer verification', 'Имя из URL, цепочку сервера, доверенные корни локального bundle', 'Собираю цепочку и проверяю её с тем же CAfile', 'Не выключаю peer verification'],
+ ['Ошибка говорит о чтении CAfile', 'Существует ли файл, права чтения и все каталоги по пути', 'Исправляю путь или права service-user', 'Не подменяю ошибку пустым CAfile'],
+ ['В браузере работает, в PHP нет', 'Какие store и версии использует каждый клиент', 'Сравниваю PHP cURL и CLI отдельно', 'Не считаю браузер доказательством для PHP'],
+ ['На одном имени работает, на другом нет', 'SNI и имя из URL', 'Запускаю s_client с -servername', 'Не проверяю только IP-адрес'],
+ ],
+ ),
+ figure(
+ '/assets/editorial/2018/tls-ca-diagnostic-2018.svg',
+ 'Последовательность диагностики PHP cURL: записать ошибку, определить версии, проверить CA file, запросить серверную цепочку с SNI, затем применить исправление при включённой проверке.',
+ 'Ошибка не ведёт сразу к настройке false: перед изменением CA bundle отделяем локальный файл, серверную цепочку и имя хоста.',
+ ),
+ heading('Сравниваю PHP-клиент и командную строку'),
+ paragraph('Команда curl -V полезна, но она описывает бинарник в shell. PHP-модуль может быть собран с другой версией libcurl или другой TLS-библиотекой. Поэтому в PHP я отдельно смотрю curl_version(); она возвращает версии cURL и SSL-библиотеки, связанные именно с расширением.'),
+ codeBlock(String.raw`
+$version = curl_version();
+
+printf("libcurl: %s\n", $version['version']);
+printf("TLS library: %s\n", $version['ssl_version']);
+printf("curl.cainfo: %s\n", ini_get('curl.cainfo') ?: '(not set)');
+`),
+ paragraph('После этого можно повторить один и тот же безопасный запрос из shell, явно задав проверяемый файл. Вместо живого адреса ниже указан шаблон: подставляю только тот hostname, к которому действительно идёт приложение. Команда не является проверкой PHP, но быстро показывает, читает ли данный CA bundle иная связка curl/OpenSSL.'),
+ codeBlock(String.raw`
+curl -v \
+ --cacert /opt/app/certs/ca-bundle.pem \
+ https://api.partner.example/
+`),
+ paragraph('Если shell проходит, а PHP нет, я не переношу вывод в решение автоматически. Сначала сравниваю путь, права запуска, curl_version() и настройку curl.cainfo. Если оба клиента не доверяют цепочке, следующий вопрос относится уже к сертификатам сервера или составу нашего trust store.'),
+ heading('Смотрю, что отдал сервер'),
+ paragraph('Для HTTPS виртуального хоста важно послать Server Name Indication. Параметр -servername у openssl s_client добавляет имя в ClientHello. Без него сервер с несколькими сайтами может вернуть сертификат по умолчанию, и мы будем разбирать не тот объект.'),
+ codeBlock(String.raw`
+openssl s_client \
+ -connect api.partner.example:443 \
+ -servername api.partner.example \
+ -showcerts \
+ -showcerts
есть важная граница: OpenSSL показывает список сертификатов, присланный сервером; это ещё не подтверждённая цепочка. Я выписываю subject и issuer каждого PEM-блока, смотрю, есть ли промежуточный сертификат, и только затем проверяю цепочку против конкретного CA bundle. Корневой CA обычно лежит у клиента, поэтому его отсутствие в выводе сервера само по себе не ошибка.'),
+ heading('Подключаю CA bundle явным путём'),
+ paragraph('Если проблема в неполном или устаревшем наборе доверенных корней, у исправления есть две границы. Для одного вызова я задаю CURLOPT_CAINFO абсолютным путём. Для всего PHP-окружения директива curl.cainfo задаёт значение по умолчанию для этой опции; PHP требует абсолютный путь. Эти способы не означают, что надо менять настройки OpenSSL stream wrapper: это другой клиентский путь.'),
+ codeBlock(String.raw`
+; php.ini — абсолютный путь, доступный пользователю PHP-FPM/Apache
+curl.cainfo="/opt/app/certs/ca-bundle-2018-08.pem"
+
+; После изменения нужен обычный перезапуск процесса PHP,
+; предусмотренный правилами конкретного окружения.
+`),
+ paragraph('Сам файл беру из доверенного канала поставщика CA store или из пакета операционной системы по правилам проекта. Не собираю trust store из случайного сертификата, скопированного из браузера. Если партнёр использует собственный CA, добавляю именно его доверенный корень после подтверждения у владельца API, а не leaf-сертификат, который завтра может поменяться.'),
+ heading('Короткий порядок проверки'),
+ orderedList([
+ 'Воспроизвести ошибку на закрытом стенде и сохранить curl_errno(), curl_error(), hostname и версии из curl_version().',
+ 'Проверить, существует ли CAfile, читается ли он пользователем PHP и не указывает ли curl.cainfo на другой файл.',
+ 'Повторить запрос CLI с явным --cacert, не смешивая результат CLI с результатом PHP.',
+ 'Получить серверный список сертификатов через openssl s_client с правильным -servername.',
+ 'Проверить, что hostname URL соответствует сертификату и что локальный trust store содержит доверенный корень для этой цепочки.',
+ 'Обновить или указать bundle, перезапустить нужный процесс и повторить тот же запрос при CURLOPT_SSL_VERIFYPEER => true.',
+ ]),
+ heading('Где рецепт не даёт готового ответа'),
+ bulletList([
+ 'Ошибка проверки может быть вызвана неверной датой на машине, отозванным сертификатом, неподходящим именем или политикой TLS-библиотеки. CA bundle закрывает только свою ветку.',
+ 'Если сервер не отдал нужный промежуточный сертификат, правильное исправление обычно находится у владельца сервера. Добавлять промежуточный сертификат в корневой trust store как постоянный обход не стоит.',
+ 'Внутренний сервис с частным CA требует управляемого распространения этого корня. Файл должен быть доступен процессу PHP, но не должен становиться редактируемым из веб-каталога.',
+ 'Параметры CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST остаются включёнными. Шифрование без проверки личности не подтверждает, кому отправлены данные.',
+ ]),
+ heading('Что считаю готовым'),
+ paragraph('Исправление готово, когда тот же PHP-код с тем же URL завершает TLS-проверку при включённых peer и hostname checks, а путь к CA bundle понятен следующему разработчику. Если после этого ошибка остаётся, у нас уже есть не совет выключить защиту, а набор фактов для разговора с владельцем API: версия клиента, имя, серверная цепочка и локальный store.'),
+ ],
+ [phpCurlError, phpCurlErrno, phpCurlVersion, phpCurlConfiguration, phpCurlConstants, curlCertificates, curlErrors, opensslSClient],
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2018-08-mechanism-tls-ca',
+ title: 'TLS в PHP. Почему CA bundle, имя хоста и SNI проверяются по-разному',
+ categories: ['PHP', 'TLS', 'OpenSSL', 'Разбор'],
+ cover: '/assets/editorial/2018/tls-ca-chain-sni-2018.svg',
+ excerpt: 'Разбираем три независимые части TLS-проверки: серверная цепочка, локальный набор доверенных CA и имя виртуального хоста в SNI.',
+ readingMinutes: 10,
+ },
+ [
+ paragraph('PHP cURL может получить сертификат и всё равно остановить запрос. Ошибка становится особенно дорогой, когда её принимают за одну настройку и выключают verification: в реальности у клиента могут не совпасть цепочка, имя хоста или сертификат, выбранный сервером по SNI.'),
+ paragraph('Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера.'),
+ heading('У HTTPS-соединения несколько условий'),
+ paragraph('TLS даёт шифрование канала, но клиенту ещё нужно принять решение о личности удалённой стороны. В связке cURL/OpenSSL для обычного HTTPS запроса важны как минимум две независимые проверки: можно ли построить доверенную цепочку до локального CA store и подходит ли имя в сертификате тому hostname, который стоит в URL.'),
+ paragraph('SNI относится к другому месту. Это расширение ClientHello: клиент сообщает серверу ожидаемое имя до выдачи сертификата. На одном IP-адресе могут жить несколько HTTPS сайтов. Если серверу не дать имя, он вправе выбрать сертификат виртуального хоста по умолчанию. После этого проверка цепочки может быть безупречной, но проверка имени правильного сайта всё равно не пройдёт.'),
+ figure(
+ '/assets/editorial/2018/tls-ca-chain-sni-2018.svg',
+ 'Схема TLS-проверки: URL задаёт имя, SNI помогает серверу выбрать сертификат, сервер передаёт leaf и промежуточные сертификаты, а клиент соединяет их с доверенным корнем из локального CA bundle и отдельно сверяет hostname.',
+ 'Сервер выбирает сертификат по SNI, а клиент затем проверяет две разные вещи: доверенную цепочку и имя из URL.',
+ ),
+ dataTable(
+ ['Часть', 'Кто её задаёт', 'Что проверяет клиент', 'Типичная граница ошибки'],
+ [
+ ['Hostname в URL', 'Код PHP', 'Что имя покрыто сертификатом', 'В URL IP или другое имя'],
+ ['SNI в ClientHello', 'TLS-клиент при соединении по имени', 'Какой виртуальный хост ответил', 'Сервер отдал сертификат default-vhost'],
+ ['Leaf и intermediate', 'HTTPS-сервер', 'Можно ли дойти от leaf до trust anchor', 'Сервер не прислал intermediate'],
+ ['CA bundle / CApath', 'Окружение клиента', 'Какой корень считается доверенным', 'Нужного корня нет или файл не читается'],
+ ],
+ ),
+ heading('Что сервер присылает, а что хранит клиент'),
+ paragraph('Сервер обычно отправляет конечный сертификат сайта и промежуточные сертификаты. Корневой сертификат чаще остаётся в локальном наборе доверия клиента. OpenSSL в документации к s_client отдельно предупреждает: -showcerts показывает именно список, присланный сервером, а не уже проверенную цепочку.'),
+ paragraph('Это различие удобно держать в голове при ошибке unable to get local issuer certificate. Она может означать, что сервер не выдал промежуточный сертификат. Может означать, что корень есть у браузера, но отсутствует в bundle процесса PHP. А может означать, что мы подключились к другому виртуальному хосту и смотрим на чужую цепочку. Одна строка без контекста не выбирает причину.'),
+ heading('Снимаю серверный список с правильным SNI'),
+ paragraph('В OpenSSL 1.0.2 параметр -servername явно задаёт TLS Server Name Indication. Для диагностики я использую hostname из URL приложения и не подставляю IP вместо него. Сохранённый вывод нужен для ручного просмотра subject и issuer; в статью и тикет не нужно копировать приватные заголовки или ключи.'),
+ codeBlock(String.raw`
+openssl s_client \
+ -connect api.partner.example:443 \
+ -servername api.partner.example \
+ -showcerts \
+ -servername только как сравнение выбора виртуального хоста. Разные сертификаты не доказывают ошибку сами по себе, но объясняют, почему проверка по IP или старый тест без SNI ведут не к тому сайту. Исправление тогда находится в hostname запроса, DNS или настройке TLS-виртуального хоста, а не в бессмысленном добавлении чужого сертификата в CAfile.'),
+ heading('Проверяю цепочку отдельно от HTTP'),
+ paragraph('После того как PEM-блоки разделены вручную, OpenSSL умеет проверить цепочку без HTTP-кода и заголовков. В этой команде конечный сертификат лежит в leaf.pem, присланный сервером intermediate — в intermediate.pem, а доверенные корни — в нашем проверяемом ca-bundle.pem.'),
+ codeBlock(String.raw`
+openssl verify \
+ -purpose sslserver \
+ -CAfile ./ca-bundle.pem \
+ -untrusted ./intermediate.pem \
+ ./leaf.pem
+`),
+ paragraph('Параметры здесь не взаимозаменяемы. В документации OpenSSL -CAfile — файл доверенных сертификатов, а -untrusted — дополнительные сертификаты для построения цепочки. Не стоит переносить промежуточный сертификат в доверенные корни только для того, чтобы команда стала зелёной. Такое смешение скрывает, кто именно должен поставлять intermediate.'),
+ heading('Имя хоста проверяется отдельно'),
+ paragraph('Даже успешный openssl verify не отвечает на вопрос, подходит ли сертификат адресу api.partner.example. Команда проверяет цепочку. cURL делает имя отдельным условием: CURLOPT_SSL_VERIFYHOST проверяет, что имя в сертификате допустимо для hostname, к которому выполняется соединение. Поэтому тестируем тот же URL, который использует приложение.'),
+ codeBlock(String.raw`
+$ch = curl_init('https://api.partner.example/v1/ping');
+curl_setopt_array($ch, array(
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CAINFO => '/opt/app/certs/ca-bundle.pem',
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+));
+
+$body = curl_exec($ch);
+if ($body === false) {
+ throw new RuntimeException(curl_errno($ch) . ': ' . curl_error($ch));
+}
+curl_close($ch);
+`),
+ paragraph('Не заменяю URL на IP и не рассчитываю, что HTTP-заголовок Host исправит TLS-идентичность. Сертификат обычно выдан на DNS-имя; IP подходит только если он действительно указан в сертификате как IP-адрес. Внутренний DNS, прокси и тестовый маршрут должны сохранить имя, которое читает cURL до отправки HTTP.'),
+ heading('Матрица неисправностей'),
+ dataTable(
+ ['Симптом после проверки', 'Наиболее узкая гипотеза', 'Проверка', 'Куда идёт исправление'],
+ [
+ ['s_client с SNI показывает ожидаемый leaf, но verify не строит цепочку', 'Нет intermediate в ответе или нужного корня в CA bundle', 'Разделить PEM и запустить openssl verify', 'Сервер или владелец trust store'],
+ ['Без SNI и с SNI разные leaf', 'Выбирается другой TLS virtual host', 'Сравнить два запуска s_client', 'URL/DNS либо TLS-конфигурация сервера'],
+ ['Цепочка проходит, PHP отказывает на имени', 'Hostname URL не покрыт SAN/CN сертификата', 'Проверить точное имя URL и сертификата', 'Код/настройка адреса или перевыпуск сертификата'],
+ ['CLI доверяет, PHP нет', 'Разные libcurl, TLS backend или CAfile', 'Собрать curl_version() и путь bundle', 'PHP-окружение'],
+ ],
+ ),
+ heading('Порядок, который не смешивает причины'),
+ orderedList([
+ 'Взять hostname прямо из конфигурации PHP-запроса и зафиксировать версию libcurl/TLS через curl_version().',
+ 'Получить серверные сертификаты через openssl s_client с этим hostname в -servername.',
+ 'Разделить leaf, intermediate и локальный CA bundle; не объявлять каждый присланный сертификат доверенным.',
+ 'Запустить openssl verify, чтобы отделить цепочку от HTTP и от проверки имени.',
+ 'Проверить тот же URL PHP-кодом при включённых CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST.',
+ 'Передать владельцу нужную ветку: недостающий intermediate, обновление CA store, неверное имя либо TLS virtual host.',
+ ]),
+ heading('Ограничения этого разбора'),
+ bulletList([
+ 'Формат, порядок и текст ошибок могут отличаться между версиями OpenSSL и libcurl. Ценны не скопированные строки, а сохранённые команды, hostname и версии.',
+ 'Проверка цепочки не заменяет проверки срока действия, политики организации или отзыва сертификата, если эти условия включены в конкретном окружении.',
+ 'Частный корпоративный CA нельзя добавлять в bundle по письму без подтверждения владельца. Доверенный корень даёт право выпускать сертификаты для той области, где ему доверяет клиент.',
+ 'Устаревший OpenSSL может иметь отдельные ограничения протоколов и шифров. Эта статья не советует включать старый протокол для обхода ошибки цепочки.',
+ ]),
+ heading('Итог'),
+ paragraph('CA bundle отвечает на вопрос, кому клиент доверяет. Серверная цепочка отвечает, может ли leaf дойти до этого доверия. SNI помогает серверу выбрать правильный leaf, а hostname check подтверждает, что он выдан нужному имени. Когда эти четыре роли разложены, ошибка TLS перестаёт быть поводом ставить false и становится обычной диагностической задачей.'),
+ ],
+ [curlCertificates, curlVerifyPeer, curlVerifyHost, opensslSClient, opensslVerify, phpCurlConstants, phpCurlVersion],
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2018-08-field-tls-ca',
+ title: 'PHP cURL. Как обновить устаревший CA bundle без отключения verification',
+ categories: ['PHP', 'cURL', 'TLS', 'Эксплуатация'],
+ cover: '/assets/editorial/2018/tls-ca-refresh-plan-2018.svg',
+ excerpt: 'Полевой порядок для старого PHP-окружения: доказать, какой trust store использует модуль, заменить bundle контролируемо и проверить партнёрский HTTPS-запрос без CURLOPT_SSL_VERIFYPEER=false.',
+ readingMinutes: 11,
+ },
+ [
+ paragraph('После смены сертификата у партнёра старый PHP-процесс начинает возвращать ошибку проверки, хотя браузер на той же машине открывает сайт. Если быстро поставить CURLOPT_SSL_VERIFYPEER в false, запросы оживут, но приложение сможет принять сертификат подменённого узла. Цена обхода — не только предупреждение в коде, а потеря проверки личности удалённой стороны.'),
+ paragraph('В полевом случае я не обновляю первый попавшийся файл. Сначала доказываю, какой именно libcurl и какой trust store использует PHP. Затем проверяю кандидатный bundle на отдельном стенде, меняю путь контролируемо и повторяю тот же запрос с включённой проверкой.'),
+ heading('Браузер не является контрольным клиентом'),
+ paragraph('Браузер может брать корни из системного хранилища и обновлять их по своим правилам. PHP extension cURL может быть собран с другой TLS-библиотекой и читать файл, заданный при сборке, в curl.cainfo или через CURLOPT_CAINFO. Поэтому фраза «в Chrome работает» полезна как симптом, но не отвечает, что должен сделать PHP-процесс.'),
+ paragraph('Я начинаю с минимального отчёта, который можно получить в закрытой административной команде. В нём нет паролей и ответа API: только версии и признак, читается ли кандидатный файл. Путь лучше не выводить в публичную страницу; достаточно оставить его в приватном журнале релиза.'),
+ codeBlock(String.raw`
+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,
+ );
+}
+`),
+ paragraph('Метод curl_version() возвращает данные о libcurl и SSL-библиотеке PHP-модуля. Этого достаточно, чтобы не сравнивать наугад PHP-FPM с командным curl. Если в отчёте bundle не читается, обновление сертификатов ещё не началось: сначала исправляю путь, владельца и права доступа для пользователя процесса.'),
+ heading('Фиксирую точку, где выбирается CA file'),
+ paragraph('В старом проекте CAfile иногда задают в трёх местах: значение по умолчанию curl.cainfo, явный CURLOPT_CAINFO в обёртке HTTP-клиента и настройки системы, с которыми собран libcurl. Я не меняю их одновременно. Иначе невозможно сказать, какая правка помогла и какое окружение останется на старом наборе после следующего деплоя.'),
+ dataTable(
+ ['Где найдено доверие', 'Как проверяю', 'Безопасное действие', 'Почему не делать иначе'],
+ [
+ ['Явный CURLOPT_CAINFO', 'Поиск в HTTP-обёртке и лог пути на закрытом стенде', 'Заменить версионный файл в конфигурации этого клиента', 'Изменение php.ini не влияет на явную опцию'],
+ ['curl.cainfo', 'Сравнить ini_get() с загруженным php.ini', 'Указать абсолютный путь и штатно перезапустить PHP', 'Относительный путь зависит от окружения'],
+ ['Системный default libcurl', 'Verbose-след и документация сборки дистрибутива', 'Обновить системный пакет по процедуре платформы', 'Нельзя считать браузерный store тем же самым'],
+ ['Частный CA партнёра', 'Подтвердить root у владельца API', 'Добавить подтверждённый root в отдельный управляемый bundle', 'Не сохранять leaf из случайного TLS-ответа как корень'],
+ ],
+ ),
+ figure(
+ '/assets/editorial/2018/tls-ca-refresh-plan-2018.svg',
+ 'Контролируемое обновление CA bundle: определить активный источник доверия, подготовить версионный кандидат, проверить его на стенде, переключить конфигурацию, перезапустить PHP и повторить запрос с включённой проверкой.',
+ 'Bundle меняется как конфигурационный артефакт: кандидат проверяется до переключения, а результат подтверждается тем же PHP-клиентом.',
+ ),
+ heading('Готовлю новый bundle как артефакт релиза'),
+ paragraph('Кандидатный файл беру из официального источника CA store или из доверенного пакета операционной системы. Его имя содержит версию или дату поставки, например ca-bundle-2018-08.pem. Такой путь лучше безымянного cacert.pem: при следующем отказе видно, какой набор проверялся, и можно откатить конфигурацию на прежний файл без ручного редактирования содержимого.'),
+ paragraph('Перед переключением я проверяю не только наличие PEM-маркеров. Беру leaf и intermediate конкретного тестового сервера, которые были собраны через openssl s_client с правильным SNI, и строю цепочку против кандидата. Это не доказывает, что bundle подходит для всего интернета, но доказывает нужный нам сценарий и не требует выдумывать результат команды.'),
+ codeBlock(String.raw`
+# 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
+`),
+ paragraph('Если команда не строит цепочку, не объявляю новый bundle плохим без разбора. Возможно, сервер не прислал intermediate. Возможно, он использует частный CA, которого нет и не должно быть в публичном store. Возможно, для проверки был использован другой hostname без SNI. Каждая ветка требует собственного исправления; ни одна не требует отключить peer verification.'),
+ heading('Переключаю PHP-клиент явно'),
+ paragraph('Для независимой интеграции мне удобнее хранить абсолютный путь в конфигурации приложения и передавать его cURL. Тогда старый и новый bundle могут лежать рядом на время проверки, а код не читает путь из веб-запроса. Если в проекте выбран curl.cainfo, выполняю тот же принцип в php.ini и фиксирую перезапуск процесса в чек-листе релиза.'),
+ codeBlock(String.raw`
+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;
+}
+`),
+ paragraph('Значения timeout в примере — проектные, а не рецепт для всех API. Они нужны, чтобы демонстрационный запрос не висел бесконечно. Важнее другое: в рабочем коде нет ветки, где ошибка сертификата меняет CURLOPT_SSL_VERIFYPEER на false. Такой переключатель превращает сетевую аварию в скрытое изменение модели доверия.'),
+ heading('Проверяю релиз тем же клиентом'),
+ paragraph('После перезапуска я выполняю один заранее согласованный запрос с тем же PHP-SAPI, который обслуживает приложение. HTTP 200 сам по себе не является единственным критерием: сохраняю, что curl_exec() не вернул false, peer verification не была ослаблена и ответ соответствует контракту тестового endpoint. Для критичного API лучше выбрать безвредный health или read-only запрос, если владелец сервиса его предоставляет.'),
+ orderedList([
+ 'Зафиксировать исходную ошибку, 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, путь настройки, дату проверки и способ отката на предыдущий файл.',
+ ]),
+ heading('Что не является исправлением'),
+ bulletList([
+ 'Не ставлю CURLOPT_SSL_VERIFYPEER => false и не понижаю CURLOPT_SSL_VERIFYHOST. Официальная документация libcurl прямо указывает, что отключение проверки делает соединение небезопасным.',
+ 'Не добавляю в доверенные корни leaf-сертификат, который сервер прислал сегодня. Leaf и intermediate могут быть заменены; доверие к ним имеет другой смысл, чем доверие к CA.',
+ 'Не загружаю bundle из URL при каждом запуске приложения. Поставка файла должна проходить контролируемый релиз, иначе мы не знаем, какой root появился в доверии.',
+ 'Не смешиваю проблему устаревшего CA store с ошибкой имени. Если URL не покрыт сертификатом, новый bundle не изменит правильный отказ.',
+ ]),
+ heading('Ограничения и следующий шаг'),
+ paragraph('CA bundle не вылечит неправильно настроенный TLS-сервер: недостающий intermediate должен поправить владелец сервера. Он также не заменяет обновление старой версии PHP или libcurl, если в ней есть известное ограничение. Но контролируемый bundle даёт короткий и проверяемый путь для обычного случая: PHP знает, какому набору CA доверять, файл читается, тестовая цепочка строится, а production-запрос проходит без снятия защиты.'),
+ heading('Итог'),
+ paragraph('Устаревший trust store — это конфигурационная проблема, а не приглашение выключить TLS-проверку. Если зафиксировать активный клиент, проверить кандидатный bundle против реальной цепочки и переключить путь как часть релиза, то ошибка становится воспроизводимой. В следующий раз команда увидит версию файла и проверку, а не загадочный false в настройках cURL.'),
+ ],
+ [phpCurlVersion, phpCurlConfiguration, phpCurlError, phpCurlConstants, curlCertificates, curlCaInfo, curlVerifyPeer, opensslSClient, opensslVerify],
+);
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
+ .map(({ bodyLength, ...revision }) => revision);
+
+const isDirectInvocation = process.argv[1]
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isDirectInvocation) {
+ if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
+ } else {
+ process.stderr.write('Usage: node web/scripts/upgrade-2018-08.mjs --print-revisions\n');
+ process.exitCode = 1;
+ }
+}