From 60cb510ef231b2c86542f69702ef7649b47ee21c Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 23 Aug 2026 21:52:48 +0000 Subject: [PATCH] =?UTF-8?q?refactor(caddy):=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D1=82=D1=8C=20site-=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=85=D0=BE=D1=81=D1=82=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D0=B7=D0=B4?= =?UTF-8?q?=20(#3062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Caddyfile | 456 +---------------- caddy/sites/apps.caddy | 475 ++++++++++++++++++ caddy/sites/infra.caddy | 64 +++ docker-compose.prod.yml | 9 + .../__tests__/public-perimeter.test.ts | 21 +- .../__tests__/RouteGuard.publicPaths.test.ts | 23 +- 6 files changed, 604 insertions(+), 444 deletions(-) create mode 100644 caddy/sites/apps.caddy create mode 100644 caddy/sites/infra.caddy diff --git a/Caddyfile b/Caddyfile index f5208996..7f99e71a 100644 --- a/Caddyfile +++ b/Caddyfile @@ -31,450 +31,24 @@ # (тег деградирует в "(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 { - # /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 НЕ трогаем — #2213-секрет всегда перезаписывается - # из env (Set-операция с непустым значением, никак не связана с auth-гейтом - # basic_auth), это единственное, что теперь отсекает подделку заголовков - # изнутри gendesign_shared network для legacy dual-mode пути. - 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 - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} - } - } - - # 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}). - header_up -X-Authenticated-User - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} - } - } - - # Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и - # handle {} ниже). Trade-In уже отработал и short-circuit'нул выше. - import caddy/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} - } - } - } -} - -www.gendsgn.ru { - redir https://gendsgn.ru{uri} permanent -} - -# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала). +# ── Site-блоки вынесены по хостам (#3059, переезд 30.08) ──────────────────── +# Раньше все восемь доменов жили прямо здесь. После разделения продуктов между +# двумя хостами это стало опасно: деплой синхронизирует рабочее дерево с +# origin/main и перечитывает конфиг, поэтому на Selectel приезжал бы файл +# целиком — и Caddy начинал бы выпускать сертификаты для obsidian/errors/git, +# чей DNS указывает на Beget. ACME падал бы на HTTP-01, с риском упереться в +# rate limit Let's Encrypt. # -# Архитектурное решение: отдельный домен, а НЕ дырка в блоке 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 — т.е. не был случайно проброшен). +# caddy/sites/apps.caddy gendsgn.ru, www, meraocenka, merahome, meraotsenka +# -> уезжают на Selectel +# caddy/sites/infra.caddy obsidian, errors, git +# -> остаются на Beget (Forgejo, GlitchTip, CouchDB) # -# 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 - } - - # Корень домена → лэндинг МЕРЫ (#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 на - # «/», и запрос закольцуется. - # `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная - # страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и - # ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент, - # когда вариант выберут и он станет корнем. - @meraPages path /estimate /oferta /refund /privacy /v3 - 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|v3)/$ - handle @meraShortSlash { - redir * /{re.shortslash.1} 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/v3 /trade-in/mera-public/v3/ - 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/ не импортируется. - handle /trade-in/_next/static/* { - 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 здесь НЕ подставляется (в отличие от :130): - # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с - # анонимного домена — расширять доверие без нужды. - 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 - } -} - -# Домены-спутники МЕРА → 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 -} - -# Obsidian Self-hosted LiveSync (CouchDB backend). -# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap -# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). -# -# DNS: A-record obsidian.gendsgn.ru → IP VPS. -# Клиенты Obsidian + Self-hosted LiveSync plugin указывают на этот URL. -obsidian.gendsgn.ru { - encode zstd gzip - - reverse_proxy couchdb:5984 { - # Большие документы (vault attachments / images) — увеличиваем timeout - transport http { - response_header_timeout 120s - } - } -} - -# GlitchTip — self-hosted error tracking (Sentry-compatible). -# DNS: A-record errors.gendsgn.ru → IP VPS. -errors.gendsgn.ru { - encode zstd gzip - - reverse_proxy glitchtip-web:8080 - - log { - output file /var/log/caddy/errors.gendsgn.ru.log - } -} - -# Forgejo — self-hosted git (migration 2026-05-16). -# DNS: A-record git.gendsgn.ru → IP VPS. -# Forgejo container из forgejo-migration/docker-compose.yml на shared -# gendesign_default network. HTTP port 3000 (default Forgejo). -# Был добавлен вручную при migration, потерян при первом auto-deploy после -# изменения Caddyfile (deploy.yml делает git reset --hard). См. fix issue. -git.gendsgn.ru { - encode zstd gzip - - reverse_proxy forgejo:3000 - - log { - output file /var/log/caddy/git.gendsgn.ru.log - } -} +# CADDY_SITES выбирает подмножество. Дефолт `*` = оба файла = ТЕКУЩЕЕ поведение +# Beget, где сейчас обслуживаются все восемь доменов — то есть до переезда +# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra. +import caddy/sites/{$CADDY_SITES:*}.caddy # Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel). # Caddy issues no TLS here (no hostname). /health remains public. diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy new file mode 100644 index 00000000..15335cdc --- /dev/null +++ b/caddy/sites/apps.caddy @@ -0,0 +1,475 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# caddy/sites/apps.caddy — домены Site Finder / МЕРА, переезжающие на Selectel +# +# Переезд GenDesign с Beget (46.173.16.127) на Selectel Poincare +# (188.246.224.93), окно 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 (домен-спутник, альт. транслит) +# +# ПОЧЕМУ ЭТОТ ФАЙЛ ВООБЩЕ СУЩЕСТВУЕТ (не один 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 { + # /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 НЕ трогаем — #2213-секрет всегда перезаписывается + # из env (Set-операция с непустым значением, никак не связана с auth-гейтом + # basic_auth), это единственное, что теперь отсекает подделку заголовков + # изнутри gendesign_shared network для legacy dual-mode пути. + 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 + header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} + } + } + + # 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}). + header_up -X-Authenticated-User + header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} + } + } + + # 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} + } + } + } +} + +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 + } + + # Корень домена → лэндинг МЕРЫ (#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 на + # «/», и запрос закольцуется. + # `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная + # страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и + # ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент, + # когда вариант выберут и он станет корнем. + @meraPages path /estimate /oferta /refund /privacy /v3 + 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|v3)/$ + handle @meraShortSlash { + redir * /{re.shortslash.1} 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/v3 /trade-in/mera-public/v3/ + 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/ не импортируется. + handle /trade-in/_next/static/* { + 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 здесь НЕ подставляется (в отличие от :130): + # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с + # анонимного домена — расширять доверие без нужды. + 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 + } +} + +# Домены-спутники МЕРА → 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 +} diff --git a/caddy/sites/infra.caddy b/caddy/sites/infra.caddy new file mode 100644 index 00000000..e8ecc929 --- /dev/null +++ b/caddy/sites/infra.caddy @@ -0,0 +1,64 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# caddy/sites/infra.caddy — инфраструктурные домены, остающиеся на Beget +# +# Переезд GenDesign с Beget (46.173.16.127) на Selectel Poincare +# (188.246.224.93), окно 30.08.2026. Три домена этого файла ОСТАЮТСЯ на +# Beget — переезжают только Site Finder/МЕРА-домены (см. +# caddy/sites/apps.caddy). Общий механизм переключения (переменная +# CADDY_SITES, `import caddy/sites/{$CADDY_SITES:*}.caddy` в корневом +# Caddyfile, мотив с ACME rate-limit) описан в шапке apps.caddy — не +# дублируем здесь, читай там. +# +# Домены в этом файле (после переезда — на Beget, CADDY_SITES=infra): +# - obsidian.gendsgn.ru — Obsidian Self-hosted LiveSync (CouchDB backend) +# - errors.gendsgn.ru — GlitchTip, self-hosted error tracking +# - git.gendsgn.ru — Forgejo, self-hosted git + Actions CI +# +# На Selectel (CADDY_SITES=apps) этот файл не импортируется вообще — Caddy +# там даже не видит эти хосты и не пытается выпускать для них сертификаты. +# ═══════════════════════════════════════════════════════════════════════════ + +# Obsidian Self-hosted LiveSync (CouchDB backend). +# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap +# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). +# +# DNS: A-record obsidian.gendsgn.ru → IP VPS. +# Клиенты Obsidian + Self-hosted LiveSync plugin указывают на этот URL. +obsidian.gendsgn.ru { + encode zstd gzip + + reverse_proxy couchdb:5984 { + # Большие документы (vault attachments / images) — увеличиваем timeout + transport http { + response_header_timeout 120s + } + } +} + +# GlitchTip — self-hosted error tracking (Sentry-compatible). +# DNS: A-record errors.gendsgn.ru → IP VPS. +errors.gendsgn.ru { + encode zstd gzip + + reverse_proxy glitchtip-web:8080 + + log { + output file /var/log/caddy/errors.gendsgn.ru.log + } +} + +# Forgejo — self-hosted git (migration 2026-05-16). +# DNS: A-record git.gendsgn.ru → IP VPS. +# Forgejo container из forgejo-migration/docker-compose.yml на shared +# gendesign_default network. HTTP port 3000 (default Forgejo). +# Был добавлен вручную при migration, потерян при первом auto-deploy после +# изменения Caddyfile (deploy.yml делает git reset --hard). См. fix issue. +git.gendsgn.ru { + encode zstd gzip + + reverse_proxy forgejo:3000 + + log { + output file /var/log/caddy/git.gendsgn.ru.log + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9819a03c..e2722941 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -441,6 +441,15 @@ services: # держится в git через caddy/local/.gitignore — иначе docker создал бы # отсутствующий bind-source сам, root-owned пустышкой. - ./caddy/local:/etc/caddy/caddy/local:ro + # Site-блоки, разложенные по хостам (#3059). Каддифайл импортирует их как + # `import caddy/sites/{$CADDY_SITES:*}.caddy` — без переменной берутся оба + # файла (текущее поведение Beget, все восемь доменов), CADDY_SITES=apps на + # Selectel даёт пять уезжающих, =infra на Beget после переезда — три + # остающихся (git / errors / obsidian). + # ⚠️ ПУСТОЕ значение переменной — не то же самое, что «не задана»: Caddy + # подставит пустую строку, получится путь caddy/sites/.caddy и конфиг не + # соберётся. Либо не задавать вовсе, либо задавать apps/infra. + - ./caddy/sites:/etc/caddy/caddy/sites:ro - ./preview:/srv/preview:ro - caddy_data:/data - caddy_config:/config diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts index a570b49b..5e7949c1 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -55,6 +55,25 @@ describe("палитра v3 доезжает до CSS", () => { }); }); +/** + * Эффективный конфиг Caddy = корневой Caddyfile + всё, что он импортирует из + * caddy/sites/*.caddy. С #3059 site-блоки живут в отдельных файлах (переезд на + * Selectel: на новом хосте поднимаются только apps, на Beget остаются infra), + * а корневой файл держит глобальные опции и два import'а. Читать один + * Caddyfile здесь больше нельзя — проверка стала бы пустой и молча зелёной. + * + * Берём ВСЕ файлы из caddy/sites/ намеренно: `import caddy/sites/{$CADDY_SITES:*}` + * без переменной раскрывается в звёздочку, то есть это и есть конфиг по умолчанию. + */ +function readEffectiveCaddyConfig(repoRoot: string): string { + const sitesDir = path.join(repoRoot, "caddy", "sites"); + const siteFiles = readdirSync(sitesDir) + .filter((f) => f.endsWith(".caddy")) + .sort() + .map((f) => readFileSync(path.join(sitesDir, f), "utf8")); + return [readFileSync(path.join(repoRoot, "Caddyfile"), "utf8"), ...siteFiles].join("\n"); +} + describe("короткие адреса публичного домена", () => { /** * Дефект 15.08.2026: подвал v3 вёл «Главную» на `/mera-public`, а Caddy на @@ -62,7 +81,7 @@ describe("короткие адреса публичного домена", () = * голый путь падал в catch-all 404. Страница, которой нет в allowlist * Caddyfile, недоступна независимо от того, что она собралась. */ - const caddyfile = readFileSync(path.join(REPO_ROOT, "Caddyfile"), "utf8"); + const caddyfile = readEffectiveCaddyConfig(REPO_ROOT); const meraBlock = caddyfile.slice( caddyfile.indexOf("meraocenka.ru {"), caddyfile.indexOf("merahome.ru {"), diff --git a/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts b/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts index 21eedf8c..9991da62 100644 --- a/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts +++ b/tradein-mvp/frontend/src/components/auth/__tests__/RouteGuard.publicPaths.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -19,6 +19,25 @@ import { isPublicPath } from "../RouteGuard"; * ТОЧНЫМ совпадением. Наивное добавление его в префиксный список сделало бы * публичным всё приложение — `"/v2/admin".startsWith("/")` истинно. */ +/** + * Эффективный конфиг Caddy = корневой Caddyfile + всё, что он импортирует из + * caddy/sites/*.caddy. С #3059 site-блоки живут в отдельных файлах (переезд на + * Selectel: на новом хосте поднимаются только apps, на Beget остаются infra), + * а корневой файл держит глобальные опции и два import'а. Читать один + * Caddyfile здесь больше нельзя — проверка стала бы пустой и молча зелёной. + * + * Берём ВСЕ файлы из caddy/sites/ намеренно: `import caddy/sites/{$CADDY_SITES:*}` + * без переменной раскрывается в звёздочку, то есть это и есть конфиг по умолчанию. + */ +function readEffectiveCaddyConfig(repoRoot: string): string { + const sitesDir = path.join(repoRoot, "caddy", "sites"); + const siteFiles = readdirSync(sitesDir) + .filter((f) => f.endsWith(".caddy")) + .sort() + .map((f) => readFileSync(path.join(sitesDir, f), "utf8")); + return [readFileSync(path.join(repoRoot, "Caddyfile"), "utf8"), ...siteFiles].join("\n"); +} + describe("isPublicPath", () => { it("пропускает поддерево лэндинга (путь без basePath)", () => { expect(isPublicPath("/mera-public")).toBe(true); @@ -50,7 +69,7 @@ describe("isPublicPath", () => { it("совпадает с тем, что публичный домен реально раздаёт (Caddyfile)", () => { // __tests__ → auth → components → src → frontend → tradein-mvp → корень. const repoRoot = path.resolve(__dirname, "../../../../../.."); - const caddyfile = readFileSync(path.join(repoRoot, "Caddyfile"), "utf8"); + const caddyfile = readEffectiveCaddyConfig(repoRoot); const served = caddyfile .match(/@meraPages path ([^\n]+)/)?.[1] .split(/\s+/)