diff --git a/.env.example b/.env.example index d97dfdd..82f46cf 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,14 @@ VPS_TUNNEL_USER=tunnel # Раскомментируй и переопредели, только если нужен нестандартный слот. #VPS_TUNNEL_PORT=9001 +# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env +# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test +# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: +# 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. +TUNNEL_KEY_B64= + # ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ────────────── ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-admin-password diff --git a/deploy/pi/README.md b/deploy/pi/README.md index ab5a77b..24cd037 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -1,89 +1,124 @@ # Raspberry Pi — ПРОД (`forbiddenstars.ru`) -Pi запускает приложение **и туннель** в Docker: `docker compose up` поднимает два сервиса — -`app` (FastAPI + SPA) и `tunnel` (ssh-контейнер). У `app` **портов на хост нет**, наружу он -доступен только через туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Публичная точка — -VPS, домен `forbiddenstars.ru`. Снаружи Pi недоступен (CGNAT) — туннель сам стучится наружу. +На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл +ключа туннеля не нужны: +- образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`); +- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64). -## 1. Ключ для туннеля -Туннель-контейнер ходит на `tunnel@VPS` по ключу из `deploy/tunnel/id_tunnel` (gitignored). -Создай ключ и добавь его pubkey в `authorized_keys` пользователя `tunnel` на VPS: +`docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и +`tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru` +(Pi за CGNAT — туннель стучится наружу сам). + +--- + +## 0. Предпосылки (один раз, не на Pi) +1. **VPS настроен**: Caddy `forbiddenstars.ru` → `127.0.0.1:9000`, пользователь `tunnel`, + сертификаты, страница-заглушка — см. `deploy/vps/README.md`. +2. **Образы собраны и запушены в реестр** (на ПК с Docker Desktop): + ```powershell + docker login gitea.arseniev.info + .\scripts\build-push.ps1 + ``` + Без этого `docker compose up` на Pi не найдёт образы в реестре. + +## 1. Система и Docker на Pi ```bash -ssh-keygen -t ed25519 -f deploy/tunnel/id_tunnel -N "" -ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17 -# (или вручную: содержимое deploy/tunnel/id_tunnel.pub -> /home/tunnel/.ssh/authorized_keys на VPS) +ssh pi@ +sudo apt update && sudo apt -y full-upgrade +sudo apt -y install curl openssh-client # base64/ssh-keygen уже есть в системе + +curl -fsSL https://get.docker.com | sudo sh # Docker Engine + compose-плагин (arm64) +sudo usermod -aG docker $USER +sudo systemctl enable --now docker # автозапуск после ребута +newgrp docker # применить группу (или перезайти по SSH) +docker version && docker compose version # проверка ``` -## 2. .env -Все настройки приложения и туннеля — в одном `.env` (контейнер форсит `APP_ENV=production`): +## 2. SSH-ключ для туннеля +**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на +`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение: +- **приватный** ключ → в `.env` как `TUNNEL_KEY_B64` (base64, одной строкой); +- **публичный** (`.pub`) → в `authorized_keys` пользователя `tunnel` на VPS. + +Сгенерировать прямо на Pi: ```bash -cp .env.example .env +ssh-keygen -t ed25519 -f ~/fs_tunnel -N "" # создаст ~/fs_tunnel (приватный) и ~/fs_tunnel.pub ``` -Минимально заполнить на Pi: +Добавить публичный ключ на VPS (Pi ходит наружу — это работает даже за CGNAT): +```bash +ssh-copy-id -i ~/fs_tunnel.pub tunnel@186.246.51.17 +# Если у tunnel нет пароля (только ключ) — добавь вручную через свой админ-доступ к VPS: +# cat ~/fs_tunnel.pub # скопируй строку +# на VPS: echo '<строка>' >> /home/tunnel/.ssh/authorized_keys ``` -SECRET_KEY=... # python -c "import secrets;print(secrets.token_urlsafe(48))" +Закодировать приватный ключ в base64 **одной строкой** — это значение для `TUNNEL_KEY_B64`: +```bash +base64 -w0 ~/fs_tunnel; echo # выведет длинную строку без переносов — скопируй её целиком +``` +> Альтернатива: если ключ уже есть на ПК (`deploy/tunnel/id_tunnel`) и его pubkey уже на VPS — +> не плоди новый, закодируй тот: +> `[Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\tunnel\id_tunnel")))` + +После того как base64 вставлен в `.env`, файлы ключа на Pi можно удалить — ключ теперь в `.env`, +а pubkey уже на VPS: +```bash +shred -u ~/fs_tunnel ~/fs_tunnel.pub # или просто rm +``` + +## 3. Два файла: docker-compose.yml + .env +```bash +mkdir -p ~/forbidden-stars && cd ~/forbidden-stars +# branch — main (или dev, если ещё не смёржено в main): +curl -fsSLO https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/docker-compose.yml +curl -fsSL https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/.env.example -o .env +nano .env +``` +Заполнить в `.env`: +``` +SECRET_KEY=... # python3 -c "import secrets;print(secrets.token_urlsafe(48))" ADMIN_USERNAME=... # логин/пароль секретной админки ADMIN_PASSWORD=... -TELEGRAM_BOT_TOKEN=... # бот @BotFather; /setdomain → forbiddenstars.ru +TELEGRAM_BOT_TOKEN=... # бот @BotFather; затем /setdomain → forbiddenstars.ru TELEGRAM_BOT_USERNAME=... -VPS_TUNNEL_HOST=186.246.51.17 # читает сервис tunnel -VPS_TUNNEL_USER=tunnel +VPS_TUNNEL_HOST=186.246.51.17 +TUNNEL_KEY_B64=... # длинная строка base64 из шага 2 (целиком, одной строкой) +IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по умолчанию +IMAGE_TAG=latest ``` -`VPS_TUNNEL_PORT` задавать не нужно — прод-контейнер по умолчанию берёт слот **9000**. -Ресурсы (`APP_MEM_LIMIT`/`APP_CPUS`) и оффсайт-бэкап (`BACKUP_*`) — по желанию. +`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать. -## 3. Запуск (образ из реестра) -Образы (`app` + `tunnel`) собираются под arm64 на ПК и пушатся в Gitea-реестр, Pi их тянет -(см. `IMAGE_REGISTRY`/`IMAGE_TAG` в `.env`). Pi 4/5 — мощный, но реестр держим как основной путь. - -На ПК (Docker Desktop) — один раз `docker login gitea.arseniev.info`, далее при каждом релизе: -```powershell -.\scripts\build-push.ps1 # Windows (PowerShell); на *nix — bash scripts/build-push.sh -``` -> `bash scripts/build-push.sh` в PowerShell уходит в WSL и падает, если нет дистрибутива — -> используй `.ps1`. Команды внутри — обычный `docker buildx`, работают и напрямую в PowerShell. -На Pi: +## 4. Запуск ```bash -docker login gitea.arseniev.info # один раз (доступ к пакету) -git pull # нужны docker-compose.yml, .env, deploy/tunnel/id_tunnel -docker compose pull # тянет свежие образы из реестра -docker compose up -d +docker login gitea.arseniev.info # один раз, доступ к реестру +docker compose up -d # pull_policy: always → тянет образы из реестра, без сборки docker compose ps # app healthy → поднимется tunnel -docker compose logs -f tunnel # ждём: [tunnel] -R 9000:app:8000 -> tunnel@... +docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@... ``` -Открой `https://forbiddenstars.ru` — должен отдать прод. +На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`. -> Альтернатива без реестра (Pi 4/5 ≥4ГБ потянет сборку): `docker compose up -d --build` прямо на Pi. -> Реестр Gitea должен быть по HTTPS с валидным сертификатом; иначе на Pi добавь его в -> `/etc/docker/daemon.json` → `insecure-registries`. +## 5. Проверка +- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку). +- У @BotFather `/setdomain` → добавь `forbiddenstars.ru` (иначе Telegram-вход не заработает). +- Админка: удержать «Меню» 10 с → `/admin/login`, войти логином/паролём из `.env`. ## Обновление ```bash -# ПК: scripts/build-push.sh -# Pi: git pull && docker compose pull && docker compose up -d +# ПК: .\scripts\build-push.ps1 +# Pi: docker compose up -d # always-pull подтянет свежий образ ``` -## Бэкап (полный + оффсайт на VPS) -`scripts/backup.sh` делает консистентный онлайн-снимок и собирает **полный** архив -`fs_.tar.gz` (БД + `uploads/` + `achievements/`) в `backups/` с ротацией. Если в `.env` -задан `BACKUP_VPS_HOST` — копия дополнительно уходит на VPS (оффсайт; Pi → VPS push, т.к. CGNAT). +## Автозапуск после перезагрузки +Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot` +контейнеры поднимутся сами (используют локальный образ, без повторного pull). -Настройка оффсайта (один раз): -```bash -# 1) отдельный ключ бэкапа + его pubkey на VPS (юзер backup — см. deploy/vps/README.md): -ssh-keygen -t ed25519 -f deploy/backup/id_backup -N "" -ssh-copy-id -i deploy/backup/id_backup.pub backup@186.246.51.17 -# 2) в .env заполнить BACKUP_VPS_HOST=186.246.51.17 (по желанию — BACKUP_VPS_USER/DIR/KEEP_*) -# 3) разовый прогон и проверка, что архив доехал на VPS: -scripts/backup.sh -# 4) cron (ежедневно в 04:00): -crontab -e -# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1 -``` +## Бэкап (опционально) +Бэкап-скриптам нужен сам `docker-compose.yml` (он на Pi) + скрипты. Скопируй рядом +`scripts/backup.sh` и `scripts/restore.sh`, настрой `BACKUP_*` в `.env` и cron. Подробно — +комментарии в `scripts/backup.sh` и `deploy/vps/README.md` §8. -Восстановление: `scripts/restore.sh backups/fs_.tar.gz` (ДЕСТРУКТИВНО — заменяет текущие -данные; сначала проверь на тест-контуре). Тома — `db-data` / `uploads-data` / `achievements-data`. - -Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт -502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в -`authorized_keys` на VPS или не заданы `VPS_TUNNEL_*` в `.env`). +## Если что-то не так +- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel` + (чаще: pubkey не в `authorized_keys` на VPS, пустой/битый `TUNNEL_KEY_B64`, либо на VPS занят + слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`). +- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным + сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`). diff --git a/deploy/tunnel/tunnel.sh b/deploy/tunnel/tunnel.sh index 9ec8628..78df777 100644 --- a/deploy/tunnel/tunnel.sh +++ b/deploy/tunnel/tunnel.sh @@ -9,18 +9,20 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}" : "${VPS_TUNNEL_PORT:?VPS_TUNNEL_PORT is required}" UPSTREAM="${UPSTREAM:-app:8000}" -# Ключ должен быть смонтирован файлом. Если тут пусто/папка — Docker создал каталог, -# потому что файла deploy/tunnel/id_tunnel на хосте нет. -if [ ! -f /key/id_tunnel ]; then - echo "[tunnel] ERROR: /key/id_tunnel is not a regular file." - echo "[tunnel] Put your private key at deploy/tunnel/id_tunnel on the host (its pubkey must be in authorized_keys of tunnel@VPS)." +# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env), +# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте). +mkdir -p /root/.ssh +KEY=/root/.ssh/id_tunnel +if [ -n "${TUNNEL_KEY_B64:-}" ]; then + echo "$TUNNEL_KEY_B64" | base64 -d > "$KEY" +elif [ -f /key/id_tunnel ]; then + cp /key/id_tunnel "$KEY" +else + echo "[tunnel] ERROR: нет ключа — задай TUNNEL_KEY_B64 в .env (base64) или примонтируй /key/id_tunnel." + echo "[tunnel] base64 ключа без переносов: base64 -w0 deploy/tunnel/id_tunnel" exit 1 fi - -# Копируем ключ и выставляем строгие права (с хоста права могут не годиться, особенно с Windows). -mkdir -p /root/.ssh -cp /key/id_tunnel /root/.ssh/id_tunnel -chmod 600 /root/.ssh/id_tunnel +chmod 600 "$KEY" echo "[tunnel] -R ${VPS_TUNNEL_PORT}:${UPSTREAM} -> ${VPS_TUNNEL_USER}@${VPS_TUNNEL_HOST}" exec ssh -N \ diff --git a/docker-compose.temp.yml b/docker-compose.temp.yml new file mode 100644 index 0000000..3baaf72 --- /dev/null +++ b/docker-compose.temp.yml @@ -0,0 +1,74 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ВРЕМЕННЫЙ ПРОД НА ПК — заглушка вместо Pi, пока он не готов. +# Поднимает forbiddenstars.ru (слот VPS 9000) с твоего ПК: поведение production, +# прод-данные в ПОСТОЯННЫХ томах (потом перенесёшь на Pi через backup/restore). +# +# Отличия от docker-compose.yml (прод на Pi): +# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится; +# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК. +# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck). +# +# Запуск: docker compose -f docker-compose.temp.yml up -d --build +# Логи: docker compose -f docker-compose.temp.yml logs -f tunnel +# Остановка: docker compose -f docker-compose.temp.yml down (тома сохраняются) +# +# ВАЖНО: слот 9000 общий с будущим прод-туннелем Pi. Перед запуском прода на Pi +# ОСТАНОВИ этот контур (down), иначе оба будут драться за VPS:9000. +# ───────────────────────────────────────────────────────────────────────────── +name: forbidden-stars-temp + +services: + app: + build: . + image: forbidden-stars:temp-prod # локальный образ x86 (на ПК); в реестр НЕ пушим + restart: unless-stopped + init: true + stop_grace_period: 30s + env_file: + - .env + environment: + APP_ENV: production # ведём себя ровно как прод + volumes: + - db-data:/data + - uploads-data:/data/uploads + - achievements-data:/data/achievements + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + mem_limit: ${APP_MEM_LIMIT:-1g} # на ПК ресурсов больше, чем на Pi + cpus: ${APP_CPUS:-2} + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # Туннель на ПРОД-слот: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru). + tunnel: + build: ./deploy/tunnel + image: forbidden-stars-tunnel:temp-prod + restart: unless-stopped + init: true + depends_on: + app: + condition: service_healthy + environment: + VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST:?VPS_TUNNEL_HOST is required in .env} + VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel} + VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот forbiddenstars.ru + UPSTREAM: app:8000 + volumes: + - ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro + mem_limit: 64m + security_opt: + - no-new-privileges:true + +volumes: + db-data: + uploads-data: + achievements-data: diff --git a/docker-compose.yml b/docker-compose.yml index 21800e9..b0a272a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,11 @@ # ───────────────────────────────────────────────────────────────────────────── # ПРОД на Raspberry Pi (forbiddenstars.ru) — единый compose: приложение + туннель. -# Все настройки и секреты — в корневом .env (см. .env.example). Этот файл их только -# пробрасывает; ничего секретного тут нет. +# На Pi нужны только ДВА файла: этот docker-compose.yml и .env. Репозиторий, сборка и +# файл ключа не нужны: образы тянутся из реестра (pull_policy: always), а приватный ключ +# туннеля лежит в .env (TUNNEL_KEY_B64, base64). build: оставлен для сборки/пуша на ПК +# (scripts/build-push.*) — на Pi он не используется. # -# Запуск/обновление: docker compose up -d --build +# Запуск/обновление: docker compose up -d (сам тянет свежие образы) # Логи: docker compose logs -f app (или: ... tunnel) # Остановка: docker compose down (данные в томах сохраняются) # Бэкап/restore: scripts/backup.sh / scripts/restore.sh @@ -18,6 +20,7 @@ services: # Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется # на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi). image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest} + pull_policy: always # на Pi всегда тянем образ из реестра (без сборки) restart: unless-stopped init: true # PID 1 пробрасывает сигналы → корректный SIGTERM stop_grace_period: 30s # время на чек-пойнт WAL SQLite перед остановкой @@ -46,22 +49,22 @@ services: max-file: "5" # SSH reverse-туннель: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru). - # Ключ — ./deploy/tunnel/id_tunnel (в git не идёт); его pubkey в authorized_keys у tunnel@VPS. + # Приватный ключ — в .env как TUNNEL_KEY_B64 (base64); pubkey — в authorized_keys у tunnel@VPS. tunnel: build: ./deploy/tunnel image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-tunnel:${IMAGE_TAG:-latest} + pull_policy: always restart: unless-stopped init: true depends_on: app: condition: service_healthy environment: - VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST:?VPS_TUNNEL_HOST is required in .env} + VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST} # обязателен; tunnel.sh упадёт с ошибкой, если пуст VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel} VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот (forbiddenstars.ru) UPSTREAM: app:8000 - volumes: - - ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro + TUNNEL_KEY_B64: ${TUNNEL_KEY_B64:-} # приватный ключ туннеля (base64), из .env mem_limit: 64m security_opt: - no-new-privileges:true