# ═══════════════════════════════════════════════════════════════════════════ # 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-переменной окружения на хосте) — по # одному значению на хост, меняется руками один раз при переезде, не через # код. # ═══════════════════════════════════════════════════════════════════════════ # 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 { # #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: `-` = # 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 { # См. комментарий над /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 { # Тот же периметр-скраб, что у @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` во # фронте — обычный , потому что 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 на # «/», и запрос закольцуется. @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business handle @meraPages { rewrite * /trade-in/mera-public{path} reverse_proxy tradein-frontend:3000 { header_up -X-Authenticated-User } } # Тот же адрес со слэшем на конце → 301 на канонический вид без слэша. # Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер # `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы # голый 404 (так было и до этого PR, с момента #2615). Заодно это # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий # со слэшем и здесь нормализуются. @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$ handle @meraShortSlash { redir * /{re.shortslash.1} 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-.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 { 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 { 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 }