All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
1. Матчер статики сужен с /trade-in/_next/* до /trade-in/_next/static/* — тот же, что у @uipreview (Caddyfile:78), проверенный в проде. Широкий вариант открывал анонимам /_next/image (оптимизатор картинок, CPU по запросу), который лэндингу не нужен: next/image, <img> и url() в mera-public не встречаются вообще, картинок нет, шрифты — next/font/google self-hosted в _next/static/media. 2. Во все три reverse_proxy блока добавлен header_up -X-Authenticated-User — единообразие с остальным периметром (:87, :129, :169). Блок не под basic_auth, т.е. анонимный клиент тем более может прислать свой заголовок. Сейчас инертно (лэндинг статичен), но станет дырой на этапе 5, когда откроется публичный /estimate. 3. Регресс-тест на сужение: /trade-in/_next/image должен отдавать 404.
400 lines
24 KiB
Caddyfile
400 lines
24 KiB
Caddyfile
# 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: `-<field>` =
|
||
# 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
|
||
}
|
||
}
|
||
|
||
# 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
|