diff --git a/.env.example b/.env.example index 50bda92..add725e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ═══════════════════════════════════════════════════════════════════════════ -# Forbidden Stars — единый .env (dev / test / prod) +# Forbidden Stars — единый .env (dev / prod) # Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт. # Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC. # ═══════════════════════════════════════════════════════════════════════════ @@ -7,7 +7,6 @@ # ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── # Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: # development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник -# test — прод-клон в Docker локально (порт 8080), вход только TG # production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). # Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. APP_ENV=development @@ -15,21 +14,21 @@ APP_ENV=development # ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ───────────────────────────────────── # LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost; # vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru. -# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им -# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают. +# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему +# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает. LOCAL_PUBLIC=local -# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod. +# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода. # Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS. VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_USER=tunnel # VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию: -# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001. +# dev (run.ps1) → 9001, прод-контейнер → 9000. # Раскомментируй и переопредели, только если нужен нестандартный слот. #VPS_TUNNEL_PORT=9001 # Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env -# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test -# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: +# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод +# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: # Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel # PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel"))) # Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS. @@ -45,10 +44,10 @@ ADMIN_BOOTSTRAP_ENABLED=true # Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только # Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у # BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod) -# и forbidden-stars.ru (dev/test). +# и forbidden-stars.ru (dev). TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_USERNAME= -# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru. +# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru. PUBLIC_BASE_URL= # ─── БЕЗОПАСНОСТЬ / СЕССИИ ──────────────────────────────────────────────────── @@ -60,12 +59,12 @@ JWT_ALGORITHM=HS256 JWT_USER_TTL_MINUTES=10080 JWT_ADMIN_TTL_MINUTES=480 # COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie): -# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно. +# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно. COOKIE_DOMAIN= # ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ──────────── -# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL -# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml). +# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL +# (том /data контейнера, см. docker-compose*.yml). DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db diff --git a/.gitattributes b/.gitattributes index 741036e..f781db5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,9 +4,9 @@ *.sh text eol=lf backend/entrypoint.sh text eol=lf -# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ───── +# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ───────── # В git эти файлы есть и доступны на всех ветках (нужны для разработки), -# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет — +# но в архив деплоя (scripts/export-prod.sh) не идут. На Docker-сборку НЕ влияет — # там чистоту образа обеспечивает .dockerignore. backend/tests/ export-ignore backend/app/auth/dev_stub.py export-ignore diff --git a/.gitignore b/.gitignore index a860c84..5620531 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ venv/ data/ backend/dev.db* -# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod) +# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod) .env !.env.example diff --git a/README.md b/README.md index ba30290..cb6d42a 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,22 @@ backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты frontend/ React + Vite SPA deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы) -scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-*.sh +scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) docker-compose.yml прод на Pi: app + tunnel + backup -docker-compose.test.yml тест-клон прода на ПК: app + tunnel + backup docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel -run.ps1 / run.sh единый лаунчер dev/test +run.ps1 / run.sh единый лаунчер dev .env.example шаблон единого .env ``` ## Локальная разработка +Всё проверяется в `development` — отдельного тестового контейнера нет. + ### Единый лаунчер (`run.ps1` / `run.sh`) -После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно, -решает `APP_ENV` в корневом `.env`: +После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает +`APP_ENV` в корневом `.env`): ```powershell .\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh) @@ -39,8 +40,8 @@ run.ps1 / run.sh единый лаунчер dev/test | `APP_ENV` в `.env` | что делает лаунчер | |---|---| | `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` | -| `test` | `docker compose -f docker-compose.test.yml up --build -d` — прод-клон (app + tunnel + backup); портов на хост нет, открывается на `https://forbidden-stars.ru` | | `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») | +| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) | Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта (после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. @@ -128,46 +129,19 @@ docker compose up -d # pull_policy: always — тянет свежие при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73). Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md). -## Test — прод-клон в контейнере на ПК - -Тот же `Dockerfile` и поведение, что у прода (FastAPI отдаёт SPA, БД на томе, вход игроков -по логину/паролю или через Telegram), но образ собирается локально — для проверки прод-сборки -до выката на Pi. Портов на хост **нет**: тест-клон виден только на `https://forbidden-stars.ru` -через свой туннель-контейнер (ключ — файл `deploy/tunnel/id_tunnel`). - -Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. -Вручную (тот же эффект): -```bash -docker compose -f docker-compose.test.yml up -d --build -# открыть https://forbidden-stars.ru (Swagger: /api/docs) -docker compose -f docker-compose.test.yml logs -f app -docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные -``` - -- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри - контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). Cookie — Secure - (снаружи HTTPS). -- От прода test отличается тем, что OpenAPI/Swagger открыт и **нет** fail-fast по дефолтным - секретам, хотя контур публичный (задача #69). Не держите в `.env` дефолтные `SECRET_KEY` / - `ADMIN_PASSWORD`, когда поднимаете test, — особенно после `restore-test` с прод-данными. -- Данные — на отдельных томах `db-data-test` / `uploads-data-test` / `achievements-data-test` - (и `backup-data-test` у контейнера бэкапов, он работает без расписания и без VPS); - с dev и Pi не пересекаются. -- Слот VPS 9001 общий с dev-туннелем (`LOCAL_PUBLIC=vps`) — поднимайте что-то одно. -- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — по - логину/паролю сразу, через Telegram — при настроенном боте (`/setdomain` → `forbidden-stars.ru`). -- Учебное восстановление прод-бэкапа в тест-клон — `.\scripts\fs-backup.ps1 restore-test` - ([`deploy/backup/README.md`, шаг 7](deploy/backup/README.md#шаг-7-учебное-восстановление-на-тест-клоне)). - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), - и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало + и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: `python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`). +- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` — + поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`, + тот же слот VPS 9000, что у Pi (одновременно не запускать). ## Аутентификация Методы входа зависят от окружения (`APP_ENV`): -| | dev | test / prod | +| | dev | prod | |---|---|---| | Логин (= ник) и пароль | ✓ | ✓ (основной) | | Telegram Login Widget | ✓ | ✓ | @@ -189,7 +163,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с `backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development` (`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`). - В test/prod аккаунт можно только отключить. + В проде аккаунт можно только отключить. - **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные @@ -197,43 +171,42 @@ docker compose -f docker-compose.test.yml down -v # остановить и с **Настройка Telegram (когда будете подключать реальный вход):** 1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**. -2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test). +2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev). 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`). 4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает. Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд. -## Окружения (dev / test / prod) +## Окружения (dev / prod) -Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер): +Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только +`development` и `production` — с любым другим значением бэкенд не стартует: -| | dev | test (прод-клон локально) | prod (Pi) | -|---|---|---|---| -| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` | -| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) | -| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | -| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbidden-stars.ru` | FastAPI, только через `https://forbiddenstars.ru` | -| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | -| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | -| Swagger (`/api/docs`) | ✓ | ✓ | ✗ | -| Fail-fast по дефолтным секретам | ✗ | ✗ | ✓ | +| | dev | prod (Pi) | +|---|---|---| +| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` | +| `APP_ENV` | `development` | `production` (форсится в compose) | +| Env-файл | единый `.env` | единый `.env` (на Pi) | +| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` | +| База данных | `backend/data/dev/…` | том `db-data` (`/data`) | +| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | +| Swagger (`/api/docs`) | ✓ | ✗ | +| Fail-fast по дефолтным секретам | ✗ | ✓ | -- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что - запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда - `production`. Отдельного `.env.test` больше нет. +- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение + `APP_ENV` из него **игнорирует** и всегда `production`. - **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` - (`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это - РАЗНЫЕ тома). Так же раздельно лежат загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). + (`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат + загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). - **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически - исключены из образа (`.dockerignore`); `test` собирается из того же `Dockerfile`, что и прод, - просто локально и с `APP_ENV=test`. + исключены из образа (`.dockerignore`). - **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило `data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71). ## Git и деплой -- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь. +- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь. - Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. @@ -241,10 +214,9 @@ docker compose -f docker-compose.test.yml down -v # остановить и с образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL). - **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится): - `scripts/export-prod.sh [ref]` / `scripts/export-test.sh [ref]` — через - `git archive` + `export-ignore` из `.gitattributes` (без тестов, stub-входа, `pyproject.toml`, - README-файлов; у прода ещё без лаунчера и тест-compose, у теста — без прод-compose). `dev_admin.py` в - `export-ignore` пока не внесён (задача #70). + `scripts/export-prod.sh [ref]` — через `git archive` + `export-ignore` из + `.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера). + `dev_admin.py` в `export-ignore` пока не внесён (задача #70). Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. @@ -252,21 +224,21 @@ docker compose -f docker-compose.test.yml down -v # остановить и с Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT). -У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост -они не публикуют (доступны только через домен): +У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он +не публикует (доступен только через домен): | | домен | как выставляется | слот VPS | |---|---|---|---| | prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 | -| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 | | dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 | -- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают - одновременно. Dev и test делят слот 9001 → по очереди. Временный прод на ПК - (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать. +- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный + прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать. - Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен. + В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на + время проверки (задача #69). - `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет). -- Ключ туннеля: test и временный прод берут файл `deploy/tunnel/id_tunnel`, прод на Pi — +- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi — `TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию (`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS. - Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`), @@ -276,7 +248,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки -скачиваются и проверяются учебным восстановлением в тест-клон (`scripts/fs-backup.ps1`). +скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`). Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md). ## Дополнения и фракции diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py index 5a771a0..62bf9bd 100644 --- a/backend/app/bootstrap.py +++ b/backend/app/bootstrap.py @@ -38,7 +38,7 @@ def _ensure_admin(session: Session) -> None: # Администратор уже существует. if not settings.is_development: - # В test/prod пароль НЕ перезаписываем (мог быть изменён через панель). + # В prod пароль НЕ перезаписываем (мог быть изменён через панель). return # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0085fbe..dfca3b6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import lru_cache from pathlib import Path -from pydantic import model_validator +from pydantic import field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково @@ -18,6 +18,9 @@ _DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production" _DEFAULT_ADMIN_PASSWORD = "change-me-admin-password" _MIN_SECRET_KEY_LENGTH = 32 +# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development. +_APP_ENVS = ("development", "production") + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -27,14 +30,13 @@ class Settings(BaseSettings): case_sensitive=False, ) - # ── Главный переключатель окружения: development | test | production ─────── + # ── Главный переключатель окружения: development | production ───────────── # development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник. - # test — прод-клон в Docker локально (порт 8080), ведёт себя как прод. # production — Docker на Pi; контейнер форсит это значение, игнорируя .env. app_env: str = "development" log_level: str = "INFO" - # Публикация локального окружения (dev/test) наружу через VPS-туннель. + # Публикация локального dev-окружения наружу через VPS-туннель. # Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель # на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie). local_public: str = "local" @@ -91,17 +93,13 @@ class Settings(BaseSettings): стартовый bootstrap в lifespan и синхронизацию админа из .env.""" return self.app_env.lower() == "development" - @property - def is_test(self) -> bool: - return self.app_env.lower() == "test" - @property def is_production(self) -> bool: return self.app_env.lower() == "production" @property def database_url(self) -> str: - """БД: dev — отдельный файл дева; test и prod — том контейнера (/data).""" + """БД: dev — отдельный файл дева; prod — том контейнера (/data).""" return self.dev_database_url if self.is_development else self.prod_database_url @property @@ -122,13 +120,24 @@ class Settings(BaseSettings): def cookie_domain_value(self) -> str | None: return self.cookie_domain or None + @field_validator("app_env") + @classmethod + def _known_app_env(cls, value: str) -> str: + """Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое + значение, кроме development, выбирает прод-пути к данным и выключает dev-вход.""" + if value.lower() not in _APP_ENVS: + raise ValueError( + f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}" + ) + return value + @model_validator(mode="after") def _forbid_default_secrets_in_prod(self) -> "Settings": """Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59). Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и - известным паролем администратора. В dev/test проверка не мешает — там дефолты норма.""" + известным паролем администратора. В dev проверка не мешает — там дефолты норма.""" if self.app_env.lower() != "production": return self problems: list[str] = [] diff --git a/backend/app/main.py b/backend/app/main.py index b91cb9c..856034c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -132,7 +132,7 @@ async def _lifespan(_app: FastAPI): hub.bind_loop(asyncio.get_running_loop()) # В DEV приложение само подтягивает справочники и админа из .env при старте - # (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). + # (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0": try: from app.bootstrap import bootstrap @@ -151,7 +151,7 @@ async def _lifespan(_app: FastAPI): def create_app() -> FastAPI: - # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для + # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для # `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем — # незачем облегчать разведку поверхности API анонимам (#61). docs_enabled = not settings.is_production @@ -165,7 +165,7 @@ def create_app() -> FastAPI: ) # CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin). - # В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем. + # В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем. if settings.is_development and settings.cors_origins_list: app.add_middleware( CORSMiddleware, @@ -204,7 +204,7 @@ def create_app() -> FastAPI: app.include_router(r, prefix="/api") # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development - # и только если код физически есть (в test/prod-образе dev_*-файлы исключены + # и только если код физически есть (в прод-образе dev_*-файлы исключены # .dockerignore, импорт просто не выполнится). if settings.is_development: for mod_name in ("dev_auth", "dev_admin"): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a3e5314..a07e8da 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -153,7 +153,7 @@ def set_user_password( # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в -# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только +# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только # отключается (PATCH is_active), удалять нельзя. diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py index 9e699f3..50e89af 100644 --- a/backend/app/routers/dev_admin.py +++ b/backend/app/routers/dev_admin.py @@ -1,9 +1,9 @@ """DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. -Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер +Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так -возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только +возможность удаления не попадает в прод — там аккаунт можно только отключить (PATCH is_active). Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index d9b2334..c445df2 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -74,7 +74,7 @@ def set_player_password(session: Session, user_id: int, new_password: str) -> Us # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: -# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). +# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа). # ─── Группы ────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_api_hardening.py b/backend/tests/test_api_hardening.py index 4de3185..d27bb3e 100644 --- a/backend/tests/test_api_hardening.py +++ b/backend/tests/test_api_hardening.py @@ -1,4 +1,4 @@ -"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6).""" +"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6).""" from __future__ import annotations from fastapi.testclient import TestClient diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7079c1b..af2e252 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -23,14 +23,12 @@ def test_enabled_methods_by_env(monkeypatch): monkeypatch.setattr(settings, "app_env", "development") assert set(enabled_methods()) == {"password", "telegram", "stub"} - monkeypatch.setattr(settings, "app_env", "test") - assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub monkeypatch.setattr(settings, "app_env", "production") assert enabled_methods() == ["password", "telegram"] # prod → без stub def test_env_flags_and_db_path(monkeypatch): - """dev → файл дева; test и prod → том /data (общая ветвь is_development).""" + """dev → файл дева; prod → том /data.""" from app.core.config import settings monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db") @@ -38,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch): monkeypatch.setattr(settings, "app_env", "development") assert settings.is_development and settings.database_url == "sqlite:///dev.db" - monkeypatch.setattr(settings, "app_env", "test") - assert settings.is_test and settings.database_url == "sqlite:////data/prod.db" monkeypatch.setattr(settings, "app_env", "production") assert settings.is_production and settings.database_url == "sqlite:////data/prod.db" diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py index 8c73066..965d38e 100644 --- a/backend/tests/test_config_security.py +++ b/backend/tests/test_config_security.py @@ -65,3 +65,15 @@ def test_development_allows_defaults(): admin_password=config._DEFAULT_ADMIN_PASSWORD, ) assert s.is_development + + +@pytest.mark.parametrize("app_env", ["test", "staging", ""]) +def test_unknown_app_env_rejected(app_env): + # Отдельного test-контура больше нет: такое значение не должно молча включать прод-пути. + with pytest.raises(ValidationError): + config.Settings(app_env=app_env) + + +def test_app_env_case_insensitive(): + s = config.Settings(app_env="Development") + assert s.is_development diff --git a/deploy/README.md b/deploy/README.md index 3a74a4d..6388984 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,6 @@ # Публикация: домены, VPS, туннели -Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет +Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет (CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые приложение само открывает к VPS. @@ -11,10 +11,8 @@ HTTPS твоими сертификатами и проксирует трафи │ ▲ туннель-КОНТЕЙНЕР │ │ └── Pi : app:8000 PROD │ forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │ - │ ▲ контейнер (test) ИЛИ │ - │ ▲ ssh с ПК (dev) │ - │ ├── ПК test : app:8000 │ - │ └── ПК dev : vite:5173 │ + │ ▲ ssh с ПК (по требованию) │ + │ └── ПК dev : vite:5173 DEV │ └───────────────────────────────────────────────────┘ ``` @@ -23,32 +21,26 @@ HTTPS твоими сертификатами и проксирует трафи хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер (`restart: unless-stopped`). См. [`pi/`](pi/README.md). -- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` собирает образы - локально и поднимает `app` + `tunnel` (`ssh -R 9001:app:8000`) + `backup` (без расписания и - без VPS). Портов на хост нет — тест виден только на `forbidden-stars.ru`. Обычно - запускается лаунчером при `APP_ENV=test`. - **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при `LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК - (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`. -- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**. - PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо. + (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**). +- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev. Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя. Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя `tunnel` на VPS): - **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет. -- **ПК, test и временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер. +- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер. - **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`). Настройка по шагам: 1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`. 2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`. -3. **ПК (dev/test)** — ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или ключ по - умолчанию в `~/.ssh` (для dev-туннеля); pubkey — в `authorized_keys` у `tunnel@VPS`. +3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод, + файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`. 4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает - снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют - восстановление на тест-клоне. + снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК. Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и diff --git a/deploy/backup/README.md b/deploy/backup/README.md index 949a464..8048f06 100644 --- a/deploy/backup/README.md +++ b/deploy/backup/README.md @@ -16,7 +16,7 @@ 4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК 5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi 6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК -7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК +7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК 8. [Восстановление прода](#8-восстановление-прода) — Pi 9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi 10. [Повседневные действия](#10-повседневные-действия) @@ -562,43 +562,42 @@ --- -## Шаг 7. Учебное восстановление на тест-клоне +## Шаг 7. Проверка скачанного архива -**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно -восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается. +**Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что +ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается. -> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона -> сохраняются в его собственный снимок `pre-restore`. +> Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм +> `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь +> проверяется содержимое архива. -1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла): +1. Распакуйте последний скачанный архив во временную папку: ```powershell - .\scripts\fs-backup.ps1 restore-test -File backups\fs_20260914_0400_3f2a9c1d.tar + $f = (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName + $d = Join-Path $env:TEMP "fs-check"; New-Item -ItemType Directory -Force $d | Out-Null + tar -xf $f -C $d ``` - В первый раз Docker соберёт образы тест-клона — это несколько минут. +2. Откройте `%TEMP%\fs-check\forbidden_stars.db` в [DB Browser for SQLite](https://sqlitebrowser.org) + (вкладка «Выполнить SQL») и выполните: -2. Проверьте, что приложение тест-клона поднялось: + ```sql + PRAGMA integrity_check; + SELECT (SELECT count(*) FROM users WHERE role = 'player') AS players, + (SELECT count(*) FROM matches) AS matches; + ``` + +3. Закройте DB Browser и удалите временную папку — данные в ней не зашифрованы: ```powershell - docker compose -f docker-compose.test.yml ps - docker compose -f docker-compose.test.yml logs --tail 20 app + Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check") ``` -3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите - `.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ, - профили, историю партий. Потом верните `APP_ENV=development`. - **Что должно получиться:** -- в выводе `restore-test`: - - `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi; - - `Данные восстановлены.`; - - `Done. The test clone now runs on the restored data.`; -- `docker compose ... ps` показывает `app` в состоянии `Up … (healthy)`; -- на сайте тест-клона — данные прода на момент снимка. - -> Этим же способом можно восстановить в тест-клон старые архивы `fs_*.tar.gz` прежнего -> `scripts/backup.sh`: `.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260710_140914.tar.gz`. +- `PRAGMA integrity_check` → `ok`; +- `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi; +- в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`. --- @@ -651,8 +650,9 @@ Если локальный репозиторий повреждён или пуст, смотрите копию на VPS: `docker compose exec backup fs-backup list vps`. -2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК: - `.\scripts\fs-backup.ps1 pull -Snapshot `, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)). +2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК: + `.\scripts\fs-backup.ps1 pull -Snapshot `, затем [шаг 7](#шаг-7-проверка-скачанного-архива). + Заодно у вас останется копия этого снимка вне Pi. 3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»: @@ -806,7 +806,7 @@ - **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`). - **Раз в месяц:** - скачать снимок на ПК (`pull`); - - раз в пару месяцев сделать учебное восстановление (`restore-test`); + - проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива)); - удалить с ПК старые архивы — они не зашифрованы. - **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что бэкапы перестали проходить (причину покажет `fs-backup status`). @@ -899,7 +899,7 @@ docker volume rm <имя тома> | `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` | | `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) | | `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` | -| `restore-test`: `Import failed … The test clone data was not changed` | архив повреждён или неполный | скачать заново (`pull`); текст ошибки выше в выводе | +| шаг 7: `integrity_check` не `ok` или счётчики не совпадают с `list` | архив повреждён или скачан не тот снимок | удалить файл из `backups\` и скачать заново (`pull -Snapshot `); если повторяется — `fs-backup verify` на Pi | --- @@ -935,11 +935,9 @@ docker volume rm <имя тома> |---|---| | `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК | | `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 | -| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон | -| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне | В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`, -`pull --repo vps`, `restore-test <архив>`, `--test` первым аргументом. +`pull --repo vps`. ### Переменные `.env` @@ -987,7 +985,7 @@ docker volume rm <имя тома> - [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5) - [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5) - [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6) -- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7) +- [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7) **Через сутки** - [ ] В `list` появился снимок с меткой `scheduled` в 04:00 diff --git a/deploy/tunnel/tunnel.sh b/deploy/tunnel/tunnel.sh index 78df777..7c99650 100644 --- a/deploy/tunnel/tunnel.sh +++ b/deploy/tunnel/tunnel.sh @@ -10,7 +10,7 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}" UPSTREAM="${UPSTREAM:-app:8000}" # Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env), -# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте). +# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте). mkdir -p /root/.ssh KEY=/root/.ssh/id_tunnel if [ -n "${TUNNEL_KEY_B64:-}" ]; then diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index c4f4f7f..08ad849 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -2,7 +2,7 @@ # Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели: # # forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD -# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST +# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV # # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к # приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host — @@ -38,10 +38,10 @@ -Server } - # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая - # политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с - # telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать - # после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61). + # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко + # ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe + # oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки, + # что вход и реал-тайм работают (#61). # header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый diff --git a/deploy/vps/README.md b/deploy/vps/README.md index d2bb558..ecc4a94 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -1,11 +1,11 @@ # VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами -для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test). +для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev). ``` -forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD -forbidden-stars.ru → 127.0.0.1:9001 ← ПК (контейнер test или ssh dev, по требованию) DEV/TEST +forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD +forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV ``` > Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит @@ -106,8 +106,8 @@ systemctl reload caddy > файл читается на каждый запрос). ## 7. Проверка -1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК - (лаунчер `run.ps1`: dev — при `LOCAL_PUBLIC=vps`, test — при `APP_ENV=test`). +1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК + (лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`). 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. 3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки» (HTTP 503), это ожидаемо. diff --git a/docker-compose.temp.yml b/docker-compose.temp.yml index 3baaf72..f949d8e 100644 --- a/docker-compose.temp.yml +++ b/docker-compose.temp.yml @@ -5,7 +5,7 @@ # # Отличия от docker-compose.yml (прод на Pi): # • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится; -# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК. +# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК. # Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck). # # Запуск: docker compose -f docker-compose.temp.yml up -d --build diff --git a/docker-compose.yml b/docker-compose.yml index 23697e6..bda1dd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: restart: unless-stopped environment: BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены - BACKUP_HOSTNAME: fs-prod # имя хоста в снимках (у тест-клона — fs-test) + BACKUP_HOSTNAME: fs-prod # имя хоста в снимках BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *} BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0} BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14} diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index cc1b46f..29294e4 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -6,7 +6,7 @@ import { Spinner } from "../../components/Spinner"; import { useToast } from "../../context/ToastContext"; import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; // DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV, -// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). +// поэтому в прод-сборке вырезается тришейкингом (как и dev-вход). import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; export function AdminAccountsPage() { diff --git a/frontend/src/pages/admin/DevDeleteAccountButton.tsx b/frontend/src/pages/admin/DevDeleteAccountButton.tsx index 3b0b7fc..e3c75a4 100644 --- a/frontend/src/pages/admin/DevDeleteAccountButton.tsx +++ b/frontend/src/pages/admin/DevDeleteAccountButton.tsx @@ -10,9 +10,9 @@ import { useToast } from "../../context/ToastContext"; * DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта. * * Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда - * (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль + * (backend/app/routers/dev_admin.py, исключён из прод-образа). Этот модуль * рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке - * он не используется и вырезается тришейкингом — в прод/тест удаление недоступно. + * он не используется и вырезается тришейкингом — в проде удаление недоступно. */ export function DevDeleteAccountButton({ userId, @@ -39,7 +39,7 @@ export function DevDeleteAccountButton({