Обратный прокси, HTTPS и заголовки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Наружу боевая машина смотрит одним процессом: обратным прокси. Он получает сертификат, слушает 80 и 443 и раздаёт запросы контейнерам — это вы уже видели в части IV, одной строкой reverse_proxy. Здесь строк будет больше, и каждая отвечает на вопрос, который до выкладки не возникает: почему у сайта один хост, а не два; что прокси дописывает к запросу и что делает с тем, что клиент написал о себе сам; откуда берётся сертификат и почему его не выпустить с ноутбука. Главный вопрос — последний: какой адрес окажется в req.ip, когда ограничитель частоты будет решать, кого не пускать. У Fastify это одна настройка, и у неё есть два неверных значения: при одном перебор проходит целиком, при другом ограничитель закрывает вход всем читателям разом. Сборка при обеих зелёная.
Начнём с того, что видно снаружи. В compose-файле автора строка ports есть у прокси (80:80, 443:443 и 443:443/udp), а у базы и у консоли хранилища она ограничена адресом 127.0.0.1 самого сервера. У приложения и у веба её нет вовсе: внутрь сети docker прокси ходит по имени сервиса, api:8000 и web:80. Почему раскладка именно такая и чем опасен лишний опубликованный порт, разобрано в «Порты: слушать нужное, публиковать одно». Здесь важно следствие: интернет видит один процесс, и всё, что этот процесс решает, решается за приложение.
А решает он немало: какому контейнеру достанется запрос, по какому имени сайт вообще отвечает, какие заголовки уедут дальше и какой заголовок кеша получит ответ. У автора граница между контейнерами проходит по путям одного домена — вот сокращённый боевой конфиг.
Хост здесь один — {$DOMAIN}. Второй блок, {$WWW_DOMAIN}, содержимого не отдаёт: он переселяет на apex постоянным редиректом — permanent у Caddy значит 301. Причина не в аккуратности: один и тот же учебник, доступный по двум именам, делит между ними сигналы поисковиков, а ради поиска сайт и затевался. Сертификат для второго имени Caddy выпустит сам, ему нужна только A-запись www на тот же адрес.
У этой пары блоков есть слабость, которую видно только на выкате. Имена приходят из окружения контейнера, и если переменная до контейнера не доехала, подстановка молча становится пустой строкой — как это устроено в compose, разбирает «Переменные: кто и когда подставляет». Прогноз: что скажет caddy validate, если WWW_DOMAIN пуста?
# Каркас боевого конфига: имена хостов приходят из окружения контейнера
{$WWW_DOMAIN} {
redir https://{$DOMAIN}{uri} permanent
}
{$DOMAIN} {
handle /api/* {
reverse_proxy api:8000
}
handle {
reverse_proxy web:80
}
}обе переменные на месте: Valid configuration код выхода 0 WWW_DOMAIN не проброшена: Error: adapting config using caddyfile: Caddyfile:3: unrecognized global option: redir код выхода 1
От пустой подстановки остаётся { … }, а фигурные скобки в начале файла Caddy читает как блок глобальных настроек, где директива redir не значит ничего. Конфиг не разбирается целиком, то есть с пустой переменной не поднимется и apex: на боевой машине это не «www не работает», а сайт в цикле рестартов. Отсюда правило выката: конфиг проверяется validate до того, как контейнер пересоздан, и проверяется с тем окружением, с которым поедет.
Теперь о том, что было обещано раньше. Разбирая статический экспорт, мы говорили, что границу между файлами и приложением проводят или по домену, или по путям одного домена. У автора она по путям, и поддомена api.java-script.pro в выкладке нет — набросок «файлы на одном имени, приложение на другом» остался наброском. Уточнить стоит и вторую половину: с диска Caddy читает только /app. Статьи учебника — тоже статический экспорт, но лежат они в своём образе с файловым сервером на :80, и прокси их проксирует, как и API. Плата — лишний прыжок внутри сети, выгода — один способ обновить любой контейнер.
Разберём эту границу на модели. Запрос приезжает в разборщик из главы 4 — тот самый, что копит буфер и разбирает голову, — а решение принимает список правил. Прогноз: какой из семи запросов уедет не туда, куда его отправил бы порядок правил в списке?
Порядок в списке не решает ничего: перед проверкой он переставлен от самого длинного пути к самому короткому, и всеохватное правило /, стоящее первым, оказывается последним. Так делает и сам Caddy — это проверено запуском: handle без матчера, написанный в файле первым, уступает handle /api/*, написанному после него, а caddy adapt показывает уже переставленные маршруты. Для читателя, пришедшего из главы 4, это разворот привычки: в своём маршрутизаторе и в своём конвейере порядок был порядком объявления, а здесь конфиг сортируется сам.
Остальное в выводе — перевод конфига на русский. /trpc и /api уезжают в приложение с no-store: ответы персональные, а Vary: Authorization сервер не ставит, и дисковый кеш браузера показал бы данные прошлого пользователя после смены учётной записи. /uploads уходит в хранилище фотографий без срезания префикса — путь /uploads/<файл> и есть запрос к бакету uploads, а о самой загрузке будет своя статья. И только у /app префикс срезается: файл на диске называется login.html, а не app/login.html.
У директив Caddy первый аргумент, начинающийся со слэша, — это не значение, а путь-матчер: respond /uploads/ 404 значит «на этом пути ответить 404», а не «ответить текстом». Пустой ответ вместо ожидаемого текста — первый признак, что аргумент ушёл в матчер.
К приложению соединение приходит не от читателя, а от прокси: в req.socket.remoteAddress лежит адрес контейнера caddy в сети docker, один и тот же у всех запросов мира. Всё, что было известно о клиенте, прокси передаёт заголовками семейства X-Forwarded-*: адрес, схему, хост. Глава 4 уже говорила, что такой заголовок дописывает прокси и что клиенту ничто не мешает прислать его самому — тогда выдумка встанет слева, а справа прокси допишет единственный адрес, который видел своими глазами. Пора уточнить: допишет, если поверит. По умолчанию Caddy не верит.
Вот эта развилка моделью. Функция собирает заголовки, которые уедут вверх, из заголовков, которые пришли снизу. Прогноз: что достанется приложению в каждом из двух случаев?
По умолчанию прокси не продолжает чужую цепочку, а заменяет её тем, что знает сам: адресом соединения, своей схемой, своим хостом. Клиентские выдумки не просто игнорируются — до приложения они не доезжают вовсе. Второй случай — прокси, у которого клиент записан в доверенные: цепочка продолжается справа, схема и хост остаются клиентскими. Оба поведения разумны, и выбор между ними осознанный: первое — когда перед прокси никого нет, второе — когда перед ним стоит ещё один прокси, CDN или балансировщик и его заголовки нужно сохранить.
Модель могла бы и врать, поэтому то же самое — на настоящем Caddy. В прогоне два входа одного процесса, и отличаются они одной настройкой: у второго в доверенные записан тот, кто к нему стучится. Запрос обоим уходит один и тот же, с тремя подделанными заголовками. Прогноз: что напечатает приложение — и по какой версии протокола до него доедет запрос, если клиент говорит по HTTP/2?
# Два входа отличаются одной настройкой: кому прокси верит на слово.
# На боевой машине это порты 443 и 443/udp одного хоста; здесь высокие порты
# и собственный CA, потому что 80 и 443 на ноутбуке заняты не нами.
{
local_certs
skip_install_trust
admin off
http_port 3932
https_port 3933
# у второго входа в доверенные записан тот, кто к нему стучится
servers :3934 {
trusted_proxies static 127.0.0.1/8 ::1
}
}
localhost:3933 {
handle {
reverse_proxy 127.0.0.1:3931
}
}
localhost:3934 {
handle {
reverse_proxy 127.0.0.1:3931
}
}клиент постучался в :3933 и получил
HTTP/2 200
alt-svc: h3=":3933"; ma=2592000
клиент постучался в :3934 и получил
HTTP/2 200
alt-svc: h3=":3934"; ma=2592000
а приложение на 127.0.0.1:3931 за это время увидело
приложению пришло: GET /api/ping-3933 HTTP/1.1
host: localhost:3933
x-forwarded-for: ::1
x-forwarded-proto: https
x-forwarded-host: localhost:3933
адрес соединения: 127.0.0.1
приложению пришло: GET /api/ping-3934 HTTP/1.1
host: localhost:3934
x-forwarded-for: 1.2.3.4, 5.6.7.8, ::1
x-forwarded-proto: http
x-forwarded-host: evil.example
адрес соединения: 127.0.0.1Три вещи в этом выводе стоит заметить. Первая: клиент получил HTTP/2 и обещание h3 в alt-svc, а приложение получило HTTP/1.1 — двоичные кадры кончились на входе. Вторая: host у приложения внешний — тот, который набрал клиент, а не имя сервиса внутри сети. Через этот вход подделать его не вышло бы: имя названо в конфиге, и запрос с чужим host ни в один блок не попадает — вход отвечает на него сам, пустым ответом, а приложение такого запроса не видит вовсе (проверено на том же стенде). Но само приложение об этом не знает и знать не может — ему видно только то, что заголовок пришёл. Третья, главная: во входе по умолчанию затёрты все три подделки — вместо цепочки клиента приложение получило адрес соединения (в этом запуске это ::1, локальная петля, а на боевой машине был бы адрес контейнера caddy), — а во входе с доверием сохранены все три, включая x-forwarded-proto: http: клиент смог сказать приложению, что пришёл без TLS.
Последнее неприятнее, чем кажется. Приложение за прокси не знает ни своей схемы, ни своего имени: TLS кончился на входе, а host — то, что написал клиент. Значит, абсолютные адреса — ссылка в письме, редирект после входа, canonical — собираются из настройки, а не из запроса. В API автора адрес письма берётся из переменной APP_URL, и это не перестраховка: задача 3 показывает, что получается иначе.
trusted_proxies — не украшение и не «сделать правильно»: это переключатель «верить тому, что клиент написал о себе». Он пишется, только когда перед входом действительно стоит свой прокси, и перечисляет его адреса, а не все частные сети на всякий случай.
Теперь к тому, ради чего вся возня с адресами. Ограничитель частоты из главы 8 считает по ключу «процедура плюс адрес»: десять вызовов в минуту на пару. Первая половина ключа известна точно. Вторая — та самая, которую прокси только что затирал и дописывал.
Сравним три способа назвать клиента на одной и той же нагрузке: тридцать запросов перебора с одного адреса, каждый с новой выдумкой в X-Forwarded-For, и восемь честных запросов читателя. Ограничитель один и тот же, предел десять. Прогноз: сколько пройдёт в каждом случае?
У Fastify всё это — одна настройка, trustProxy. В боевом main.ts автора стоит trustProxy: 1 с комментарием «доверяем ровно одному прыжку — тому X-Forwarded-For, который дописал Caddy». Прогноз: что окажется в request.ip при четырёх значениях настройки и четырёх видах запроса — и верен ли комментарий?
// Четыре приложения Fastify отличаются одной настройкой — тем, каким соседям // сервер верит, когда достаёт адрес клиента. Запросы идут через inject: порт // не нужен, а адрес соединения задаётся полем remoteAddress import Fastify from "fastify"; const настройки = [ ["без trustProxy", {}], ["trustProxy: true", { trustProxy: true }], ["trustProxy: 1", { trustProxy: 1 }], ['trustProxy: "127.0.0.1"', { trustProxy: "127.0.0.1" }], ]; // remoteAddress — адрес того, кто соединился с сервером: прокси или сам клиент const запросы = [ ["заголовка нет", { remoteAddress: "127.0.0.1" }], ["прокси дописал один адрес", { remoteAddress: "127.0.0.1", "x-forwarded-for": "203.0.113.9" }], ["клиент прислал свой, прокси дописал свой", { remoteAddress: "127.0.0.1", "x-forwarded-for": "198.51.100.7, 203.0.113.9" }], ["мимо прокси, со своим заголовком", { remoteAddress: "198.51.100.200", "x-forwarded-for": "9.9.9.9" }], ]; for (const [подпись, опции] of настройки) { const app = Fastify(опции); app.get("/ping", (запрос) => ({ ip: запрос.ip, ips: запрос.ips })); console.log(подпись); for (const [что, { remoteAddress, ...headers }] of запросы) { const ответ = await app.inject({ method: "GET", url: "/ping", headers, remoteAddress }); const { ip, ips } = ответ.json(); console.log(` ${что}`); console.log(` request.ip = ${ip}`); console.log(` request.ips = ${ips === undefined ? "undefined" : ips.join(" | ")}`); } await app.close(); }
без trustProxy
заголовка нет
request.ip = 127.0.0.1
request.ips = undefined
прокси дописал один адрес
request.ip = 127.0.0.1
request.ips = undefined
клиент прислал свой, прокси дописал свой
request.ip = 127.0.0.1
request.ips = undefined
мимо прокси, со своим заголовком
request.ip = 198.51.100.200
request.ips = undefined
trustProxy: true
заголовка нет
request.ip = 127.0.0.1
request.ips = 127.0.0.1
прокси дописал один адрес
request.ip = 203.0.113.9
request.ips = 127.0.0.1 | 203.0.113.9
клиент прислал свой, прокси дописал свой
request.ip = 198.51.100.7
request.ips = 127.0.0.1 | 203.0.113.9 | 198.51.100.7
мимо прокси, со своим заголовком
request.ip = 9.9.9.9
request.ips = 198.51.100.200 | 9.9.9.9
trustProxy: 1
заголовка нет
request.ip = 127.0.0.1
request.ips = 127.0.0.1
прокси дописал один адрес
request.ip = 127.0.0.1
request.ips = 127.0.0.1
клиент прислал свой, прокси дописал свой
request.ip = 127.0.0.1
request.ips = 127.0.0.1
мимо прокси, со своим заголовком
request.ip = 198.51.100.200
request.ips = 198.51.100.200
trustProxy: "127.0.0.1"
заголовка нет
request.ip = 127.0.0.1
request.ips = 127.0.0.1
прокси дописал один адрес
request.ip = 203.0.113.9
request.ips = 127.0.0.1 | 203.0.113.9
клиент прислал свой, прокси дописал свой
request.ip = 203.0.113.9
request.ips = 127.0.0.1 | 203.0.113.9
мимо прокси, со своим заголовком
request.ip = 198.51.100.200
request.ips = 198.51.100.200Комментарий верен не на всякой версии, и это худший вид неверности. На fastify 5.12.4, которым снят прогон, trustProxy: 1 не читает X-Forwarded-For вовсе: в request.ip приходит адрес соединения, то есть адрес контейнера прокси — ровно тот случай, от которого комментарий обещал спасти. Ограничитель на такой настройке ведёт себя как средняя строка модели: десять запросов в минуту на весь интернет, и первый же перебор закрывает вход остальным.
| Значение trustProxy | Прокси дописал адрес | Клиент подделал слева | Запрос пришёл мимо прокси |
|---|---|---|---|
| нет вовсе | адрес прокси | адрес прокси | настоящий адрес того, кто пришёл |
true | адрес, дописанный прокси | подделка клиента | подделка клиента |
1 (fastify 5.12.4) | адрес прокси | адрес прокси | настоящий адрес того, кто пришёл |
"127.0.0.1" — адрес прокси строкой | адрес, дописанный прокси | адрес, дописанный прокси | настоящий адрес того, кто пришёл |
Ветку с числом переписали в патче 5.12.1: на 5.12.0 она ещё читала счётчик прыжков. Сравнение двух версий одного файла объясняет прогон лучше любого пересказа.
Причина названа в самом комментарии: счётчик прыжков не проверяет, кто именно соединился, поэтому клиент, достучавшийся до приложения напрямую, набирает нужное число прыжков сам. На 5.11.0 — той версии, что стоит в apps/api учебника, — число работало как задумано: request.ip брал адрес, дописанный доверенным прыжком. Но и там запрос мимо прокси получал в request.ip свою же подделку; проверено запуском на обеих версиях.
Что писать вместо числа: адрес или подсеть прокси строкой (trustProxy: "172.18.0.0/16") либо свою функцию. Тогда настройка отвечает не на вопрос «сколько прыжков читать», а на вопрос «кому верить», и ответ перестаёт зависеть от версии. Обновление патча не должно менять смысл строки, от которой зависит защита, — и убедиться в этом можно единственным способом: прогоном, который печатает request.ip.
Сеть — половина замка. Ограничитель верит прокси только потому, что мимо прокси до приложения не добраться: у сервиса api нет опубликованных портов. Открыли порт «на время отладки» — и адрес в ключе снова стал тем, что прислал клиент.
Сертификат — единственная часть выкладки, которую вы не пишете и почти не настраиваете: Caddy получает его сам по ACME и сам продлевает. Нужны три вещи: публичное имя, A-запись этого имени на адрес сервера и доступный снаружи вход. Проверку центр проводит сам, постучавшись к вам по этому имени, и Caddy умеет обе её разновидности — по HTTP на порту 80 и по TLS на 443. Порт 80 нужен и после выпуска: с него уезжает редирект на HTTPS для всех, кто пришёл по привычке, причём редирект 308, а не 301, — при нём сохраняются и метод, и тело запроса. И HTTPS нужен не только ради шифрования: Chrome не поставит приложение с сайта без него, а localhost считается безопасным ровно для того, чтобы разработка обходилась без сертификата.
Посмотреть на выпуск можно и не на боевой машине — с одной оговоркой, о которой сразу после. В конфиге прогона два блока и высокие порты вместо 80 и 443 — на ноутбуке настоящие заняты не нами. Прогноз: сколько сертификатов возьмёт Caddy при первом старте и что он скажет про порт без TLS?
# Тот же каркас, что на боевой машине: блок www только редиректит на apex,
# весь контент живёт на одном хосте. Отличия локального запуска — три строки
# в глобальном блоке и высокие порты.
{
# на боевой машине сертификат выдаёт ACME по публичному имени; здесь —
# собственный CA Caddy, потому что в имя .localhost никакой ACME не поверит
local_certs
# корневой сертификат этого CA в системное хранилище НЕ ставим
skip_install_trust
admin off
http_port 3932
https_port 3933
}
www.jsp.localhost:3933 {
redir https://jsp.localhost{uri} permanent
}
jsp.localhost:3933 {
handle /api/* {
reverse_proxy 127.0.0.1:3931
}
handle {
reverse_proxy 127.0.0.1:3931
}
}{"level":"info","ts":<время>,"msg":"maxprocs: Leaving GOMAXPROCS=10: CPU quota undefined"}
{"level":"info","ts":<время>,"msg":"GOMEMLIMIT is updated","GOMEMLIMIT":15461882265,"previous":9223372036854775807}
{"level":"info","ts":<время>,"msg":"using config from file","file":"Caddyfile"}
{"level":"info","ts":<время>,"msg":"adapted config to JSON","adapter":"caddyfile"}
{"level":"warn","ts":<время>,"logger":"admin","msg":"admin endpoint disabled"}
{"level":"info","ts":<время>,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"<указатель>"}
{"level":"info","ts":<время>,"logger":"http.auto_https","msg":"server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS","server_name":"srv0","https_port":3933}
{"level":"info","ts":<время>,"logger":"http.auto_https","msg":"enabling automatic HTTP->HTTPS redirects","server_name":"srv0"}
{"level":"info","ts":<время>,"logger":"http","msg":"enabling HTTP/3 listener","addr":":3933"}
{"level":"info","ts":<время>,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
{"level":"warn","ts":<время>,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":3932"}
{"level":"warn","ts":<время>,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":3932"}
{"level":"info","ts":<время>,"logger":"http.log","msg":"server running","name":"remaining_auto_https_redirects","protocols":["h1","h2","h3"]}
{"level":"info","ts":<время>,"logger":"http","msg":"enabling automatic TLS certificate management","domains":["www.jsp.localhost","jsp.localhost"]}
{"level":"info","ts":<время>,"logger":"pki.ca.local","msg":"root certificate trust store installation disabled; unconfigured clients may show warnings","path":"storage:pki/authorities/local/root.crt"}
{"level":"info","ts":<время>,"msg":"autosaved config (load with --resume flag)","file":"<хранилище>/autosave.json"}
{"level":"info","ts":<время>,"msg":"serving initial configuration"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"acquiring lock","identifier":"jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"acquiring lock","identifier":"www.jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls","msg":"cleaning storage unit","storage":"FileStorage:<хранилище>"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"lock acquired","identifier":"jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"obtaining certificate","identifier":"jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"lock acquired","identifier":"www.jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"obtaining certificate","identifier":"www.jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls","msg":"finished cleaning storage units"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"jsp.localhost","issuer":"local"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"releasing lock","identifier":"jsp.localhost"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"www.jsp.localhost","issuer":"local"}
{"level":"info","ts":<время>,"logger":"tls.obtain","msg":"releasing lock","identifier":"www.jsp.localhost"}
{"level":"info","ts":<время>,"msg":"shutting down apps, then terminating","signal":"SIGTERM"}
{"level":"warn","ts":<время>,"msg":"exiting; byeee!! 👋","signal":"SIGTERM"}
{"level":"info","ts":<время>,"logger":"http","msg":"servers shutting down with eternal grace period"}
{"level":"info","ts":<время>,"msg":"shutdown complete","signal":"SIGTERM","exit_code":0}Сертификатов два — по одному на имя, включая то, которое только редиректит: чтобы ответить редиректом по HTTPS, сертификат нужен и ему. Дальше по журналу видно устройство входа. На порту HTTPS (здесь 3933, на боевой машине 443) поднят отдельный слушатель HTTP/3 и объявлены протоколы h1, h2, h3; на порту без TLS (3932 вместо 80) и h2, и h3 пропущены с пометкой «requires TLS» — согласовать версию там нечем. Выпуск идёт под замком на имя (acquiring lock, lock acquired) — два процесса с одним хранилищем не полезут за одним сертификатом вдвоём. А последние строки — остановка по сигналу: «servers shutting down with eternal grace period», то есть вход по умолчанию ждёт дослуживающие запросы без ограничения по времени. Что это значит для приложения и почему у него срок должен быть, разбирает «Остановка: перестать принимать, дослужить, закрыть».
Теперь оговорка. Издатель в этих строках — local: сертификат выписал собственный центр Caddy, потому что в имя .localhost никакой ACME не поверит, а публичного имени у ноутбука нет. На боевой машине те же строки печатаются с издателем ACME, и разница между ними одна — имя издателя. Поэтому прогон помечен как снятый руками: выпуск настоящего сертификата воспроизводится только там, где есть публичное имя.
| Что нужно для сертификата | Чего не будет без этого |
|---|---|
| A-запись имени на адрес сервера | центру некуда стучаться: попытки будут повторяться, а браузер — ругаться на сертификат |
| порты 80 и 443, открытые снаружи | проверять имя нечем: обе проверки идут снаружи, а с закрытым 80 теряется ещё и редирект с http |
| имя в конфиге, а не адрес | на голый адрес Caddy выпишет сертификат своего внутреннего центра, а не публичного, и браузер ему не поверит |
том под хранилище /data | каждое пересоздание контейнера — новый выпуск, а повторные выпуски упираются в ограничения центра |
И про версии протокола, раз журнал их назвал. Наружу вход отдаёт h2 и h3 — в compose для этого опубликованы и 443/tcp, и 443/udp, — а до приложения запрос доезжает по HTTP/1.1; это видно в прогоне с двумя входами одного прокси. Поэтому ваш сервер из главы 4, знающий только HTTP/1.1, остаётся честной моделью того, что происходит в контейнере.
Том с сертификатами стоит в compose не для порядка: потеряли /data — потеряли выпущенное, а новые выпуски упираются в ограничения центра. Теряют этот том чаще всего вместе с «давайте пересоздадим всё с нуля».
Осталось место, где Caddy работает не прокси, а файловым сервером: путь /app с веб-версией мобильного приложения. Здесь handle_path срезает префикс, file_server читает диск, а рядом с каждым текстовым файлом на этапе сборки образа положены .br и .gz — precompressed отдаёт готовый вариант по Accept-Encoding клиента и не тратит процессор на сжатие каждого ответа.
И ровно здесь живёт ловушка, из-за которой у хешированных имён и появилась отдельная ветка. Заголовок кеша выставляется по адресу запроса, то есть до того, как выяснилось, есть ли такой файл. Прогноз: что получит браузер, попросив чанк, которого на диске уже нет, если на все адреса стоит один фолбэк на оболочку?
Пропавший чанк с общим фолбэком получает 200, тело index.html и заголовок «неизменяемо, год» — по адресу, который кончается на .js. Браузер падает на Unexpected token '<' и держит этот ответ год, а кеш service worker кладёт его к себе, потому что ответ успешный. Приложение после этого не загружается ни у кого, кто попал в окно выката, и чинится только сменой адреса. Отдельная ветка без фолбэка отвечает 404 — и это правильный ответ: отсутствующий файл остаётся отсутствующим.
Отсюда две ветки в конфиге вместо одной: у хешированных имён (/_expo и /assets) своя, с годовым immutable и без try_files, у всего остального — no-cache и фолбэк на оболочку, потому что адрес страницы и должен отдавать оболочку. А handle_errors снимает кеш с любой ошибки: 404, закешированный на год, — это файл, который появился через секунду после выката и до которого браузер больше не пошёл.
Вход собран: одна дверь, граница по путям, сертификат, честный адрес клиента и заголовки кеша, которые не врут. Чего у него ещё нет — ответа на вопрос «а почему 502?». За этим следующая статья, где журнал прокси и журнал приложения читаются вместе.
- Наружу опубликован только прокси: у приложения нет строки
portsвовсе, и это половина защиты ограничителя, а не деталь конфига. - Граница между контейнерами у автора идёт по путям одного домена:
/apiи/trpc— в приложение,/uploads— в хранилище,/app— файлы с диска, остальное — в контейнер с учебником. Второе имя только редиректит на apex, а порядок блоков в конфиге не порядок проверки: побеждает самый длинный путь. - До приложения едет не тот запрос, что прислал клиент: адрес, схема и хост приезжают в
X-Forwarded-*, и по умолчанию Caddy затирает в них клиентские выдумки. Абсолютные адреса приложение всё равно собирает из настройки, а не из запроса. request.ip— не факт, а следствие настройки:trueберёт выдумку клиента, число наfastify 5.12.4не читает цепочку вовсе, и только адрес прокси строкой даёт тот адрес, который дописал доверенный прыжок.- Сертификат берёт и продлевает вход: нужны публичное имя, A-запись и открытый порт 80, а хранилище — том, который нельзя терять. Наружу идут h2 и h3, до приложения — HTTP/1.1.
- Заголовок кеша ставится по адресу до проверки файла, поэтому у хешированных имён своя ветка без фолбэка, а с ошибок кеш снимается всегда.
1/4Клиент прислал X-Forwarded-For: 1.2.3.4, перед Caddy никого нет, trusted_proxies не настроен. Что увидит приложение?
2/4В приложении на fastify 5.12.4 стоит trustProxy: 1. Что окажется в request.ip у запроса, пришедшего через прокси?
3/4В конфиге handle без матчера написан первым, а handle /api/* — последним. Куда уедет запрос /api/health?
4/4Почему у хешированных имён под /app своя ветка без try_files?
Адрес, по которому считает ограничитель
Напишите адресКлиента: она получает цепочку X-Forwarded-For строкой, адрес соединения и список адресов своего прокси, а возвращает адрес, по которому ограничитель будет вести счёт. Доверять можно одному прыжку — тому, кто соединился с сервером, — и только если этот адрес есть в списке. Список случаев и печать не трогайте.
Проверок две, и первая — про соединение, а не про заголовок. Если соединился не наш прокси, цепочку читать нельзя вовсе: её целиком написал тот, кто пришёл. Если наш — доверяем ровно одному прыжку, то есть берём последний элемент цепочки: его дописал прокси, а всё, что левее, приехало снизу.
Меняются только первые две строки вывода: подделка слева отброшена, в ключ идёт 203.0.113.9. Третий случай — прокси не дописал ничего (так не бывает, но падать на этом нельзя), поэтому filter(Boolean) и запасной вариант. Последние две строки не меняются, и это не недоработка: запрос пришёл мимо прокси, и его собственный адрес — единственное, чему тут можно верить. Обратите внимание, что от подделки в пятом случае защищает не эта функция, а отсутствие опубликованного порта у приложения.
Правило для фотографий
Допишите в список правил два: корень бакета (/uploads/ ровно) наружу отдавать нельзя — это запрос листинга; объект под тем же префиксом уезжает в хранилище, и раз его имя неизменяемо, ответ можно кешировать на год. Правило с флагом точный сравнивает путь целиком, остальные — по началу; функцию решить и список путей не трогайте.
Оба правила про один и тот же префикс, поэтому сортировка их не разведёт: у них одинаковая длина пути, а sort устойчив и сохраняет порядок объявления. Значит, точное правило пишется первым — иначе объектное поймает и корень.
В боевом конфиге это те же два правила, только матчерами: @bucketRoot path /uploads/ с respond 404 стоит выше reverse_proxy minio:9000, а годовой immutable висит на @bucketObject not path /uploads/. Второй матчер нужен ровно затем, чтобы 404 корня не уехал клиенту с пометкой «неизменяемо, год».
Ссылка в письме из-за прокси
Ссылку на сброс пароля собирает приложение, которое стоит за прокси: ни своей схемы, ни своего имени оно не знает. Сейчас адрес собирается из запроса. Перепишите ссылкаСброса так, чтобы адрес в письме не зависел ни от одного заголовка.
Из запроса тут верить нельзя ничему: x-forwarded-proto зависит от настройки доверия на входе, а host приложение проверить не может — оно не знает, отсекает ли прокси чужое имя. У автора отсекает, потому что имя названо в конфиге; у входа с матчером-звёздочкой или при прямом доступе к порту приложения — нет. Поэтому базовый адрес берётся из настройки, а аргумент запрос в теле больше не нужен.
До правки вывод показывает обе беды сразу: во второй строке схема съехала на http, потому что доверенного заголовка не было, а в третьей письмо уводит на evil.example — достаточно, чтобы запрос с таким Host доехал до приложения. Письмо со ссылкой сброса — это пароль, который сервер сам разослал по почте, и адрес в нём подделывать нельзя тем более. В API автора база берётся из переменной APP_URL.