gendesign/Caddyfile
bot-backend 7424c283d5
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
## Экран проверки — /estimate

Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).

Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».

## Короткие адреса

Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.

Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.

Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.

## Три дефекта, найденных на живом сайте

1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
   --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
   лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
   каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
   Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
   B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
   которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.

## Периметр и приватность

Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.

П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).

## Проверено

vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.

Мутации структурных гейтов:
  убрать --b2c-accent-text из b2cVars   → падает тест палитры
  убрать /estimate из @meraPages        → падает тест маршрутов
  добавить импорт next/link             → падает тест basePath
  откат                                 → 14 passed

Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.

Refs #2894, #2895
2026-08-16 09:27:04 +03:00

488 lines
30 KiB
Caddyfile
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
}
}
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
# юридических документов (оферта ссылается на meraocenka.ru/refund,
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
# поэтому обязаны резолвиться сами по себе.
#
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
# фронте — обычный <a>, потому что next/link подставляет basePath).
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой странице.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
# (и проверка в scripts/smoke-mera-perimeter.sh).
#
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
@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 на короткие. Один канонический адрес у
# страницы, а не два работающих.
#
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
# канонический адрес один.
#
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
#
# Query-строка при редиректе не переносится: ни одна из этих страниц
# параметров не принимает (форма проверки шлёт данные телом POST, а
# черновик с лэндинга едет через sessionStorage — специально чтобы адрес
# квартиры не попал в access-лог).
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
handle @meraLongRoot {
redir / permanent
}
@meraLongSub path_regexp meralong ^/trade-in/mera-public/(.+)$
handle @meraLongSub {
redir /{re.meralong.1} 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
}
}
# 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