Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b33b442e9 | ||
|
|
d12afe62d0 | ||
|
|
30270916a4 | ||
|
|
2f0b22ccd4 | ||
|
|
89cbb81be7 | ||
|
|
f5f8dc03ce | ||
|
|
ec1e94119c | ||
|
|
8d4f7a95df | ||
|
|
5b89dc9d2f | ||
|
|
00bd3c72b6 |
+14
-6
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
|
||||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
@@ -44,8 +44,8 @@ ADMIN_NICKNAME=Администратор
|
|||||||
ADMIN_BOOTSTRAP_ENABLED=true
|
ADMIN_BOOTSTRAP_ENABLED=true
|
||||||
|
|
||||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
|
||||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||||
# и forbidden-stars.ru (dev).
|
# и forbidden-stars.ru (dev).
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
@@ -81,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
|
|||||||
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
||||||
|
|
||||||
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
||||||
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр,
|
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
|
||||||
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
||||||
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
||||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
||||||
@@ -89,7 +89,8 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
||||||
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
||||||
# Подними, если у Pi больше RAM/ядер.
|
# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
|
||||||
|
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
|
||||||
#APP_MEM_LIMIT=512m
|
#APP_MEM_LIMIT=512m
|
||||||
#APP_CPUS=1.5
|
#APP_CPUS=1.5
|
||||||
|
|
||||||
@@ -123,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
|
|||||||
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
||||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
||||||
BACKUP_SSH_KEY_B64=
|
BACKUP_SSH_KEY_B64=
|
||||||
|
# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10).
|
||||||
|
# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через
|
||||||
|
# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения.
|
||||||
|
BACKUP_TELEGRAM_CHAT_ID=
|
||||||
|
#BACKUP_TELEGRAM_BOT_TOKEN=
|
||||||
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
||||||
# docker-compose.yml прода.
|
# docker-compose.yml прода.
|
||||||
BACKUP_PI_SSH=pi@192.168.1.10
|
BACKUP_PI_SSH=pi@192.168.1.10
|
||||||
BACKUP_PI_DIR=~/forbidden-stars
|
BACKUP_PI_DIR=~/forbidden-stars
|
||||||
|
|
||||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
|
||||||
|
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
|
||||||
|
# пояс устройства игрока не учитывается.
|
||||||
APP_TZ_OFFSET_HOURS=3
|
APP_TZ_OFFSET_HOURS=3
|
||||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|||||||
@@ -3,16 +3,3 @@
|
|||||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
backend/entrypoint.sh text eol=lf
|
backend/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ─────────
|
|
||||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
|
||||||
# но в архив деплоя (scripts/export-prod.sh) не идут. На Docker-сборку НЕ влияет —
|
|
||||||
# там чистоту образа обеспечивает .dockerignore.
|
|
||||||
backend/tests/ export-ignore
|
|
||||||
backend/app/auth/dev_stub.py export-ignore
|
|
||||||
backend/app/routers/dev_auth.py export-ignore
|
|
||||||
backend/pyproject.toml export-ignore
|
|
||||||
README.md export-ignore
|
|
||||||
.gitignore export-ignore
|
|
||||||
.gitattributes export-ignore
|
|
||||||
.dockerignore export-ignore
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
||||||
frontend/ React + Vite SPA
|
frontend/ React + Vite SPA
|
||||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
||||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh
|
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
|
||||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
docker-compose.yml прод на Pi: app + tunnel + backup
|
||||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
||||||
@@ -214,10 +214,6 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
||||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||||
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
|
|
||||||
`scripts/export-prod.sh <dir> [ref]` — через `git archive` + `export-ignore` из
|
|
||||||
`.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера).
|
|
||||||
`dev_admin.py` в `export-ignore` пока не внесён (задача #70).
|
|
||||||
|
|
||||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||||
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||||
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
|
||||||
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
|
||||||
|
включая IP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||||
|
|
||||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
||||||
и dev-входом.
|
Telegram, /auth/login, /auth/register и dev-вход.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Главный переключатель окружения: development | production ─────────────
|
# ── Главный переключатель окружения: development | production ─────────────
|
||||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
|
||||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
@@ -42,8 +42,9 @@ class Settings(BaseSettings):
|
|||||||
# от этого зависят Secure-cookie и проверка секретов при старте.
|
# от этого зависят Secure-cookie и проверка секретов при старте.
|
||||||
local_public: str = "local"
|
local_public: str = "local"
|
||||||
|
|
||||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
|
||||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
|
||||||
|
# на фронте — оно приходит туда в GET /api/auth/config (#68).
|
||||||
app_tz_offset_hours: int = 3
|
app_tz_offset_hours: int = 3
|
||||||
|
|
||||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||||
@@ -67,8 +68,8 @@ class Settings(BaseSettings):
|
|||||||
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
||||||
cookie_domain: str | None = None
|
cookie_domain: str | None = None
|
||||||
|
|
||||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
|
||||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
|
||||||
telegram_bot_token: str | None = None
|
telegram_bot_token: str | None = None
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
public_base_url: str | None = None
|
public_base_url: str | None = None
|
||||||
@@ -139,6 +140,15 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@field_validator("app_tz_offset_hours")
|
||||||
|
@classmethod
|
||||||
|
def _known_tz_offset(cls, value: int) -> int:
|
||||||
|
"""Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
|
||||||
|
сдвинутое на сутки."""
|
||||||
|
if not -12 <= value <= 14:
|
||||||
|
raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
|
||||||
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _forbid_default_secrets_when_published(self) -> "Settings":
|
def _forbid_default_secrets_when_published(self) -> "Settings":
|
||||||
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
||||||
перезапуска, но окно короткое, а рестарты редки;
|
перезапуска, но окно короткое, а рестарты редки;
|
||||||
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
||||||
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
|
||||||
про SSE-шину), общий для всех воркеров.
|
про SSE-шину), общий для всех воркеров.
|
||||||
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
||||||
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
||||||
|
|||||||
@@ -175,6 +175,6 @@ def is_session_revoked(payload: dict) -> bool:
|
|||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
|
||||||
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
|
||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
|
||||||
|
|
||||||
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
|
||||||
где БД поднимается из чистого состояния.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from app.db.session import engine
|
|
||||||
from app.seed.reference_data import seed_reference_data
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_and_seed() -> None:
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as session:
|
|
||||||
seed_reference_data(session)
|
|
||||||
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
|
|||||||
return s.AuthConfig(
|
return s.AuthConfig(
|
||||||
methods=enabled_methods(),
|
methods=enabled_methods(),
|
||||||
telegram_bot_username=settings.telegram_bot_username,
|
telegram_bot_username=settings.telegram_bot_username,
|
||||||
|
tz_offset_hours=settings.app_tz_offset_hours,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||||
|
|
||||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||||
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
|
||||||
входа по логину остаётся только на деве.
|
без пароля остаётся только на деве (по логину и паролю входят везде).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
|
|||||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
|
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
|
||||||
|
# показывает время всем игрокам — независимо от пояса устройства (#68).
|
||||||
|
tz_offset_hours: int
|
||||||
|
|
||||||
|
|
||||||
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
||||||
|
|||||||
@@ -140,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
|
|||||||
def test_app_env_case_insensitive():
|
def test_app_env_case_insensitive():
|
||||||
s = config.Settings(app_env="Development")
|
s = config.Settings(app_env="Development")
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("offset", [-13, 15])
|
||||||
|
def test_tz_offset_out_of_range_rejected(offset):
|
||||||
|
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
|
||||||
|
config.Settings(app_env="development", app_tz_offset_hours=offset)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
|
||||||
|
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
|
||||||
|
from app.routers import auth
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
|
||||||
|
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
FROM restic/restic:0.19.1
|
FROM restic/restic:0.19.1
|
||||||
|
|
||||||
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
||||||
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
|
||||||
RUN apk add --no-cache sqlite supercronic tini
|
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
||||||
|
RUN apk add --no-cache sqlite supercronic tini curl
|
||||||
|
|
||||||
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
||||||
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
||||||
|
|||||||
+55
-5
@@ -737,16 +737,23 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
||||||
Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его
|
Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до
|
||||||
журнале (`docker compose logs backup`) нормально увидеть одно из двух:
|
10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`)
|
||||||
|
нормально увидеть одно из трёх:
|
||||||
|
|
||||||
- если `backup` успел раньше, чем `app` создал БД:
|
- если приложение так и не поднялось и БД нет:
|
||||||
|
|
||||||
```
|
```
|
||||||
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
||||||
Первый бэкап не удался — следующая попытка по расписанию.
|
Первый бэкап не удался — следующая попытка по расписанию.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- если БД есть, но без таблиц (миграции ещё не прошли):
|
||||||
|
|
||||||
|
```
|
||||||
|
Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches.
|
||||||
|
```
|
||||||
|
|
||||||
- если БД уже была:
|
- если БД уже была:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -763,8 +770,8 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
||||||
Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания
|
Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
|
||||||
БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте.
|
бэкапов — такой не выбирайте.
|
||||||
|
|
||||||
5. Восстановите, подставив ID из `list vps`:
|
5. Восстановите, подставив ID из `list vps`:
|
||||||
|
|
||||||
@@ -831,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
|
|||||||
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
||||||
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
||||||
|
|
||||||
|
### Отчёты в Telegram
|
||||||
|
|
||||||
|
Бот приложения (тот же, что для входа через Telegram) может присылать вам отчёты о бэкапах
|
||||||
|
и отвечать на команды. Пока `BACKUP_TELEGRAM_CHAT_ID` пуст, отчёты выключены.
|
||||||
|
|
||||||
|
**Что приходит само:**
|
||||||
|
- после каждого бэкапа — «✅ Бэкап»: сколько игроков и партий в БД, по каждому репозиторию
|
||||||
|
снимок, число снимков и размер;
|
||||||
|
- если бэкап не удался, в том числе не начавшись (нет пароля, занята другая операция, БД
|
||||||
|
не прошла проверку) — «❌ Бэкап не удался» с причиной и упавшими репозиториями;
|
||||||
|
- после еженедельной проверки — «🔍 Проверка данных: OK» или «❌».
|
||||||
|
|
||||||
|
**Команды боту** (отвечает только чатам из `BACKUP_TELEGRAM_CHAT_ID`):
|
||||||
|
- `/backups` — хранящиеся снимки: по репозиторию число и размер, 10 последних с временем,
|
||||||
|
игроками и партиями, 📌 у именованных;
|
||||||
|
- `/status` — то же, что `fs-backup status`;
|
||||||
|
- `/help` — список команд.
|
||||||
|
|
||||||
|
**Настройка:**
|
||||||
|
1. В Telegram найдите бота приложения и напишите ему `/start`.
|
||||||
|
2. Узнайте id своего чата:
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup telegram chats
|
||||||
|
# 123456789 @you /start
|
||||||
|
```
|
||||||
|
Когда бот уже работает (id вписан), он сам забирает сообщения. Тогда id нового чата
|
||||||
|
ищите в журнале: `docker compose logs backup | grep "чужого чата"`.
|
||||||
|
3. Впишите id в `.env` — `BACKUP_TELEGRAM_CHAT_ID=123456789`, несколько через запятую —
|
||||||
|
и пересоздайте контейнер: `docker compose up -d backup`.
|
||||||
|
4. Проверьте связь: `docker compose exec backup fs-backup telegram test` — в чат придёт
|
||||||
|
пробное сообщение.
|
||||||
|
|
||||||
|
Токен бота по умолчанию берётся из `TELEGRAM_BOT_TOKEN` приложения. Чтобы слать отчёты от
|
||||||
|
другого бота, задайте `BACKUP_TELEGRAM_BOT_TOKEN`. Если Telegram недоступен, бэкапы работают
|
||||||
|
как обычно — в журнале будет только строка «Telegram: … не отправлено».
|
||||||
|
|
||||||
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
||||||
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
||||||
> не трогает.
|
> не трогает.
|
||||||
@@ -852,6 +895,8 @@ docker compose logs --tail 100 backup
|
|||||||
| Симптом | Причина | Что сделать |
|
| Симптом | Причина | Что сделать |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
||||||
|
| отчёты в Telegram не приходят, `telegram test` пишет «отправить не удалось» | неверный chat id или токен, боту не писали `/start` | раздел 10, «Отчёты в Telegram»: написать боту, взять id из журнала, `docker compose up -d backup` |
|
||||||
|
| бот не отвечает на `/backups`, в журнале «сообщение из чужого чата» | ваш id не в `BACKUP_TELEGRAM_CHAT_ID` | вписать id из этой строки журнала, `docker compose up -d backup` |
|
||||||
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
||||||
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
||||||
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
||||||
@@ -920,6 +965,9 @@ docker volume rm <имя тома>
|
|||||||
| `recover` | разбор прерванного восстановления |
|
| `recover` | разбор прерванного восстановления |
|
||||||
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
||||||
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
||||||
|
| `telegram chats` | id чатов, писавших боту, — для `BACKUP_TELEGRAM_CHAT_ID` |
|
||||||
|
| `telegram test` | пробное сообщение в чаты `BACKUP_TELEGRAM_CHAT_ID` |
|
||||||
|
| `telegram bot` | бот-слушатель команд `/backups`, `/status` (контейнер запускает его сам) |
|
||||||
| `help` | краткая справка |
|
| `help` | краткая справка |
|
||||||
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
||||||
|
|
||||||
@@ -955,6 +1003,8 @@ docker volume rm <имя тома>
|
|||||||
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
||||||
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
||||||
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
||||||
|
| `BACKUP_TELEGRAM_CHAT_ID` | Pi | — | id чатов для отчётов и команд, через запятую; пусто = без Telegram |
|
||||||
|
| `BACKUP_TELEGRAM_BOT_TOKEN` | Pi | `TELEGRAM_BOT_TOKEN` | токен бота, если отчёты должен слать другой бот |
|
||||||
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
||||||
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
||||||
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ set -eu
|
|||||||
|
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||||
|
|
||||||
|
# Telegram-бот (#83): отвечает владельцу на /backups и /status. Стартует раньше проверки
|
||||||
|
# пароля — при выключенных бэкапах скажет, что они выключены. Упал — перезапуск через минуту.
|
||||||
|
if [ -n "${BACKUP_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${BACKUP_TELEGRAM_CHAT_ID:-}" ]; then
|
||||||
|
( while :; do fs-backup telegram bot || true; sleep 60; done ) &
|
||||||
|
else
|
||||||
|
log "Telegram не настроен (BACKUP_TELEGRAM_CHAT_ID/токен бота) — отчёты в Telegram выключены."
|
||||||
|
fi
|
||||||
|
|
||||||
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
||||||
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
||||||
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
||||||
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if ! fs-backup has-snapshots; then
|
if ! fs-backup has-snapshots; then
|
||||||
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу."
|
# Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
|
||||||
|
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
|
||||||
|
# равно: guard_empty не сохранит БД без таблиц.
|
||||||
|
_app="${BACKUP_APP_HOST:-app}"
|
||||||
|
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
|
||||||
|
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
|
||||||
|
_waited=0
|
||||||
|
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
|
||||||
|
if [ "$_waited" -ge "$_wait" ]; then
|
||||||
|
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
_waited=$((_waited + 10))
|
||||||
|
done
|
||||||
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+243
-4
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
|
|||||||
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
||||||
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
||||||
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
||||||
|
TG_TOKEN="${BACKUP_TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
TG_CHATS="${BACKUP_TELEGRAM_CHAT_ID:-}"
|
||||||
|
TG_API="${BACKUP_TELEGRAM_API:-https://api.telegram.org}"
|
||||||
|
LAST_ERROR="" # текст последнего die — причина сбоя в отчёте Telegram
|
||||||
|
|
||||||
# ─── Общие функции ────────────────────────────────────────────────────────────
|
# ─── Общие функции ────────────────────────────────────────────────────────────
|
||||||
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
||||||
die() { log "ОШИБКА: $*"; exit 1; }
|
die() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; }
|
||||||
|
|
||||||
require_password() {
|
require_password() {
|
||||||
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
||||||
@@ -118,6 +122,99 @@ human() { # байты → «12.3 MB»
|
|||||||
|
|
||||||
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
||||||
|
|
||||||
|
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
|
||||||
|
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
|
||||||
|
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
|
||||||
|
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
|
||||||
|
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
|
||||||
|
|
||||||
|
tg_escape() { sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'; }
|
||||||
|
|
||||||
|
tg_chat_ids() { printf '%s' "$TG_CHATS" | tr ',;' ' '; }
|
||||||
|
|
||||||
|
tg_api() { # tg_api <метод> [аргументы curl…] → JSON ответа
|
||||||
|
_method="$1"
|
||||||
|
shift
|
||||||
|
curl -fsS --max-time 70 "$@" "$TG_API/bot$TG_TOKEN/$_method" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_post() { # tg_post <chat_id> <html> — одно сообщение в один чат
|
||||||
|
tg_api sendMessage -o /dev/null \
|
||||||
|
--data-urlencode "chat_id=$1" \
|
||||||
|
--data-urlencode "text=$2" \
|
||||||
|
--data-urlencode "parse_mode=HTML" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_send() { # tg_send <html> — во все чаты владельца
|
||||||
|
tg_enabled || return 0
|
||||||
|
for _chat in $(tg_chat_ids); do
|
||||||
|
tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
repo_summary() { # repo_summary <repo> → «снимков 30 · 45.2 MB»
|
||||||
|
if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then
|
||||||
|
printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | {
|
||||||
|
IFS="$(printf '\t')" read -r _cnt _size
|
||||||
|
echo "снимков $_cnt · $(human "$_size")"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "размер недоступен"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают
|
||||||
|
# cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе.
|
||||||
|
notify_run() { # notify_run <код выхода>
|
||||||
|
tg_enabled || return 0
|
||||||
|
_when="$(date '+%d.%m %H:%M')"
|
||||||
|
_db="БД: игроков $_players, партий $_matches"
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
_text="✅ <b>Бэкап</b> $_when
|
||||||
|
$_db"
|
||||||
|
else
|
||||||
|
_text="❌ <b>Бэкап не удался</b> $_when
|
||||||
|
$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)"
|
||||||
|
[ "$_players" = "?" ] || _text="$_text
|
||||||
|
$_db"
|
||||||
|
fi
|
||||||
|
for _item in $_ok_repos; do
|
||||||
|
_text="$_text
|
||||||
|
• ${_item%%:*}: снимок <code>${_item#*:}</code> · $(repo_summary "${_item%%:*}")"
|
||||||
|
done
|
||||||
|
for _repo in $_failed; do
|
||||||
|
_text="$_text
|
||||||
|
• $_repo: ошибка"
|
||||||
|
done
|
||||||
|
[ "$1" -eq 0 ] || _text="$_text
|
||||||
|
Подробности: <code>docker compose logs backup</code>"
|
||||||
|
tg_send "$_text"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_verify() { # notify_verify <код выхода>
|
||||||
|
tg_enabled || return 0
|
||||||
|
_when="$(date '+%d.%m %H:%M')"
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
_text="🔍 <b>Проверка данных: OK</b> $_when"
|
||||||
|
else
|
||||||
|
_text="❌ <b>Проверка данных не прошла</b> $_when
|
||||||
|
$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
|
||||||
|
fi
|
||||||
|
for _item in $_verified; do
|
||||||
|
_rest="${_item#*:}"
|
||||||
|
_text="$_text
|
||||||
|
• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
|
||||||
|
done
|
||||||
|
for _repo in $_failed; do
|
||||||
|
_text="$_text
|
||||||
|
• $_repo: ошибка"
|
||||||
|
done
|
||||||
|
[ "$1" -eq 0 ] || _text="$_text
|
||||||
|
Подробности: <code>docker compose logs backup</code>"
|
||||||
|
tg_send "$_text"
|
||||||
|
}
|
||||||
|
|
||||||
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
||||||
db_ok() {
|
db_ok() {
|
||||||
[ -s "$1" ] || return 1
|
[ -s "$1" ] || return 1
|
||||||
@@ -178,6 +275,17 @@ forget_repo() {
|
|||||||
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
||||||
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
||||||
guard_empty() { # guard_empty <игроков> <партий>
|
guard_empty() { # guard_empty <игроков> <партий>
|
||||||
|
# «?» — счётчики не прочитались: в БД нет таблиц (миграции ещё не прошли). Такой снимок
|
||||||
|
# стал бы latest и сломал бы restore latest (#74) — отказ, как и для пустой БД.
|
||||||
|
case "$1$2" in
|
||||||
|
*'?'*)
|
||||||
|
for _r in $(repos); do mark "run-$_r" err "в БД нет таблиц users/matches — бэкап не сделан"; done
|
||||||
|
log "Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches."
|
||||||
|
log "Похоже, приложение ещё не применило миграции. Бэкап НЕ сделан, чтобы снимок без данных"
|
||||||
|
log "не стал последним. Дождитесь запуска приложения; если так и задумано:"
|
||||||
|
die "fs-backup run --allow-empty"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
||||||
@@ -236,11 +344,18 @@ cmd_run() {
|
|||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
_players="?"
|
||||||
|
_matches="?"
|
||||||
|
_ok_repos=""
|
||||||
|
_failed=""
|
||||||
|
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
|
||||||
|
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
|
||||||
|
trap '_rc=$?; notify_run "$_rc"' EXIT
|
||||||
require_password
|
require_password
|
||||||
setup_ssh
|
setup_ssh
|
||||||
take_lock
|
take_lock
|
||||||
warn_leftovers
|
warn_leftovers
|
||||||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
|
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
|
||||||
|
|
||||||
log "Снимок данных: консистентная копия БД…"
|
log "Снимок данных: консистентная копия БД…"
|
||||||
stage_db
|
stage_db
|
||||||
@@ -252,7 +367,6 @@ cmd_run() {
|
|||||||
log "БД в порядке: игроков $_players, партий $_matches."
|
log "БД в порядке: игроков $_players, партий $_matches."
|
||||||
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
||||||
|
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
||||||
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
||||||
@@ -260,6 +374,7 @@ cmd_run() {
|
|||||||
&& r "$_repo" check >&2; then
|
&& r "$_repo" check >&2; then
|
||||||
_sid="$(latest_short_id "$_repo")"
|
_sid="$(latest_short_id "$_repo")"
|
||||||
mark "run-$_repo" ok "снимок $_sid"
|
mark "run-$_repo" ok "снимок $_sid"
|
||||||
|
_ok_repos="$_ok_repos $_repo:$_sid"
|
||||||
log "OK: репозиторий $_repo, снимок $_sid."
|
log "OK: репозиторий $_repo, снимок $_sid."
|
||||||
else
|
else
|
||||||
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
||||||
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
|
|||||||
|
|
||||||
# ─── verify ───────────────────────────────────────────────────────────────────
|
# ─── verify ───────────────────────────────────────────────────────────────────
|
||||||
cmd_verify() {
|
cmd_verify() {
|
||||||
|
_verified=""
|
||||||
|
_failed=""
|
||||||
|
trap '_rc=$?; notify_verify "$_rc"' EXIT
|
||||||
require_password
|
require_password
|
||||||
setup_ssh
|
setup_ssh
|
||||||
take_lock
|
take_lock
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
||||||
_tmp="$RUNTIME_DIR/verify.db"
|
_tmp="$RUNTIME_DIR/verify.db"
|
||||||
@@ -378,6 +495,7 @@ cmd_verify() {
|
|||||||
&& db_ok "$_tmp"; then
|
&& db_ok "$_tmp"; then
|
||||||
_counts="$(db_counts "$_tmp")"
|
_counts="$(db_counts "$_tmp")"
|
||||||
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
||||||
|
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
|
||||||
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
||||||
else
|
else
|
||||||
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
||||||
@@ -779,6 +897,121 @@ cmd_init() {
|
|||||||
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── telegram ─────────────────────────────────────────────────────────────────
|
||||||
|
tg_help_text() {
|
||||||
|
cat <<'EOF'
|
||||||
|
<b>Бэкапы Forbidden Stars</b>
|
||||||
|
/backups — хранящиеся снимки по репозиториям
|
||||||
|
/status — последние бэкапы и проверки, размеры
|
||||||
|
Отчёт о каждом бэкапе и проверке приходит сюда сам.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
|
||||||
|
[ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
|
||||||
|
setup_ssh
|
||||||
|
for _repo in $(repos); do
|
||||||
|
echo "<b>$_repo</b> · $(repo_summary "$_repo")"
|
||||||
|
if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
|
||||||
|
printf '%s' "$_json" | jq -r '
|
||||||
|
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
|
||||||
|
def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
|
||||||
|
sort_by(.time) | .[-10:] | reverse | .[] |
|
||||||
|
"<code>\(.time[5:16] | sub("T"; " "))</code> игроков \(tagval("players:")), партий \(tagval("matches:"))"
|
||||||
|
+ (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
|
||||||
|
else
|
||||||
|
echo "репозиторий недоступен или занят — повторите позже"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_allowed() { # tg_allowed <chat_id> — чат из белого списка владельца
|
||||||
|
for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_reply() { # tg_reply <chat_id> <текст команды>
|
||||||
|
# Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота.
|
||||||
|
case "$2" in
|
||||||
|
/backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;;
|
||||||
|
/status*) _reply="<pre>$(cmd_status 2>&1 | head -n 60 | tg_escape)</pre>" ;;
|
||||||
|
*) _reply="$(tg_help_text)" ;;
|
||||||
|
esac
|
||||||
|
[ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup."
|
||||||
|
tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие
|
||||||
|
# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение
|
||||||
|
# restic совместимо с идущим бэкапом.
|
||||||
|
cmd_telegram_bot() {
|
||||||
|
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота."
|
||||||
|
_offset_file="$STATE_DIR/telegram.offset"
|
||||||
|
_updates="$RUNTIME_DIR/telegram-updates"
|
||||||
|
log "Telegram-бот: слушаю команды /backups, /status, /help."
|
||||||
|
while :; do
|
||||||
|
_offset="$(cat "$_offset_file" 2>/dev/null || echo 0)"
|
||||||
|
if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \
|
||||||
|
--data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then
|
||||||
|
sleep 30
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются.
|
||||||
|
printf '%s' "$_resp" | jq -r '.result[]? |
|
||||||
|
[.update_id, (.message.chat.id // ""), (.message.from.username // ""),
|
||||||
|
((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \
|
||||||
|
> "$_updates" 2>/dev/null || { sleep 30; continue; }
|
||||||
|
while IFS="$(printf '\037')" read -r _uid _chat _from _text; do
|
||||||
|
echo $((_uid + 1)) > "$_offset_file"
|
||||||
|
[ -n "$_chat" ] || continue
|
||||||
|
if tg_allowed "$_chat"; then
|
||||||
|
tg_reply "$_chat" "$_text"
|
||||||
|
else
|
||||||
|
log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID."
|
||||||
|
fi
|
||||||
|
done < "$_updates"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не
|
||||||
|
# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он
|
||||||
|
# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»).
|
||||||
|
cmd_telegram_chats() {
|
||||||
|
[ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN."
|
||||||
|
_resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \
|
||||||
|
|| die "Telegram не ответил (неверный токен или нет сети)."
|
||||||
|
_chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null)
|
||||||
|
| "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')"
|
||||||
|
if [ -z "$_chats" ]; then
|
||||||
|
echo "Сообщений боту нет: напишите ему /start в Telegram и повторите."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):"
|
||||||
|
printf '%s\n' "$_chats"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_telegram() {
|
||||||
|
case "${1:-}" in
|
||||||
|
bot) cmd_telegram_bot ;;
|
||||||
|
chats) cmd_telegram_chats ;;
|
||||||
|
test)
|
||||||
|
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)."
|
||||||
|
_sent=0
|
||||||
|
for _chat in $(tg_chat_ids); do
|
||||||
|
if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then
|
||||||
|
_sent=$((_sent + 1))
|
||||||
|
log "Telegram: тестовое сообщение в чат $_chat отправлено."
|
||||||
|
else
|
||||||
|
log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло."
|
||||||
|
;;
|
||||||
|
*) die "telegram: укажите chats, test или bot" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
||||||
@@ -806,6 +1039,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
|
|||||||
export <id|latest> [--repo local|vps] > fs.tar
|
export <id|latest> [--repo local|vps] > fs.tar
|
||||||
снимок в tar без сжатия (нужен exec -T)
|
снимок в tar без сжатия (нужен exec -T)
|
||||||
|
|
||||||
|
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
|
||||||
|
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
|
||||||
|
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
|
||||||
|
telegram bot бот-слушатель команд (контейнер запускает его сам)
|
||||||
|
|
||||||
Прочее:
|
Прочее:
|
||||||
init создать репозитории (делается автоматически)
|
init создать репозитории (делается автоматически)
|
||||||
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
||||||
@@ -833,6 +1071,7 @@ case "$_cmd" in
|
|||||||
restic) cmd_restic "$@" ;;
|
restic) cmd_restic "$@" ;;
|
||||||
health) cmd_health "$@" ;;
|
health) cmd_health "$@" ;;
|
||||||
has-snapshots) cmd_has_snapshots "$@" ;;
|
has-snapshots) cmd_has_snapshots "$@" ;;
|
||||||
|
telegram) cmd_telegram "$@" ;;
|
||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
*) usage >&2; exit 2 ;;
|
*) usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -35,6 +35,32 @@ newgrp docker # применить группу
|
|||||||
docker version && docker compose version # проверка
|
docker version && docker compose version # проверка
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Лимиты памяти (memory cgroup)
|
||||||
|
Прошивка Raspberry Pi сама добавляет ядру `cgroup_disable=memory`. Без контроллера `memory`
|
||||||
|
Docker **молча игнорирует** `mem_limit` из `docker-compose.yml` (app 512m, tunnel 64m, backup
|
||||||
|
384m) и на каждый контейнер пишет «Your kernel does not support memory limit capabilities or
|
||||||
|
the cgroup is not mounted. Limitation discarded.». Тогда утечка или тяжёлый бэкап могут
|
||||||
|
занять всю RAM Pi, и OOM-killer прибьёт что попало.
|
||||||
|
|
||||||
|
Проверка — если есть вывод, лимиты не работают:
|
||||||
|
```bash
|
||||||
|
docker info 2>&1 | grep -i "no memory limit"
|
||||||
|
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
|
||||||
|
```
|
||||||
|
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
|
||||||
|
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
|
||||||
|
контейнеры поднимутся сами:
|
||||||
|
```bash
|
||||||
|
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
|
||||||
|
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
|
||||||
|
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
|
||||||
|
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
|
||||||
|
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
|
||||||
|
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
|
||||||
|
|
||||||
## 2. SSH-ключ для туннеля
|
## 2. SSH-ключ для туннеля
|
||||||
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
||||||
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
||||||
@@ -154,3 +180,6 @@ docker compose exec backup fs-backup list # хронология снимк
|
|||||||
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
||||||
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
||||||
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
||||||
|
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
|
||||||
|
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
|
||||||
|
«Лимиты памяти».
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
||||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||||
#
|
#
|
||||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
|
||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
|
|||||||
+11
-5
@@ -21,8 +21,8 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
|
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
|
||||||
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
|
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
||||||
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -42,7 +42,9 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
mem_limit: ${APP_MEM_LIMIT:-512m} # лимиты под Pi; переопределяются в .env
|
# Лимиты под Pi; переопределяются в .env. mem_limit действует, только если на Pi включён
|
||||||
|
# memory cgroup — иначе Docker его молча игнорирует (deploy/pi/README.md, раздел 1).
|
||||||
|
mem_limit: ${APP_MEM_LIMIT:-512m}
|
||||||
cpus: ${APP_CPUS:-1.5}
|
cpus: ${APP_CPUS:-1.5}
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -75,8 +77,8 @@ services:
|
|||||||
|
|
||||||
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
||||||
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
||||||
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
|
# только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
|
||||||
# восстановление — deploy/backup/README.md.
|
# отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
|
||||||
backup:
|
backup:
|
||||||
build: ./deploy/backup
|
build: ./deploy/backup
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
||||||
@@ -97,6 +99,10 @@ services:
|
|||||||
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
||||||
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
||||||
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
|
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
|
||||||
|
# Отчёты и бот в Telegram (#83). Единственное исключение из «секреты приложения сюда
|
||||||
|
# не передаются»: по умолчанию — токен бота приложения (тот же ForbidenStarsBot).
|
||||||
|
BACKUP_TELEGRAM_BOT_TOKEN: ${BACKUP_TELEGRAM_BOT_TOKEN:-${TELEGRAM_BOT_TOKEN:-}}
|
||||||
|
BACKUP_TELEGRAM_CHAT_ID: ${BACKUP_TELEGRAM_CHAT_ID:-}
|
||||||
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/fs-db # живая БД (снимается консистентно)
|
- db-data:/fs-db # живая БД (снимается консистентно)
|
||||||
|
|||||||
+19
-1
@@ -1,15 +1,33 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/router";
|
import { router } from "./app/router";
|
||||||
|
import { Spinner } from "./components/Spinner";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
import { setAppTzOffsetHours } from "./domain/format";
|
||||||
|
import { useAuthConfig } from "./hooks/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
|
||||||
|
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
|
||||||
|
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
|
||||||
|
*/
|
||||||
|
function AppTimeZone({ children }: { children: ReactNode }) {
|
||||||
|
const { data, isPending } = useAuthConfig();
|
||||||
|
if (data) setAppTzOffsetHours(data.tz_offset_hours);
|
||||||
|
if (isPending) return <Spinner />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouterProvider router={router} />
|
<AppTimeZone>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AppTimeZone>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+2
@@ -1610,6 +1610,8 @@ export interface components {
|
|||||||
methods: string[];
|
methods: string[];
|
||||||
/** Telegram Bot Username */
|
/** Telegram Bot Username */
|
||||||
telegram_bot_username?: string | null;
|
telegram_bot_username?: string | null;
|
||||||
|
/** Tz Offset Hours */
|
||||||
|
tz_offset_hours: number;
|
||||||
};
|
};
|
||||||
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
||||||
Body_add_attachment_api_matches__match_id__attachments_post: {
|
Body_add_attachment_api_matches__match_id__attachments_post: {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
// Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
|
||||||
const APP_TZ_OFFSET_MIN = 3 * 60;
|
// Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
|
||||||
|
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
|
||||||
|
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
|
||||||
|
let appTzOffsetMin = 3 * 60;
|
||||||
|
|
||||||
|
export function setAppTzOffsetHours(hours: number): void {
|
||||||
|
appTzOffsetMin = hours * 60;
|
||||||
|
}
|
||||||
|
|
||||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
|
||||||
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
|
||||||
function shifted(iso: string): Date {
|
function shifted(iso: string): Date {
|
||||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
||||||
@@ -50,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
|
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
|
||||||
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
|
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
|
||||||
export function toAppLocalInput(iso: string): string {
|
export function toAppLocalInput(iso: string): string {
|
||||||
const d = shifted(iso);
|
const d = shifted(iso);
|
||||||
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое.
|
// Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
|
||||||
|
// null — поле пустое.
|
||||||
export function fromAppLocalInput(value: string): string | null {
|
export function fromAppLocalInput(value: string): string | null {
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000;
|
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
|
||||||
return new Date(ms).toISOString();
|
return new Date(ms).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
[33mcommit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mdev[m[33m)[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Wed Jun 17 04:45:33 2026 +0300
|
|
||||||
|
|
||||||
Добавление ssh-ключа к контейнеру tunnel
|
|
||||||
|
|
||||||
[33mcommit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Wed Jun 17 04:27:11 2026 +0300
|
|
||||||
|
|
||||||
Правки локального размещения дева, перенос тунеля в контейнер
|
|
||||||
|
|
||||||
[33mcommit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 19:15:54 2026 +0300
|
|
||||||
|
|
||||||
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
|
|
||||||
|
|
||||||
[33mcommit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f[m[33m ([m[1;31morigin/main[m[33m, [m[1;32mmain[m[33m)[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 17:41:06 2026 +0300
|
|
||||||
|
|
||||||
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
|
|
||||||
|
|
||||||
[33mcommit 6ab74f01aaf1ac042a269c7660b3e564b93350c7[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 16:53:41 2026 +0300
|
|
||||||
|
|
||||||
first commit
|
|
||||||
@@ -135,7 +135,7 @@ switch ($appEnv) {
|
|||||||
}
|
}
|
||||||
"production" {
|
"production" {
|
||||||
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
||||||
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build"
|
Write-Host "Deploy: PC (main branch) scripts\build-push.ps1, then on Pi: docker compose up -d"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ case "$app_env" in
|
|||||||
;;
|
;;
|
||||||
production)
|
production)
|
||||||
echo "production лаунчером не запускается — прод обособлен."
|
echo "production лаунчером не запускается — прод обособлен."
|
||||||
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
|
echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
|
|
||||||
# прод-контейнера (без тестов, dev-входа и dev-лаунчера).
|
|
||||||
#
|
|
||||||
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
|
|
||||||
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DEST="${1:?Укажите целевую папку: scripts/export-prod.sh <dir> [ref]}"
|
|
||||||
REF="${2:-HEAD}"
|
|
||||||
|
|
||||||
mkdir -p "$DEST"
|
|
||||||
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
|
|
||||||
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
|
||||||
|
|
||||||
# dev-специфика в проде не нужна (лаунчер)
|
|
||||||
rm -f "$DEST/run.ps1" "$DEST/run.sh"
|
|
||||||
|
|
||||||
echo "[export-prod] Прод выгружен в: $DEST"
|
|
||||||
echo " дальше: cp .env.example .env && docker compose up -d --build"
|
|
||||||
Reference in New Issue
Block a user