diff --git a/DOCS_AI.md b/DOCS_AI.md index 6afb1f4..d3381c8 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -429,13 +429,20 @@ switch_language ru|en `lib/backup.sh`. Собирает в `.tar.gz`: - `/etc/telemt/config.toml` - `/opt/gotelegram/config.json` +- `/opt/gotelegram/disabled_users.json` - `/var/www/gotelegram-site/` (если есть) -- `/etc/letsencrypt/live//` + `/etc/letsencrypt/archive//` (если Pro) +- `/opt/gotelegram/custom_templates/`, `/opt/gotelegram/templates_catalog.json` +- `/opt/gotelegram/stats_history.csv`, `/opt/gotelegram/user_stats_history.csv`, `/opt/gotelegram/shared-443.json` +- `/opt/gotelegram-bot/.env`, bot i18n/lang +- `/opt/gotelegram-admin/server.py`, `/opt/gotelegram-admin/static/` +- `/etc/letsencrypt/live//` + `/etc/letsencrypt/archive//` + `/etc/letsencrypt/renewal/.conf` (если Pro) - `/etc/nginx/sites-available/gotelegram` (если есть) -Складывает в `/opt/gotelegram/backups/backup_YYYY-MM-DD_HH-MM-SS.tar.gz`. +Складывает в `/opt/gotelegram/backups/gotelegram_backup_YYYYMMDD_HHMMSS.tar.gz`. -`restore_backup` разворачивает архив обратно, перезапускает telemt и nginx. +`restore_backup [password] [yes]` разворачивает архив обратно, перезапускает telemt, nginx, bot и admin. Третий аргумент `yes` нужен для неинтерактивного restore из web-admin/бота; перед ним они создают свежий safety backup. Restore понимает legacy-архивы раннего бота `backup_*.tar.gz`, где внутри лежали только `opt/gotelegram/config.json` и `etc/telemt/config.toml`. + +Расписания бекапов: `set_backup_schedule off|daily|weekly|monthly` пишет `/etc/systemd/system/gotelegram-backup.{service,timer}` и `/opt/gotelegram/backup_schedule.json`. OnCalendar: daily `*-*-* 03:20:00`, weekly `Sun 03:20:00`, monthly `*-*-01 03:20:00`. Автоматическая чистка держит 30 последних `.tar.gz*` архивов. ### 13.1 Local Web Admin (v2.5.0) @@ -449,9 +456,9 @@ switch_language ru|en - write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически. - язык панели читается из `config.json.language`, затем из `/opt/gotelegram/.language`, fallback `en`; `POST /api/settings/language` сохраняет RU/EN в общий конфиг, marker file и bot `.env`; `gotelegram-bot/i18n.py` использует тот же источник как default до per-user override; - UI построен вкладками (`dashboard`, `traffic`, `keys`, `backups`, `logs`, `settings`), есть иконки меню, полезный overview-блок по реальным TCP/UDP-слушателям 443, light/dark theme в `localStorage` и promo-modal раз в 24 часа через `localStorage`; -- `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status` и `port_443`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats?range=15m|1h|24h|month` отдаёт выбранное окно и `summary_rows`; `/api/users//traffic?range=15m|1h|24h|month` отдаёт per-user историю по `telemt total_octets`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. +- `/api/overview` отдаёт `stats_status`, `admin_bind`, `site_status`, `port_443` и `backup_schedule`; `/api/site/check` проверяет `https://config.domain/` и считает OK только HTTP 200; `/api/stats?range=15m|1h|24h|month` отдаёт выбранное окно и `summary_rows`; `/api/users//traffic?range=15m|1h|24h|month` отдаёт per-user историю по `telemt total_octets`; `/api/users//qr` отдаёт PNG QR для Telegram proxy link через `qrencode`; `/api/stats/collect` делает разовый сбор, `/api/stats/repair` устанавливает/перезапускает `gotelegram-stats`. -Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users//enabled`, генерация proxy links, общий traffic history из `/opt/gotelegram/stats_history.csv`, per-user traffic history из `/opt/gotelegram/user_stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). +Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users//enabled`, генерация proxy links и QR, общий traffic history из `/opt/gotelegram/stats_history.csv`, per-user traffic history из `/opt/gotelegram/user_stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, `POST /api/backups/schedule`, `POST /api/backups/restore` (background restore job, только basename из backup dir), структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). Traffic retention: обе CSV-истории хранятся максимум 365 дней. Последние 31 день остаются с поминутным разрешением, более старые точки автоматически уплотняются до одной последней cumulative-точки в час. Чистка/уплотнение запускается не чаще одного раза в час, а per-user сбор пишет данные не чаще одного раза в минуту, даже если systemd-loop вызывает `stats_collect` каждую секунду. Параметры можно переопределить переменными `STATS_RETENTION_DAYS`, `STATS_MINUTE_RETENTION_DAYS`, `STATS_CLEANUP_INTERVAL`. @@ -468,7 +475,7 @@ Traffic retention: обе CSV-истории хранятся максимум 3 Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. Операции с ключами в web-admin и Telegram-боте берут общий file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace и quoted keys (`"a.b"`), а telemt restart для add/delete/enable/disable ставится через `systemctl --no-block restart`, чтобы switch в UI не зависал на `wait_tcp_port`. -`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Restart collector в Traffic. Backup v1.5 сохраняет `admin_web/server.py`, `admin_web/static/`, `disabled_users.json`, `stats_history.csv`, `user_stats_history.csv` и `shared-443.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. +`install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Restart collector в Traffic. Backup v1.6 сохраняет `admin_web/server.py`, `admin_web/static/`, `disabled_users.json`, `stats_history.csv`, `user_stats_history.csv`, `backup_schedule.json` и `shared-443.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. ### 13.2 Upgrade migration (v2.5.0) @@ -644,7 +651,7 @@ with socket.create_connection(("95.163.176.222", 443), timeout=5) as s: ## 17. Changelog -- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/runtime info); добавлена локальная web-админка goTelegram Pro `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, иконками, блоком реальных TCP/UDP-слушателей 443, promo-modal раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, site check на HTTP 200, structured journal logs, light/dark theme, адаптивом, быстрыми switch-переключателями ключей, traffic history 15m/1h/24h/month с переключением график/строки, per-user traffic history из `telemt total_octets` и stats collector restart endpoint; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.5` сохраняет bot `.env`, bot lang files, disabled user keys, web-admin server/static, custom templates, templates catalog, stats history, user stats history, shared-443 config и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher. +- **2.5.0 (2026-04-24)** — крупный maintenance pass в ветке `codex`: единая версия `2.5.0` в runtime и документации; удалён дефолтный PAT из `bootstrap.sh` (токен теперь только через `GOTELEGRAM_PAT`); `generate_telemt_toml` добавляет `[server.api]` на `127.0.0.1:9091` и metrics на `127.0.0.1:9090`, что нужно для управления пользователями и статистики; Telegram-бот получил меню `🔑 Keys` для `[access.users]` (добавить/отключить/включить/удалить/показать ссылку/QR/runtime info); добавлена локальная web-админка goTelegram Pro `gotelegram-admin` на `127.0.0.1:1984` с SSH-tunnel инструкцией в боте без отдельного web-admin токена, вкладочной UI-навигацией, иконками, блоком реальных TCP/UDP-слушателей 443, promo-modal раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, site check на HTTP 200, structured journal logs, light/dark theme, адаптивом, быстрыми switch-переключателями ключей, QR-кодами, traffic history 15m/1h/24h/month с переключением график/строки, per-user traffic history из `telemt total_octets` и stats collector restart endpoint; исправлено чтение traffic CSV в боте (header больше не ломает parsing); бот сам делает `stats_collect` перед показом статистики; `iptables` добавлен в optional deps и stats collector пытается установить его; CLI-смена шаблона теперь обновляет `config.json.template_id`, чтобы бот не показывал первый установленный шаблон; backup/restore версии `1.6` сохраняет bot `.env`, bot lang files, disabled user keys, backup schedule, web-admin server/static, custom templates, templates catalog, stats history, user stats history, shared-443 config и полноценную структуру Let's Encrypt (`live/archive/renewal`) для переезда на новый сервер; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher. - **2.4.6 (2026-04-10)** — universal `apt_lock_wait` helper: ожидание dpkg/apt lock при unattended-upgrades, исправляет установку nginx/certbot/python на свежих VPS. - **2.4.3 (2026-04-10)** — iter3-фикс: `bot_action_dispatch` оборачивается во `flock -w 30` на `/var/lock/gotelegram-bot-action.lock`. Обнаружена гонка: параллельные `change-lite-domain` получали `"no secret in config"`, потому что один процесс читал `config.json`, пока другой делал `jq ... > tmp && mv`. `util-linux` (содержит `flock`) добавлен в `critical` deps, `check_deps_present` и маппинги `apt_pkg_for_cmd`/`dnf_pkg_for_cmd`. - **2.4.2 (2026-04-10)** — реализация non-interactive `bot_action_*` в install.sh (change-template + change-lite-domain с JSON-ответом). bot.py подключает `run_bot_action()` и делает реальную работу вместо stub'ов. Критфиксы: (a) `safe_edit_message` принимает `disable_web_page_preview` (иначе TypeError в success-пути cb_pro_confirm); (b) чтение/запись `config['template_id']` вместо `config['template']` (save_gotelegram_config всегда писал `template_id`, бот смотрел не туда); (c) `bot_update_config_field` использует shell `date -Iseconds` вместо `jq now|todate` (jq 1.5 совместимость для Debian 10); (d) `asyncio.Lock _BOT_ACTION_LOCK` сериализует callback'и в процессе бота; (e) валидация `_TPL_ID_RE`/`_DOMAIN_RE` до subprocess. Полный аудит и автоустановка зависимостей: `ensure_deps` разделяет critical/optional, дедуплицирует пакеты, re-verify после install; `apt_pkg_for_cmd` и `dnf_pkg_for_cmd` мапят команды на пакеты (dig→dnsutils/bind-utils, xxd→xxd/vim-common, flock→util-linux). `check_deps_present` — быстрый чек без `apt-get update`. diff --git a/DOCS_HUMAN.md b/DOCS_HUMAN.md index b80be41..ed8c940 100644 --- a/DOCS_HUMAN.md +++ b/DOCS_HUMAN.md @@ -125,11 +125,11 @@ CLI и бот переведены на русский и английский. ## 7. Бекап и восстановление -Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, `/opt/gotelegram/disabled_users.json`, данные nginx-сайта, сертификаты, состояние Telegram-бота и локальной web-админки. По умолчанию в `/opt/gotelegram/backups/`. +Пункт 8 делает один файл `.tar.gz` со всем, что нужно: `/etc/telemt/config.toml`, `/opt/gotelegram/config.json`, `/opt/gotelegram/disabled_users.json`, данные nginx-сайта, сертификаты Let's Encrypt (`live/archive/renewal`), пользовательские шаблоны, каталог шаблонов, историю трафика, состояние Telegram-бота и локальной web-админки. По умолчанию архивы лежат в `/opt/gotelegram/backups/`. -Пункт 9 принимает такой архив и восстанавливает всё обратно (конфиг, сервис, ссылка — всё то же, что было). +Пункт 9 принимает такой архив и восстанавливает всё обратно (конфиг, сервис, ссылка — всё то же, что было). Перед восстановлением из админки или Telegram-бота автоматически создаётся свежий safety-бекап текущего состояния. -Хорошая практика: делать бекап каждый раз перед пунктами 1 (переустановка) и 10 (обновление telemt). +В web-админке и Telegram-боте есть сценарии автобэкапов: выключено, каждый день, каждую неделю или каждый месяц. Это systemd timer `gotelegram-backup.timer`; автоматическая чистка оставляет последние 30 архивов, чтобы каталог не рос бесконечно. Старые простые архивы `backup_*.tar.gz`, которые создавал ранний Telegram-бот только из двух конфигов, тоже распознаются при восстановлении как legacy-формат. --- @@ -145,7 +145,7 @@ CLI и бот переведены на русский и английский. - есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile; - Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`. -В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, генерация ссылок, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, такая же статистика по каждому ключу, кнопка разового обновления статистики, кнопка перезапуска сборщика, список бекапов и просмотр логов с количеством строк и статусом `journalctl`. +В админке есть dashboard, проверка сайта `https://домен/` на HTTP 200, статус сервисов, полезный блок «кто слушает порт 443» по данным `ss`, управление ключами `[access.users]` с добавлением, удалением и быстрым отключением через switch, генерация ссылок и QR-кодов для импорта в Telegram, traffic history по периодам 15 минут / 1 час / 24 часа / месяц с переключателем график/строки, такая же статистика по каждому ключу, кнопка разового обновления статистики, кнопка перезапуска сборщика, список/создание/восстановление бекапов, расписание автобэкапов и просмотр логов с количеством строк и статусом `journalctl`. История трафика хранится максимум 1 год. Чтобы файлы не разрастались, последние 31 день пишутся поминутно, а более старая история автоматически уплотняется до одной точки в час. Для обычного просмотра 15 минут / 1 час / 24 часа / месяц детализация остаётся полной. @@ -255,7 +255,7 @@ A: Сам MTProxy — да, это публичная технология из ## Changelog (коротко) -- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка, текущий runtime и история трафика по ключу; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, блоком реальных TCP/UDP-слушателей 443, подсказками к техническим терминам, traffic history по периодам 15 минут / 1 час / 24 часа / месяц и per-user traffic history; backup/restore сохраняет bot `.env`, языки бота, отключённые ключи, web-admin server/static, custom templates, stats history, user stats history, shared-443 config и структуру Let's Encrypt для переезда на новый VPS; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher. +- **2.5.0** — единая версия по коду и документации; удалён дефолтный PAT из `bootstrap.sh`; исправлена статистика в боте (CSV header больше не ломает чтение истории, бот сам обновляет snapshot); CLI-смена шаблона теперь обновляет `config.json.template_id`, поэтому бот показывает текущий шаблон; telemt TOML включает локальный API `127.0.0.1:9091` и metrics на `127.0.0.1:9090`; добавлено меню Telegram-бота для отдельных ключей пользователей (`[access.users]`): список, добавление, отключение/включение, удаление, ссылка, QR-код, текущий runtime и история трафика по ключу; добавлена локальная web-админка goTelegram Pro на `127.0.0.1:1984` под SSH tunnel без отдельного токена, с вкладками, иконками, promo-разделом раз в 24 часа, i18n от языка установки, ручным переключателем RU/EN, проверкой сайта на HTTP 200, тёмной темой, адаптивом, быстрыми switch-переключателями ключей, QR-кодами, блоком реальных TCP/UDP-слушателей 443, подсказками к техническим терминам, traffic history по периодам 15 минут / 1 час / 24 часа / месяц и per-user traffic history; backup/restore сохраняет bot `.env`, языки бота, отключённые ключи, web-admin server/static, custom templates, stats history, user stats history, shared-443 config и структуру Let's Encrypt для переезда на новый VPS; добавлены ручные/ежедневные/еженедельные/ежемесячные бэкапы, восстановление из админки/бота с safety-бекапом и legacy-restore для старых `backup_*.tar.gz`; добавлен безопасный детект 3x-ui/Xray на 443 и управляемый nginx stream shared-443 dispatcher. - **2.4.6** — ожидание apt/dpkg lock на свежих Ubuntu/Debian, чтобы установка nginx/certbot/Python не падала во время unattended-upgrades. - **2.4.3** — фикс гонки в `bot_action_dispatch`: параллельные вызовы `change-lite-domain`/`change-template` (например, два пользователя бота одновременно) могли получить ошибку «no secret in config», если один процесс читал `config.json` в момент, когда другой его перезаписывал через `jq`. Теперь диспетчер оборачивается в `flock(1)` с таймаутом 30 с; `util-linux` (содержит `flock`) добавлен в критические зависимости. - **2.4.2** — смена шаблона и домена маскировки **прямо из Telegram-бота** без SSH. Раньше эти пункты меню показывали сообщение «сделай через CLI», теперь бот вызывает `install.sh --action=change-template --json` / `--action=change-lite-domain --json` и разбирает ответ. Плюс: безопасный `safe_edit_message` принимает `disable_web_page_preview`; поле статуса шаблона наконец-то отображается (раньше читалось не из того ключа JSON); полный аудит и автоустановка системных зависимостей при первом запуске (`curl jq openssl git xxd tar dig flock` + опциональные `qrencode bc`); `asyncio.Lock` в боте сериализует параллельные callback'и; валидация tpl\_id (`[A-Za-z0-9_-]{1,64}`) и домена до subprocess. diff --git a/admin-web/server.py b/admin-web/server.py index 8b4039f..99bda4e 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -16,6 +16,7 @@ import mimetypes import os import re import secrets +import shlex import socket import subprocess import time @@ -42,6 +43,8 @@ BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot")) DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json")) USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock")) SHARED_443_CONFIG = Path(os.getenv("GOTELEGRAM_SHARED_443", "/opt/gotelegram/shared-443.json")) +BACKUP_SCHEDULE_FILE = Path(os.getenv("GOTELEGRAM_BACKUP_SCHEDULE", "/opt/gotelegram/backup_schedule.json")) +BACKUP_RESTORE_LOG = Path(os.getenv("GOTELEGRAM_BACKUP_RESTORE_LOG", "/var/log/gotelegram-restore.log")) HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) @@ -49,6 +52,7 @@ VERSION = "2.5.0" USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") LANG_RE = re.compile(r"^(en|ru)$") SENSITIVE_CONFIG_KEYS = {"secret"} +BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$") TRAFFIC_WINDOWS = { "15m": 15 * 60, "1h": 60 * 60, @@ -75,6 +79,19 @@ def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]: return 125, "", str(exc) +def run_bytes(cmd: list[str], timeout: int = 8) -> tuple[int, bytes, str]: + try: + proc = subprocess.run( + cmd, + capture_output=True, + timeout=timeout, + check=False, + ) + return proc.returncode, proc.stdout, proc.stderr.decode("utf-8", errors="replace") + except Exception as exc: # pragma: no cover - system dependent + return 125, b"", str(exc) + + class FileLock: def __init__(self, path: Path): self.path = path @@ -909,6 +926,55 @@ def list_backups() -> list[dict[str, Any]]: return items[:30] +def backup_schedule_calendar(frequency: str) -> str | None: + calendars = { + "off": None, + "daily": "*-*-* 03:20:00", + "weekly": "Sun 03:20:00", + "monthly": "*-*-01 03:20:00", + } + if frequency not in calendars: + raise ValueError("unsupported backup schedule") + return calendars[frequency] + + +def backup_schedule_status() -> dict[str, Any]: + raw = load_json(BACKUP_SCHEDULE_FILE, {}) or {} + if not isinstance(raw, dict): + raw = {} + frequency = str(raw.get("frequency") or "off") + try: + calendar = backup_schedule_calendar(frequency) + except ValueError: + frequency = "off" + calendar = None + active_code, active, _ = run(["systemctl", "is-active", "gotelegram-backup.timer"], timeout=5) + enabled_code, enabled, _ = run(["systemctl", "is-enabled", "gotelegram-backup.timer"], timeout=5) + _, next_run, _ = run(["systemctl", "show", "gotelegram-backup.timer", "--property=NextElapseUSecRealtime", "--value"], timeout=5) + return { + "frequency": frequency, + "calendar": calendar, + "enabled": enabled_code == 0 and enabled.strip() == "enabled", + "active": active_code == 0 and active.strip() == "active", + "next": next_run.strip(), + "updated_at": raw.get("updated_at") or "", + } + + +def set_backup_schedule(frequency: str) -> tuple[bool, str, dict[str, Any]]: + backup_schedule_calendar(frequency) + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + f"set_backup_schedule {shlex.quote(frequency)}" + ) + code, stdout, stderr = run(["bash", "-lc", script], timeout=120) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, message, backup_schedule_status() + + def create_backup() -> tuple[bool, str]: script = ( "source /opt/gotelegram/lib/common.sh; " @@ -917,13 +983,71 @@ def create_backup() -> tuple[bool, str]: "source /opt/gotelegram/lib/website.sh; " "source /opt/gotelegram/lib/backup.sh; " "load_language \"$(detect_language 2>/dev/null || echo en)\"; " - "create_backup \"\"" + "create_backup \"\"; " + "cleanup_old_backups 30" ) code, stdout, stderr = run(["bash", "-lc", script], timeout=180) text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] return code == 0, text +def safe_backup_path(name: str) -> Path: + raw = str(name or "").strip() + if not raw or raw != os.path.basename(raw) or not BACKUP_NAME_RE.match(raw) or raw.endswith(".sha256"): + raise ValueError("invalid backup name") + candidate = (BACKUP_DIR / raw).resolve() + base = BACKUP_DIR.resolve() + if base != candidate.parent: + raise ValueError("invalid backup path") + if not candidate.exists(): + raise FileNotFoundError("backup not found") + return candidate + + +def launch_restore_backup(name: str, password: str = "") -> dict[str, Any]: + backup_path = safe_backup_path(name) + if backup_path.name.endswith(".enc") and not password: + raise ValueError("password required for encrypted backup") + BACKUP_RESTORE_LOG.parent.mkdir(parents=True, exist_ok=True) + quoted_path = shlex.quote(str(backup_path)) + quoted_password = shlex.quote(password) + quoted_log = shlex.quote(str(BACKUP_RESTORE_LOG)) + script = ( + "sleep 1; " + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\" >/dev/null 2>&1 || true; " + f"restore_backup {quoted_path} {quoted_password} yes; " + "cleanup_old_backups 30" + ) + with BACKUP_RESTORE_LOG.open("ab") as log: + log.write(f"\n[{utc_now()}] restore requested for {backup_path.name}\n".encode("utf-8")) + subprocess.Popen( + ["bash", "-lc", f"{script} >> {quoted_log} 2>&1"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"name": backup_path.name, "started": True, "log": str(BACKUP_RESTORE_LOG)} + + +def user_qr_png(name: str) -> tuple[bytes, str]: + users = read_user_records() + record = users.get(name) + if not record: + raise FileNotFoundError("user not found") + link = proxy_link(str(record.get("secret", ""))) + code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", link], timeout=8) + if code != 0 or not image: + raise RuntimeError(error.strip() or "qrencode is not installed") + return image, link + + def read_log_payload(service: str) -> dict[str, Any]: allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"} if service not in allowed: @@ -999,6 +1123,7 @@ def overview_payload() -> dict[str, Any]: "stats_status": stats_status(current, history), "runtime_summary": summary, "backups": list_backups(), + "backup_schedule": backup_schedule_status(), } @@ -1017,6 +1142,14 @@ class AdminHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def send_error_json(self, status: int, message: str) -> None: self.send_json({"ok": False, "error": message}, status) @@ -1046,6 +1179,23 @@ class AdminHandler(BaseHTTPRequestHandler): record = users[name] items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name))) self.send_json({"ok": True, "data": items}) + elif path.startswith("/api/users/") and path.endswith("/qr"): + name = urllib.parse.unquote(path[len("/api/users/"):-len("/qr")]) + try: + png, link = user_qr_png(name) + except FileNotFoundError: + self.send_error_json(404, "user not found") + return + except Exception as exc: + self.send_error_json(503, str(exc)) + return + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Proxy-Link", urllib.parse.quote(link, safe="")) + self.send_header("Content-Length", str(len(png))) + self.end_headers() + self.wfile.write(png) elif path.startswith("/api/users/") and path.endswith("/traffic"): name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")]) users = read_user_records() @@ -1084,6 +1234,8 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_json({"ok": True, "data": user_payload(name, record["secret"], record["enabled"], include_runtime=True, traffic_snapshot=latest_user_stats().get(name))}) elif path == "/api/backups": self.send_json({"ok": True, "data": list_backups()}) + elif path == "/api/backups/schedule": + self.send_json({"ok": True, "data": backup_schedule_status()}) elif path == "/api/stats": qs = urllib.parse.parse_qs(parsed.query) range_key = normalize_range(qs.get("range", ["1h"])[0]) @@ -1179,6 +1331,27 @@ class AdminHandler(BaseHTTPRequestHandler): elif path == "/api/backups": ok, result = create_backup() self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) + elif path == "/api/backups/schedule": + try: + frequency = str(body.get("frequency") or "off").strip().lower() + ok, message, status = set_backup_schedule(frequency) + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + self.send_json({"ok": ok, "data": {"message": message, "schedule": status}}, 200 if ok else 500) + elif path == "/api/backups/restore": + try: + payload = launch_restore_backup(str(body.get("name") or ""), str(body.get("password") or "")) + except FileNotFoundError: + self.send_error_json(404, "backup not found") + return + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + except Exception as exc: + self.send_error_json(500, str(exc)) + return + self.send_json({"ok": True, "data": payload}, 202) elif path == "/api/stats/collect": ok, message, payload = run_stats_action("collect") payload["message"] = message diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 7f8638e..c3f8372 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -64,6 +64,7 @@ const i18n = { addKey: "Add key", copyLink: "Copy link", copySecret: "Copy secret", + showQr: "QR", delete: "Delete", enabled: "Enabled", disabled: "Disabled", @@ -73,6 +74,20 @@ const i18n = { enableKey: "Enable key", main: "main", createBackup: "Create backup", + restoreBackup: "Restore", + backupScheduleTitle: "Automatic backups", + backupScheduleLoading: "Loading schedule...", + backupIncludesTitle: "Backup contents", + backupIncludesText: "telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history.", + scheduleOff: "Off", + scheduleDaily: "Daily", + scheduleWeekly: "Weekly", + scheduleMonthly: "Monthly", + scheduleSaved: "Schedule saved", + scheduleNext: "Next run: {value}", + scheduleDisabled: "Automatic backups are disabled", + backupRestoreStarted: "Restore started", + confirmRestoreBackup: "Restore backup", loadLogs: "Load", panelLanguage: "Panel language", theme: "Theme", @@ -122,6 +137,7 @@ const i18n = { keyCreated: "Key created", keyDeleted: "Key deleted", backupCreated: "Backup created", + qrUnavailable: "QR code is unavailable", serviceRestarted: "Service restarted", statsRepaired: "Collector restarted", statsCollected: "Statistics collected", @@ -189,6 +205,8 @@ const i18n = { promoHosting1: "Hosting #1", promoHosting2: "Hosting #2", promoTips: "Tips", + qrEyebrow: "QR import", + qrTitle: "Scan Telegram proxy", pageDashboardTitle: "Dashboard", pageDashboardKicker: "Local Admin", pageTrafficTitle: "Traffic", @@ -264,6 +282,7 @@ const i18n = { addKey: "Добавить ключ", copyLink: "Копировать ссылку", copySecret: "Копировать секрет", + showQr: "QR", delete: "Удалить", enabled: "Включён", disabled: "Отключён", @@ -273,6 +292,20 @@ const i18n = { enableKey: "Включить ключ", main: "основной", createBackup: "Создать бекап", + restoreBackup: "Восстановить", + backupScheduleTitle: "Автобекапы", + backupScheduleLoading: "Загрузка расписания...", + backupIncludesTitle: "Что входит в бекап", + backupIncludesText: "конфиг telemt, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL-сертификаты, бот, админка и история трафика.", + scheduleOff: "Выкл", + scheduleDaily: "Каждый день", + scheduleWeekly: "Каждую неделю", + scheduleMonthly: "Каждый месяц", + scheduleSaved: "Расписание сохранено", + scheduleNext: "Следующий запуск: {value}", + scheduleDisabled: "Автобекапы отключены", + backupRestoreStarted: "Восстановление запущено", + confirmRestoreBackup: "Восстановить бекап", loadLogs: "Загрузить", panelLanguage: "Язык панели", theme: "Тема", @@ -322,6 +355,7 @@ const i18n = { keyCreated: "Ключ создан", keyDeleted: "Ключ удалён", backupCreated: "Бекап создан", + qrUnavailable: "QR-код недоступен", serviceRestarted: "Сервис перезапущен", statsRepaired: "Сборщик перезапущен", statsCollected: "Статистика собрана", @@ -389,6 +423,8 @@ const i18n = { promoHosting1: "Хостинг #1", promoHosting2: "Хостинг #2", promoTips: "Чаевые", + qrEyebrow: "QR-импорт", + qrTitle: "Сканирование прокси Telegram", pageDashboardTitle: "Обзор", pageDashboardKicker: "Локальная админка", pageTrafficTitle: "Трафик", @@ -420,6 +456,8 @@ const state = { userTrafficView: "chart", userTraffic: null, userTrafficLoading: false, + backupSchedule: null, + qrLink: "", pendingUsers: new Set(), }; @@ -514,6 +552,7 @@ function applyI18n() { $("#visualText").textContent = t("visualText"); updateTrafficControls(); updateUserTrafficControls(); + renderBackupSchedule(); updatePageTitle(); } @@ -1091,7 +1130,12 @@ function renderUsers() { ${escapeHtml(user.secret)} - + +
+ + +
+
${escapeHtml(trafficTotal)} @@ -1109,6 +1153,7 @@ function renderUsers() { function renderBackups(backups) { const box = $("#backupsList"); + renderBackupSchedule(); if (!backups.length) { box.innerHTML = `
${escapeHtml(t("noBackups"))}
`; return; @@ -1119,11 +1164,26 @@ function renderBackups(backups) { ${escapeHtml(item.name)} ${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}
-
${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}
+
+ ${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""} + +
`).join(""); } +function renderBackupSchedule() { + const schedule = state.backupSchedule || state.overview?.backup_schedule || { frequency: "off" }; + const frequency = schedule.frequency || "off"; + $$("[data-backup-schedule]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.backupSchedule === frequency); + }); + const next = schedule.next && schedule.next !== "n/a" ? schedule.next : ""; + $("#backupScheduleMeta").textContent = frequency === "off" + ? t("scheduleDisabled") + : t("scheduleNext").replace("{value}", next || (schedule.calendar || "--")); +} + function renderEvents() { const box = $("#events"); if (!state.events.length) { @@ -1162,6 +1222,7 @@ async function refreshAll() { btn.disabled = true; try { state.overview = await api("/api/overview"); + state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); if (!state.stats) { @@ -1324,6 +1385,53 @@ async function createBackup() { } } +async function setBackupSchedule(frequency) { + $$("[data-backup-schedule]").forEach((btn) => { btn.disabled = true; }); + try { + const data = await api("/api/backups/schedule", { + method: "POST", + body: JSON.stringify({ frequency }), + }); + state.backupSchedule = data.schedule || data; + renderBackupSchedule(); + addEvent(t("scheduleSaved"), frequency); + toast(t("scheduleSaved")); + } catch (err) { + toast(err.message); + } finally { + $$("[data-backup-schedule]").forEach((btn) => { btn.disabled = false; }); + } +} + +async function restoreBackup(name) { + const data = await api("/api/backups/restore", { + method: "POST", + body: JSON.stringify({ name }), + }); + addEvent(t("backupRestoreStarted"), data.name || name); + toast(t("backupRestoreStarted")); + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 4000); +} + +function showUserQr(name) { + const user = state.users.find((item) => item.name === name); + if (!user) { + toast(t("qrUnavailable")); + return; + } + state.qrLink = user.link || ""; + $("#qrTitle").textContent = `${t("qrTitle")} · ${user.name}`; + $("#qrMeta").textContent = user.link || ""; + const img = $("#qrImage"); + img.alt = `${user.name} Telegram proxy QR`; + img.onerror = () => { + img.removeAttribute("src"); + toast(t("qrUnavailable")); + }; + img.src = `/api/users/${encodeURIComponent(user.name)}/qr?ts=${Date.now()}`; + $("#qrModal").hidden = false; +} + async function loadLogs() { const service = $("#logService").value; const btn = $("#loadLogsBtn"); @@ -1436,11 +1544,18 @@ document.addEventListener("click", async (eventObj) => { } else if (button.dataset.userTraffic) { state.userTrafficUser = button.dataset.userTraffic; refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message)); + } else if (button.dataset.userQr) { + showUserQr(button.dataset.userQr); } else if (button.dataset.userTrafficRange) { changeUserTrafficRange(button.dataset.userTrafficRange); } else if (button.dataset.userTrafficView) { state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart"; renderUserTraffic(); + } else if (button.dataset.backupSchedule) { + setBackupSchedule(button.dataset.backupSchedule); + } else if (button.dataset.restoreBackup) { + const name = button.dataset.restoreBackup; + if (confirm(`${t("confirmRestoreBackup")} ${name}?`)) restoreBackup(name).catch((err) => toast(err.message)); } else if (button.dataset.copy) { await copyText(button.dataset.copy); } else if (button.dataset.delete) { @@ -1480,6 +1595,12 @@ $("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventO $("#promoClose").addEventListener("click", () => { $("#promoModal").hidden = true; }); +$("#qrClose").addEventListener("click", () => { + $("#qrModal").hidden = true; +}); +$("#qrCopyBtn").addEventListener("click", () => { + if (state.qrLink) copyText(state.qrLink); +}); $("#createBackupBtn").addEventListener("click", createBackup); $("#loadLogsBtn").addEventListener("click", loadLogs); $("#repairStatsBtn").addEventListener("click", repairStats); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 19d5f01..cd4d84e 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -272,6 +272,22 @@ +
+
+ Automatic backups + Loading schedule... +
+
+ + + + +
+
+
+ Backup contents + telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history. +
@@ -351,6 +367,18 @@
+ - + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index cfd54ca..d70c0f2 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -923,6 +923,39 @@ td small { gap: 10px; } +.mini-actions, +.backup-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.backup-schedule, +.backup-includes { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; + margin-bottom: 12px; +} + +.backup-schedule { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.backup-schedule span, +.backup-includes span { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} + .backup-item, .event, .settings-list > div { @@ -965,6 +998,36 @@ td small { word-break: break-word; } +.qr-card { + width: min(440px, calc(100vw - 32px)); +} + +.qr-frame { + display: grid; + place-items: center; + margin: 12px 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.qr-frame img { + display: block; + width: min(280px, 70vw); + aspect-ratio: 1; + object-fit: contain; +} + +.modal-note { + max-height: 92px; + overflow: auto; + color: var(--muted); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + .toast { position: fixed; right: 22px; @@ -1215,12 +1278,23 @@ td small { } .backup-item, + .backup-schedule, .event, .settings-list > div, .port-listener, .port-empty { grid-template-columns: 1fr; } + + .mini-actions, + .backup-actions { + justify-content: stretch; + } + + .mini-actions button, + .backup-actions button { + flex: 1 1 130px; + } } @media (max-width: 460px) { diff --git a/gotelegram-bot/README.md b/gotelegram-bot/README.md index c77739d..827484e 100644 --- a/gotelegram-bot/README.md +++ b/gotelegram-bot/README.md @@ -20,6 +20,7 @@ Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux se - **Template Browsing** - Browse categories → templates → preview → install - **Per-user MTProxy Keys** - Manage telemt `[access.users]` from inline bot menus +- **Per-user QR Import** - Show QR codes for every Telegram proxy key - **Local Web Admin** - Shows SSH tunnel instructions for the 127.0.0.1:1984 dashboard - **V1 Migration** - Detects old mtg Docker container and offers migration - **Access Control** - ALLOWED_IDS from .env @@ -96,6 +97,7 @@ WantedBy=multi-user.target - `TELEMT_SERVICE` - `telemt` (systemd service name) - `WEBSITE_ROOT` - `/var/www/gotelegram-site` - `BACKUP_DIR` - `/opt/gotelegram/backups` +- `BACKUP_SCHEDULE_FILE` - `/opt/gotelegram/backup_schedule.json` - `TEMPLATES_CATALOG` - `/opt/gotelegram/templates_catalog.json` ## Architecture @@ -114,6 +116,7 @@ Organized by feature: - Installation (quick/stealth modes) - Status monitoring - Backup/restore +- Backup schedules: off, daily, weekly, monthly - SSL management - Updates - Removal diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 037d003..333c48c 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -15,6 +15,7 @@ import json import logging import os import re +import shlex import shutil import subprocess import sys @@ -111,6 +112,7 @@ TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_SERVICE = "telemt" WEBSITE_ROOT = "/var/www/gotelegram-site" BACKUP_DIR = "/opt/gotelegram/backups" +BACKUP_SCHEDULE_FILE = "/opt/gotelegram/backup_schedule.json" TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json" INSTALL_SH = "/opt/gotelegram/install.sh" @@ -1846,12 +1848,14 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No buttons = [ [InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")], + [InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")], [InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")], [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")], ] if name == "main": buttons = [ [InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")], + [InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")], [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")], ] await safe_edit_message( @@ -1863,6 +1867,48 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No ) +async def cb_user_qr(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_qr_") + users = load_user_records() + record = users.get(name) + if not record: + await query.answer("Ключ не найден", show_alert=True) + return + link = await get_proxy_link_for_secret(str(record.get("secret", ""))) + if not link: + await query.answer("Ссылка недоступна", show_alert=True) + return + + qr_file = f"/tmp/gotelegram_user_qr_{hashlib.sha256(name.encode()).hexdigest()[:10]}.png" + code, _, _ = await sh("which", "qrencode") + if code == 0: + code, _, _ = await sh("qrencode", "-o", qr_file, link) + if code == 0 and os.path.exists(qr_file): + try: + with open(qr_file, "rb") as f: + await query.message.reply_photo( + photo=f, + caption=f"📷 QR: {html.escape(name)}\n\n{html.escape(link)}", + parse_mode="HTML", + ) + finally: + try: + os.remove(qr_file) + except OSError: + pass + else: + await safe_edit_message( + query, + f"🔗 {html.escape(name)}\n\n{html.escape(link)}", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data=f"user_view_{name}")]]), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() @@ -2046,34 +2092,146 @@ async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No # BACKUP & RESTORE # ============================================================================ +def list_backup_names(limit: int = 10) -> List[str]: + try: + if not os.path.exists(BACKUP_DIR): + return [] + names = [ + f for f in os.listdir(BACKUP_DIR) + if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256") + ] + return sorted(names, reverse=True)[:limit] + except Exception: + return [] + + +def safe_backup_path(name: str) -> Optional[str]: + raw = os.path.basename(str(name or "").strip()) + if raw != name or not raw.endswith((".tar.gz", ".tar.gz.enc")) or raw.endswith(".sha256"): + return None + path = os.path.abspath(os.path.join(BACKUP_DIR, raw)) + base = os.path.abspath(BACKUP_DIR) + if os.path.dirname(path) != base or not os.path.exists(path): + return None + return path + + +def backup_schedule_state() -> Dict[str, Any]: + raw = load_json(BACKUP_SCHEDULE_FILE) or {} + if not isinstance(raw, dict): + raw = {} + frequency = str(raw.get("frequency") or "off") + if frequency not in {"off", "daily", "weekly", "monthly"}: + frequency = "off" + return { + "frequency": frequency, + "calendar": raw.get("calendar") or "", + "updated_at": raw.get("updated_at") or "", + } + + +async def run_full_backup() -> Tuple[bool, str]: + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\"; " + "cleanup_old_backups 30" + ) + code, stdout, stderr = await sh("bash", "-lc", script, timeout=240) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, message + + +async def set_full_backup_schedule(frequency: str) -> Tuple[bool, str]: + if frequency not in {"off", "daily", "weekly", "monthly"}: + return False, "unsupported schedule" + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + f"set_backup_schedule {shlex.quote(frequency)}" + ) + code, stdout, stderr = await sh("bash", "-lc", script, timeout=120) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, message + + +async def launch_full_restore(backup_path: str) -> None: + quoted_path = shlex.quote(backup_path) + script = ( + "sleep 1; " + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\" >/dev/null 2>&1 || true; " + f"restore_backup {quoted_path} \"\" yes; " + "cleanup_old_backups 30" + ) + await asyncio.create_subprocess_exec( + "bash", + "-lc", + script, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Backup menu.""" query = update.callback_query await query.answer() + user_id = _uid(update) + backups = list_backup_names() + schedule = backup_schedule_state() + labels = { + "off": "выключено" if get_user_lang(user_id) == "ru" else "off", + "daily": "каждый день" if get_user_lang(user_id) == "ru" else "daily", + "weekly": "каждую неделю" if get_user_lang(user_id) == "ru" else "weekly", + "monthly": "каждый месяц" if get_user_lang(user_id) == "ru" else "monthly", + } - # List existing backups - backups = [] - try: - if os.path.exists(BACKUP_DIR): - backups = sorted( - [f for f in os.listdir(BACKUP_DIR) - if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")], - reverse=True, - ) - except Exception: - pass - - buttons = [[InlineKeyboardButton("💾 Create Backup", callback_data="backup_create")]] + buttons = [ + [InlineKeyboardButton("💾 Создать сейчас" if get_user_lang(user_id) == "ru" else "💾 Create now", callback_data="backup_create")], + [ + InlineKeyboardButton("◯ Выкл" if get_user_lang(user_id) == "ru" else "◯ Off", callback_data="backup_schedule_off"), + InlineKeyboardButton("☀ День" if get_user_lang(user_id) == "ru" else "☀ Daily", callback_data="backup_schedule_daily"), + ], + [ + InlineKeyboardButton("◷ Неделя" if get_user_lang(user_id) == "ru" else "◷ Weekly", callback_data="backup_schedule_weekly"), + InlineKeyboardButton("◴ Месяц" if get_user_lang(user_id) == "ru" else "◴ Monthly", callback_data="backup_schedule_monthly"), + ], + ] if backups: - buttons.append( - [InlineKeyboardButton("📋 List Backups", callback_data="backup_list")] + buttons.append([InlineKeyboardButton("📋 Список" if get_user_lang(user_id) == "ru" else "📋 List", callback_data="backup_list")]) + buttons.append([InlineKeyboardButton("↩️ Восстановить" if get_user_lang(user_id) == "ru" else "↩️ Restore", callback_data="menu_restore")]) + + buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]) + + if get_user_lang(user_id) == "ru": + text = ( + "💾 Бекапы\n\n" + f"Файлов: {len(backups)}\n" + f"Расписание: {labels.get(schedule['frequency'], schedule['frequency'])}\n\n" + "В бекап входит: telemt config, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL, бот, админка и история трафика." + ) + else: + text = ( + "💾 Backups\n\n" + f"Files: {len(backups)}\n" + f"Schedule: {labels.get(schedule['frequency'], schedule['frequency'])}\n\n" + "Backups include telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL, bot, admin panel and traffic history." ) - - buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]) - - text = f"💾 Backup Management\n\nExisting backups: {len(backups)}" keyboard = InlineKeyboardMarkup(buttons) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -2082,54 +2240,60 @@ async def cb_backup_create(update: Update, context: ContextTypes.DEFAULT_TYPE) - """Create backup.""" query = update.callback_query await query.answer() + user_id = _uid(update) - await safe_edit_message(query,"⏳ Creating backup...") + await safe_edit_message(query, "⏳ Создаю полный бекап..." if get_user_lang(user_id) == "ru" else "⏳ Creating full backup...") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_file = os.path.join(BACKUP_DIR, f"backup_{timestamp}.tar.gz") - - os.makedirs(BACKUP_DIR, exist_ok=True) - code, _, stderr = await sh( - "tar", "-czf", backup_file, GOTELEGRAM_CONFIG, TELEMT_CONFIG - ) - - if code == 0: - text = f"✅ Backup created:\n{html.escape(backup_file)}" + ok, message = await run_full_backup() + if ok: + text = f"✅ Бекап создан:\n{html.escape(message)}" if get_user_lang(user_id) == "ru" else f"✅ Backup created:\n{html.escape(message)}" else: - text = f"❌ Backup failed:\n{html.escape(stderr[:500])}" + text = f"❌ Ошибка бекапа:\n{html.escape(message[:500])}" if get_user_lang(user_id) == "ru" else f"❌ Backup failed:\n{html.escape(message[:500])}" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_backup")]] + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") +async def cb_backup_schedule_set(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + frequency = query.data.removeprefix("backup_schedule_") + await safe_edit_message(query, "⏳ Сохраняю расписание..." if get_user_lang(user_id) == "ru" else "⏳ Saving schedule...") + ok, message = await set_full_backup_schedule(frequency) + if ok: + text = "✅ Расписание обновлено." if get_user_lang(user_id) == "ru" else "✅ Backup schedule updated." + else: + text = f"❌ {html.escape(message[:500])}" + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]]), + parse_mode="HTML", + ) + + async def cb_backup_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """List backups.""" query = update.callback_query await query.answer() + user_id = _uid(update) - backups = [] - try: - if os.path.exists(BACKUP_DIR): - backups = sorted( - [f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")], - reverse=True, - ) - except Exception: - pass + backups = list_backup_names() if not backups: - text = "No backups found" + text = "Бекапов нет" if get_user_lang(user_id) == "ru" else "No backups found" else: - text = "📋 Available Backups\n\n" + text = "📋 Доступные бекапы\n\n" if get_user_lang(user_id) == "ru" else "📋 Available Backups\n\n" for backup in backups[:10]: path = os.path.join(BACKUP_DIR, backup) size = os.path.getsize(path) / (1024 * 1024) text += f"{html.escape(backup)} ({size:.2f} MB)\n" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_backup")]] + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -2138,24 +2302,17 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> """Restore menu.""" query = update.callback_query await query.answer() + user_id = _uid(update) - backups = [] - try: - if os.path.exists(BACKUP_DIR): - backups = sorted( - [f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")], - reverse=True, - ) - except Exception: - pass + backups = list_backup_names() if not backups: - text = "❌ No backups available" + text = "❌ Нет доступных бекапов" if get_user_lang(user_id) == "ru" else "❌ No backups available" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]] ) else: - text = "Select backup to restore:" + text = "Выберите бекап для восстановления:" if get_user_lang(user_id) == "ru" else "Select backup to restore:" buttons = [] for i, backup in enumerate(backups[:10]): buttons.append( @@ -2165,7 +2322,7 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) ] ) - buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]) + buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]) keyboard = InlineKeyboardMarkup(buttons) # Store backup list in user_data for retrieval context.user_data["backup_list"] = backups[:10] @@ -2174,12 +2331,16 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Execute backup restoration.""" + """Confirm or execute backup restoration.""" query = update.callback_query data = query.data + user_id = _uid(update) try: - idx = int(data.removeprefix("restore_idx_")) + if data.startswith("restore_yes_"): + idx = int(data.removeprefix("restore_yes_")) + else: + idx = int(data.removeprefix("restore_idx_")) except ValueError: await query.answer("Invalid backup selection") return @@ -2193,24 +2354,40 @@ async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) backup_path = os.path.join(BACKUP_DIR, backup_name) await query.answer() - await safe_edit_message(query,f"⏳ Restoring from {html.escape(backup_name)}...") - - if not os.path.exists(backup_path): - text = "❌ Backup file not found" - else: - # Simple restore: extract tar to overwrite configs - code, _, stderr = await sh( - "tar", "-xzf", backup_path, "-C", "/", timeout=60 + if data.startswith("restore_idx_"): + text = ( + f"Восстановить {html.escape(backup_name)}?\n\n" + "Перед восстановлением будет создан свежий safety-бекап." + ) if get_user_lang(user_id) == "ru" else ( + f"Restore {html.escape(backup_name)}?\n\n" + "A fresh safety backup will be created before restoring." + ) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("✅ Восстановить" if get_user_lang(user_id) == "ru" else "✅ Restore", callback_data=f"restore_yes_{idx}")], + [InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_restore")], + ]) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + return + + await safe_edit_message(query, f"⏳ Восстановление запущено: {html.escape(backup_name)}..." if get_user_lang(user_id) == "ru" else f"⏳ Restore started: {html.escape(backup_name)}...") + + safe_path = safe_backup_path(backup_name) + if not safe_path: + text = "❌ Файл бекапа не найден" if get_user_lang(user_id) == "ru" else "❌ Backup file not found" + elif safe_path.endswith(".enc"): + text = "❌ Зашифрованный бекап пока восстанавливается через CLI: gotelegram → Восстановить." if get_user_lang(user_id) == "ru" else "❌ Encrypted backups are restored from CLI for now: gotelegram → Restore." + else: + await launch_full_restore(safe_path) + text = ( + f"✅ Восстановление {html.escape(backup_name)} запущено в фоне.\n" + "Сервисы могут перезапуститься, через минуту откройте статус." + ) if get_user_lang(user_id) == "ru" else ( + f"✅ Restore for {html.escape(backup_name)} started in background.\n" + "Services may restart; check status in about a minute." ) - if code == 0: - # Restart services - await sh("systemctl", "restart", TELEMT_SERVICE) - text = f"✅ Restored from {html.escape(backup_name)}" - else: - text = f"❌ Restore failed:\n{html.escape(stderr[:500])}" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -2814,6 +2991,10 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "change_pro": cb_change_pro, "install_migrate": cb_install_migrate, "menu_stats": cb_menu_stats, + "backup_schedule_off": cb_backup_schedule_set, + "backup_schedule_daily": cb_backup_schedule_set, + "backup_schedule_weekly": cb_backup_schedule_set, + "backup_schedule_monthly": cb_backup_schedule_set, } # Custom git template URL prompt @@ -2839,6 +3020,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await cb_user_add(update, context) elif data.startswith("user_view_"): await cb_user_view(update, context) + elif data.startswith("user_qr_"): + await cb_user_qr(update, context) elif data.startswith("user_toggle_"): await cb_user_toggle(update, context) elif data.startswith("user_del_yes_"): @@ -2851,7 +3034,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await cb_pro_template(update, context) elif data.startswith("pro_confirm_"): await cb_pro_confirm(update, context) - elif data.startswith("restore_idx_"): + elif data.startswith("restore_idx_") or data.startswith("restore_yes_"): await cb_restore_backup(update, context) elif data in handlers: await handlers[data](update, context) diff --git a/lib/backup.sh b/lib/backup.sh index 0e63425..7b40bb1 100755 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -27,6 +27,9 @@ create_backup() { if [ -f "$GOTELEGRAM_DIR/disabled_users.json" ]; then cp "$GOTELEGRAM_DIR/disabled_users.json" "$tmp_dir/disabled_users.json" 2>/dev/null fi + if [ -f "$GOTELEGRAM_DIR/backup_schedule.json" ]; then + cp "$GOTELEGRAM_DIR/backup_schedule.json" "$tmp_dir/backup_schedule.json" 2>/dev/null + fi # Language marker (i18n) if [ -f "$GOTELEGRAM_DIR/.language" ]; then @@ -106,7 +109,7 @@ create_backup() { cat > "$tmp_dir/metadata.json" << EOMETA { - "backup_version": "1.5", + "backup_version": "1.6", "gotelegram_version": "$GOTELEGRAM_VERSION", "created_at": "$(date -Iseconds)", "hostname": "$(hostname)", @@ -173,6 +176,7 @@ EOMETA restore_backup() { local backup_file="$1" local password="$2" + local assume_yes="$3" if [ ! -f "$backup_file" ]; then if type tf &>/dev/null; then @@ -218,6 +222,18 @@ restore_backup() { backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1) [ -z "$backup_dir" ] && backup_dir="$tmp_dir" + # Legacy bot backups before v2.5.0 stored absolute paths directly in tar: + # opt/gotelegram/config.json and etc/telemt/config.toml. + if [ ! -f "$backup_dir/config.toml" ] && [ -f "$tmp_dir/etc/telemt/config.toml" ]; then + cp "$tmp_dir/etc/telemt/config.toml" "$backup_dir/config.toml" 2>/dev/null || true + fi + if [ ! -f "$backup_dir/gotelegram.json" ] && [ -f "$tmp_dir/opt/gotelegram/config.json" ]; then + cp "$tmp_dir/opt/gotelegram/config.json" "$backup_dir/gotelegram.json" 2>/dev/null || true + fi + if [ ! -f "$backup_dir/disabled_users.json" ] && [ -f "$tmp_dir/opt/gotelegram/disabled_users.json" ]; then + cp "$tmp_dir/opt/gotelegram/disabled_users.json" "$backup_dir/disabled_users.json" 2>/dev/null || true + fi + # Проверяем метаданные if [ -f "$backup_dir/metadata.json" ]; then local bk_version bk_mode bk_ip bk_lang bk_date @@ -233,7 +249,7 @@ restore_backup() { echo "" fi - if ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then + if [ "$assume_yes" != "yes" ] && ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then rm -rf "$tmp_dir" return 0 fi @@ -261,6 +277,18 @@ restore_backup() { cp "$backup_dir/disabled_users.json" "$GOTELEGRAM_DIR/disabled_users.json" chmod 600 "$GOTELEGRAM_DIR/disabled_users.json" 2>/dev/null || true fi + if [ -f "$backup_dir/backup_schedule.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$backup_dir/backup_schedule.json" "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null + chmod 600 "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || true + if command -v jq >/dev/null 2>&1; then + local restored_schedule + restored_schedule=$(jq -r '.frequency // "off"' "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || echo "off") + case "$restored_schedule" in + off|daily|weekly|monthly) set_backup_schedule "$restored_schedule" >/dev/null 2>&1 || true ;; + esac + fi + fi # Восстанавливаем language marker (i18n) if [ -f "$backup_dir/.language" ]; then @@ -374,7 +402,7 @@ list_backups() { echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" local i=1 - for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do + for f in "$BACKUP_DIR"/*.tar.gz*; do [ -f "$f" ] || continue [[ "$f" == *.sha256 ]] && continue local size date_str name @@ -393,11 +421,11 @@ list_backups() { cleanup_old_backups() { local keep="${1:-5}" local count - count=$(find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | wc -l) + count=$(find "$BACKUP_DIR" -name "*.tar.gz*" ! -name "*.sha256" 2>/dev/null | wc -l) if [ "$count" -gt "$keep" ]; then local to_delete=$((count - keep)) - find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | sort | head -n "$to_delete" | while read -r f; do + find "$BACKUP_DIR" -name "*.tar.gz*" ! -name "*.sha256" 2>/dev/null | sort | head -n "$to_delete" | while read -r f; do rm -f "$f" "${f}.sha256" done if type tf &>/dev/null; then @@ -408,6 +436,84 @@ cleanup_old_backups() { fi } +# ── Расписание бекапов ─────────────────────────────────────────────────────── +backup_schedule_calendar() { + case "${1:-off}" in + off) echo "" ;; + daily) echo "*-*-* 03:20:00" ;; + weekly) echo "Sun 03:20:00" ;; + monthly) echo "*-*-01 03:20:00" ;; + *) return 1 ;; + esac +} + +set_backup_schedule() { + local frequency="${1:-off}" + local calendar + if ! calendar=$(backup_schedule_calendar "$frequency"); then + log_error "Unsupported backup schedule: $frequency" + return 1 + fi + + mkdir -p "$GOTELEGRAM_DIR" "$BACKUP_DIR" + + if [ "$frequency" = "off" ]; then + systemctl disable --now gotelegram-backup.timer >/dev/null 2>&1 || true + rm -f /etc/systemd/system/gotelegram-backup.timer /etc/systemd/system/gotelegram-backup.service + systemctl daemon-reload >/dev/null 2>&1 || true + else + cat > /etc/systemd/system/gotelegram-backup.service << 'EOSVC' +[Unit] +Description=goTelegram Pro backup +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +Environment=GOTELEGRAM_BACKUP_KEEP=30 +ExecStart=/bin/bash -lc 'source /opt/gotelegram/lib/common.sh; source /opt/gotelegram/lib/i18n.sh; source /opt/gotelegram/lib/telemt.sh; source /opt/gotelegram/lib/website.sh; source /opt/gotelegram/lib/backup.sh; load_language "$(detect_language 2>/dev/null || echo en)"; create_backup ""; cleanup_old_backups "${GOTELEGRAM_BACKUP_KEEP:-30}"' +EOSVC + + cat > /etc/systemd/system/gotelegram-backup.timer << EOTIMER +[Unit] +Description=goTelegram Pro scheduled backup + +[Timer] +OnCalendar=$calendar +Persistent=true +RandomizedDelaySec=15m +Unit=gotelegram-backup.service + +[Install] +WantedBy=timers.target +EOTIMER + systemctl daemon-reload >/dev/null 2>&1 || return 1 + systemctl enable --now gotelegram-backup.timer >/dev/null 2>&1 || return 1 + fi + + cat > "$GOTELEGRAM_DIR/backup_schedule.json" << EOSCHEDULE +{ + "frequency": "$frequency", + "calendar": "$calendar", + "keep": 30, + "updated_at": "$(date -Iseconds)" +} +EOSCHEDULE + chmod 600 "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || true + log_success "Backup schedule: $frequency" + echo "$frequency" +} + +backup_schedule_status() { + local frequency="off" calendar="" + if [ -f "$GOTELEGRAM_DIR/backup_schedule.json" ] && command -v jq >/dev/null 2>&1; then + frequency=$(jq -r '.frequency // "off"' "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || echo "off") + calendar=$(jq -r '.calendar // ""' "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || echo "") + fi + echo "frequency=$frequency calendar=$calendar" + systemctl list-timers gotelegram-backup.timer --no-pager 2>/dev/null || true +} + # ── Интерактивный бекап ────────────────────────────────────────────────────── interactive_backup() { echo "" @@ -447,7 +553,7 @@ interactive_restore() { local backup_file="" if [[ "$choice" =~ ^[0-9]+$ ]]; then local i=1 - for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do + for f in "$BACKUP_DIR"/*.tar.gz*; do [ -f "$f" ] || continue [[ "$f" == *.sha256 ]] && continue if [ "$i" -eq "$choice" ]; then diff --git a/tests/test_admin_features.py b/tests/test_admin_features.py new file mode 100644 index 0000000..6a0cd5f --- /dev/null +++ b/tests/test_admin_features.py @@ -0,0 +1,62 @@ +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SERVER_PATH = ROOT / "admin-web" / "server.py" + + +def load_server(tmpdir: Path): + os.environ["GOTELEGRAM_BACKUP_DIR"] = str(tmpdir / "backups") + os.environ["GOTELEGRAM_DIR"] = str(tmpdir / "gotelegram") + module_name = "gotelegram_admin_server_test" + sys.modules.pop(module_name, None) + spec = importlib.util.spec_from_file_location(module_name, SERVER_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class AdminFeatureTests(unittest.TestCase): + def test_backup_path_accepts_only_local_archives(self): + with tempfile.TemporaryDirectory() as raw: + tmpdir = Path(raw) + server = load_server(tmpdir) + server.BACKUP_DIR.mkdir(parents=True) + good = server.BACKUP_DIR / "gotelegram_backup_20260425_120000.tar.gz" + good.write_text("backup", encoding="utf-8") + encrypted = server.BACKUP_DIR / "gotelegram_backup_20260425_120001.tar.gz.enc" + encrypted.write_text("backup", encoding="utf-8") + legacy = server.BACKUP_DIR / "backup_20260425_120002.tar.gz" + legacy.write_text("backup", encoding="utf-8") + + self.assertEqual(server.safe_backup_path(good.name), good.resolve()) + self.assertEqual(server.safe_backup_path(encrypted.name), encrypted.resolve()) + self.assertEqual(server.safe_backup_path(legacy.name), legacy.resolve()) + + with self.assertRaises(ValueError): + server.safe_backup_path("../outside.tar.gz") + with self.assertRaises(ValueError): + server.safe_backup_path("gotelegram_backup_20260425_120000.tar.gz.sha256") + with self.assertRaises(FileNotFoundError): + server.safe_backup_path("missing.tar.gz") + + def test_backup_schedule_calendar_rejects_unknown_values(self): + with tempfile.TemporaryDirectory() as raw: + server = load_server(Path(raw)) + self.assertEqual(server.backup_schedule_calendar("daily"), "*-*-* 03:20:00") + self.assertEqual(server.backup_schedule_calendar("weekly"), "Sun 03:20:00") + self.assertEqual(server.backup_schedule_calendar("monthly"), "*-*-01 03:20:00") + self.assertIsNone(server.backup_schedule_calendar("off")) + + with self.assertRaises(ValueError): + server.backup_schedule_calendar("hourly") + + +if __name__ == "__main__": + unittest.main()