Author SHA1 Message Date
NotBigGhost 30270916a4 Merge pull request 'Часовой пояс отображения захардкожен на фронте и не зависит от APP_TZ_OFFSET_HOURS (#68)' (#98) from issue-68-tz-offset into dev
Reviewed-on: #98
2026-09-30 13:03:39 +03:00
NotBigGhost 2f0b22ccd4 Merge pull request 'Уведомление о бэкапах в тг боте (#83)' (#97) from issue-83-backup-telegram into dev
Reviewed-on: #97
2026-09-30 13:02:05 +03:00
NotBigGhost 89cbb81be7 Merge pull request 'Баг ограничения ресурсов пая (#67)' (#96) from issue-67-pi-memory-cgroup into dev
Reviewed-on: #96
2026-09-30 13:00:43 +03:00
NotBigGhostandClaude Opus 5 8d4f7a95df Время: один пояс приложения из .env и на сервере, и на фронте
APP_TZ_OFFSET_HOURS влиял только на «дату игры», а фронт показывал время в
захардкоженных +3 — при другом значении дата и время партии противоречили друг
другу. Теперь /api/auth/config отдаёт tz_offset_hours, App.tsx выставляет его в
format.ts до первой отрисовки страниц, и в нём же показывается всё время и
вводятся даты объявлений (запасное значение +3, если конфиг недоступен). Пояс
устройства не учитывается — решение владельца. Валидатор ограничивает смещение
−12..14. #68

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:05:30 +03:00
NotBigGhostandClaude Opus 5 5b89dc9d2f Бэкапы: отчёты и команды в Telegram-боте
Каждый run и verify шлёт в чаты BACKUP_TELEGRAM_CHAT_ID одно сообщение: успех
со сводкой (игроки/партии, по репозиторию снимок, число снимков, размер) или
сбой с причиной — в том числе ранний отказ (нет пароля, занят lock, битая БД):
обработчик EXIT берёт текст последнего die. Недоступный Telegram бэкап не ломает.

Бот-слушатель (fs-backup telegram bot, long polling, стартует из entrypoint в
фоне) отвечает на /backups (хранящиеся снимки), /status и /help только чатам из
белого списка; чужие chat id пишет в журнал. telegram chats — узнать свой id,
telegram test — проверить связь. Токен по умолчанию — TELEGRAM_BOT_TOKEN
приложения (тот же бот), BACKUP_TELEGRAM_BOT_TOKEN переопределяет. В образ
добавлен curl. Настройка — deploy/backup/README.md, раздел 10. #83

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 02:38:49 +03:00
NotBigGhostandClaude Opus 5 00bd3c72b6 Деплой: как включить memory cgroup на Pi, иначе mem_limit не действует
На Pi при каждом `docker compose up` контейнеры пишут «Your kernel does not
support memory limit capabilities…». Причина — прошивка Raspberry Pi добавляет
ядру cgroup_disable=memory: контроллера memory нет, и Docker молча игнорирует
mem_limit (app 512m, tunnel 64m, backup 384m).

deploy/pi/README.md: проверка, дописывание cgroup_enable=memory в cmdline.txt с
бэкапом, перезагрузка и контроль; строка в «Если что-то не так». Пояснения
у mem_limit в compose и в .env.example. #67

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 02:21:19 +03:00
14 changed files with 405 additions and 23 deletions
+10 -2
View File
@@ -89,7 +89,8 @@ IMAGE_TAG=latest
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
# Лимиты под 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_CPUS=1.5
@@ -123,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
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 и где там лежит
# docker-compose.yml прода.
BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+12 -2
View File
@@ -42,8 +42,9 @@ class Settings(BaseSettings):
# от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
# на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -139,6 +140,15 @@ class Settings(BaseSettings):
)
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")
def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
+1
View File
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig(
methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username,
tz_offset_hours=settings.app_tz_offset_hours,
)
+3
View File
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = []
telegram_bot_username: str | None = None
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
# показывает время всем игрокам — независимо от пояса устройства (#68).
tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
+17
View File
@@ -140,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
def test_app_env_case_insensitive():
s = config.Settings(app_env="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
+3 -2
View File
@@ -4,8 +4,9 @@
FROM restic/restic:0.19.1
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini curl
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
+43
View File
@@ -831,6 +831,42 @@ ID одного и того же снимка в `local` и `vps` разные
**Обновить образ бэкапа** (после изменений в `deploy/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` данные
> не трогает.
@@ -852,6 +888,8 @@ docker compose logs --tail 100 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_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
@@ -920,6 +958,9 @@ docker volume rm <имя тома>
| `recover` | разбор прерванного восстановления |
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
| `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` | краткая справка |
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
@@ -955,6 +996,8 @@ docker volume rm <имя тома>
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
| `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_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
+8
View File
@@ -5,6 +5,14 @@ set -eu
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-петля и спам в логах) — ждём,
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
if [ -z "${BACKUP_PASSWORD:-}" ]; then
+232 -4
View File
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
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-потоком.
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() {
[ -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"; }
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
tg_escape() { sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/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 не создаются).
db_ok() {
[ -s "$1" ] || return 1
@@ -236,11 +333,18 @@ cmd_run() {
esac
shift
done
_players="?"
_matches="?"
_ok_repos=""
_failed=""
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
trap '_rc=$?; notify_run "$_rc"' EXIT
require_password
setup_ssh
take_lock
warn_leftovers
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
log "Снимок данных: консистентная копия БД…"
stage_db
@@ -252,7 +356,6 @@ cmd_run() {
log "БД в порядке: игроков $_players, партий $_matches."
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
_failed=""
for _repo in $(repos); do
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
@@ -260,6 +363,7 @@ cmd_run() {
&& r "$_repo" check >&2; then
_sid="$(latest_short_id "$_repo")"
mark "run-$_repo" ok "снимок $_sid"
_ok_repos="$_ok_repos $_repo:$_sid"
log "OK: репозиторий $_repo, снимок $_sid."
else
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
@@ -365,10 +469,12 @@ cmd_has_snapshots() {
# ─── verify ───────────────────────────────────────────────────────────────────
cmd_verify() {
_verified=""
_failed=""
trap '_rc=$?; notify_verify "$_rc"' EXIT
require_password
setup_ssh
take_lock
_failed=""
for _repo in $(repos); do
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
_tmp="$RUNTIME_DIR/verify.db"
@@ -378,6 +484,7 @@ cmd_verify() {
&& db_ok "$_tmp"; then
_counts="$(db_counts "$_tmp")"
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
else
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
@@ -779,6 +886,121 @@ cmd_init() {
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() {
cat <<'EOF'
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
@@ -806,6 +1028,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
export <id|latest> [--repo local|vps] > fs.tar
снимок в tar без сжатия (нужен exec -T)
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
telegram bot бот-слушатель команд (контейнер запускает его сам)
Прочее:
init создать репозитории (делается автоматически)
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
@@ -833,6 +1060,7 @@ case "$_cmd" in
restic) cmd_restic "$@" ;;
health) cmd_health "$@" ;;
has-snapshots) cmd_has_snapshots "$@" ;;
telegram) cmd_telegram "$@" ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac
+29
View File
@@ -35,6 +35,32 @@ newgrp docker # применить группу
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-ключ для туннеля
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
`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`).
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
сертификатом (иначе хост в `/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,
«Лимиты памяти».
+9 -3
View File
@@ -42,7 +42,9 @@ services:
timeout: 5s
retries: 3
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}
security_opt:
- no-new-privileges:true
@@ -75,8 +77,8 @@ services:
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
# восстановление — deploy/backup/README.md.
# только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
# отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
backup:
build: ./deploy/backup
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_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
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}
volumes:
- db-data:/fs-db # живая БД (снимается консистентно)
+19 -1
View File
@@ -1,15 +1,33 @@
import { QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/router";
import { Spinner } from "./components/Spinner";
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() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} />
<AppTimeZone>
<RouterProvider router={router} />
</AppTimeZone>
</ToastProvider>
</QueryClientProvider>
);
+2
View File
@@ -1610,6 +1610,8 @@ export interface components {
methods: string[];
/** Telegram Bot Username */
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: {
+17 -9
View File
@@ -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");
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
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, "игрок", "игрока", "игроков").
@@ -50,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
}
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
export function toAppLocalInput(iso: string): string {
const d = shifted(iso);
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 {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
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();
}