# 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 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-запуска, БЕЗ функционала). # # Архитектурное решение: отдельный домен, а НЕ дырка в блоке 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 (тот же принцип, что # уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО # появления 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 } } # Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается # на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с # basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle # ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ # документ был бы недоступен с публичной страницы. # # Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*. # B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history) # под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это — # в scripts/smoke-mera-perimeter.sh. handle /trade-in/mera-public/* { reverse_proxy tradein-frontend:3000 { header_up -X-Authenticated-User } } # 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 } } # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, # /scrapers/*, /trade-in/api/*, /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 } } # Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1). # DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека). # Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared # gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на # этом домене, main-сайт не страдает (как obsidian.gendsgn.ru). # # ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75). # Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma — # НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup. status.gendsgn.ru { encode zstd gzip reverse_proxy uptime-kuma:3001 log { output file /var/log/caddy/status.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 } } # 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. :80 { encode zstd gzip route { # /health — public, без auth (GHA deploy smoke check, liveness probe). handle /health { reverse_proxy backend:8000 } # Auth gate (same snippet as gendsgn.ru). 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} } } } } # Test deploy flow 2026-05-15T21:43:32Z