mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-06-14 11:42:45 +00:00
v2.5.0: add QR import and backup scheduling
This commit is contained in:
21
DOCS_AI.md
21
DOCS_AI.md
@@ -429,13 +429,20 @@ switch_language ru|en
|
|||||||
`lib/backup.sh`. Собирает в `.tar.gz`:
|
`lib/backup.sh`. Собирает в `.tar.gz`:
|
||||||
- `/etc/telemt/config.toml`
|
- `/etc/telemt/config.toml`
|
||||||
- `/opt/gotelegram/config.json`
|
- `/opt/gotelegram/config.json`
|
||||||
|
- `/opt/gotelegram/disabled_users.json`
|
||||||
- `/var/www/gotelegram-site/` (если есть)
|
- `/var/www/gotelegram-site/` (если есть)
|
||||||
- `/etc/letsencrypt/live/<domain>/` + `/etc/letsencrypt/archive/<domain>/` (если 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/<domain>/` + `/etc/letsencrypt/archive/<domain>/` + `/etc/letsencrypt/renewal/<domain>.conf` (если Pro)
|
||||||
- `/etc/nginx/sites-available/gotelegram` (если есть)
|
- `/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 <file> [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)
|
### 13.1 Local Web Admin (v2.5.0)
|
||||||
|
|
||||||
@@ -449,9 +456,9 @@ switch_language ru|en
|
|||||||
- write-запросы дополнительно требуют `X-GoTelegram-Admin: 1`, фронтенд добавляет его автоматически.
|
- 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;
|
- язык панели читается из `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`;
|
- 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/<name>/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/<name>/traffic?range=15m|1h|24h|month` отдаёт per-user историю по `telemt total_octets`; `/api/users/<name>/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/<name>/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/<name>/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`.
|
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`.
|
Отключённые ключи хранятся в `/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)
|
### 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
|
## 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.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.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`.
|
- **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`.
|
||||||
|
|||||||
@@ -125,11 +125,11 @@ CLI и бот переведены на русский и английский.
|
|||||||
|
|
||||||
## 7. Бекап и восстановление
|
## 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;
|
- есть светлая/тёмная тема, вкладки и адаптивная вёрстка под desktop/mobile;
|
||||||
- Telegram-бот показывает инструкцию для Termius и обычную команду `ssh -L 1984:127.0.0.1:1984 root@SERVER`.
|
- 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 часа / месяц детализация остаётся полной.
|
История трафика хранится максимум 1 год. Чтобы файлы не разрастались, последние 31 день пишутся поминутно, а более старая история автоматически уплотняется до одной точки в час. Для обычного просмотра 15 минут / 1 час / 24 часа / месяц детализация остаётся полной.
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ A: Сам MTProxy — да, это публичная технология из
|
|||||||
|
|
||||||
## Changelog (коротко)
|
## 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.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.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.
|
- **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.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import shlex
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
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"))
|
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"))
|
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"))
|
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")
|
HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1")
|
||||||
PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984"))
|
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}$")
|
USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
|
||||||
LANG_RE = re.compile(r"^(en|ru)$")
|
LANG_RE = re.compile(r"^(en|ru)$")
|
||||||
SENSITIVE_CONFIG_KEYS = {"secret"}
|
SENSITIVE_CONFIG_KEYS = {"secret"}
|
||||||
|
BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$")
|
||||||
TRAFFIC_WINDOWS = {
|
TRAFFIC_WINDOWS = {
|
||||||
"15m": 15 * 60,
|
"15m": 15 * 60,
|
||||||
"1h": 60 * 60,
|
"1h": 60 * 60,
|
||||||
@@ -75,6 +79,19 @@ def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
|
|||||||
return 125, "", str(exc)
|
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:
|
class FileLock:
|
||||||
def __init__(self, path: Path):
|
def __init__(self, path: Path):
|
||||||
self.path = path
|
self.path = path
|
||||||
@@ -909,6 +926,55 @@ def list_backups() -> list[dict[str, Any]]:
|
|||||||
return items[:30]
|
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]:
|
def create_backup() -> tuple[bool, str]:
|
||||||
script = (
|
script = (
|
||||||
"source /opt/gotelegram/lib/common.sh; "
|
"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/website.sh; "
|
||||||
"source /opt/gotelegram/lib/backup.sh; "
|
"source /opt/gotelegram/lib/backup.sh; "
|
||||||
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
|
"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)
|
code, stdout, stderr = run(["bash", "-lc", script], timeout=180)
|
||||||
text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
|
text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
|
||||||
return code == 0, text
|
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]:
|
def read_log_payload(service: str) -> dict[str, Any]:
|
||||||
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"}
|
allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"}
|
||||||
if service not in allowed:
|
if service not in allowed:
|
||||||
@@ -999,6 +1123,7 @@ def overview_payload() -> dict[str, Any]:
|
|||||||
"stats_status": stats_status(current, history),
|
"stats_status": stats_status(current, history),
|
||||||
"runtime_summary": summary,
|
"runtime_summary": summary,
|
||||||
"backups": list_backups(),
|
"backups": list_backups(),
|
||||||
|
"backup_schedule": backup_schedule_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1017,6 +1142,14 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
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:
|
def send_error_json(self, status: int, message: str) -> None:
|
||||||
self.send_json({"ok": False, "error": message}, status)
|
self.send_json({"ok": False, "error": message}, status)
|
||||||
|
|
||||||
@@ -1046,6 +1179,23 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
record = users[name]
|
record = users[name]
|
||||||
items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name)))
|
items.append(user_payload(name, record["secret"], record["enabled"], traffic_snapshot=latest.get(name)))
|
||||||
self.send_json({"ok": True, "data": items})
|
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"):
|
elif path.startswith("/api/users/") and path.endswith("/traffic"):
|
||||||
name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")])
|
name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")])
|
||||||
users = read_user_records()
|
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))})
|
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":
|
elif path == "/api/backups":
|
||||||
self.send_json({"ok": True, "data": list_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":
|
elif path == "/api/stats":
|
||||||
qs = urllib.parse.parse_qs(parsed.query)
|
qs = urllib.parse.parse_qs(parsed.query)
|
||||||
range_key = normalize_range(qs.get("range", ["1h"])[0])
|
range_key = normalize_range(qs.get("range", ["1h"])[0])
|
||||||
@@ -1179,6 +1331,27 @@ class AdminHandler(BaseHTTPRequestHandler):
|
|||||||
elif path == "/api/backups":
|
elif path == "/api/backups":
|
||||||
ok, result = create_backup()
|
ok, result = create_backup()
|
||||||
self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500)
|
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":
|
elif path == "/api/stats/collect":
|
||||||
ok, message, payload = run_stats_action("collect")
|
ok, message, payload = run_stats_action("collect")
|
||||||
payload["message"] = message
|
payload["message"] = message
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const i18n = {
|
|||||||
addKey: "Add key",
|
addKey: "Add key",
|
||||||
copyLink: "Copy link",
|
copyLink: "Copy link",
|
||||||
copySecret: "Copy secret",
|
copySecret: "Copy secret",
|
||||||
|
showQr: "QR",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
enabled: "Enabled",
|
enabled: "Enabled",
|
||||||
disabled: "Disabled",
|
disabled: "Disabled",
|
||||||
@@ -73,6 +74,20 @@ const i18n = {
|
|||||||
enableKey: "Enable key",
|
enableKey: "Enable key",
|
||||||
main: "main",
|
main: "main",
|
||||||
createBackup: "Create backup",
|
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",
|
loadLogs: "Load",
|
||||||
panelLanguage: "Panel language",
|
panelLanguage: "Panel language",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
@@ -122,6 +137,7 @@ const i18n = {
|
|||||||
keyCreated: "Key created",
|
keyCreated: "Key created",
|
||||||
keyDeleted: "Key deleted",
|
keyDeleted: "Key deleted",
|
||||||
backupCreated: "Backup created",
|
backupCreated: "Backup created",
|
||||||
|
qrUnavailable: "QR code is unavailable",
|
||||||
serviceRestarted: "Service restarted",
|
serviceRestarted: "Service restarted",
|
||||||
statsRepaired: "Collector restarted",
|
statsRepaired: "Collector restarted",
|
||||||
statsCollected: "Statistics collected",
|
statsCollected: "Statistics collected",
|
||||||
@@ -189,6 +205,8 @@ const i18n = {
|
|||||||
promoHosting1: "Hosting #1",
|
promoHosting1: "Hosting #1",
|
||||||
promoHosting2: "Hosting #2",
|
promoHosting2: "Hosting #2",
|
||||||
promoTips: "Tips",
|
promoTips: "Tips",
|
||||||
|
qrEyebrow: "QR import",
|
||||||
|
qrTitle: "Scan Telegram proxy",
|
||||||
pageDashboardTitle: "Dashboard",
|
pageDashboardTitle: "Dashboard",
|
||||||
pageDashboardKicker: "Local Admin",
|
pageDashboardKicker: "Local Admin",
|
||||||
pageTrafficTitle: "Traffic",
|
pageTrafficTitle: "Traffic",
|
||||||
@@ -264,6 +282,7 @@ const i18n = {
|
|||||||
addKey: "Добавить ключ",
|
addKey: "Добавить ключ",
|
||||||
copyLink: "Копировать ссылку",
|
copyLink: "Копировать ссылку",
|
||||||
copySecret: "Копировать секрет",
|
copySecret: "Копировать секрет",
|
||||||
|
showQr: "QR",
|
||||||
delete: "Удалить",
|
delete: "Удалить",
|
||||||
enabled: "Включён",
|
enabled: "Включён",
|
||||||
disabled: "Отключён",
|
disabled: "Отключён",
|
||||||
@@ -273,6 +292,20 @@ const i18n = {
|
|||||||
enableKey: "Включить ключ",
|
enableKey: "Включить ключ",
|
||||||
main: "основной",
|
main: "основной",
|
||||||
createBackup: "Создать бекап",
|
createBackup: "Создать бекап",
|
||||||
|
restoreBackup: "Восстановить",
|
||||||
|
backupScheduleTitle: "Автобекапы",
|
||||||
|
backupScheduleLoading: "Загрузка расписания...",
|
||||||
|
backupIncludesTitle: "Что входит в бекап",
|
||||||
|
backupIncludesText: "конфиг telemt, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL-сертификаты, бот, админка и история трафика.",
|
||||||
|
scheduleOff: "Выкл",
|
||||||
|
scheduleDaily: "Каждый день",
|
||||||
|
scheduleWeekly: "Каждую неделю",
|
||||||
|
scheduleMonthly: "Каждый месяц",
|
||||||
|
scheduleSaved: "Расписание сохранено",
|
||||||
|
scheduleNext: "Следующий запуск: {value}",
|
||||||
|
scheduleDisabled: "Автобекапы отключены",
|
||||||
|
backupRestoreStarted: "Восстановление запущено",
|
||||||
|
confirmRestoreBackup: "Восстановить бекап",
|
||||||
loadLogs: "Загрузить",
|
loadLogs: "Загрузить",
|
||||||
panelLanguage: "Язык панели",
|
panelLanguage: "Язык панели",
|
||||||
theme: "Тема",
|
theme: "Тема",
|
||||||
@@ -322,6 +355,7 @@ const i18n = {
|
|||||||
keyCreated: "Ключ создан",
|
keyCreated: "Ключ создан",
|
||||||
keyDeleted: "Ключ удалён",
|
keyDeleted: "Ключ удалён",
|
||||||
backupCreated: "Бекап создан",
|
backupCreated: "Бекап создан",
|
||||||
|
qrUnavailable: "QR-код недоступен",
|
||||||
serviceRestarted: "Сервис перезапущен",
|
serviceRestarted: "Сервис перезапущен",
|
||||||
statsRepaired: "Сборщик перезапущен",
|
statsRepaired: "Сборщик перезапущен",
|
||||||
statsCollected: "Статистика собрана",
|
statsCollected: "Статистика собрана",
|
||||||
@@ -389,6 +423,8 @@ const i18n = {
|
|||||||
promoHosting1: "Хостинг #1",
|
promoHosting1: "Хостинг #1",
|
||||||
promoHosting2: "Хостинг #2",
|
promoHosting2: "Хостинг #2",
|
||||||
promoTips: "Чаевые",
|
promoTips: "Чаевые",
|
||||||
|
qrEyebrow: "QR-импорт",
|
||||||
|
qrTitle: "Сканирование прокси Telegram",
|
||||||
pageDashboardTitle: "Обзор",
|
pageDashboardTitle: "Обзор",
|
||||||
pageDashboardKicker: "Локальная админка",
|
pageDashboardKicker: "Локальная админка",
|
||||||
pageTrafficTitle: "Трафик",
|
pageTrafficTitle: "Трафик",
|
||||||
@@ -420,6 +456,8 @@ const state = {
|
|||||||
userTrafficView: "chart",
|
userTrafficView: "chart",
|
||||||
userTraffic: null,
|
userTraffic: null,
|
||||||
userTrafficLoading: false,
|
userTrafficLoading: false,
|
||||||
|
backupSchedule: null,
|
||||||
|
qrLink: "",
|
||||||
pendingUsers: new Set(),
|
pendingUsers: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -514,6 +552,7 @@ function applyI18n() {
|
|||||||
$("#visualText").textContent = t("visualText");
|
$("#visualText").textContent = t("visualText");
|
||||||
updateTrafficControls();
|
updateTrafficControls();
|
||||||
updateUserTrafficControls();
|
updateUserTrafficControls();
|
||||||
|
renderBackupSchedule();
|
||||||
updatePageTitle();
|
updatePageTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1091,7 +1130,12 @@ function renderUsers() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
||||||
<td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></td>
|
<td data-label="${escapeAttr(t("tableLink"))}">
|
||||||
|
<div class="mini-actions">
|
||||||
|
<button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button>
|
||||||
|
<button class="soft" data-user-qr="${escapeAttr(user.name)}">${escapeHtml(t("showQr"))}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
<td data-label="${escapeAttr(t("tableTraffic"))}">
|
||||||
<div class="traffic-cell">
|
<div class="traffic-cell">
|
||||||
<strong>${escapeHtml(trafficTotal)}</strong>
|
<strong>${escapeHtml(trafficTotal)}</strong>
|
||||||
@@ -1109,6 +1153,7 @@ function renderUsers() {
|
|||||||
|
|
||||||
function renderBackups(backups) {
|
function renderBackups(backups) {
|
||||||
const box = $("#backupsList");
|
const box = $("#backupsList");
|
||||||
|
renderBackupSchedule();
|
||||||
if (!backups.length) {
|
if (!backups.length) {
|
||||||
box.innerHTML = `<div class="empty">${escapeHtml(t("noBackups"))}</div>`;
|
box.innerHTML = `<div class="empty">${escapeHtml(t("noBackups"))}</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -1119,11 +1164,26 @@ function renderBackups(backups) {
|
|||||||
<strong>${escapeHtml(item.name)}</strong>
|
<strong>${escapeHtml(item.name)}</strong>
|
||||||
<span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
|
<span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</div>
|
<div class="backup-actions">
|
||||||
|
<span>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</span>
|
||||||
|
<button class="soft" data-restore-backup="${escapeAttr(item.name)}">${escapeHtml(t("restoreBackup"))}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join("");
|
`).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() {
|
function renderEvents() {
|
||||||
const box = $("#events");
|
const box = $("#events");
|
||||||
if (!state.events.length) {
|
if (!state.events.length) {
|
||||||
@@ -1162,6 +1222,7 @@ async function refreshAll() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
state.overview = await api("/api/overview");
|
state.overview = await api("/api/overview");
|
||||||
|
state.backupSchedule = state.overview.backup_schedule || state.backupSchedule;
|
||||||
updateLanguageFromOverview(state.overview);
|
updateLanguageFromOverview(state.overview);
|
||||||
state.users = await api("/api/users");
|
state.users = await api("/api/users");
|
||||||
if (!state.stats) {
|
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() {
|
async function loadLogs() {
|
||||||
const service = $("#logService").value;
|
const service = $("#logService").value;
|
||||||
const btn = $("#loadLogsBtn");
|
const btn = $("#loadLogsBtn");
|
||||||
@@ -1436,11 +1544,18 @@ document.addEventListener("click", async (eventObj) => {
|
|||||||
} else if (button.dataset.userTraffic) {
|
} else if (button.dataset.userTraffic) {
|
||||||
state.userTrafficUser = button.dataset.userTraffic;
|
state.userTrafficUser = button.dataset.userTraffic;
|
||||||
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
|
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
|
||||||
|
} else if (button.dataset.userQr) {
|
||||||
|
showUserQr(button.dataset.userQr);
|
||||||
} else if (button.dataset.userTrafficRange) {
|
} else if (button.dataset.userTrafficRange) {
|
||||||
changeUserTrafficRange(button.dataset.userTrafficRange);
|
changeUserTrafficRange(button.dataset.userTrafficRange);
|
||||||
} else if (button.dataset.userTrafficView) {
|
} else if (button.dataset.userTrafficView) {
|
||||||
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
|
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
|
||||||
renderUserTraffic();
|
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) {
|
} else if (button.dataset.copy) {
|
||||||
await copyText(button.dataset.copy);
|
await copyText(button.dataset.copy);
|
||||||
} else if (button.dataset.delete) {
|
} else if (button.dataset.delete) {
|
||||||
@@ -1480,6 +1595,12 @@ $("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventO
|
|||||||
$("#promoClose").addEventListener("click", () => {
|
$("#promoClose").addEventListener("click", () => {
|
||||||
$("#promoModal").hidden = true;
|
$("#promoModal").hidden = true;
|
||||||
});
|
});
|
||||||
|
$("#qrClose").addEventListener("click", () => {
|
||||||
|
$("#qrModal").hidden = true;
|
||||||
|
});
|
||||||
|
$("#qrCopyBtn").addEventListener("click", () => {
|
||||||
|
if (state.qrLink) copyText(state.qrLink);
|
||||||
|
});
|
||||||
$("#createBackupBtn").addEventListener("click", createBackup);
|
$("#createBackupBtn").addEventListener("click", createBackup);
|
||||||
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
||||||
$("#repairStatsBtn").addEventListener("click", repairStats);
|
$("#repairStatsBtn").addEventListener("click", repairStats);
|
||||||
|
|||||||
@@ -272,6 +272,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="createBackupBtn" type="button" data-i18n="createBackup">Create backup</button>
|
<button id="createBackupBtn" type="button" data-i18n="createBackup">Create backup</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="backup-schedule">
|
||||||
|
<div>
|
||||||
|
<strong data-i18n="backupScheduleTitle">Automatic backups</strong>
|
||||||
|
<span id="backupScheduleMeta" data-i18n="backupScheduleLoading">Loading schedule...</span>
|
||||||
|
</div>
|
||||||
|
<div class="segmented compact" role="group" aria-label="Backup schedule">
|
||||||
|
<button type="button" data-backup-schedule="off" data-i18n="scheduleOff">Off</button>
|
||||||
|
<button type="button" data-backup-schedule="daily" data-i18n="scheduleDaily">Daily</button>
|
||||||
|
<button type="button" data-backup-schedule="weekly" data-i18n="scheduleWeekly">Weekly</button>
|
||||||
|
<button type="button" data-backup-schedule="monthly" data-i18n="scheduleMonthly">Monthly</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="backup-includes">
|
||||||
|
<strong data-i18n="backupIncludesTitle">Backup contents</strong>
|
||||||
|
<span data-i18n="backupIncludesText">telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history.</span>
|
||||||
|
</div>
|
||||||
<div id="backupsList" class="backup-list"></div>
|
<div id="backupsList" class="backup-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -351,6 +367,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
<div id="qrModal" class="promo-modal" hidden>
|
||||||
|
<div class="promo-card qr-card" role="dialog" aria-modal="true" aria-labelledby="qrTitle">
|
||||||
|
<button id="qrClose" class="icon-btn ghost" type="button" aria-label="Close" data-i18n-aria-label="ariaClose">×</button>
|
||||||
|
<p class="eyebrow" data-i18n="qrEyebrow">QR import</p>
|
||||||
|
<h2 id="qrTitle" data-i18n="qrTitle">Scan Telegram proxy</h2>
|
||||||
|
<div class="qr-frame">
|
||||||
|
<img id="qrImage" alt="Telegram proxy QR">
|
||||||
|
</div>
|
||||||
|
<p id="qrMeta" class="modal-note"></p>
|
||||||
|
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="promoModal" class="promo-modal" hidden>
|
<div id="promoModal" class="promo-modal" hidden>
|
||||||
<div class="promo-card" role="dialog" aria-modal="true" aria-labelledby="promoTitle">
|
<div class="promo-card" role="dialog" aria-modal="true" aria-labelledby="promoTitle">
|
||||||
<button id="promoClose" class="icon-btn ghost" type="button" aria-label="Close" data-i18n-aria-label="ariaClose">×</button>
|
<button id="promoClose" class="icon-btn ghost" type="button" aria-label="Close" data-i18n-aria-label="ariaClose">×</button>
|
||||||
@@ -372,6 +400,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/app.js?v=2.5.0-admin8" type="module"></script>
|
<script src="/app.js?v=2.5.0-admin9" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -923,6 +923,39 @@ td small {
|
|||||||
gap: 10px;
|
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,
|
.backup-item,
|
||||||
.event,
|
.event,
|
||||||
.settings-list > div {
|
.settings-list > div {
|
||||||
@@ -965,6 +998,36 @@ td small {
|
|||||||
word-break: break-word;
|
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 {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 22px;
|
right: 22px;
|
||||||
@@ -1215,12 +1278,23 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-item,
|
.backup-item,
|
||||||
|
.backup-schedule,
|
||||||
.event,
|
.event,
|
||||||
.settings-list > div,
|
.settings-list > div,
|
||||||
.port-listener,
|
.port-listener,
|
||||||
.port-empty {
|
.port-empty {
|
||||||
grid-template-columns: 1fr;
|
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) {
|
@media (max-width: 460px) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux se
|
|||||||
|
|
||||||
- **Template Browsing** - Browse categories → templates → preview → install
|
- **Template Browsing** - Browse categories → templates → preview → install
|
||||||
- **Per-user MTProxy Keys** - Manage telemt `[access.users]` from inline bot menus
|
- **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
|
- **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
|
- **V1 Migration** - Detects old mtg Docker container and offers migration
|
||||||
- **Access Control** - ALLOWED_IDS from .env
|
- **Access Control** - ALLOWED_IDS from .env
|
||||||
@@ -96,6 +97,7 @@ WantedBy=multi-user.target
|
|||||||
- `TELEMT_SERVICE` - `telemt` (systemd service name)
|
- `TELEMT_SERVICE` - `telemt` (systemd service name)
|
||||||
- `WEBSITE_ROOT` - `/var/www/gotelegram-site`
|
- `WEBSITE_ROOT` - `/var/www/gotelegram-site`
|
||||||
- `BACKUP_DIR` - `/opt/gotelegram/backups`
|
- `BACKUP_DIR` - `/opt/gotelegram/backups`
|
||||||
|
- `BACKUP_SCHEDULE_FILE` - `/opt/gotelegram/backup_schedule.json`
|
||||||
- `TEMPLATES_CATALOG` - `/opt/gotelegram/templates_catalog.json`
|
- `TEMPLATES_CATALOG` - `/opt/gotelegram/templates_catalog.json`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -114,6 +116,7 @@ Organized by feature:
|
|||||||
- Installation (quick/stealth modes)
|
- Installation (quick/stealth modes)
|
||||||
- Status monitoring
|
- Status monitoring
|
||||||
- Backup/restore
|
- Backup/restore
|
||||||
|
- Backup schedules: off, daily, weekly, monthly
|
||||||
- SSL management
|
- SSL management
|
||||||
- Updates
|
- Updates
|
||||||
- Removal
|
- Removal
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -111,6 +112,7 @@ TELEMT_CONFIG = "/etc/telemt/config.toml"
|
|||||||
TELEMT_SERVICE = "telemt"
|
TELEMT_SERVICE = "telemt"
|
||||||
WEBSITE_ROOT = "/var/www/gotelegram-site"
|
WEBSITE_ROOT = "/var/www/gotelegram-site"
|
||||||
BACKUP_DIR = "/opt/gotelegram/backups"
|
BACKUP_DIR = "/opt/gotelegram/backups"
|
||||||
|
BACKUP_SCHEDULE_FILE = "/opt/gotelegram/backup_schedule.json"
|
||||||
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
|
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
|
||||||
INSTALL_SH = "/opt/gotelegram/install.sh"
|
INSTALL_SH = "/opt/gotelegram/install.sh"
|
||||||
|
|
||||||
@@ -1846,12 +1848,14 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
|||||||
|
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")],
|
[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("🗑 Удалить", callback_data=f"user_del_{name}")],
|
||||||
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
||||||
]
|
]
|
||||||
if name == "main":
|
if name == "main":
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")],
|
[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")],
|
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
|
||||||
]
|
]
|
||||||
await safe_edit_message(
|
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"<b>📷 QR: {html.escape(name)}</b>\n\n<code>{html.escape(link)}</code>",
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(qr_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await safe_edit_message(
|
||||||
|
query,
|
||||||
|
f"<b>🔗 {html.escape(name)}</b>\n\n<code>{html.escape(link)}</code>",
|
||||||
|
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:
|
async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
@@ -2046,34 +2092,146 @@ async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
|||||||
# BACKUP & RESTORE
|
# 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:
|
async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Backup menu."""
|
"""Backup menu."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
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
|
buttons = [
|
||||||
backups = []
|
[InlineKeyboardButton("💾 Создать сейчас" if get_user_lang(user_id) == "ru" else "💾 Create now", callback_data="backup_create")],
|
||||||
try:
|
[
|
||||||
if os.path.exists(BACKUP_DIR):
|
InlineKeyboardButton("◯ Выкл" if get_user_lang(user_id) == "ru" else "◯ Off", callback_data="backup_schedule_off"),
|
||||||
backups = sorted(
|
InlineKeyboardButton("☀ День" if get_user_lang(user_id) == "ru" else "☀ Daily", callback_data="backup_schedule_daily"),
|
||||||
[f for f in os.listdir(BACKUP_DIR)
|
],
|
||||||
if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
|
[
|
||||||
reverse=True,
|
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"),
|
||||||
except Exception:
|
],
|
||||||
pass
|
]
|
||||||
|
|
||||||
buttons = [[InlineKeyboardButton("💾 Create Backup", callback_data="backup_create")]]
|
|
||||||
|
|
||||||
if backups:
|
if backups:
|
||||||
buttons.append(
|
buttons.append([InlineKeyboardButton("📋 Список" if get_user_lang(user_id) == "ru" else "📋 List", callback_data="backup_list")])
|
||||||
[InlineKeyboardButton("📋 List Backups", 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 = (
|
||||||
|
"<b>💾 Бекапы</b>\n\n"
|
||||||
|
f"Файлов: <code>{len(backups)}</code>\n"
|
||||||
|
f"Расписание: <b>{labels.get(schedule['frequency'], schedule['frequency'])}</b>\n\n"
|
||||||
|
"В бекап входит: telemt config, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL, бот, админка и история трафика."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = (
|
||||||
|
"<b>💾 Backups</b>\n\n"
|
||||||
|
f"Files: <code>{len(backups)}</code>\n"
|
||||||
|
f"Schedule: <b>{labels.get(schedule['frequency'], schedule['frequency'])}</b>\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"<b>💾 Backup Management</b>\n\nExisting backups: {len(backups)}"
|
|
||||||
keyboard = InlineKeyboardMarkup(buttons)
|
keyboard = InlineKeyboardMarkup(buttons)
|
||||||
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
|
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."""
|
"""Create backup."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
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")
|
ok, message = await run_full_backup()
|
||||||
backup_file = os.path.join(BACKUP_DIR, f"backup_{timestamp}.tar.gz")
|
if ok:
|
||||||
|
text = f"✅ Бекап создан:\n<code>{html.escape(message)}</code>" if get_user_lang(user_id) == "ru" else f"✅ Backup created:\n<code>{html.escape(message)}</code>"
|
||||||
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<code>{html.escape(backup_file)}</code>"
|
|
||||||
else:
|
else:
|
||||||
text = f"❌ Backup failed:\n<code>{html.escape(stderr[:500])}</code>"
|
text = f"❌ Ошибка бекапа:\n<code>{html.escape(message[:500])}</code>" if get_user_lang(user_id) == "ru" else f"❌ Backup failed:\n<code>{html.escape(message[:500])}</code>"
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(
|
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")
|
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:
|
async def cb_backup_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""List backups."""
|
"""List backups."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
|
user_id = _uid(update)
|
||||||
|
|
||||||
backups = []
|
backups = list_backup_names()
|
||||||
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
|
|
||||||
|
|
||||||
if not backups:
|
if not backups:
|
||||||
text = "No backups found"
|
text = "Бекапов нет" if get_user_lang(user_id) == "ru" else "No backups found"
|
||||||
else:
|
else:
|
||||||
text = "<b>📋 Available Backups</b>\n\n"
|
text = "<b>📋 Доступные бекапы</b>\n\n" if get_user_lang(user_id) == "ru" else "<b>📋 Available Backups</b>\n\n"
|
||||||
for backup in backups[:10]:
|
for backup in backups[:10]:
|
||||||
path = os.path.join(BACKUP_DIR, backup)
|
path = os.path.join(BACKUP_DIR, backup)
|
||||||
size = os.path.getsize(path) / (1024 * 1024)
|
size = os.path.getsize(path) / (1024 * 1024)
|
||||||
text += f"<code>{html.escape(backup)}</code> ({size:.2f} MB)\n"
|
text += f"<code>{html.escape(backup)}</code> ({size:.2f} MB)\n"
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(
|
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")
|
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."""
|
"""Restore menu."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
|
user_id = _uid(update)
|
||||||
|
|
||||||
backups = []
|
backups = list_backup_names()
|
||||||
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
|
|
||||||
|
|
||||||
if not backups:
|
if not backups:
|
||||||
text = "❌ No backups available"
|
text = "❌ Нет доступных бекапов" if get_user_lang(user_id) == "ru" else "❌ No backups available"
|
||||||
keyboard = InlineKeyboardMarkup(
|
keyboard = InlineKeyboardMarkup(
|
||||||
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
|
[[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
text = "Select backup to restore:"
|
text = "Выберите бекап для восстановления:" if get_user_lang(user_id) == "ru" else "Select backup to restore:"
|
||||||
buttons = []
|
buttons = []
|
||||||
for i, backup in enumerate(backups[:10]):
|
for i, backup in enumerate(backups[:10]):
|
||||||
buttons.append(
|
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)
|
keyboard = InlineKeyboardMarkup(buttons)
|
||||||
# Store backup list in user_data for retrieval
|
# Store backup list in user_data for retrieval
|
||||||
context.user_data["backup_list"] = backups[:10]
|
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:
|
async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Execute backup restoration."""
|
"""Confirm or execute backup restoration."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
data = query.data
|
data = query.data
|
||||||
|
user_id = _uid(update)
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
await query.answer("Invalid backup selection")
|
await query.answer("Invalid backup selection")
|
||||||
return
|
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)
|
backup_path = os.path.join(BACKUP_DIR, backup_name)
|
||||||
|
|
||||||
await query.answer()
|
await query.answer()
|
||||||
await safe_edit_message(query,f"⏳ Restoring from {html.escape(backup_name)}...")
|
if data.startswith("restore_idx_"):
|
||||||
|
text = (
|
||||||
if not os.path.exists(backup_path):
|
f"Восстановить <code>{html.escape(backup_name)}</code>?\n\n"
|
||||||
text = "❌ Backup file not found"
|
"Перед восстановлением будет создан свежий safety-бекап."
|
||||||
else:
|
) if get_user_lang(user_id) == "ru" else (
|
||||||
# Simple restore: extract tar to overwrite configs
|
f"Restore <code>{html.escape(backup_name)}</code>?\n\n"
|
||||||
code, _, stderr = await sh(
|
"A fresh safety backup will be created before restoring."
|
||||||
"tar", "-xzf", backup_path, "-C", "/", timeout=60
|
)
|
||||||
|
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"✅ Восстановление <code>{html.escape(backup_name)}</code> запущено в фоне.\n"
|
||||||
|
"Сервисы могут перезапуститься, через минуту откройте статус."
|
||||||
|
) if get_user_lang(user_id) == "ru" else (
|
||||||
|
f"✅ Restore for <code>{html.escape(backup_name)}</code> 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<code>{html.escape(stderr[:500])}</code>"
|
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(
|
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")
|
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,
|
"change_pro": cb_change_pro,
|
||||||
"install_migrate": cb_install_migrate,
|
"install_migrate": cb_install_migrate,
|
||||||
"menu_stats": cb_menu_stats,
|
"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
|
# 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)
|
await cb_user_add(update, context)
|
||||||
elif data.startswith("user_view_"):
|
elif data.startswith("user_view_"):
|
||||||
await cb_user_view(update, context)
|
await cb_user_view(update, context)
|
||||||
|
elif data.startswith("user_qr_"):
|
||||||
|
await cb_user_qr(update, context)
|
||||||
elif data.startswith("user_toggle_"):
|
elif data.startswith("user_toggle_"):
|
||||||
await cb_user_toggle(update, context)
|
await cb_user_toggle(update, context)
|
||||||
elif data.startswith("user_del_yes_"):
|
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)
|
await cb_pro_template(update, context)
|
||||||
elif data.startswith("pro_confirm_"):
|
elif data.startswith("pro_confirm_"):
|
||||||
await cb_pro_confirm(update, context)
|
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)
|
await cb_restore_backup(update, context)
|
||||||
elif data in handlers:
|
elif data in handlers:
|
||||||
await handlers[data](update, context)
|
await handlers[data](update, context)
|
||||||
|
|||||||
118
lib/backup.sh
118
lib/backup.sh
@@ -27,6 +27,9 @@ create_backup() {
|
|||||||
if [ -f "$GOTELEGRAM_DIR/disabled_users.json" ]; then
|
if [ -f "$GOTELEGRAM_DIR/disabled_users.json" ]; then
|
||||||
cp "$GOTELEGRAM_DIR/disabled_users.json" "$tmp_dir/disabled_users.json" 2>/dev/null
|
cp "$GOTELEGRAM_DIR/disabled_users.json" "$tmp_dir/disabled_users.json" 2>/dev/null
|
||||||
fi
|
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)
|
# Language marker (i18n)
|
||||||
if [ -f "$GOTELEGRAM_DIR/.language" ]; then
|
if [ -f "$GOTELEGRAM_DIR/.language" ]; then
|
||||||
@@ -106,7 +109,7 @@ create_backup() {
|
|||||||
|
|
||||||
cat > "$tmp_dir/metadata.json" << EOMETA
|
cat > "$tmp_dir/metadata.json" << EOMETA
|
||||||
{
|
{
|
||||||
"backup_version": "1.5",
|
"backup_version": "1.6",
|
||||||
"gotelegram_version": "$GOTELEGRAM_VERSION",
|
"gotelegram_version": "$GOTELEGRAM_VERSION",
|
||||||
"created_at": "$(date -Iseconds)",
|
"created_at": "$(date -Iseconds)",
|
||||||
"hostname": "$(hostname)",
|
"hostname": "$(hostname)",
|
||||||
@@ -173,6 +176,7 @@ EOMETA
|
|||||||
restore_backup() {
|
restore_backup() {
|
||||||
local backup_file="$1"
|
local backup_file="$1"
|
||||||
local password="$2"
|
local password="$2"
|
||||||
|
local assume_yes="$3"
|
||||||
|
|
||||||
if [ ! -f "$backup_file" ]; then
|
if [ ! -f "$backup_file" ]; then
|
||||||
if type tf &>/dev/null; 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)
|
backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1)
|
||||||
[ -z "$backup_dir" ] && backup_dir="$tmp_dir"
|
[ -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
|
if [ -f "$backup_dir/metadata.json" ]; then
|
||||||
local bk_version bk_mode bk_ip bk_lang bk_date
|
local bk_version bk_mode bk_ip bk_lang bk_date
|
||||||
@@ -233,7 +249,7 @@ restore_backup() {
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then
|
if [ "$assume_yes" != "yes" ] && ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then
|
||||||
rm -rf "$tmp_dir"
|
rm -rf "$tmp_dir"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -261,6 +277,18 @@ restore_backup() {
|
|||||||
cp "$backup_dir/disabled_users.json" "$GOTELEGRAM_DIR/disabled_users.json"
|
cp "$backup_dir/disabled_users.json" "$GOTELEGRAM_DIR/disabled_users.json"
|
||||||
chmod 600 "$GOTELEGRAM_DIR/disabled_users.json" 2>/dev/null || true
|
chmod 600 "$GOTELEGRAM_DIR/disabled_users.json" 2>/dev/null || true
|
||||||
fi
|
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)
|
# Восстанавливаем language marker (i18n)
|
||||||
if [ -f "$backup_dir/.language" ]; then
|
if [ -f "$backup_dir/.language" ]; then
|
||||||
@@ -374,7 +402,7 @@ list_backups() {
|
|||||||
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
|
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
|
||||||
|
|
||||||
local i=1
|
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 "$f" ] || continue
|
||||||
[[ "$f" == *.sha256 ]] && continue
|
[[ "$f" == *.sha256 ]] && continue
|
||||||
local size date_str name
|
local size date_str name
|
||||||
@@ -393,11 +421,11 @@ list_backups() {
|
|||||||
cleanup_old_backups() {
|
cleanup_old_backups() {
|
||||||
local keep="${1:-5}"
|
local keep="${1:-5}"
|
||||||
local count
|
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
|
if [ "$count" -gt "$keep" ]; then
|
||||||
local to_delete=$((count - keep))
|
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"
|
rm -f "$f" "${f}.sha256"
|
||||||
done
|
done
|
||||||
if type tf &>/dev/null; then
|
if type tf &>/dev/null; then
|
||||||
@@ -408,6 +436,84 @@ cleanup_old_backups() {
|
|||||||
fi
|
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() {
|
interactive_backup() {
|
||||||
echo ""
|
echo ""
|
||||||
@@ -447,7 +553,7 @@ interactive_restore() {
|
|||||||
local backup_file=""
|
local backup_file=""
|
||||||
if [[ "$choice" =~ ^[0-9]+$ ]]; then
|
if [[ "$choice" =~ ^[0-9]+$ ]]; then
|
||||||
local i=1
|
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 "$f" ] || continue
|
||||||
[[ "$f" == *.sha256 ]] && continue
|
[[ "$f" == *.sha256 ]] && continue
|
||||||
if [ "$i" -eq "$choice" ]; then
|
if [ "$i" -eq "$choice" ]; then
|
||||||
|
|||||||
62
tests/test_admin_features.py
Normal file
62
tests/test_admin_features.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user