diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml new file mode 100644 index 00000000..db0304ba --- /dev/null +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -0,0 +1,38 @@ +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS- +# сертификата для meraocenka.ru, т.е. осмысленны ТОЛЬКО против прода после +# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) — +# страхует от случайной регрессии периметра (например, будущий PR по ошибке +# открывает B2B-путь на публичном домене, или basic_auth gate на gendsgn.ru +# случайно снимают). +# +# ДО того как появится DNS A-record meraocenka.ru → IP VPS, проверки 1 и 2 +# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс, +# просто домен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового +# домена и обязаны быть зелёными всегда. +name: perimeter-smoke-mera + +on: + workflow_dispatch: {} + schedule: + # Раз в сутки, 06:17 UTC — вне пиков, время произвольное. + - cron: '17 6 * * *' + +concurrency: + group: perimeter-smoke-mera + cancel-in-progress: false + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Run perimeter smoke checks + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh diff --git a/Caddyfile b/Caddyfile index ad74ba62..59402dbf 100644 --- a/Caddyfile +++ b/Caddyfile @@ -193,6 +193,119 @@ 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). diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh new file mode 100644 index 00000000..cda7725a --- /dev/null +++ b/scripts/smoke-mera-perimeter.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# Проверяет инварианты периметра (см. корневой Caddyfile): +# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). +# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 — +# политика ПДн, на которую ссылается футер. +# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 — +# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево +# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без. +# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B +# закрыты. Именно API, а не страница: см. комментарий у проверки ниже. +# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder). +# 5. merahome.ru и meraotsenka.ru отдают 301 на канонический meraocenka.ru. +# +# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS +# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока +# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake +# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от +# DNS нового домена и обязаны быть зелёными всегда. +# +# Запуск вручную: +# bash scripts/smoke-mera-perimeter.sh +# Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron). +set -uo pipefail + +BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}" +BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}" + +fail=0 + +check() { + local desc="$1" url="$2" expected="$3" + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null) + if [ "$code" = "$expected" ]; then + echo "PASS: $desc ($url -> $code)" + else + echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" + fail=1 + fi +} + +echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" + +# 1. Публичный домен отдаёт 200 анонимно. +check "meraocenka.ru root — public 200" "$BASE_MERA/" 200 + +# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер. +# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно +# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile, +# обязательный по 152-ФЗ документ станет недоступен с публичной страницы. +check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200 + +# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. +check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 + +# 2b. Те же B2B-пути в basePath-форме — 404. Это регресс-тест именно на +# matcher `handle /trade-in/mera-public/*`: расширь его случайно до +# `/trade-in/*` — и B2B-дерево уедет наружу через публичный домен, а +# проверка 2 (/v2 без префикса) этого НЕ заметит. +check "meraocenka.ru/trade-in/v2 — B2B path must 404" "$BASE_MERA/trade-in/v2" 404 +check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)" "$BASE_MERA/trade-in/api/v1/me" 404 + +# 2c. Статика проксируется ТОЛЬКО из _next/static/*. Оптимизатор картинок +# /_next/image на лэндинге не нужен (next/image там не импортируется) и +# наружу не открыт — иначе аноним получил бы CPU-нагрузку по запросу. +# Ловит расширение матчера обратно до `/trade-in/_next/*`. +check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 + +# 3. B2B-данные trade-in по-прежнему закрыты анониму. +# +# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200. +# После #2555/#2558 trade-in ушёл с Caddy basic_auth на собственный логин: +# страница рендерится анониму, а RouteGuard уже на клиенте уводит на /login. +# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при +# открытом наружу бэкенде. +check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401 +check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401 +check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401 + +# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга +# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается +# на уровне Caddy до бэкенда). +check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401 + +# 5. Домены-спутники ведут на канонический (301, без следования редиректу — +# curl без -L, поэтому ждём именно код редиректа, а не 200 конечной страницы). +# Как и проверки 1-2, требуют DNS + выпущенного сертификата. +check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301 +check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301 + +echo "========================================" +if [ "$fail" -eq 0 ]; then + echo "ALL CHECKS PASSED" +else + echo "SOME CHECKS FAILED — see FAIL lines above" +fi + +exit "$fail"