feat(mera/b2c): публичный периметр на отдельном домене meraocenka.ru — этап 1 из 8 #2545
3 changed files with 251 additions and 0 deletions
38
.forgejo/workflows/perimeter-smoke.yml
Normal file
38
.forgejo/workflows/perimeter-smoke.yml
Normal file
|
|
@ -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
|
||||
113
Caddyfile
113
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).
|
||||
|
|
|
|||
100
scripts/smoke-mera-perimeter.sh
Normal file
100
scripts/smoke-mera-perimeter.sh
Normal file
|
|
@ -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:-<no response>}', 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"
|
||||
Loading…
Add table
Reference in a new issue