diff --git a/editorial/production/README.md b/editorial/production/README.md
index 2948867..fde89d5 100644
--- a/editorial/production/README.md
+++ b/editorial/production/README.md
@@ -1,6 +1,6 @@
# Производство редакционных партий
-На 31 июля 2026 года строгий аудит проходит 79 из 358 созданных материалов. Остальные 279 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
+На 31 июля 2026 года строгий аудит проходит 82 из 358 созданных материалов. Остальные 276 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
diff --git a/editorial/reviews/2020-04-draft.md b/editorial/reviews/2020-04-draft.md
new file mode 100644
index 0000000..7145a67
--- /dev/null
+++ b/editorial/reviews/2020-04-draft.md
@@ -0,0 +1,143 @@
+# P26 · апрель 2020 · Reverse proxy перед приложением — тройное ревью
+
+Статус: **принят в publication registry 31 июля 2026 года**. Registry
+накладывает ревизии по стабильным slug и сохраняет дату и автора базового
+архива. `articles.json`, стандарт, очередь и package-конфигурация не
+перезаписывались.
+
+## Состав пакета
+
+| Материал | Stable slug | Визуал | Основной текст по draft gate |
+| --- | --- | --- | ---: |
+| Практика | `editorial-2020-04-practice-reverse-proxy` | `reverse-proxy-request-hops-2020.svg` | 9 620 знаков |
+| Механизм | `editorial-2020-04-mechanism-reverse-proxy` | `reverse-proxy-header-boundary-2020.svg` | 10 533 знака |
+| Разбор | `editorial-2020-04-field-reverse-proxy` | `reverse-proxy-diagnosis-2020.svg` | 10 504 знака |
+
+Ревизии не содержат полей `date` и `author`: при будущей интеграции сохраняются
+дата, автор и slug базового архива.
+
+## Проход 1 — факты и техника
+
+### Проверенные опоры
+
+- [Nginx `ngx_http_proxy_module`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html): `proxy_pass`, явная подстановка `Host`, `proxy_set_header`, а также различие `proxy_connect_timeout`, `proxy_send_timeout` и `proxy_read_timeout`.
+- [Nginx `ngx_http_realip_module`](https://nginx.org/en/docs/http/ngx_http_realip_module.html): адрес по header меняется только для источника, который явно назван через `set_real_ip_from`.
+- [Nginx `ngx_http_log_module`](https://nginx.org/en/docs/http/ngx_http_log_module.html): `log_format`, `access_log`, `request_time` и роль формата журнала как наблюдаемого артефакта.
+- [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239): `Forwarded`, связь со старым семейством `X-Forwarded-*` и ограничение целостности forwarded-данных без доверенной цепочки proxy.
+- [RFC 7230](https://www.rfc-editor.org/rfc/rfc7230): исторический HTTP/1.1-контекст апреля 2020 года для маршрутизации и `Host`.
+
+### Что проверено в тексте
+
+1. Reverse proxy описан как граница двух HTTP-hop-ов, а не как «прозрачный провод».
+2. `proxy_read_timeout` назван интервалом между последовательными чтениями ответа, а не полной длительностью ответа.
+3. `Host`, `X-Forwarded-*` и real IP не объявлены достоверными без источника и договора доверия.
+4. 502/504 названы входом в диагностическую ветку, а не самостоятельным диагнозом.
+5. Учебная конфигурация не подменяет реальную: `127.0.0.1`, пути, timeout-значения, URL и токены явно помечены заполнителями.
+
+### Границы и запреты
+
+В текст не добавлены service mesh, OpenTelemetry, Kubernetes Ingress или
+современные облачные практики как опыт автора 2020 года. Не опубликованы
+реальные hostnames, IP-адреса, cookie, токены, secrets или пользовательские
+данные. `curl`, access-log и значения времени даны как учебные либо
+синтетические примеры; они не выдаются за запуск на стенде.
+
+**Вердикт прохода 1: пройден.** Технические утверждения привязаны к первичным
+источникам, а недоказанные эксплуатационные выводы явно ограничены.
+
+## Проход 2 — редактура, голос и структура
+
+### Проверка голоса M3 / апрель 2020
+
+Автор развивает ширину от HTTP и локальной доставки к backend boundary:
+`клиент → Nginx → приложение`, конфигурация, таймаут, безопасный access-log.
+Тон прагматичный: «симптом → причина → проверка → действие». Он не говорит
+от лица владельца крупной платформы и не обещает универсальные значения
+таймаутов. Слова `upstream`, `hop`, `TLS termination`, `Forwarded` и real IP
+привязаны к конфигурации, журналу или конкретному ограничению.
+
+### Обязательные элементы в каждой статье
+
+| Критерий | Практика | Механизм | Разбор |
+| --- | --- | --- | --- |
+| Проблема и цена в первых двух абзацах | Да | Да | Да |
+| 5–15 тыс. знаков основного текста | Да | Да | Да |
+| `table` с `caption` и `thead` | Да | Да | Да |
+| `figure` с содержательными `alt` и подписью | Да | Да | Да |
+| Конфигурация, curl или log-пример | Nginx + curl | Nginx + log format | log format + синтетический log + curl |
+| Нумерованный маршрут | Да | Да | Да |
+| Не менее двух первичных/официальных ссылок | 5 | 5 | 5 |
+
+Проверены повторы, канцелярские обобщения и анахронизмы. Заголовки отвечают
+именно на вопрос соответствующей статьи: практический контракт, механизм двух
+соединений и безопасное расследование. В финалах зафиксированы следующий шаг
+и границы применимости, а не обещание «решить все proxy-проблемы».
+
+**Вердикт прохода 2: пройден.** Плотность достаточна, тон соответствует
+переходу автора к delivery и backend boundary в 2020 году.
+
+## Проход 3 — визуал и выпускной preflight
+
+### Реально выполненные проверки
+
+```text
+node --check scripts/upgrade-2020-04.mjs
+→ code 0
+
+npm run audit:draft -- scripts/upgrade-2020-04.mjs
+→ code 0
+→ PASS practice: 9 620 body chars
+→ PASS mechanism: 10 533 body chars
+→ PASS field: 10 504 body chars
+
+xmllint --noout public/assets/editorial/2020/reverse-proxy-request-hops-2020.svg \
+ public/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg \
+ public/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg
+→ code 0
+```
+
+`npm` напечатал известные предупреждения пользовательской конфигурации
+`store-dir`, `cache-dir`, `public-hoist-pattern`; команда завершилась кодом 0,
+они не относятся к P26.
+
+### Визуальный preflight
+
+Все три SVG отрендерены Sharp при ширине 375 px и просмотрены вручную:
+
+- `reverse-proxy-request-hops-2020.svg` — вертикальная последовательность
+ клиента, Nginx, приложения и совместной диагностики; переполнения и обрезания
+ не обнаружены.
+- `reverse-proxy-header-boundary-2020.svg` — крупные карточки источника,
+ договора и чтения приложением; вторичный текст остаётся различимым.
+- `reverse-proxy-diagnosis-2020.svg` — пять вертикальных шагов расследования;
+ стрелки не перекрывают подписи, финальная карточка читается на 375 px.
+
+SVG не содержат внешних ресурсов. В HTML каждой статьи есть отдельные
+содержательные `alt` и `figcaption`; таблицы завёрнуты в `table-scroll`, как
+требует существующий шаблон.
+
+Основной редактор независимо просмотрел финальные raster-версии трёх схем на
+375 px и подтвердил этот результат. После подключения registry strict audit и
+production build также пройдены. Они не подменяют запуск Nginx, `curl` или
+browser на конкретном стенде.
+
+### Невыполненные проверки, которые не выдаются за факты
+
+- Nginx не запускался и конфигурация не загружалась в daemon.
+- Учебный `curl` не выполнялся.
+- Browser, CI, staging и production-прогон не выполнялись.
+- Не было доступа к реальному access-log или логу приложения; строка в статье
+ явно названа синтетической.
+
+### Интеграционная проверка
+
+| Проверка | Результат |
+| --- | --- |
+| Strict audit после подключения registry | PASS: 9 620 / 10 533 / 10 504 знака; 1 / 2 / 1 table и 2 / 2 / 3 code example |
+| `npm run build` | PASS, code 0, 374 статические страницы |
+| Scope/self-review | PASS: в revision нет `date`/`author`, `articles.json` не перезаписан |
+
+**Выпусковой вердикт: тройное ревью пройдено, пакет принят к публикации.**
+Статические проверки подтверждают качество редакционного слоя, но не
+утверждают фактическое поведение Nginx, доступность стенда или реальный
+access-log; для конкретного проекта это отдельный сценарий.
diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs
index 1a11c19..0d930b6 100644
--- a/web/data/editorial-revisions.mjs
+++ b/web/data/editorial-revisions.mjs
@@ -22,6 +22,7 @@ import { revisions as december2019Revisions } from '../scripts/upgrade-2019-12.m
import { revisions as january2020Revisions } from '../scripts/upgrade-2020-01.mjs';
import { revisions as february2020Revisions } from '../scripts/upgrade-2020-02.mjs';
import { revisions as march2020Revisions } from '../scripts/upgrade-2020-03.mjs';
+import { revisions as april2020Revisions } from '../scripts/upgrade-2020-04.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -49,4 +50,5 @@ export const editorialRevisions = [
...january2020Revisions,
...february2020Revisions,
...march2020Revisions,
+ ...april2020Revisions,
];
diff --git a/web/public/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg b/web/public/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg
new file mode 100644
index 0000000..a08e865
--- /dev/null
+++ b/web/public/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg
@@ -0,0 +1,58 @@
+
diff --git a/web/public/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg b/web/public/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg
new file mode 100644
index 0000000..368b0b3
--- /dev/null
+++ b/web/public/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg
@@ -0,0 +1,47 @@
+
diff --git a/web/public/assets/editorial/2020/reverse-proxy-request-hops-2020.svg b/web/public/assets/editorial/2020/reverse-proxy-request-hops-2020.svg
new file mode 100644
index 0000000..7d33e13
--- /dev/null
+++ b/web/public/assets/editorial/2020/reverse-proxy-request-hops-2020.svg
@@ -0,0 +1,54 @@
+
diff --git a/web/scripts/upgrade-2020-04.mjs b/web/scripts/upgrade-2020-04.mjs
new file mode 100644
index 0000000..f141d24
--- /dev/null
+++ b/web/scripts/upgrade-2020-04.mjs
@@ -0,0 +1,373 @@
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+ return '
[\s\S]*?<\/div>/g, ''),
+ );
+}
+
+function createRevision(meta, bodyParts, sources) {
+ const bodyHtml = bodyParts.join('\n');
+ const proseLength = proseText(bodyHtml).length;
+
+ if (proseLength < 5000 || proseLength > 15000) {
+ throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
+ }
+
+ if (sources.length < 2) {
+ throw new Error(meta.slug + ': at least two primary or official sources are required');
+ }
+
+ return {
+ ...meta,
+ contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
+ proseLength,
+ };
+}
+
+const nginxProxy = {
+ title: 'Nginx: модуль ngx_http_proxy_module',
+ url: 'https://nginx.org/en/docs/http/ngx_http_proxy_module.html',
+ note: 'первичная документация директив proxy_pass, proxy_set_header, proxy_connect_timeout, proxy_send_timeout и proxy_read_timeout',
+};
+
+const nginxRealIp = {
+ title: 'Nginx: модуль ngx_http_realip_module',
+ url: 'https://nginx.org/en/docs/http/ngx_http_realip_module.html',
+ note: 'модуль меняет адрес клиента только по указанному заголовку и только для источников, явно объявленных доверенными через set_real_ip_from',
+};
+
+const nginxLog = {
+ title: 'Nginx: модуль ngx_http_log_module',
+ url: 'https://nginx.org/en/docs/http/ngx_http_log_module.html',
+ note: 'справочник log_format, access_log и переменной request_time; формат журнала должен быть частью диагностического контракта',
+};
+
+const rfc7239 = {
+ title: 'RFC 7239: Forwarded HTTP Extension',
+ url: 'https://www.rfc-editor.org/rfc/rfc7239',
+ note: 'стандартный заголовок Forwarded, его связь с X-Forwarded-* и ограничение: данные заголовка нельзя считать достоверными без доверенной цепочки proxy',
+};
+
+const rfc7230 = {
+ title: 'RFC 7230: HTTP/1.1 Message Syntax and Routing',
+ url: 'https://www.rfc-editor.org/rfc/rfc7230',
+ note: 'историческая для апреля 2020 года спецификация HTTP/1.1: маршрутизация, Host и границы между получателем и отправителем сообщения',
+};
+
+const boundaryConfig = [
+ '# Учебная конфигурация: имена, адреса и значения не относятся к рабочему контуру.',
+ 'upstream app_backend {',
+ ' server 127.0.0.1:3000;',
+ '}',
+ '',
+ 'server {',
+ ' listen 8080;',
+ ' server_name _;',
+ '',
+ ' location / {',
+ ' proxy_http_version 1.1;',
+ ' proxy_set_header Host $host;',
+ ' proxy_set_header X-Real-IP $remote_addr;',
+ ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
+ ' proxy_set_header X-Forwarded-Proto $scheme;',
+ ' proxy_set_header Connection "";',
+ '',
+ ' proxy_connect_timeout 3s;',
+ ' proxy_send_timeout 10s;',
+ ' proxy_read_timeout 15s;',
+ ' proxy_pass http://app_backend;',
+ ' }',
+ '}',
+];
+
+const boundaryCurl = [
+ '# Учебный запрос; не выполнялся в browser, staging или production.',
+ '# и — подстановки для изолированного стенда, не реальные адреса.',
+ 'curl -i --max-time 5 \\',
+ " '/health' \\",
+ " -H 'Host: ' \\",
+ " -H 'X-Debug-Token: proxy-study-2020-04'",
+ '',
+ '# Сверяем статус, Location, Set-Cookie и заголовок, который приложение',
+ '# записывает как схему или request token. Сам curl не доказывает путь до upstream.',
+];
+
+const headerLogConfig = [
+ '# Учебный log_format; путь журнала и значения подставляются в стенде.',
+ "log_format proxy_boundary '$request_method $uri status=$status '",
+ " 'request=$request_time upstream=$upstream_addr '",
+ " 'upstream_status=$upstream_status '",
+ " 'connect=$upstream_connect_time '",
+ " 'header=$upstream_header_time '",
+ " 'response=$upstream_response_time';",
+ '',
+ 'access_log /path/to/proxy-boundary.log proxy_boundary;',
+];
+
+const syntheticLog = [
+ '# Синтетический пример для чтения полей; это не access-log реального сервера.',
+ 'GET /health status=504 request=3.001 upstream= upstream_status=-',
+ 'connect=0.001 header=- response=3.001',
+ '',
+ '# Это формирует гипотезу «proxy не получил заголовки ответа до своего лимита»,',
+ '# но не называет причину: её проверяют по конфигурации и логу приложения.',
+];
+
+const practiceArticle = createRevision(
+ {
+ slug: 'editorial-2020-04-practice-reverse-proxy',
+ title: 'Reverse proxy перед приложением: собираем проверяемую HTTP-границу',
+ categories: ['HTTP', 'Nginx', 'Практика'],
+ cover: '/assets/editorial/2020/reverse-proxy-request-hops-2020.svg',
+ excerpt: 'Proxy создаёт второе HTTP-соединение и меняет контекст запроса. Собираем малый контракт для заголовков, таймаутов и диагностики вместо правок «вслепую».',
+ readingMinutes: 14,
+ },
+ [
+ paragraph('Симптом обычно выглядит как ошибка приложения: внешний запрос приходит по HTTPS, а код считает его HTTP; все посетители в журнале имеют один адрес; после добавления proxy часть URL вдруг теряет префикс. Цена не в одном неверном заголовке. Неправильная схема ломает редирект или флаг cookie, общий адрес клиента делает бесполезным разбор ошибок, а случайный таймаут превращает медленный ответ в обвинение «бэкенд упал». Если сразу менять middleware или увеличивать лимит, команда лечит последний наблюдаемый слой, а не причину.'),
+ paragraph('У reverse proxy простая, но важная роль: он принимает внешнее HTTP-соединение и создаёт отдельное соединение к приложению. Между ними меняются адрес peer, схема, часть заголовков, лимиты ожидания и журнал. Поэтому в апреле 2020 года я бы начал не с универсальной конфигурации, а с короткого контракта по hop-ам: что видит клиент, что обязан передать Nginx, что приложение имеет право считать доверенным и какой один маршрут докажет это на изолированном стенде. Ниже приведены учебные значения; они не были применены к browser, staging или production.'),
+ heading('Прокси — не прозрачный провод'),
+ paragraph('Пока клиент подключён к Nginx, для Nginx адрес клиента — адрес удалённой стороны этого соединения. Когда Nginx обращается к upstream, приложение видит уже Nginx как непосредственного соседа. Это не ошибка и не повод вручную подменять адрес в каждом контроллере. Это граница, на которой нужно явно договориться о передаваемых данных. Аналогично с TLS: TLS может завершаться перед приложением, а внутренний hop остаётся обычным HTTP. Приложение не может вычислить внешнюю схему из сокета, если proxy не передал ей согласованный признак.'),
+ paragraph('В этой модели полезно разделить три класса данных. Первый — маршрут: метод, URI и Host, по которым приложение выбирает обработчик и строит ссылки. Второй — происхождение: схема и цепочка адресов, нужные для ограниченного набора решений. Третий — время: когда proxy смог подключиться к upstream, отправить запрос и дождаться следующего байта ответа. Смешать их легко: например, таблица в базе говорит, что URL правильный, а проблема на самом деле в Host, который по умолчанию у proxy не обязан совпадать с внешним host.'),
+ dataTable(
+ 'Контракт запроса по hop-ам: наблюдение должно иметь владельца',
+ ['Слой', 'Что он видит', 'Что может изменить', 'Что проверять'],
+ [
+ ['Клиент', 'внешний URL, статус и ответные заголовки', 'только свой запрос', 'метод, путь, ожидаемый статус и безопасный диагностический токен'],
+ ['Nginx', 'входной запрос и отдельный upstream-hop', 'Host, X-Forwarded-*, лимиты соединения и чтения', 'явные proxy_set_header, proxy_pass и формат access-log'],
+ ['Приложение', 'соединение от proxy и переданные заголовки', 'свою логику маршрута и журнал', 'какие заголовки допустимы только от доверенного proxy'],
+ ['Журнал', 'итог обработки на proxy', 'ничего не исправляет', 'статус, request_time и upstream-поля рядом, без секретов и пользовательских данных'],
+ ],
+ ),
+ paragraph('Эта таблица намеренно не называет конкретную топологию сети. Для одного проекта Nginx стоит прямо перед приложением; для другого перед ним уже есть балансировщик. Во втором случае $remote_addr на Nginx может обозначать предыдущий proxy, а не браузер. Подключать модуль realip имеет смысл только после того, как список доверенных источников определён отдельно. Его set_real_ip_from — это не косметическая настройка, а разрешение заменить адрес по заголовку.'),
+ heading('Минимальная конфигурация как объект ревью'),
+ paragraph('В учебной конфигурации ниже upstream намеренно локальный, а server_name не раскрывает ни одного рабочего имени. Она показывает форму договора, а не готовый фрагмент для копирования. Host передаётся явно, потому что у Nginx есть свои значения по умолчанию для заголовков proxied request. X-Forwarded-For накапливается через $proxy_add_x_forwarded_for, а X-Forwarded-Proto фиксирует схему hop-а, который пришёл на Nginx. Приложение должно принимать эти поля только из согласованной границы, а не из любого прямого HTTP-запроса.'),
+ codeBlock(boundaryConfig),
+ paragraph('Три таймаута в примере отвечают на разные вопросы. proxy_connect_timeout ограничивает установление соединения с upstream. proxy_send_timeout относится к передаче запроса upstream между последовательными операциями записи. proxy_read_timeout относится к промежутку между чтениями ответа, а не к полной длительности ответа. Поэтому число 15s не является «временем работы API»: потоковый ответ может жить дольше, если upstream регулярно отдаёт байты, а тихий запрос может оборваться раньше, если приложение перестало отвечать. Значения здесь учебные, их нельзя переносить в рабочий контур без бюджета ожидания всего маршрута.'),
+ figure(
+ '/assets/editorial/2020/reverse-proxy-request-hops-2020.svg',
+ 'Вертикальная схема трёх hop-ов: клиент передаёт запрос Nginx, Nginx фиксирует контракт заголовков и таймаутов, затем создаёт отдельный запрос к приложению; внизу показана точка совместной диагностики по access-log и логу приложения',
+ 'Один пользовательский запрос даёт как минимум два HTTP-hop-а. Ошибку ищем на том hop-е, где меняется нужный сигнал, а не в абстрактном «сервере».',
+ ),
+ heading('Один безопасный маршрут вместо широкого smoke-теста'),
+ paragraph('Чтобы проверить контракт, не нужен полный прогон сайта. Нужен маршрут без пользовательских данных и с понятным ответом: например, /health или отдельный endpoint стенда. В запрос добавляется учебный токен, который можно увидеть и в журнале proxy, и в логе приложения, если приложение уже умеет его писать. Токен не становится средством аутентификации и не заменяет request ID; он всего лишь связывает две записи в контролируемом упражнении. Если таких журналов нет, сначала добавляют безопасный формат, а потом запускают проверку.'),
+ codeBlock(boundaryCurl),
+ paragraph('После такого запроса нельзя делать вывод «приложение работает за proxy», если пришёл только 200. Проверяются четыре вещи: status и ответные заголовки снаружи, запись proxy с тем же токеном, запись приложения с этим же токеном и точное значение схемы или host, которое приложение использовало. Если внешнее соединение TLS, а приложение видит HTTP, это может быть корректно на внутреннем hop-е. Ошибкой становится не сам HTTP, а отсутствие согласованного сигнала, по которому код различает внешнюю схему.'),
+ heading('Типовые симптомы не лечатся одним заголовком'),
+ paragraph('Симптом «все адреса одинаковые» имеет минимум две причины. Либо приложение честно пишет peer address и видит Nginx, либо оно доверяет неподтверждённому X-Forwarded-For. Проверка разная: сначала определяем, есть ли прямой доступ к приложению и какой proxy имеет право дописывать заголовок. Действие — закрепить этот маршрут в конфигурации и в приложении, а не заменить адрес на первое значение из любой строки. RFC 7239 отдельно напоминает: forwarded-информация может быть изменена на пути и не становится достоверной без доверенной цепочки.'),
+ paragraph('Симптом «редирект ведёт на http» тоже не означает, что Nginx сломан. Сначала проверяют, где завершается TLS, какой заголовок proxy передаёт и какое поле реально читает фреймворк. Затем сравнивают один учебный запрос до и после конфигурации. Если приложение использует один заголовок, а proxy передаёт другой, исправляется контракт на границе или адаптер приложения — но не добавляется ещё один независимый способ угадать схему. Так сохраняется возможность объяснить поведение новому человеку по четырём строкам конфигурации.'),
+ heading('Маршрут внедрения и отката'),
+ orderedList([
+ 'Записать один симптом в наблюдаемой форме: неверный Host, схема, адрес клиента, 502/504 или неожиданный timeout; назвать его цену для пользователя или разработки.',
+ 'Нарисовать реальные hop-ы без адресов и секретов: кто принимает внешний запрос, где заканчивается TLS и какой процесс является upstream для Nginx.',
+ 'Согласовать для приложения явный набор полей: Host, признак схемы, forwarded-цепочка и один безопасный диагностический токен; отдельно назвать, кто имеет право их выставлять.',
+ 'Собрать итоговую конфигурацию Nginx и проверить её синтаксис в изолированной среде; не менять одновременно route, приложение и значения таймаутов.',
+ 'Выполнить один учебный curl-маршрут на стенде, сопоставить записи proxy и приложения, затем изменить только ту строку конфигурации, которая объясняет расхождение.',
+ 'Сохранить ожидаемый результат и обратный шаг: какие поля и лимиты вернуть, если проверка не подтверждает гипотезу. Без этого увеличение таймаута остаётся необъяснимым обходом.',
+ ]),
+ heading('Граница должна быть маленькой и явной'),
+ paragraph('Reverse proxy полезен тем, что отделяет внешний HTTP от процесса приложения. Но это разделение требует контракта: что передаётся, чему доверяют, сколько ждут и где виден результат. В апреле 2020 года для небольшого сервиса достаточно Nginx-конфигурации, одного диагностического маршрута, таблицы сигналов и короткого access-log. Не требуется усложнять схему service mesh, Kubernetes Ingress или распределённой трассировкой, чтобы перестать угадывать причину 502.'),
+ paragraph('Эта статья не запускала Nginx, curl, browser, staging или production и не использует реальные hostnames, IP-адреса либо секреты. Конфигурация, адрес 127.0.0.1, путь журнала и таймауты — только учебные заполнители. Перед применением в проекте нужно отдельно подтвердить топологию, доверенные proxy, таймаут приложения, безопасный диагностический маршрут и правила хранения журналов.'),
+ ],
+ [nginxProxy, nginxRealIp, nginxLog, rfc7239, rfc7230],
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2020-04-mechanism-reverse-proxy',
+ title: 'Под капотом reverse proxy: два HTTP-соединения, заголовки и таймауты',
+ categories: ['HTTP', 'Nginx', 'Архитектура'],
+ cover: '/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg',
+ excerpt: 'Почему приложение видит не тот адрес и схему, а 504 не объясняет причину само по себе: раскладываем proxy на отдельные соединения и проверяем контракт заголовков.',
+ readingMinutes: 15,
+ },
+ [
+ paragraph('Симптом становится дорогим, когда proxy считают невидимой деталью: приложение разрешает небезопасный редирект, потому что видит HTTP; ограничение по адресу клиента работает для всей аудитории как для одного посетителя; ответ внезапно обрывается по 504, и команда увеличивает все таймауты разом. Цена такой реакции — не только задержка. Появляется недоверенный источник данных, длиннее висят соединения, а журнал больше не показывает, на каком участке запрос перестал двигаться.'),
+ paragraph('Причина в механике: reverse proxy не пересылает тот же самый TCP/HTTP-разговор без изменений. Он завершает входной запрос, принимает решение по своему server/location, а затем создаёт request к upstream. У нового request есть свой peer address, свои default headers, свой момент подключения и свои интервалы чтения. Для автора, который в 2020 году изучает delivery и backend boundary, это не повод строить новую платформу. Достаточно назвать эти состояния и проверить их в маленькой конфигурации, не выдавая учебный запуск за production-факт.'),
+ heading('Два соединения дают две правды о запросе'),
+ paragraph('На внешнем hop-е клиент отправляет method, target, Host, заголовки и тело Nginx. Nginx может выбрать location, переписать URI, добавить или удалить заголовок, буферизовать данные и закрыть соединение по своему лимиту. На внутреннем hop-е upstream получает то, что сформировал proxy. Поэтому слово «оригинальный запрос» в обсуждении часто мешает: полезнее каждый раз уточнять, о каком hop-е идёт речь. Внешний URL мог быть HTTPS, а внутренний hop — HTTP; это корректно, если контракт сообщает приложению исходную схему и эта информация пришла от доверенной стороны.'),
+ paragraph('В Nginx proxy_set_header не является декоративным списком. Документация модуля фиксирует, что по умолчанию proxy задаёт собственные значения для Host и Connection; при явных директивах на уровне location важно увидеть итоговую конфигурацию, а не надеяться на наследование. Именно поэтому ревью конфигурации должно отвечать на прямой вопрос: чему равен Host у upstream, какой заголовок сигнализирует схему и какие headers сознательно не передаются. Иначе приложение может собрать URL с именем upstream либо потерять нужный признак внешнего запроса.'),
+ dataTable(
+ 'Данные на границе proxy: значение без источника не становится фактом',
+ ['Сигнал', 'На каком hop-е появляется', 'Риск ложного чтения', 'Проверяемое правило'],
+ [
+ ['Host', 'во внешнем запросе; proxy формирует значение для upstream', 'upstream получает своё имя вместо внешнего маршрута', 'явно задать policy Host и сверить её с маршрутизацией приложения'],
+ ['X-Forwarded-Proto или Forwarded', 'proxy после TLS termination или иной согласованной границы', 'клиент мог прислать поле сам, а приложение приняло его как достоверное', 'принимать схему только из пути, где известен и ограничен источник proxy'],
+ ['X-Forwarded-For / X-Real-IP', 'на proxy; возможно, раньше уже был другой proxy', 'все пользователи выглядят одним peer либо подставленная цепочка считается клиентом', 'назвать доверенные hop-ы и правило разбора до включения realip или кода приложения'],
+ ['request_time и upstream-поля', 'в момент записи access-log', '504 считают названием причины, а не результатом ожидания', 'сравнить время proxy с событием приложения и конкретным proxy timeout'],
+ ],
+ ),
+ paragraph('Стандартный Forwarded из RFC 7239 описывает параметры for, by, host и proto. При этом в старых приложениях широко встречаются X-Forwarded-*, а переход между форматами не происходит сам. RFC подчёркивает более важное ограничение: эти данные можно изменить на пути, в том числе клиентом. Поэтому приложение не должно превращать header в источник прав для каждого прямого соединения. Nginx realip module также требует явно указать доверенные адреса через set_real_ip_from; это хороший сигнал, что доверие — часть конфигурации, а не строка парсинга.'),
+ heading('Заголовки должны описывать ровно один договор'),
+ paragraph('Ниже не «рецепт для любого сервера», а учебный минимум. Он показывает, где находятся точки договора. Host передаётся как $host; X-Real-IP показывает непосредственный peer, а X-Forwarded-For расширяет уже имеющуюся цепочку. Если Nginx сам расположен за другим proxy, этот peer может быть адресом предыдущего hop-а. Исправлять это нужно не редактированием X-Forwarded-For в приложении, а отдельной проверкой реальной цепочки и доверенных источников.'),
+ codeBlock(boundaryConfig),
+ paragraph('Схема X-Forwarded-Proto $scheme тоже имеет границу. Она показывает схему соединения, пришедшего именно на этот Nginx. Если TLS завершился раньше, $scheme может быть http, хотя пользователь открыл HTTPS. В таком случае нельзя просто жёстко записать https: сначала фиксируют, где происходит termination, какой proxy законно сообщает исходную схему и как этот proxy защищён от прямого обхода. Результатом является один договор, а не несколько конкурирующих headers, из которых код выбирает удобный.'),
+ figure(
+ '/assets/editorial/2020/reverse-proxy-header-boundary-2020.svg',
+ 'Вертикальная схема границы заголовков: внешний запрос несёт недоверенные клиентские поля, Nginx формирует договор Host и X-Forwarded-*, приложение читает только согласованные поля от известного proxy, а отдельный access-log связывает оба hop-а',
+ 'Header становится пригодным для решений только вместе с источником. Строка без описанной доверенной границы не доказывает ни схему, ни адрес клиента.',
+ ),
+ heading('Три таймаута отвечают на разные паузы'),
+ paragraph('Число в конфигурации не равно времени ответа API. proxy_connect_timeout ограничивает установление соединения с proxied server. proxy_send_timeout применим между последовательными операциями записи запроса upstream. proxy_read_timeout применяется между последовательными операциями чтения ответа. Последний пункт особенно важен в диагностике: длинный ответ, который регулярно отдаёт данные, и зависший upstream без следующего байта выглядят для него по-разному. Называть оба случая «медленным запросом» значит потерять полезную развилку.'),
+ paragraph('На практике я сначала рисую бюджет без выдуманных чисел: сколько имеет право ждать клиент, сколько proxy готов ждать подключения, сколько — первый или следующий байт, и где у приложения собственное ограничение. Затем выбираю один класс запроса. Для обычного JSON endpoint не нужен бесконечный read timeout. Для streaming endpoint нельзя применять тот же лимит без понимания пауз в протоколе. Если timeout меняется, рядом фиксируется ожидаемое изменение в access-log и способ вернуть старое значение. Это делает конфигурацию предметом ревью, а не накоплением чисел из чужих статей.'),
+ dataTable(
+ 'Таймаутный разбор: что наблюдать до изменения числа',
+ ['Наблюдение', 'Не делать вывод', 'Проверка', 'Следующее действие'],
+ [
+ ['Не удалось подключиться к upstream', '«приложение медленно отвечает»', 'отдельно сверить proxy_connect_timeout и доступность именно upstream-hop-а', 'исследовать адрес, порт, процесс или лимит подключения; не увеличивать read timeout'],
+ ['Подключение есть, но ответ не даёт следующего байта', '«нужно увеличить всё»', 'сопоставить proxy_read_timeout, access-log и приложение на одном токене', 'найти ожидание в приложении или осознанно изменить лимит для данного типа endpoint'],
+ ['Клиент разрывает запрос раньше proxy', '«Nginx вернул ошибку сам»', 'смотреть внешний timeout клиента отдельно от request_time proxy', 'согласовать бюджет между клиентом, proxy и приложением'],
+ ['У streaming endpoint есть регулярные части ответа', '«долгий ответ всегда ошибка»', 'проверить паузу между частями и назначение endpoint', 'выделить отдельную policy, а не переносить общий JSON-лимит'],
+ ],
+ ),
+ heading('Журнал связывает гипотезу с hop-ом'),
+ paragraph('Access-log полезен, когда в одной строке есть путь, итоговый status, полное request_time и upstream-поля. Nginx log module документирует log_format и $request_time; proxy module даёт данные о выбранном upstream и времени этого взаимодействия. Такой формат не раскрывает cookie, authorization header или реальный адрес пользователя. Он нужен, чтобы сделать различимыми хотя бы две гипотезы: proxy не подключился к upstream или подключился, но не получил заголовки ответа до своего лимита.'),
+ codeBlock(headerLogConfig),
+ paragraph('Сам лог не говорит, какой именно запрос в приложении был дорогим. Для этого в учебном упражнении добавляют безопасный токен и сверяют его с логом приложения, если такой лог уже существует. Не стоит добавлять в access-log полный query string, тело или заголовок авторизации ради удобства расследования. В 2020 году для небольшой команды достаточно минимального формата и дисциплины: одно поле добавляют только если заранее понятно, какую развилку оно поможет проверить.'),
+ heading('Последовательность разбора механизма'),
+ orderedList([
+ 'Назвать внешний симптом и стоимость: неверная схема, адрес, Host, 502/504 или превышение ожидания; не смешивать эти варианты в одну жалобу.',
+ 'Нарисовать два соединения и отметить TLS termination, Nginx location и конкретный upstream; не публиковать реальные имена, адреса и секреты.',
+ 'Проверить итоговую конфигурацию proxy_set_header: Host, схема, цепочка адресов, Connection и policy для недоверенных прямых запросов.',
+ 'Разделить connect, send и read timeout, затем сопоставить каждый лимит с типом endpoint и наблюдаемым промежутком ожидания.',
+ 'Добавить или проверить минимальный log_format без чувствительных данных и пройти один контролируемый запрос на изолированном стенде.',
+ 'Изменить одну причину, повторить тот же маршрут и записать ограничение: какие hop-ы или proxy ещё не входят в договор.',
+ ]),
+ heading('Что эта модель не обещает'),
+ paragraph('Она не делает header безопасным сама по себе, не превращает status 504 в диагноз и не выбирает «правильные» таймауты без нагрузки и сценария. Она лишь отделяет факты: где запрос был принят, где был сформирован новый request, что proxy передал дальше и между какими операциями истекло время. Для начинающего движения в delivery это уже заметный шаг: ошибка перестаёт быть свойством «сервера вообще» и получает конкретную границу, конфигурацию и проверку.'),
+ paragraph('Здесь не запускались Nginx, curl, browser, CI, staging или production. Адрес 127.0.0.1, путь журнала и таймауты принадлежат учебному примеру, а не реальному контуру; никаких hostnames, секретов и фактических IP не публикуется. Перед использованием нужны отдельные проверки топологии, доверенных proxy, версии Nginx, поведения фреймворка при forwarded headers и требований к хранению диагностических журналов.'),
+ ],
+ [nginxProxy, nginxRealIp, nginxLog, rfc7239, rfc7230],
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2020-04-field-reverse-proxy',
+ title: 'Разбор reverse proxy: как не перепутать 504, неверную схему и адрес клиента',
+ categories: ['HTTP', 'Nginx', 'Отладка'],
+ cover: '/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg',
+ excerpt: '502 или 504 — это начало расследования, а не имя причины. Собираем безопасную таблицу сигналов, учебный access-log и маршрут проверки одного proxy-hop-а.',
+ readingMinutes: 14,
+ },
+ [
+ paragraph('Симптомы proxy часто приходят пачкой: клиент получает 504, приложение пишет HTTP вместо HTTPS, а журнал видит один и тот же адрес для каждого пользователя. Цена поспешного исправления заметна быстро: таймаут увеличивают для всех endpoint-ов, в код добавляют исключение для «настоящей схемы», а access-log остаётся без полей, которые могли бы отделить подключение от ожидания ответа. Через неделю тот же сбой возвращается, но уже с другим status и без возможности сравнить два случая.'),
+ paragraph('Полезный разбор начинается с ограничения. Здесь нет production-инцидента и нет реального access-log: ниже синтетический сценарий с учебными полями. Его задача — научить отличать сигнал от вывода. Reverse proxy формирует отдельный upstream-hop, поэтому один status не говорит, где именно потеряно время или кто изменил заголовок. В апреле 2020 года достаточно собрать путь одного запроса, безопасный формат лога и короткую таблицу «симптом → причина → проверка → действие», не приписывая себе запуск на живом контуре.'),
+ heading('Начинаем с одного маршрута и одного вопроса'),
+ paragraph('Выбираю маршрут, на котором нет персональных данных и который допускает повтор: /health, техническая страница или специальный стендовый endpoint. Вопрос тоже один. Например: «получил ли Nginx заголовки ответа upstream до истечения своего read timeout?» Это лучше, чем вопрос «почему всё медленно». Для ответа нужны совпадающие признаки: путь и безопасный токен в входном запросе, итоговый status и время в access-log, плюс запись приложения, если она уже умеет логировать этот токен. Если последней записи нет, это результат наблюдения, а не повод придумать её содержание.'),
+ paragraph('Следующий вопрос — о границе доверия: «кто установил X-Forwarded-Proto и X-Forwarded-For?» Приложение может видеть адрес Nginx совершенно корректно. Проблемой это становится, когда приложение использует этот peer как пользовательский адрес или принимает forwarded-header от прямого внешнего соединения. Nginx realip module требует отметить trusted sources, а RFC 7239 предупреждает, что forwarded-информация может быть изменена на пути. Поэтому сначала рисуется топология, затем определяется доверенный proxy, и только после этого меняется конфигурация или middleware.'),
+ dataTable(
+ 'Карта диагностики: status — вход в ветку, а не готовый диагноз',
+ ['Симптом', 'Вероятная граница', 'Минимальная проверка', 'Ограниченное действие'],
+ [
+ ['504 на одном endpoint', 'ожидание proxy между операциями чтения upstream', 'сопоставить proxy_read_timeout, request_time, upstream_header_time и лог приложения на одном токене', 'исправить задержку upstream или отдельно пересмотреть лимит именно этого endpoint'],
+ ['502 после смены proxy_pass', 'маршрут до upstream, URI или ответ upstream', 'прочитать итоговый location/proxy_pass и status upstream без изменения таймаутов', 'вернуть один неверный маршрут либо исправить конфигурацию; не лечить 502 read timeout'],
+ ['Приложение строит http-ссылку', 'TLS termination и договор о схеме', 'сверить внешний маршрут, $scheme на Nginx и поле, которое читает фреймворк', 'зафиксировать единственный доверенный forwarded-сигнал'],
+ ['Все пользователи имеют адрес proxy', 'peer address и realip boundary', 'выяснить, существует ли прямой доступ к приложению и какие hop-ы доверены', 'не парсить первый X-Forwarded-For; задать список доверенных источников отдельно'],
+ ],
+ ),
+ paragraph('В таблице нет строки «перезапустить всё». Перезапуск может совпасть с исчезновением эффекта, но не доказать причину. Если конфигурация изменилась одновременно с приложением, сначала возвращают понятный контроль: один location, один upstream, один запрос. Если симптом связан с timeout, нельзя одновременно увеличивать proxy_read_timeout, менять пул соединений и добавлять retry. Иначе следующая запись в журнале не ответит, какая из трёх правок что изменила.'),
+ heading('Учебный access-log должен сохранять развилку'),
+ paragraph('Nginx пишет access-log в формате, который задаётся через log_format. Для proxy-разбора хватает небольшой строки: метод и URI, итоговый status, $request_time, выбранный upstream, его status и времена подключения, заголовков и ответа. Значения upstream-полей иногда остаются пустыми; это тоже сигнал о том, что нужно проверить конкретную ветку, а не маскировать пустоту нулём. Формат не должен писать authorization, cookie, тело или реальный IP пользователя: диагностика не оправдывает сбор лишних данных.'),
+ codeBlock(headerLogConfig),
+ paragraph('После настройки формата удобнее прочитать одну синтетическую строку, чем десятки настоящих. В примере ниже connect маленький, а header отсутствует до 3.001 секунды. Это поддерживает гипотезу, что Nginx быстро начал upstream-hop, но не получил заголовки ответа до лимита. Это не доказывает, что виновата база, GC, внешняя зависимость или сам framework. Следующий шаг — посмотреть конфигурацию и безопасную запись приложения для того же токена, а не объявить причину по одной цифре.'),
+ codeBlock(syntheticLog),
+ paragraph('Та же дисциплина применима к 502. Status 502 сообщает, что proxy не смог отдать клиенту корректный upstream-ответ в данной конфигурации, но не заменяет сравнение location, proxy_pass, URL и лога upstream. Если рядом нет upstream_status, это может сузить ветку, но не выбирает её автоматически. Правильная запись расследования звучит короче и честнее: «на учебном маршруте proxy не получил заголовки upstream до указанного лимита; следующий эксперимент — сверить endpoint и лог процесса». В ней нет ни выдуманного запуска, ни обещания, что увеличение таймаута решит всё.'),
+ figure(
+ '/assets/editorial/2020/reverse-proxy-diagnosis-2020.svg',
+ 'Вертикальная схема расследования reverse proxy: зафиксировать один симптом, выбрать один безопасный маршрут, проверить контракт headers и timeout, сопоставить proxy access-log с логом приложения, затем изменить только подтверждённую границу и повторить маршрут',
+ 'Диагностика не начинает с значения timeout. Сначала определяется hop и наблюдаемый сигнал, затем меняется одна причина.',
+ ),
+ heading('Неверная схема и адрес — отдельные ветки'),
+ paragraph('Схема и адрес часто попадают в один «proxy bug», хотя проверяются по-разному. Для схемы важно место TLS termination и заголовок, который приложение реально читает. На внутреннем HTTP-hop-е $scheme может быть http, даже если внешний пользователь пришёл по HTTPS. Для адреса важно, кто является непосредственным peer и кто имеет право заменить его данными из header. set_real_ip_from описывает доверенную сторону; отсутствие такого знания нельзя компенсировать тем, что приложение возьмёт первый элемент X-Forwarded-For.'),
+ paragraph('Если проект использует стандартный Forwarded, правила его сохранения и расширения должны быть описаны вместе с proxy. Если используется X-Forwarded-*, это не делает решение неверным, но добавляет обязанность назвать формат и источник. Нельзя склеивать значения из Via, Forwarded и X-заголовков так, будто они всегда описывают одну упорядоченную цепочку. RFC 7239 прямо отделяет эти форматы и не обещает, что каждый proxy обновляет их одинаково. Для небольшого сервиса практичнее выбрать один договор и проверить его на одном маршруте.'),
+ heading('Порядок проверки без широкого перезапуска'),
+ orderedList([
+ 'Зафиксировать один симптом, один URL-шаблон без чувствительных данных и один ожидаемый результат; назвать стоимость, если он повторится.',
+ 'Отметить на схеме внешний клиентский hop, Nginx и upstream; отдельно указать, где завершается TLS и есть ли proxy перед Nginx.',
+ 'Проверить итоговые директивы proxy_pass, proxy_set_header и timeout для конкретного location, а не фрагмент из другого include-файла.',
+ 'Собрать безопасный access-log с request_time и upstream-полями, затем выполнить один учебный запрос на изолированном стенде с диагностическим токеном.',
+ 'Сопоставить status и время proxy с записью приложения только для этого токена; если записи приложения нет, зафиксировать неизвестное вместо домысла.',
+ 'Изменить одну подтверждённую границу, повторить тот же запрос и записать критерий отката. Если результат не изменился, вернуться к следующей строке таблицы, а не расширять действие.',
+ ]),
+ heading('Пример проверки заголовков без реального endpoint'),
+ paragraph('Ниже показана команда с подстановками. Её назначение — сделать будущий учебный запрос повторяемым: оператор заменяет только URL и Host стенда, а не копирует скрытые cookie или рабочую авторизацию. До запуска нужно договориться, какой ответ безопасно проверять и где появится диагностический токен. Команда не была выполнена и не подтверждает доступность какого-либо адреса.'),
+ codeBlock(boundaryCurl),
+ paragraph('После запуска стендового варианта смотрят не только на 200. Для схемы проверяют, что приложение использовало согласованный forwarded-signal, а не peer socket. Для адреса проверяют путь доверия от входного proxy до кода. Для timeout сравнивают нужный интервал с данными proxy и лога приложения. Если обработчик отдаёт streaming response, отдельной проверкой фиксируют интервал между частями: proxy_read_timeout измеряет паузу между чтениями, а не полную длительность передачи. Это ограничение меняет постановку задачи и должно остаться рядом с выбранным значением.'),
+ heading('Что остаётся после исправления'),
+ paragraph('Хороший разбор оставляет не «правильный timeout», а повторяемый маршрут: схема hop-ов, список заголовков с источником, минимальный log_format и один безопасный запрос. В следующий раз это позволяет ответить быстрее: проблема в соединении с upstream, в паузе ответа, в контракте схемы или в недоверенном адресе. Так автор развивает ширину от frontend HTTP к delivery boundary, но не притворяется владельцем большой распределённой платформы. Для этого уровня достаточно видеть свою границу и не скрывать неизвестное.'),
+ paragraph('Реальные Nginx, curl, browser, CI, staging и production в этой статье не запускались. Синтетическая строка лога, значения времени, адрес 127.0.0.1, URL-подстановки и путь файла приведены только для обучения; hostnames, секреты, реальные IP и пользовательские данные не публикуются. Перед применением нужен отдельный прогон в согласованном стенде, проверка версии Nginx, владельца конфигурации, доверенных proxy и политики журналирования.'),
+ ],
+ [nginxProxy, nginxRealIp, nginxLog, rfc7239, rfc7230],
+);
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
+ .map(({ proseLength, ...revision }) => revision);
+
+const isDirectRun = process.argv[1]
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isDirectRun) {
+ if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
+ } else {
+ process.stderr.write('Usage: node web/scripts/upgrade-2020-04.mjs --print-revisions\n');
+ process.exitCode = 1;
+ }
+}