Коммит выше свёл окно подмены фронта с 30–90 с до его собственных ~0,5 с. Эти полсекунды по-прежнему видны посетителю: Caddy набирает апстрим сразу и при отказе соединения отдаёт ошибку без повтора (замер на стенде: 1 × 502). (tradein_frontend_retry) — lb_try_duration 2s + lb_try_interval 100ms, импортируется во все 9 блоков `reverse_proxy tradein-frontend:3000` (gendsgn.ru/trade-in/* и публичные пути meraocenka.ru). В #3274 lb_try_duration отвергнут — и правильно: держать посетителя минуту в ожидании ретрая хуже честной ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Возражение снято ПОРЯДКОМ, а не спором: окно сначала сведено к сотням миллисекунд правкой деплоя, и только после этого ретрай применим. Стенд (реальный образ фронта + Caddy 2, подмена под пробой 5 раз/с): без ретрая 1 × 502, с ретраем 241/241 × 200, один запрос подождал 0,55 с. Потолок измерен там же: при длительной аварии каждый запрос ждёт 2,07 с и получает ту же 503-заглушку, что и раньше. Бэкенду ретрай НАМЕРЕННО не дан: его старт — единицы секунд, 2 с не хватит, а больше — уже та самая плата ожиданием, против которой возражал issue. Гейт scripts/check-frontend-swap-window.py держит обе половины: `frontend` не должен вернуться в общий SERVICES, каждый апстрим фронта обязан нести импорт. Обе половины обратимы молча — деплой остаётся зелёным, а отказ видно только непрерывной пробой во время деплоя. Фальсифицирован: снятие любой половины красит гейт (проверено на реальных файлах, не только --selftest). Refs #3274
765 lines
57 KiB
Text
765 lines
57 KiB
Text
# ═══════════════════════════════════════════════════════════════════════════
|
||
# caddy/sites/apps.caddy — домены Site Finder / МЕРА, переезжающие на Selectel
|
||
#
|
||
# Переезд GenDesign с Beget (46.173.16.127) на Selectel Poincare
|
||
# (188.124.37.140), окно 30.08.2026. Пять доменов из этого файла переезжают
|
||
# на новый VPS, три инфраструктурных (obsidian/errors/git.gendsgn.ru,
|
||
# см. caddy/sites/infra.caddy) остаются на Beget.
|
||
#
|
||
# Домены в этом файле (после переезда — на Selectel):
|
||
# - gendsgn.ru — Site Finder + Trade-In, основной прод-домен
|
||
# - www.gendsgn.ru — 301 на apex (канонический URL)
|
||
# - meraocenka.ru — публичный лендинг МЕРА B2C (канонический домен)
|
||
# - merahome.ru — 301 на meraocenka.ru (домен-спутник)
|
||
# - meraotsenka.ru — 301 на meraocenka.ru (домен-спутник, альт. транслит)
|
||
# - www.mera*.ru — 301 на meraocenka.ru (все три www-формы)
|
||
#
|
||
# ПОЧЕМУ ЭТОТ ФАЙЛ ВООБЩЕ СУЩЕСТВУЕТ (не один Caddyfile на оба хоста).
|
||
# Деплой делает git reset --hard на origin/main + `caddy reload` — на прод
|
||
# приезжает ВЕСЬ репозиторий, включая site-блоки для доменов, которых на этом
|
||
# хосте физически нет. Если бы этот файл жил как
|
||
# site-блоки прямо в корневом Caddyfile, Caddy на Selectel пытался бы
|
||
# выпускать сертификаты ACME для obsidian/errors/git.gendsgn.ru (DNS которых
|
||
# указывает на Beget) — challenge недостижим, попытки ретраятся с backoff и
|
||
# могут упереться в rate limit Let's Encrypt (5 failed validations/hostname/
|
||
# hour). Симметрично на Beget после переезда — попытки для gendsgn.ru и
|
||
# остальных доменов из этого файла. Разделение на apps.caddy / infra.caddy +
|
||
# переменная CADDY_SITES убирает эту проблему: каждый хост импортирует
|
||
# ТОЛЬКО свой набор доменов.
|
||
#
|
||
# КАК ПЕРЕКЛЮЧАЕТСЯ. Корневой Caddyfile содержит
|
||
# `import caddy/sites/{$CADDY_SITES:*}.caddy` — Caddy подставляет переменные
|
||
# окружения в текст Caddyfile ДО парсинга (включая пути import), поэтому
|
||
# значение CADDY_SITES определяет, какой файл(ы) реально импортируются:
|
||
# - CADDY_SITES не задан (default `*`) → import caddy/sites/*.caddy →
|
||
# ОБА файла, apps.caddy И infra.caddy — текущее поведение Beget ДО
|
||
# переезда, когда один VPS обслуживал все восемь доменов.
|
||
# - CADDY_SITES=apps (Selectel, после cutover) → только этот файл.
|
||
# - CADDY_SITES=infra (Beget, после cutover) → только infra.caddy.
|
||
# Переменная задаётся в environment секции caddy-сервиса в
|
||
# docker-compose.prod.yml (или runtime-переменной окружения на хосте) — по
|
||
# одному значению на хост, меняется руками один раз при переезде, не через
|
||
# код.
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
# ── (tradein_frontend_retry) — подмена контейнера фронта без 502 (#3274) ─────
|
||
#
|
||
# ЧТО ЭТО. Ретрай ПОДКЛЮЧЕНИЯ к tradein-frontend: пока идёт подмена
|
||
# контейнера, Caddy не отдаёт ошибку сразу, а до 2 с переспрашивает апстрим с
|
||
# шагом 100 мс. Импортируется ВНУТРЬ каждого `reverse_proxy tradein-frontend`
|
||
# в этом файле (9 блоков: gendsgn.ru/trade-in/* и все публичные пути
|
||
# meraocenka.ru).
|
||
#
|
||
# ПОЧЕМУ ЭТО НЕ ТО, ЧТО ОТВЕРГНУТО В #3274. Там `lb_try_duration` отвергнут
|
||
# для окна 30–90 с — держать посетителя минуту в ожидании хуже честной
|
||
# ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Правка в
|
||
# deploy-tradein.yml (frontend вынесен из общего `up -d`) СНАЧАЛА сводит окно
|
||
# к этим сотням миллисекунд, и только после этого ретрай становится
|
||
# применим. Числа, на которых это стоит (замеры 11.09):
|
||
# - прод, ОДИН сервис в `up -d`: контейнер создан 16:42:17.5 → запущен
|
||
# 16:42:18.0 — 0,5 с;
|
||
# - прод, ПАЧКА сервисов в одном `up -d`: создан 15:01:40 → запущен
|
||
# 15:02:10 — 30 с (фаза start ждёт готовности зависимостей, а старый
|
||
# контейнер снесён ещё в фазе create);
|
||
# - стенд (реальный образ фронта + Caddy 2), одиночная подмена: без ретрая
|
||
# 1 × 502, с ретраем 241/241 × 200, один запрос подождал 0,55 с.
|
||
#
|
||
# ПОТОЛОК. 2 с — это ЦЕНА ОЖИДАНИЯ при настоящей аварии: если фронт лежит
|
||
# долго, каждый запрос сначала висит 2 с и лишь потом получает 503-заглушку
|
||
# (../deploy-window.caddy.snippet). Поэтому не «побольше на всякий случай»:
|
||
# запас над измеренными 0,5 с четырёхкратный, дальше растёт только вред.
|
||
# Бэкенду (tradein-backend) этот приём НАМЕРЕННО не дан: его старт — единицы
|
||
# секунд (uvicorn + подключение к БД), там 2 с не хватит, а больше — уже тот
|
||
# самый вред. Его окно закрывается отдельно, не этой правкой.
|
||
(tradein_frontend_retry) {
|
||
lb_try_duration 2s
|
||
lb_try_interval 100ms
|
||
}
|
||
|
||
# Caddy config.
|
||
#
|
||
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
|
||
# - www.gendsgn.ru — 301 redirect to apex (canonical URL).
|
||
# - :80 — fallback for raw IP access. Closed by same auth gate (pilot phase).
|
||
#
|
||
# `handle /api/*` (NOT `handle_path`) — we keep the /api prefix because
|
||
# the FastAPI router is mounted at /api/v1/*.
|
||
#
|
||
# Auth gate: basic_auth for pilot phase (2026-05-23).
|
||
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
|
||
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
|
||
#
|
||
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
|
||
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
|
||
# import'а — у trade-in своя авторизация (форма входа + opaque session-cookie,
|
||
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
|
||
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
|
||
# него — НЕ трогать caddy/users.caddy.snippet).
|
||
#
|
||
# IMPORTANT: route { } block is required to preserve directive order.
|
||
# Without route { }, Caddy executes directives in hard-coded default order
|
||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||
# ineffective. With route { }, handlers are matched top-to-bottom as written.
|
||
|
||
# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw
|
||
# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла
|
||
# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention
|
||
# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды.
|
||
# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401
|
||
# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает).
|
||
# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip.
|
||
|
||
gendsgn.ru {
|
||
encode zstd gzip
|
||
|
||
log {
|
||
output file /var/log/caddy/gendsgn.ru.log {
|
||
roll_size 50MiB
|
||
roll_keep 5
|
||
roll_keep_for 720h
|
||
}
|
||
format json
|
||
}
|
||
|
||
# Отдельный лог только для auth-событий.
|
||
# Forwarder (ops/glitchtip-auth-forwarder) читает именно этот файл.
|
||
# Retention 7 дней (меньше чем main log) — содержит plain Base64 credentials.
|
||
log auth_audit {
|
||
output file /var/log/caddy/auth_audit.log {
|
||
roll_size 10MiB
|
||
roll_keep 3
|
||
roll_keep_for 168h
|
||
}
|
||
format json
|
||
}
|
||
|
||
route {
|
||
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
|
||
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
||
# /health и /api/*, остальное забирает фронт, у которого такого
|
||
# маршрута нет. Но в бэкенде путь ОТКРЫТ без авторизации — иначе
|
||
# агент Alloy изнутри docker-сети получал бы 401 (см. комментарий у
|
||
# `_PUBLIC_PATHS` в backend/app/main.py). Одной строки `handle
|
||
# /metrics { reverse_proxy backend:8000 }`, добавленной когда-нибудь
|
||
# по невнимательности, хватит, чтобы выставить наружу внутреннее
|
||
# устройство продукта. Явный 404 делает закрытость решением, а не
|
||
# следствием текущего порядка директив, и стоит первым — route
|
||
# матчит сверху вниз и short-circuit'ит.
|
||
handle /metrics {
|
||
respond 404
|
||
}
|
||
|
||
# /health и /preview/* — public, без auth, short-circuit.
|
||
handle /health {
|
||
reverse_proxy backend:8000
|
||
}
|
||
|
||
# Static HTML mockups для review (audit alternatives).
|
||
# Public access — без auth (по запросу 2026-05-17).
|
||
handle_path /preview/* {
|
||
root * /srv/preview
|
||
file_server browse
|
||
}
|
||
|
||
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
||
# «денежного экрана» без бэкенда → axe/lighthouse гоняются без креды.
|
||
# Реальных клиентских данных нет (статичная фикстура) → безопасно публично.
|
||
# ДО auth-import: route матчит сверху вниз, handle short-circuit'ит.
|
||
# Без strip — Next.js basePath=/trade-in ждёт префикс в URL (как @tradein).
|
||
# ui-preview + его статика (_next/static — CSS/JS бандлы, без секретов).
|
||
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
||
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
||
handle @uipreview {
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
|
||
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
|
||
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
|
||
# (страница статична, у tradein-frontend нет секрета для
|
||
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
|
||
# а не полагаясь на то, что downstream ничего не делает с заголовком.
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
|
||
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
|
||
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
|
||
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
|
||
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
|
||
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
|
||
# как /health и /preview/* выше).
|
||
#
|
||
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
|
||
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
|
||
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
|
||
# никогда не резолвится авторизованным юзером на этом пути.
|
||
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
|
||
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
|
||
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
|
||
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
|
||
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
|
||
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
|
||
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
|
||
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
|
||
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
|
||
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
|
||
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
|
||
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
|
||
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
|
||
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
|
||
# delete) — корректное поведение не должно зависеть от того, как именно
|
||
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
|
||
# X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
|
||
# правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
|
||
# анонимный, до всякого логина trade-in.
|
||
#
|
||
# ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
|
||
# ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
|
||
# сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
|
||
# X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
|
||
# authenticated user»). А X-Authenticated-User на этом же хопе строкой
|
||
# ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
|
||
# может по построению, сравнение недостижимо. Второй потребитель
|
||
# секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
|
||
# берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
|
||
# правку не реагирует.
|
||
#
|
||
# ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
|
||
# в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
|
||
# Инжект работал в обратную сторону: раздавал внутренний секрет по
|
||
# анонимному пути (он же виден в env контейнера Caddy). Легитимный
|
||
# dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
|
||
# -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
|
||
# см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
|
||
handle /trade-in/api/* {
|
||
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
||
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
||
# префикса basePath /trade-in (Next.js basePath leak).
|
||
uri strip_prefix /trade-in
|
||
reverse_proxy tradein-backend:8000 {
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов».
|
||
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
||
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
|
||
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
|
||
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
|
||
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
|
||
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
|
||
# бэке; без валидной сессии юзер получит редирект на /login, а не
|
||
# контент. Смысл переноса — не открыть страницу всем, а убрать
|
||
# несогласованность: короткий URL не должен быть строже (Caddy
|
||
# basic_auth) целевого адреса, к которому и так уже нет
|
||
# basic_auth-барьера (только собственный login trade-in).
|
||
#
|
||
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
|
||
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
|
||
# показывается (deny в auth/roles.yaml для pilot и analyst + в
|
||
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
|
||
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
|
||
# адрес приведёт на NoAccessScreen.
|
||
@saleshare path /sale-share /sale-share/
|
||
handle @saleshare {
|
||
redir /trade-in/sale-share permanent
|
||
}
|
||
|
||
# Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша),
|
||
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
|
||
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
|
||
@tradein path /trade-in /trade-in/*
|
||
handle @tradein {
|
||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
|
||
# причине здесь больше нет инжекта X-Internal-Auth-Secret
|
||
# (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
|
||
# Next-сервер tradein-frontend, в его коде заголовок не читается
|
||
# и дальше в backend не пробрасывается (SSR не форвардит
|
||
# входящие заголовки — `headers()` во фронте не используется).
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
|
||
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
|
||
import ../users.caddy.snippet
|
||
|
||
handle /api/* {
|
||
reverse_proxy backend:8000 {
|
||
header_up X-Authenticated-User {http.auth.user.id}
|
||
}
|
||
}
|
||
|
||
handle {
|
||
reverse_proxy frontend:3000 {
|
||
header_up X-Authenticated-User {http.auth.user.id}
|
||
}
|
||
}
|
||
}
|
||
|
||
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
|
||
# оставляет 30–90 с, в которые контейнера нет и посетитель видит голый 502
|
||
# (замер по access-логам: 23 × 502 на этом домене за 4 суток, все —
|
||
# на окнах деплоя). Снипет подменяет это на 503 + Retry-After и читаемое
|
||
# тело; разбор «что лечится, а что нет» — в самом снипете.
|
||
#
|
||
# ГЕЙТ ПО ПУТИ ОБЯЗАТЕЛЕН: `handle_errors` объявляется на весь site-блок,
|
||
# а этот блок обслуживает ещё и Site Finder («Птица») — его апстримы
|
||
# (backend, frontend) деплоятся отдельным пайплайном и в задачу не входят.
|
||
# Пути вне матчера не попадают ни в один вложенный handle, ошибка остаётся
|
||
# необработанной, и Caddy отдаёт ровно то, что отдавал раньше. Проверено на
|
||
# живом Caddy 2.11.3 (dead-upstream 127.0.0.1:9): `/` и `/api/v1/*` —
|
||
# прежний пустой 502, `/trade-in/*` — новый 503.
|
||
#
|
||
# Матчер здесь сверяется с ИСХОДНЫМ путём запроса, а не с переписанным:
|
||
# для error-маршрута Caddy восстанавливает запрос, каким он пришёл. Поэтому
|
||
# `/trade-in/api/*` внутри снипета матчится, хотя на основном маршруте до
|
||
# падения апстрима уже отработал `uri strip_prefix /trade-in`. Тоже
|
||
# проверено на стенде, а не выведено из документации.
|
||
handle_errors 502 503 504 {
|
||
@tradeinScope path /trade-in /trade-in/*
|
||
handle @tradeinScope {
|
||
import ../deploy-window.caddy.snippet
|
||
}
|
||
}
|
||
}
|
||
|
||
www.gendsgn.ru {
|
||
redir https://gendsgn.ru{uri} permanent
|
||
}
|
||
|
||
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
|
||
#
|
||
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
|
||
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
|
||
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
|
||
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
|
||
# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов
|
||
# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два
|
||
# handle (корень "/" + статика Next _next/*), всё остальное — финальный
|
||
# catch-all `handle { respond 404 }`. Регресс-тест на эту модель:
|
||
# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не
|
||
# 200/401 — т.е. не был случайно проброшен).
|
||
#
|
||
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
|
||
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
|
||
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
|
||
# на internal-путь /trade-in/mera-public (страница-заглушка,
|
||
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
|
||
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
|
||
# это не HTTP-редирект браузера.
|
||
#
|
||
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
|
||
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
|
||
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
|
||
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
|
||
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
|
||
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
|
||
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
|
||
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
|
||
# автоматический HTTPS в Caddy изолирован per-hostname. Повторные неудачные попытки ДО
|
||
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
|
||
# validations/hostname/hour) — не критично, просто подождать; `docker volume
|
||
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
|
||
meraocenka.ru {
|
||
encode zstd gzip
|
||
|
||
log {
|
||
output file /var/log/caddy/meraocenka.ru.log
|
||
}
|
||
|
||
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
|
||
# финальный `handle { respond 404 }`, так что путь и без этой строки не
|
||
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
||
# Alloy внутри docker-сети (см. `_PUBLIC_PATHS` в app/core/rbac.py).
|
||
# Явный отказ на публичном домене делает закрытость решением, а не
|
||
# следствием того, что список пока никто не расширил.
|
||
handle /metrics {
|
||
respond 404
|
||
}
|
||
|
||
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
|
||
# полноценную страницу). rewrite добавляет basePath-префикс только для
|
||
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
|
||
handle / {
|
||
rewrite * /trade-in/mera-public
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
|
||
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
|
||
# тем более может прислать свой X-Authenticated-User. Сейчас
|
||
# инертно (лэндинг статичен, backend-вызовов нет), но снимаем
|
||
# ради единообразия периметра, а не полагаясь на то, что
|
||
# downstream ничего не делает с заголовком — иначе на этапе 5,
|
||
# когда откроется публичный /estimate, это станет дырой.
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
|
||
# юридических документов (оферта ссылается на meraocenka.ru/refund,
|
||
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
|
||
# поэтому обязаны резолвиться сами по себе.
|
||
#
|
||
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
|
||
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
|
||
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
|
||
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
|
||
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
|
||
# фронте — обычный <a>, потому что next/link подставляет basePath).
|
||
#
|
||
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
|
||
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
|
||
# который в ней указан. Каноничность для поисковиков задана отдельно, через
|
||
# `alternates.canonical` на каждой странице.
|
||
#
|
||
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
|
||
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
|
||
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
|
||
# (и проверка в scripts/smoke-mera-perimeter.sh).
|
||
#
|
||
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
|
||
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
|
||
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
|
||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||
# «/», и запрос закольцуется.
|
||
# /sitemap.xml дописан той же строкой, а не отдельным handle (как
|
||
# robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml
|
||
# (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает
|
||
# rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы:
|
||
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
||
# robots.txt существует только в корне app/, не под mera-public/), эта
|
||
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
|
||
handle @meraPages {
|
||
rewrite * /trade-in/mera-public{path}
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# Тот же адрес со слэшем на конце → 301 на канонический вид без слэша.
|
||
# Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер
|
||
# `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы
|
||
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
||
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
||
# со слэшем и здесь нормализуются.
|
||
# БАГ, найденный замером на живом проде 10.09.2026:
|
||
# https://meraocenka.ru/articles/?utm_source=vc -> Location:
|
||
# https://meraocenka.ru/articles — query терялся, потому что
|
||
# `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата
|
||
# пути и не переносит исходную query-строку. Вред тихий: статьи
|
||
# публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают
|
||
# слэш к скопированной ссылке — то есть именно трафик по опубликованному
|
||
# посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не
|
||
# туда), ошибки никто не видел.
|
||
#
|
||
# Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri
|
||
# strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен
|
||
# матчером до канонического короткого вида), `redir * {uri} permanent`
|
||
# переносит query как есть. Обёртка `route` обязательна по той же
|
||
# причине, что у соседей — внутри `handle` порядок директив определяет
|
||
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
|
||
handle @meraShortSlash {
|
||
route {
|
||
uri strip_suffix /
|
||
redir * {uri} permanent
|
||
}
|
||
}
|
||
|
||
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
|
||
# дизайна стоит в корне, каталог `v3/` во фронте удалён, и проксировать
|
||
# сюда нечего — Next отдал бы 404. Но адрес жил на публичном домене больше
|
||
# двух недель: владелец сравнивал по нему версии, и ссылка почти наверняка
|
||
# осталась в закладках и в переписке. Поэтому 301 на корень, а не молчание
|
||
# белого списка: человек по старой ссылке попадает ровно на то, что по ней
|
||
# и смотрел, только теперь это главная.
|
||
#
|
||
# ЧЕТЫРЕ ФОРМЫ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным
|
||
# `/trade-in/mera-public/v3` — вторая пара до 29.08.2026 жила в
|
||
# @meraLongPages и оттуда выбыла вместе со страницей. Короткие формы её
|
||
# не подхватывают (матчер `path` требует точного совпадения), поэтому
|
||
# длинный адрес превью падал в catch-all 404 — тот же класс дефекта,
|
||
# ради которого длинные адреса вообще оставлены живыми. Все четыре формы
|
||
# ведут в один корень, поэтому и матчер один.
|
||
#
|
||
# QUERY ПЕРЕНОСИТСЯ — `redir * {uri}`, как в @meraLongPages, а не
|
||
# `redir * /`, как было здесь до этой правки. Причина ровно та же, что
|
||
# записана у @meraLongPages: по разосланной ссылке приходят с UTM-метками,
|
||
# и `/v3?utm=…` не должен терять атрибуцию на редиректе. Оба
|
||
# `uri strip_prefix` срезают путь целиком (для `/v3` — до пустого, для
|
||
# `/v3/` — до `/`), а `{uri}` при пустом пути даёт `/`, поэтому запрос без
|
||
# параметров отдаёт чистый `Location: /` без хвоста `?`. Обёртка `route`
|
||
# обязательна по той же причине, что у @meraLongPages: внутри `handle`
|
||
# порядок директив определяет Caddy, и без неё `redir` выполнится раньше
|
||
# `uri`. Проверено на живом Caddy 2.11 (прод — `caddy:2`): все четыре формы
|
||
# с параметрами и без дают 301 на `/` с сохранённой query, а лишний
|
||
# сегмент (`/trade-in/mera-public/v3/evil`) по-прежнему падает в 404.
|
||
@meraV3Gone path /v3 /v3/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
|
||
handle @meraV3Gone {
|
||
route {
|
||
uri strip_prefix /trade-in/mera-public
|
||
uri strip_prefix /v3
|
||
redir * {uri} permanent
|
||
}
|
||
}
|
||
|
||
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
|
||
# страницы, а не два работающих.
|
||
#
|
||
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
|
||
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
|
||
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
|
||
# канонический адрес один.
|
||
#
|
||
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
|
||
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
|
||
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
|
||
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
|
||
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
|
||
#
|
||
# `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он
|
||
# начинается со слэша, Caddy разбирает как inline path-matcher — то есть
|
||
# `redir / permanent` означает «для пути / редиректить на permanent», а не
|
||
# «редиректить на /». Проверено на живом Caddy: без `*` длинные адреса
|
||
# отдавали пустой 200 (матчер не совпадал, директива не срабатывала, тело
|
||
# пустое) — хуже, чем 404, потому что выглядит как рабочая пустая страница.
|
||
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
|
||
handle @meraLongRoot {
|
||
redir * / permanent
|
||
}
|
||
|
||
# Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы
|
||
# (со слэшем на конце и без) — не шаблоном и не регекспом.
|
||
#
|
||
# ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант
|
||
# `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый
|
||
# редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
|
||
# `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`,
|
||
# а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это
|
||
# готовая фишинговая заготовка с домена, который напечатан внутри оферты и
|
||
# уходит модератору эквайера. Проверено на живом Caddy, воспроизводится.
|
||
# С поимённым списком такой путь просто не матчится и падает в 404 ниже.
|
||
#
|
||
# ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке.
|
||
# `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками
|
||
# после редиректа не теряют атрибуцию. Обёртка `route` обязательна —
|
||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||
# (бесконечный цикл; поймано на локальном стенде).
|
||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||
handle @meraLongPages {
|
||
route {
|
||
uri strip_prefix /trade-in/mera-public
|
||
redir * {uri} permanent
|
||
}
|
||
}
|
||
|
||
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
|
||
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
|
||
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
|
||
#
|
||
# Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview
|
||
# (:78), который в проде доказал, что этого хватает для рендера. Широкий
|
||
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
|
||
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
|
||
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
|
||
#
|
||
# #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
|
||
# App Router раскладывает код постранично: `chunks/app/<маршрут>/page-<hash>.js`
|
||
# (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
|
||
# `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
|
||
# и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
|
||
# это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
|
||
# /sale-share, /login, /ui-preview: страницы закрытого контура, которые
|
||
# аноним с публичного домена качал целиком и читал в них имена внутренних
|
||
# ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
|
||
# `not` вместо негативного lookahead, RE2 его не поддерживает.
|
||
#
|
||
# `_not-found` в исключениях — это не маршрут продукта, а внутренняя
|
||
# страница Next (404 при клиентской навигации по битому href). Закрытого
|
||
# кода не несёт, а без исключения клиентский переход падал бы в
|
||
# error-boundary вместо честного «не найдено».
|
||
#
|
||
# Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
|
||
# лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
|
||
# маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
|
||
# без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
|
||
# код», и городить второй матчер ради него смысла нет. Полное разделение —
|
||
# split build (отдельный public-бандл), отдельная задача.
|
||
#
|
||
# `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
|
||
# директив Caddy), поэтому вложенный матчер не зависит от сортировки
|
||
# handle-блоков между собой.
|
||
handle /trade-in/_next/static/* {
|
||
@foreignRouteChunk {
|
||
path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
|
||
not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
|
||
}
|
||
respond @foreignRouteChunk 404
|
||
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# #2631: favicon — единственный корневой статик, который браузер запрашивает
|
||
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
|
||
# по корневому пути через basePath /trade-in.
|
||
handle /favicon.ico {
|
||
rewrite * /trade-in/favicon.ico
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next
|
||
# robots.txt — конвенция корня app/ (src/app/robots.ts), а не
|
||
# mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не
|
||
# обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу
|
||
# favicon.ico прямо над этим блоком, включая тот же периметр-скраб
|
||
# заголовка.
|
||
handle /robots.txt {
|
||
rewrite * /trade-in/robots.txt
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# og-mera.png — картинка превью ссылок (og:image). Её просят НЕ браузеры
|
||
# посетителей, а обходчики мессенджеров и соцсетей (Telegram, VK,
|
||
# WhatsApp), причём анонимно и без Referer: если путь не открыт, ссылка на
|
||
# сайт разворачивается без картинки, и понять это по логам приложения
|
||
# нельзя — запрос просто не доходит.
|
||
#
|
||
# Отдельный handle по той же причине, что у robots.txt: файл лежит в
|
||
# `public/` фронта и раздаётся Next'ом по basePath-корню
|
||
# (/trade-in/og-mera.png), а не под /trade-in/mera-public{path}, куда
|
||
# рерайтит @meraPages. Адрес намеренно короткий: он попадает в разметку
|
||
# каждой публичной страницы и расходится по чужим кэшам, так что менять
|
||
# его потом дорого.
|
||
handle /og-mera.png {
|
||
rewrite * /trade-in/og-mera.png
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# logo-mera.png — логотип организации из микроразметки (Organization.logo).
|
||
# Отдельный файл от og-mera.png выше: превью ссылки широкое, а логотип
|
||
# поисковик обрезает близко к квадрату. Свой handle по той же причине —
|
||
# файл лежит в `public/` и раздаётся по basePath-корню.
|
||
handle /logo-mera.png {
|
||
rewrite * /trade-in/logo-mera.png
|
||
reverse_proxy tradein-frontend:3000 {
|
||
import tradein_frontend_retry
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до
|
||
# бэкенда. Под /api/public/ по определению не лежит ничего закрытого:
|
||
# гарантию даёт структура пакета app/api/public/, а не аккуратность этого
|
||
# матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий:
|
||
# /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже.
|
||
#
|
||
# ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*.
|
||
# Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же
|
||
# страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru
|
||
# корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь
|
||
# потребовал бы там ВТОРОГО handle, выигрывающего у существующего по
|
||
# специфичности — то есть работоспособность публичной формы зависела бы от
|
||
# порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in
|
||
# запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь
|
||
# нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools
|
||
# публичного домена; он там и так виден на всех чанках Next (basePath).
|
||
#
|
||
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
|
||
# часть маршрута FastAPI.
|
||
#
|
||
# X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
|
||
# проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
|
||
# расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
|
||
# gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
|
||
# заголовок нужен только внутрисетевому dual-mode трафику, который ходит
|
||
# мимо прокси и несёт его сам.
|
||
handle /trade-in/api/public/* {
|
||
uri strip_prefix /trade-in
|
||
reverse_proxy tradein-backend:8000 {
|
||
header_up -X-Authenticated-User
|
||
}
|
||
}
|
||
|
||
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
||
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
|
||
handle {
|
||
respond 404
|
||
}
|
||
|
||
# Окно деплоя (#3274). В отличие от gendsgn.ru гейт по пути не нужен: все
|
||
# апстримы этого блока — контейнеры МЕРЫ (tradein-frontend/tradein-backend),
|
||
# чей деплой и создаёт окно. 15 × 502 за 4 суток, все на окнах мержа.
|
||
#
|
||
# Белый список выше это НЕ ослабляет. Во-первых, `handle_errors`
|
||
# срабатывает только на перечисленные статусы, а отказ белого списка —
|
||
# 404. Во-вторых, `respond 404` пишет ответ напрямую и ошибкой маршрута
|
||
# вообще не является. Проверено на стенде: при мёртвом апстриме `/admin`
|
||
# по-прежнему отдаёт 404, а не страницу обновления.
|
||
handle_errors 502 503 504 {
|
||
import ../deploy-window.caddy.snippet
|
||
}
|
||
}
|
||
|
||
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
|
||
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
|
||
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
|
||
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
|
||
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
|
||
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
|
||
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
|
||
# лучше завести записи сразу).
|
||
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
|
||
merahome.ru {
|
||
redir https://meraocenka.ru{uri} permanent
|
||
}
|
||
|
||
meraotsenka.ru {
|
||
redir https://meraocenka.ru{uri} permanent
|
||
}
|
||
|
||
# www-формы всех трёх доменов МЕРА → 301 на канонический apex.
|
||
#
|
||
# ЗАЧЕМ ОТДЕЛЬНЫЕ БЛОКИ. A-записи `www.*` заведены и указывают на прод, но
|
||
# site-блоков под них не было — Caddy матчит строго по имени хоста и на
|
||
# неизвестное имя сертификата не выпускает. Клиент, набравший привычное
|
||
# «www.», получал не редирект, а обрыв TLS-рукопожатия (alert 80): для
|
||
# браузера это «сайт не открывается», без единой строки в логах доступа.
|
||
# Найдено сквозным аудитом периметра 27.08; www.gendsgn.ru такой блок имел
|
||
# с самого начала (выше) и потому работал — здесь ровно тот же приём.
|
||
#
|
||
# `{uri}` сохраняет путь и query: ссылка с визитки не теряет ?id=.
|
||
www.meraocenka.ru {
|
||
redir https://meraocenka.ru{uri} permanent
|
||
}
|
||
|
||
www.merahome.ru {
|
||
redir https://meraocenka.ru{uri} permanent
|
||
}
|
||
|
||
www.meraotsenka.ru {
|
||
redir https://meraocenka.ru{uri} permanent
|
||
}
|