v2.5.0: add QR import and backup scheduling

This commit is contained in:
Виталий Литвинов
2026-04-25 14:39:56 +03:00
parent c7540a97f7
commit b2ab0dca57
10 changed files with 854 additions and 97 deletions

View File

@@ -20,6 +20,7 @@ Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux se
- **Template Browsing** - Browse categories → templates → preview → install
- **Per-user MTProxy Keys** - Manage telemt `[access.users]` from inline bot menus
- **Per-user QR Import** - Show QR codes for every Telegram proxy key
- **Local Web Admin** - Shows SSH tunnel instructions for the 127.0.0.1:1984 dashboard
- **V1 Migration** - Detects old mtg Docker container and offers migration
- **Access Control** - ALLOWED_IDS from .env
@@ -96,6 +97,7 @@ WantedBy=multi-user.target
- `TELEMT_SERVICE` - `telemt` (systemd service name)
- `WEBSITE_ROOT` - `/var/www/gotelegram-site`
- `BACKUP_DIR` - `/opt/gotelegram/backups`
- `BACKUP_SCHEDULE_FILE` - `/opt/gotelegram/backup_schedule.json`
- `TEMPLATES_CATALOG` - `/opt/gotelegram/templates_catalog.json`
## Architecture
@@ -114,6 +116,7 @@ Organized by feature:
- Installation (quick/stealth modes)
- Status monitoring
- Backup/restore
- Backup schedules: off, daily, weekly, monthly
- SSL management
- Updates
- Removal

View File

@@ -15,6 +15,7 @@ import json
import logging
import os
import re
import shlex
import shutil
import subprocess
import sys
@@ -111,6 +112,7 @@ TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt"
WEBSITE_ROOT = "/var/www/gotelegram-site"
BACKUP_DIR = "/opt/gotelegram/backups"
BACKUP_SCHEDULE_FILE = "/opt/gotelegram/backup_schedule.json"
TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
INSTALL_SH = "/opt/gotelegram/install.sh"
@@ -1846,12 +1848,14 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
buttons = [
[InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")],
[InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")],
[InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")],
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
]
if name == "main":
buttons = [
[InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")],
[InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")],
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
]
await safe_edit_message(
@@ -1863,6 +1867,48 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
)
async def cb_user_qr(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_qr_")
users = load_user_records()
record = users.get(name)
if not record:
await query.answer("Ключ не найден", show_alert=True)
return
link = await get_proxy_link_for_secret(str(record.get("secret", "")))
if not link:
await query.answer("Ссылка недоступна", show_alert=True)
return
qr_file = f"/tmp/gotelegram_user_qr_{hashlib.sha256(name.encode()).hexdigest()[:10]}.png"
code, _, _ = await sh("which", "qrencode")
if code == 0:
code, _, _ = await sh("qrencode", "-o", qr_file, link)
if code == 0 and os.path.exists(qr_file):
try:
with open(qr_file, "rb") as f:
await query.message.reply_photo(
photo=f,
caption=f"<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:
query = update.callback_query
await query.answer()
@@ -2046,34 +2092,146 @@ async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
# BACKUP & RESTORE
# ============================================================================
def list_backup_names(limit: int = 10) -> List[str]:
try:
if not os.path.exists(BACKUP_DIR):
return []
names = [
f for f in os.listdir(BACKUP_DIR)
if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")
]
return sorted(names, reverse=True)[:limit]
except Exception:
return []
def safe_backup_path(name: str) -> Optional[str]:
raw = os.path.basename(str(name or "").strip())
if raw != name or not raw.endswith((".tar.gz", ".tar.gz.enc")) or raw.endswith(".sha256"):
return None
path = os.path.abspath(os.path.join(BACKUP_DIR, raw))
base = os.path.abspath(BACKUP_DIR)
if os.path.dirname(path) != base or not os.path.exists(path):
return None
return path
def backup_schedule_state() -> Dict[str, Any]:
raw = load_json(BACKUP_SCHEDULE_FILE) or {}
if not isinstance(raw, dict):
raw = {}
frequency = str(raw.get("frequency") or "off")
if frequency not in {"off", "daily", "weekly", "monthly"}:
frequency = "off"
return {
"frequency": frequency,
"calendar": raw.get("calendar") or "",
"updated_at": raw.get("updated_at") or "",
}
async def run_full_backup() -> Tuple[bool, str]:
script = (
"source /opt/gotelegram/lib/common.sh; "
"source /opt/gotelegram/lib/i18n.sh; "
"source /opt/gotelegram/lib/telemt.sh; "
"source /opt/gotelegram/lib/website.sh; "
"source /opt/gotelegram/lib/backup.sh; "
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
"create_backup \"\"; "
"cleanup_old_backups 30"
)
code, stdout, stderr = await sh("bash", "-lc", script, timeout=240)
message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
return code == 0, message
async def set_full_backup_schedule(frequency: str) -> Tuple[bool, str]:
if frequency not in {"off", "daily", "weekly", "monthly"}:
return False, "unsupported schedule"
script = (
"source /opt/gotelegram/lib/common.sh; "
"source /opt/gotelegram/lib/i18n.sh; "
"source /opt/gotelegram/lib/backup.sh; "
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
f"set_backup_schedule {shlex.quote(frequency)}"
)
code, stdout, stderr = await sh("bash", "-lc", script, timeout=120)
message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0]
return code == 0, message
async def launch_full_restore(backup_path: str) -> None:
quoted_path = shlex.quote(backup_path)
script = (
"sleep 1; "
"source /opt/gotelegram/lib/common.sh; "
"source /opt/gotelegram/lib/i18n.sh; "
"source /opt/gotelegram/lib/telemt.sh; "
"source /opt/gotelegram/lib/website.sh; "
"source /opt/gotelegram/lib/backup.sh; "
"load_language \"$(detect_language 2>/dev/null || echo en)\"; "
"create_backup \"\" >/dev/null 2>&1 || true; "
f"restore_backup {quoted_path} \"\" yes; "
"cleanup_old_backups 30"
)
await asyncio.create_subprocess_exec(
"bash",
"-lc",
script,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Backup menu."""
query = update.callback_query
await query.answer()
user_id = _uid(update)
backups = list_backup_names()
schedule = backup_schedule_state()
labels = {
"off": "выключено" if get_user_lang(user_id) == "ru" else "off",
"daily": "каждый день" if get_user_lang(user_id) == "ru" else "daily",
"weekly": "каждую неделю" if get_user_lang(user_id) == "ru" else "weekly",
"monthly": "каждый месяц" if get_user_lang(user_id) == "ru" else "monthly",
}
# List existing backups
backups = []
try:
if os.path.exists(BACKUP_DIR):
backups = sorted(
[f for f in os.listdir(BACKUP_DIR)
if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
reverse=True,
)
except Exception:
pass
buttons = [[InlineKeyboardButton("💾 Create Backup", callback_data="backup_create")]]
buttons = [
[InlineKeyboardButton("💾 Создать сейчас" if get_user_lang(user_id) == "ru" else "💾 Create now", callback_data="backup_create")],
[
InlineKeyboardButton("◯ Выкл" if get_user_lang(user_id) == "ru" else "◯ Off", callback_data="backup_schedule_off"),
InlineKeyboardButton("☀ День" if get_user_lang(user_id) == "ru" else "☀ Daily", callback_data="backup_schedule_daily"),
],
[
InlineKeyboardButton("◷ Неделя" if get_user_lang(user_id) == "ru" else "◷ Weekly", callback_data="backup_schedule_weekly"),
InlineKeyboardButton("◴ Месяц" if get_user_lang(user_id) == "ru" else "◴ Monthly", callback_data="backup_schedule_monthly"),
],
]
if backups:
buttons.append(
[InlineKeyboardButton("📋 List Backups", callback_data="backup_list")]
buttons.append([InlineKeyboardButton("📋 Список" if get_user_lang(user_id) == "ru" else "📋 List", callback_data="backup_list")])
buttons.append([InlineKeyboardButton("↩️ Восстановить" if get_user_lang(user_id) == "ru" else "↩️ Restore", callback_data="menu_restore")])
buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")])
if get_user_lang(user_id) == "ru":
text = (
"<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)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -2082,54 +2240,60 @@ async def cb_backup_create(update: Update, context: ContextTypes.DEFAULT_TYPE) -
"""Create backup."""
query = update.callback_query
await query.answer()
user_id = _uid(update)
await safe_edit_message(query,"⏳ Creating backup...")
await safe_edit_message(query, "⏳ Создаю полный бекап..." if get_user_lang(user_id) == "ru" else "⏳ Creating full backup...")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = os.path.join(BACKUP_DIR, f"backup_{timestamp}.tar.gz")
os.makedirs(BACKUP_DIR, exist_ok=True)
code, _, stderr = await sh(
"tar", "-czf", backup_file, GOTELEGRAM_CONFIG, TELEMT_CONFIG
)
if code == 0:
text = f"✅ Backup created:\n<code>{html.escape(backup_file)}</code>"
ok, message = await run_full_backup()
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>"
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(
[[InlineKeyboardButton("« Back", callback_data="menu_backup")]]
[[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
async def cb_backup_schedule_set(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
frequency = query.data.removeprefix("backup_schedule_")
await safe_edit_message(query, "⏳ Сохраняю расписание..." if get_user_lang(user_id) == "ru" else "⏳ Saving schedule...")
ok, message = await set_full_backup_schedule(frequency)
if ok:
text = "✅ Расписание обновлено." if get_user_lang(user_id) == "ru" else "✅ Backup schedule updated."
else:
text = f"{html.escape(message[:500])}"
await safe_edit_message(
query,
text,
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]]),
parse_mode="HTML",
)
async def cb_backup_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""List backups."""
query = update.callback_query
await query.answer()
user_id = _uid(update)
backups = []
try:
if os.path.exists(BACKUP_DIR):
backups = sorted(
[f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
reverse=True,
)
except Exception:
pass
backups = list_backup_names()
if not backups:
text = "No backups found"
text = "Бекапов нет" if get_user_lang(user_id) == "ru" else "No backups found"
else:
text = "<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]:
path = os.path.join(BACKUP_DIR, backup)
size = os.path.getsize(path) / (1024 * 1024)
text += f"<code>{html.escape(backup)}</code> ({size:.2f} MB)\n"
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton("« Back", callback_data="menu_backup")]]
[[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -2138,24 +2302,17 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
"""Restore menu."""
query = update.callback_query
await query.answer()
user_id = _uid(update)
backups = []
try:
if os.path.exists(BACKUP_DIR):
backups = sorted(
[f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
reverse=True,
)
except Exception:
pass
backups = list_backup_names()
if not backups:
text = "❌ No backups available"
text = " Нет доступных бекапов" if get_user_lang(user_id) == "ru" else " No backups available"
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
[[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]]
)
else:
text = "Select backup to restore:"
text = "Выберите бекап для восстановления:" if get_user_lang(user_id) == "ru" else "Select backup to restore:"
buttons = []
for i, backup in enumerate(backups[:10]):
buttons.append(
@@ -2165,7 +2322,7 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
]
)
buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")])
buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")])
keyboard = InlineKeyboardMarkup(buttons)
# Store backup list in user_data for retrieval
context.user_data["backup_list"] = backups[:10]
@@ -2174,12 +2331,16 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Execute backup restoration."""
"""Confirm or execute backup restoration."""
query = update.callback_query
data = query.data
user_id = _uid(update)
try:
idx = int(data.removeprefix("restore_idx_"))
if data.startswith("restore_yes_"):
idx = int(data.removeprefix("restore_yes_"))
else:
idx = int(data.removeprefix("restore_idx_"))
except ValueError:
await query.answer("Invalid backup selection")
return
@@ -2193,24 +2354,40 @@ async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE)
backup_path = os.path.join(BACKUP_DIR, backup_name)
await query.answer()
await safe_edit_message(query,f"⏳ Restoring from {html.escape(backup_name)}...")
if not os.path.exists(backup_path):
text = "❌ Backup file not found"
else:
# Simple restore: extract tar to overwrite configs
code, _, stderr = await sh(
"tar", "-xzf", backup_path, "-C", "/", timeout=60
if data.startswith("restore_idx_"):
text = (
f"Восстановить <code>{html.escape(backup_name)}</code>?\n\n"
"Перед восстановлением будет создан свежий safety-бекап."
) if get_user_lang(user_id) == "ru" else (
f"Restore <code>{html.escape(backup_name)}</code>?\n\n"
"A fresh safety backup will be created before restoring."
)
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("✅ Восстановить" if get_user_lang(user_id) == "ru" else "✅ Restore", callback_data=f"restore_yes_{idx}")],
[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_restore")],
])
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
return
await safe_edit_message(query, f"⏳ Восстановление запущено: {html.escape(backup_name)}..." if get_user_lang(user_id) == "ru" else f"⏳ Restore started: {html.escape(backup_name)}...")
safe_path = safe_backup_path(backup_name)
if not safe_path:
text = "❌ Файл бекапа не найден" if get_user_lang(user_id) == "ru" else "❌ Backup file not found"
elif safe_path.endswith(".enc"):
text = "❌ Зашифрованный бекап пока восстанавливается через CLI: gotelegram → Восстановить." if get_user_lang(user_id) == "ru" else "❌ Encrypted backups are restored from CLI for now: gotelegram → Restore."
else:
await launch_full_restore(safe_path)
text = (
f"✅ Восстановление <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(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
[[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -2814,6 +2991,10 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
"change_pro": cb_change_pro,
"install_migrate": cb_install_migrate,
"menu_stats": cb_menu_stats,
"backup_schedule_off": cb_backup_schedule_set,
"backup_schedule_daily": cb_backup_schedule_set,
"backup_schedule_weekly": cb_backup_schedule_set,
"backup_schedule_monthly": cb_backup_schedule_set,
}
# Custom git template URL prompt
@@ -2839,6 +3020,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await cb_user_add(update, context)
elif data.startswith("user_view_"):
await cb_user_view(update, context)
elif data.startswith("user_qr_"):
await cb_user_qr(update, context)
elif data.startswith("user_toggle_"):
await cb_user_toggle(update, context)
elif data.startswith("user_del_yes_"):
@@ -2851,7 +3034,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await cb_pro_template(update, context)
elif data.startswith("pro_confirm_"):
await cb_pro_confirm(update, context)
elif data.startswith("restore_idx_"):
elif data.startswith("restore_idx_") or data.startswith("restore_yes_"):
await cb_restore_backup(update, context)
elif data in handlers:
await handlers[data](update, context)