From 0e38c2b5b63156af582e5f4c0ec96a46423189cd Mon Sep 17 00:00:00 2001 From: anten-ka Date: Sun, 12 Apr 2026 00:07:03 +0300 Subject: [PATCH] v2.4.9: UBF v2.0 backup + manual secret recovery - lib/backup.sh: complete rewrite for Unified Backup Format v2.0 * metadata.json with backup_id (GT-YYMMDD-) and SHA-256 fingerprint * secrets.json with raw_secret, faketls_secret, proxy_link, bot_token * structured dirs: telemt/, gotelegram/, nginx/, letsencrypt/, site/, bot/ * auto-detect and auto-migrate v1.1 -> v2.0 on restore * parse_manual_secret: accepts tg://proxy URL, ee-prefix, or raw 32-hex * manual_secret_input: interactive entry with env var export - install.sh: new 3-option menu_install (new / restore / existing key) * install_lite_mode + install_pro_mode respect GOTELEGRAM_EXISTING_* env vars - lib/lang/ru.sh + en.sh: v2.4.9 i18n strings (backup_*, manual_secret_*, install_menu_*) - lib/common.sh + gotelegram-bot/bot.py: version bump to 2.4.9 --- gotelegram-bot/bot.py | 2 +- install.sh | 93 +++++++- lib/backup.sh | 497 +++++++++++++++++++++++++++++++++--------- lib/common.sh | 2 +- lib/lang/en.sh | 65 ++++++ lib/lang/ru.sh | 65 ++++++ 6 files changed, 605 insertions(+), 119 deletions(-) mode change 100755 => 100644 lib/backup.sh diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 8f71c03..830496d 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -100,7 +100,7 @@ logger = logging.getLogger(__name__) # CONFIGURATION # ============================================================================ -GOTELEGRAM_VERSION = "2.4.8" +GOTELEGRAM_VERSION = "2.4.9" GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json" TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_SERVICE = "telemt" diff --git a/install.sh b/install.sh index 28dfa31..01e94d1 100644 --- a/install.sh +++ b/install.sh @@ -258,6 +258,49 @@ menu_install() { fi fi + # Always start from a clean slate — any leftover env vars from a previous + # manual-key entry must not leak into a "new install" flow. + unset GOTELEGRAM_EXISTING_SECRET GOTELEGRAM_EXISTING_DOMAIN GOTELEGRAM_EXISTING_PORT + + # ── Step 1: install source picker ──────────────────────────────────────── + echo "" + echo -e " ${BOLD}${WHITE}$(_t_or install_source_title 'Источник установки')${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${CYAN}1)${NC} ${GREEN}$(_t_or install_menu_new 'Новая установка')${NC}" + echo -e " ${DIM}$(_t_or install_menu_new_desc 'Сгенерировать новый ключ и настроить с нуля')${NC}" + echo "" + echo -e " ${CYAN}2)${NC} ${BLUE}$(_t_or install_menu_restore 'Восстановить из бекапа')${NC}" + echo -e " ${DIM}$(_t_or install_menu_restore_desc 'Полное восстановление из файла .tar.gz[.enc]')${NC}" + echo "" + echo -e " ${CYAN}3)${NC} ${YELLOW}$(_t_or install_menu_existing_key 'Использовать существующий ключ')${NC}" + echo -e " ${DIM}$(_t_or install_menu_existing_key_desc 'Ввести ссылку tg://proxy или ключ вручную')${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -ne " ${WHITE}$(_t_or install_source_choice 'Выберите источник')${NC} " + read -r src_choice + src_choice="${src_choice:-}" + + case "$src_choice" in + 1) : ;; # fall through to mode picker + 2) + if type interactive_restore &>/dev/null; then + interactive_restore + else + log_error "backup.sh not loaded" + fi + return + ;; + 3) + if type manual_secret_input &>/dev/null; then + manual_secret_input || return + else + log_error "backup.sh not loaded" + return + fi + ;; + *) log_error "$(tf install_bad_choice "${src_choice:-}")" ; return ;; + esac + + # ── Step 2: lite/pro mode picker ───────────────────────────────────────── echo "" echo -e " ${BOLD}${WHITE}$(t install_select_mode)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" @@ -274,11 +317,19 @@ menu_install() { read -r mode_choice mode_choice="${mode_choice:-}" + # If user provided an ee-prefixed key with a domain, hint at pro mode + if [ -n "${GOTELEGRAM_EXISTING_DOMAIN:-}" ] && [ "$mode_choice" = "1" ]; then + log_warning "$(_t_or install_hint_pro_mode 'Ключ содержит домен — обычно это Pro режим')" + fi + case "$mode_choice" in 1) install_lite_mode ;; 2) install_pro_mode ;; *) log_error "$(tf install_bad_choice "${mode_choice:-}")" ;; esac + + # Clean up env vars after install, just in case + unset GOTELEGRAM_EXISTING_SECRET GOTELEGRAM_EXISTING_DOMAIN GOTELEGRAM_EXISTING_PORT } # ── Lite mode ─────────────────────────────────────────────────────────────── @@ -290,10 +341,15 @@ install_lite_mode() { domain=$(select_quick_domain) [ $? -ne 0 ] && return - # Port selection + # Port selection — if user provided a port via existing-key flow, reuse it local port - port=$(select_port) - [ $? -ne 0 ] && return + if [ -n "${GOTELEGRAM_EXISTING_PORT:-}" ] && [[ "$GOTELEGRAM_EXISTING_PORT" =~ ^[0-9]+$ ]]; then + port="$GOTELEGRAM_EXISTING_PORT" + log_info "$(_t_or install_reuse_port 'Используется порт из ключа'): ${port}" + else + port=$(select_port) + [ $? -ne 0 ] && return + fi # Preflight: port conflict check (checks the external port only for lite) if ! preflight_check "lite" "$port"; then @@ -301,9 +357,14 @@ install_lite_mode() { return fi - # Generate secret + # Secret: reuse if provided via manual_secret_input, otherwise generate new local secret - secret=$(generate_hex 32) + if [ -n "${GOTELEGRAM_EXISTING_SECRET:-}" ]; then + secret="$GOTELEGRAM_EXISTING_SECRET" + log_info "$(_t_or install_reuse_secret 'Используется переданный ключ'): ${secret:0:8}...${secret: -4}" + else + secret=$(generate_hex 32) + fi # Confirm local ip @@ -354,10 +415,16 @@ install_pro_mode() { return fi - # Enter domain - echo "" - echo -ne " ${WHITE}$(t install_enter_domain)${NC} " - read -r user_domain + # Enter domain — if provided via existing-key flow, reuse it + local user_domain="" + if [ -n "${GOTELEGRAM_EXISTING_DOMAIN:-}" ]; then + user_domain="$GOTELEGRAM_EXISTING_DOMAIN" + log_info "$(_t_or install_reuse_domain 'Используется домен из ключа'): ${user_domain}" + else + echo "" + echo -ne " ${WHITE}$(t install_enter_domain)${NC} " + read -r user_domain + fi if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then log_error "$(tf install_bad_domain "${user_domain:-}")" @@ -399,8 +466,14 @@ install_pro_mode() { # Generate fake-TLS secret (ee + secret + hex domain) # ee prefix tells Telegram client to masquerade traffic as TLS to domain + # Reuse existing secret if manual_secret_input provided it local raw_secret - raw_secret=$(generate_hex 32) + if [ -n "${GOTELEGRAM_EXISTING_SECRET:-}" ]; then + raw_secret="$GOTELEGRAM_EXISTING_SECRET" + log_info "$(_t_or install_reuse_secret 'Используется переданный ключ'): ${raw_secret:0:8}...${raw_secret: -4}" + else + raw_secret=$(generate_hex 32) + fi local domain_hex domain_hex=$(printf '%s' "$user_domain" | xxd -p | tr -d '\n') local faketls_secret="ee${raw_secret}${domain_hex}" diff --git a/lib/backup.sh b/lib/backup.sh old mode 100755 new mode 100644 index f5d3cd6..fed1c0a --- a/lib/backup.sh +++ b/lib/backup.sh @@ -1,71 +1,179 @@ #!/bin/bash -# GoTelegram v2.4 — backup and restore (i18n-aware) +# GoTelegram v2.4.9 — Unified Backup Format (UBF) v2.0 +# +# UBF v2.0 layout (inside the tarball): +# gotelegram_backup_YYYYMMDD_HHMMSS_/ +# ├── metadata.json # backup_id, versions, fingerprint, ... +# ├── secrets.json # raw_secret, faketls_secret, proxy_link, bot_token +# ├── telemt/config.toml +# ├── gotelegram/config.json +# ├── gotelegram/.language +# ├── nginx/site.conf +# ├── letsencrypt/ +# │ ├── live//{fullchain,privkey,chain,cert}.pem +# │ └── renewal/.conf +# ├── site/ (nginx document root) +# └── bot/.env +# +# Backup ID format: GT-YYMMDD- +# Archive name: gotelegram_backup_YYYYMMDD_HHMMSS_.tar.gz[.enc] +# +# Encryption: AES-256-CBC + PBKDF2 (optional, password-based) +# Integrity: SHA-256 sidecar file (.sha256) +# +# Restore path automatically detects v1.1 (legacy) vs v2.0 layouts by reading +# metadata.json.backup_version. When restoring a v1.1 archive the script +# immediately writes a fresh v2.0 backup alongside the old one, so subsequent +# reinstalls can benefit from the new format. -# ── Создание бекапа ────────────────────────────────────────────────────────── +# ── Utility: generate a backup ID from a raw secret ───────────────────────── +# Format: GT-YYMMDD-. Deterministic per-day per-key; easy to read. +generate_backup_id() { + local raw_secret="$1" + local date_part + date_part=$(date +%y%m%d) + local last6="000000" + if [ -n "$raw_secret" ] && [ ${#raw_secret} -ge 6 ]; then + last6="${raw_secret: -6}" + last6=$(echo "$last6" | tr 'A-F' 'a-f') + fi + echo "GT-${date_part}-${last6}" +} + +# ── Utility: SHA-256 fingerprint of a raw secret ──────────────────────────── +secret_fingerprint() { + local raw_secret="$1" + [ -z "$raw_secret" ] && { echo ""; return; } + printf '%s' "$raw_secret" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}' +} + +# ── Utility: hex-encode an ASCII string (for fake-TLS secret) ─────────────── +_hex_encode() { + printf '%s' "$1" | xxd -p | tr -d '\n' +} + +# ── Создание бекапа (UBF v2.0) ────────────────────────────────────────────── create_backup() { - local password="$1" + local password="${1:-}" local output_dir="${2:-$BACKUP_DIR}" + + # Pull current config (so backup_id can include the real secret) + local raw_secret domain mode engine port lang tpl_id mask_host + raw_secret=$(config_get secret 2>/dev/null || echo "") + domain=$(config_get domain 2>/dev/null || echo "") + mode=$(config_get mode 2>/dev/null || echo "unknown") + engine=$(config_get engine 2>/dev/null || echo "telemt") + port=$(config_get port 2>/dev/null || echo "443") + lang=$(type get_language &>/dev/null && get_language 2>/dev/null || echo "en") + tpl_id=$(config_get template_id 2>/dev/null || echo "") + mask_host=$(config_get mask_host 2>/dev/null || echo "") + + # Sanitise port + [[ "$port" =~ ^[0-9]+$ ]] || port=443 + + # Backup id / short id (last 6 of secret, or random if unknown) + local backup_id short_id + backup_id=$(generate_backup_id "$raw_secret") + short_id="${backup_id##*-}" + local timestamp timestamp=$(date +%Y%m%d_%H%M%S) - local backup_name="gotelegram_backup_${timestamp}" + local backup_name="gotelegram_backup_${timestamp}_${short_id}" local tmp_dir="/tmp/${backup_name}" mkdir -p "$tmp_dir" "$output_dir" - # Собираем файлы log_info "$(_t_or backup_collecting 'Собираю конфигурацию...')" - # telemt конфиг + # ── telemt ── if [ -f "$TELEMT_CONFIG" ]; then - cp "$TELEMT_CONFIG" "$tmp_dir/config.toml" + mkdir -p "$tmp_dir/telemt" + cp "$TELEMT_CONFIG" "$tmp_dir/telemt/config.toml" fi - # GoTelegram конфиг + # ── gotelegram ── + mkdir -p "$tmp_dir/gotelegram" if [ -f "$GOTELEGRAM_CONFIG" ]; then - cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json" + cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram/config.json" fi - - # Language marker (i18n) if [ -f "$GOTELEGRAM_DIR/.language" ]; then - cp "$GOTELEGRAM_DIR/.language" "$tmp_dir/.language" + cp "$GOTELEGRAM_DIR/.language" "$tmp_dir/gotelegram/.language" fi - # nginx конфиг (stealth mode) + # ── nginx ── if [ -f "$NGINX_SITE_CONF" ]; then - cp "$NGINX_SITE_CONF" "$tmp_dir/nginx.conf" + mkdir -p "$tmp_dir/nginx" + cp "$NGINX_SITE_CONF" "$tmp_dir/nginx/site.conf" fi - # SSL сертификаты - local domain - domain=$(config_get domain 2>/dev/null) + # ── Let's Encrypt (full tree: live//*.pem + renewal/.conf) ── if [ -n "$domain" ] && [ -d "/etc/letsencrypt/live/$domain" ]; then - mkdir -p "$tmp_dir/certs" - cp "/etc/letsencrypt/live/$domain/fullchain.pem" "$tmp_dir/certs/" 2>/dev/null - cp "/etc/letsencrypt/live/$domain/privkey.pem" "$tmp_dir/certs/" 2>/dev/null - log_dim "SSL сертификаты включены" + mkdir -p "$tmp_dir/letsencrypt/live/$domain" + # Follow symlinks — letsencrypt's live/ tree is symlinks into archive/ + cp -L "/etc/letsencrypt/live/$domain/"*.pem "$tmp_dir/letsencrypt/live/$domain/" 2>/dev/null + if [ -f "/etc/letsencrypt/renewal/${domain}.conf" ]; then + mkdir -p "$tmp_dir/letsencrypt/renewal" + cp "/etc/letsencrypt/renewal/${domain}.conf" "$tmp_dir/letsencrypt/renewal/" + fi + log_dim "$(_t_or backup_ssl_included 'SSL-сертификаты включены (+ chain + renewal)')" fi - # Шаблон сайта (если есть) + # ── Website template ── if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then mkdir -p "$tmp_dir/site" - cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/" + cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/" 2>/dev/null log_dim "$(_t_or backup_site_included 'Шаблон сайта включён')" fi - # Метаданные - local ip mode engine lang port domain + # ── Telegram bot ── + if [ -f "$BOT_DIR/.env" ]; then + mkdir -p "$tmp_dir/bot" + cp "$BOT_DIR/.env" "$tmp_dir/bot/.env" + chmod 600 "$tmp_dir/bot/.env" 2>/dev/null + log_dim "$(_t_or backup_bot_included 'Конфиг Telegram-бота включён')" + fi + + # ── secrets.json ── + local faketls_secret="" proxy_link="" bot_token="" + if [ -n "$raw_secret" ] && [ "$mode" = "pro" ] && [ -n "$domain" ]; then + faketls_secret="ee${raw_secret}$(_hex_encode "$domain")" + fi + if type generate_proxy_link &>/dev/null; then + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + proxy_link=$(generate_proxy_link "$domain" "$port" "$raw_secret" "$domain" 2>/dev/null || echo "") + elif [ -n "$raw_secret" ]; then + local ip + ip=$(get_server_ip) + proxy_link=$(generate_proxy_link "$ip" "$port" "$raw_secret" "$mask_host" 2>/dev/null || echo "") + fi + fi + if [ -f "$BOT_DIR/.env" ]; then + bot_token=$(grep -E '^BOT_TOKEN=' "$BOT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-) + bot_token="${bot_token%\"}" + bot_token="${bot_token#\"}" + fi + + cat > "$tmp_dir/secrets.json" << EOSEC +{ + "version": "1", + "raw_secret": "${raw_secret}", + "faketls_secret": "${faketls_secret}", + "proxy_link": "${proxy_link}", + "bot_token": "${bot_token}", + "exported_at": "$(date -Iseconds)" +} +EOSEC + chmod 600 "$tmp_dir/secrets.json" + + # ── metadata.json v2.0 ── + local ip fingerprint ip=$(get_server_ip) - mode=$(config_get mode 2>/dev/null || echo "unknown") - engine=$(config_get engine 2>/dev/null || echo "telemt") - lang=$(type get_language &>/dev/null && get_language 2>/dev/null || echo "en") - port=$(config_get port 2>/dev/null || echo "443") - # Ensure port is numeric; fall back to 443 if garbage - [[ "$port" =~ ^[0-9]+$ ]] || port=443 - domain=$(config_get domain 2>/dev/null || echo "") + fingerprint=$(secret_fingerprint "$raw_secret") cat > "$tmp_dir/metadata.json" << EOMETA { - "backup_version": "1.1", + "backup_version": "2.0", + "backup_id": "${backup_id}", "gotelegram_version": "$GOTELEGRAM_VERSION", "created_at": "$(date -Iseconds)", "hostname": "$(hostname)", @@ -74,34 +182,37 @@ create_backup() { "mode": "$mode", "language": "$lang", "port": $port, - "domain": "$domain" + "domain": "$domain", + "template_id": "$tpl_id", + "mask_host": "$mask_host", + "secret_fingerprint_sha256": "$fingerprint", + "has_secrets": true, + "has_letsencrypt": $([ -d "$tmp_dir/letsencrypt" ] && echo true || echo false), + "has_site": $([ -d "$tmp_dir/site" ] && echo true || echo false), + "has_bot": $([ -d "$tmp_dir/bot" ] && echo true || echo false) } EOMETA - # Архивируем + # ── Archive ── local tar_file="/tmp/${backup_name}.tar.gz" if ! tar czf "$tar_file" -C /tmp "$backup_name" 2>/dev/null; then log_error "$(_t_or backup_archive_err 'Ошибка создания архива')" - rm -rf "$tmp_dir" - rm -f "$tar_file" + rm -rf "$tmp_dir"; rm -f "$tar_file" return 1 fi - if [ ! -f "$tar_file" ]; then log_error "$(_t_or backup_archive_missing 'Архив не создан')" rm -rf "$tmp_dir" return 1 fi - # Шифруем если задан пароль + # ── Encrypt (optional) ── local final_file="" if [ -n "$password" ]; then final_file="${output_dir}/${backup_name}.tar.gz.enc" - openssl enc -aes-256-cbc -salt -pbkdf2 -in "$tar_file" -out "$final_file" -pass "pass:${password}" 2>/dev/null - if [ $? -ne 0 ]; then + if ! openssl enc -aes-256-cbc -salt -pbkdf2 -in "$tar_file" -out "$final_file" -pass "pass:${password}" 2>/dev/null; then log_error "$(_t_or backup_encrypt_err 'Ошибка шифрования')" - rm -f "$tar_file" - rm -rf "$tmp_dir" + rm -f "$tar_file"; rm -rf "$tmp_dir" return 1 fi rm -f "$tar_file" @@ -111,27 +222,36 @@ EOMETA mv "$tar_file" "$final_file" fi - # SHA256 подпись + # SHA-256 sidecar sha256sum "$final_file" > "${final_file}.sha256" 2>/dev/null - # Очистка + # Cleanup rm -rf "$tmp_dir" local size size=$(du -h "$final_file" | cut -f1) - if type tf &>/dev/null; then - log_success "$(tf backup_created_fmt "$final_file" "$size")" - else - log_success "Бекап создан: $final_file ($size)" + + echo "" >&2 + echo -e " ${BOLD}${GREEN}✓ $(_t_or backup_created 'Бекап создан')${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + echo -e " ${WHITE}$(_t_or backup_id_label 'Backup ID'):${NC} ${BOLD}${CYAN}${backup_id}${NC}" >&2 + echo -e " ${WHITE}$(_t_or backup_file_label 'Файл'):${NC} ${final_file}" >&2 + echo -e " ${WHITE}$(_t_or backup_size_label 'Размер'):${NC} ${size}" >&2 + if [ -n "$raw_secret" ]; then + echo -e " ${WHITE}$(_t_or backup_key_label 'Ключ в бекапе (fingerprint)'):${NC} ${DIM}${fingerprint:0:32}...${NC}" >&2 fi + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + echo "" >&2 + + # Only the final file path on stdout (callers capture it) echo "$final_file" return 0 } -# ── Восстановление из бекапа ──────────────────────────────────────────────── +# ── Восстановление из бекапа (auto-detect v1.1 vs v2.0) ───────────────────── restore_backup() { local backup_file="$1" - local password="$2" + local password="${2:-}" if [ ! -f "$backup_file" ]; then if type tf &>/dev/null; then @@ -145,17 +265,16 @@ restore_backup() { local tmp_dir="/tmp/gotelegram_restore_$$" mkdir -p "$tmp_dir" - # Расшифровываем если нужно + # ── Decrypt if needed ── local tar_file="" if echo "$backup_file" | grep -q '\.enc$'; then if [ -z "$password" ]; then - echo -ne " $(_t_or backup_enter_pass 'Введите пароль от бекапа'): " + echo -ne " $(_t_or backup_enter_pass 'Введите пароль от бекапа'): " >&2 read -rs password - echo "" + echo "" >&2 fi tar_file="/tmp/gotelegram_restore_$$.tar.gz" - openssl enc -aes-256-cbc -d -pbkdf2 -in "$backup_file" -out "$tar_file" -pass "pass:${password}" 2>/dev/null - if [ $? -ne 0 ]; then + if ! openssl enc -aes-256-cbc -d -pbkdf2 -in "$backup_file" -out "$tar_file" -pass "pass:${password}" 2>/dev/null; then log_error "$(_t_or backup_bad_pass 'Неверный пароль или повреждённый файл')" rm -rf "$tmp_dir" "$tar_file" return 1 @@ -164,105 +283,155 @@ restore_backup() { tar_file="$backup_file" fi - # Распаковываем - tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null - if [ $? -ne 0 ]; then + # ── Extract ── + if ! tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null; then log_error "$(_t_or backup_extract_err 'Ошибка распаковки архива')" rm -rf "$tmp_dir" + [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file" return 1 fi - # Находим папку бекапа + # Find the single top-level dir inside the archive local backup_dir - backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1) + backup_dir=$(find "$tmp_dir" -maxdepth 1 -mindepth 1 -type d -name "gotelegram_backup_*" | head -1) [ -z "$backup_dir" ] && backup_dir="$tmp_dir" - # Проверяем метаданные + # ── Parse metadata.json ── + local bk_version="1.1" bk_id="" bk_mode="" bk_domain="" bk_ip="" bk_lang="" bk_date="" if [ -f "$backup_dir/metadata.json" ]; then - local bk_version bk_mode bk_ip bk_lang bk_date - bk_version=$(jq -r '.gotelegram_version // "unknown"' "$backup_dir/metadata.json") + bk_version=$(jq -r '.backup_version // "1.1"' "$backup_dir/metadata.json") + bk_id=$(jq -r '.backup_id // empty' "$backup_dir/metadata.json") bk_mode=$(jq -r '.mode // "unknown"' "$backup_dir/metadata.json") - bk_ip=$(jq -r '.ip // "unknown"' "$backup_dir/metadata.json") + bk_domain=$(jq -r '.domain // empty' "$backup_dir/metadata.json") + bk_ip=$(jq -r '.ip // "-"' "$backup_dir/metadata.json") bk_lang=$(jq -r '.language // "-"' "$backup_dir/metadata.json") bk_date=$(jq -r '.created_at // "-"' "$backup_dir/metadata.json") - echo "" - echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_label 'Бекап'):${NC}" - echo -e " $(_t_or backup_version_label 'Версия'): $bk_version | $(_t_or backup_mode_label 'Режим'): $bk_mode | IP: $bk_ip | $(_t_or backup_lang_label 'Язык'): $bk_lang" - echo -e " $(_t_or backup_date_label 'Дата'): $bk_date" - echo "" fi + echo "" >&2 + echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_label 'Бекап'):${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + if [ -n "$bk_id" ]; then + echo -e " ${WHITE}$(_t_or backup_id_label 'Backup ID'):${NC} ${BOLD}${CYAN}${bk_id}${NC}" >&2 + fi + echo -e " ${WHITE}$(_t_or backup_format_label 'Формат'):${NC} UBF ${bk_version}" >&2 + echo -e " ${WHITE}$(_t_or backup_mode_label 'Режим'):${NC} ${bk_mode}${bk_domain:+ | $bk_domain}" >&2 + echo -e " ${WHITE}$(_t_or backup_lang_label 'Язык'):${NC} ${bk_lang} | IP: ${bk_ip}" >&2 + echo -e " ${WHITE}$(_t_or backup_date_label 'Дата'):${NC} ${bk_date}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + echo "" >&2 + if ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then rm -rf "$tmp_dir" + [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file" return 0 fi - # Останавливаем сервисы stop_telemt 2>/dev/null systemctl stop nginx 2>/dev/null - # Восстанавливаем telemt конфиг - if [ -f "$backup_dir/config.toml" ]; then + # ── Detect layout ── + # v2.0 paths: telemt/config.toml, gotelegram/config.json, nginx/site.conf, letsencrypt/live// + # v1.1 paths: config.toml, gotelegram.json, nginx.conf, certs/ + local src_telemt src_gt src_lang src_nginx src_le_live src_le_renewal src_site src_bot + if [ "$bk_version" = "2.0" ] || [ -d "$backup_dir/telemt" ]; then + src_telemt="$backup_dir/telemt/config.toml" + src_gt="$backup_dir/gotelegram/config.json" + src_lang="$backup_dir/gotelegram/.language" + src_nginx="$backup_dir/nginx/site.conf" + src_le_live=$(find "$backup_dir/letsencrypt/live" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | head -1) + src_le_renewal="$backup_dir/letsencrypt/renewal" + src_site="$backup_dir/site" + src_bot="$backup_dir/bot/.env" + else + src_telemt="$backup_dir/config.toml" + src_gt="$backup_dir/gotelegram.json" + src_lang="$backup_dir/.language" + src_nginx="$backup_dir/nginx.conf" + src_le_live="$backup_dir/certs" # v1.1 dumps certs flat + src_le_renewal="" + src_site="$backup_dir/site" + src_bot="" # v1.1 never backed up bot + fi + + # ── telemt config ── + if [ -f "$src_telemt" ]; then mkdir -p /etc/telemt - cp "$backup_dir/config.toml" "$TELEMT_CONFIG" + cp "$src_telemt" "$TELEMT_CONFIG" chmod 600 "$TELEMT_CONFIG" log_success "$(_t_or backup_restored_telemt 'telemt конфиг восстановлен')" fi - # Восстанавливаем GoTelegram конфиг - if [ -f "$backup_dir/gotelegram.json" ]; then + # ── GoTelegram config ── + if [ -f "$src_gt" ]; then mkdir -p "$GOTELEGRAM_DIR" - cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG" + cp "$src_gt" "$GOTELEGRAM_CONFIG" log_success "$(_t_or backup_restored_gotelegram 'GoTelegram конфиг восстановлен')" fi - # Восстанавливаем language marker (i18n) - if [ -f "$backup_dir/.language" ]; then + # ── Language ── + if [ -f "$src_lang" ]; then mkdir -p "$GOTELEGRAM_DIR" - cp "$backup_dir/.language" "$GOTELEGRAM_DIR/.language" + cp "$src_lang" "$GOTELEGRAM_DIR/.language" log_success "$(_t_or backup_restored_lang 'Язык интерфейса восстановлен')" fi - # Восстанавливаем nginx конфиг - if [ -f "$backup_dir/nginx.conf" ]; then + # ── nginx ── + if [ -f "$src_nginx" ]; then mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled - cp "$backup_dir/nginx.conf" "$NGINX_SITE_CONF" + cp "$src_nginx" "$NGINX_SITE_CONF" ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" log_success "$(_t_or backup_restored_nginx 'nginx конфиг восстановлен')" fi - # Восстанавливаем SSL - if [ -d "$backup_dir/certs" ]; then - local domain - domain=$(config_get domain 2>/dev/null) - if [ -n "$domain" ]; then - local cert_dir="/etc/letsencrypt/live/$domain" - mkdir -p "$cert_dir" - cp "$backup_dir/certs/"* "$cert_dir/" 2>/dev/null - log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')" + # ── Let's Encrypt (v2.0: full tree; v1.1: just flat certs/) ── + if [ -n "$bk_domain" ] && [ -d "$src_le_live" ]; then + local live_dir="/etc/letsencrypt/live/$bk_domain" + mkdir -p "$live_dir" + cp "$src_le_live/"*.pem "$live_dir/" 2>/dev/null + if [ -n "$src_le_renewal" ] && [ -f "$src_le_renewal/${bk_domain}.conf" ]; then + mkdir -p /etc/letsencrypt/renewal + cp "$src_le_renewal/${bk_domain}.conf" "/etc/letsencrypt/renewal/" fi + log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')" fi - # Восстанавливаем шаблон сайта - if [ -d "$backup_dir/site" ]; then + # ── Site ── + if [ -d "$src_site" ] && [ -f "$src_site/index.html" ]; then mkdir -p "$WEBSITE_ROOT" - cp -r "$backup_dir/site"/* "$WEBSITE_ROOT/" + cp -r "$src_site"/* "$WEBSITE_ROOT/" chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null log_success "$(_t_or backup_restored_site 'Шаблон сайта восстановлен')" fi - # Запускаем сервисы - if is_telemt_installed; then + # ── Bot .env (v2.0 only) ── + if [ -n "$src_bot" ] && [ -f "$src_bot" ]; then + mkdir -p "$BOT_DIR" + cp "$src_bot" "$BOT_DIR/.env" + chmod 600 "$BOT_DIR/.env" + log_success "$(_t_or backup_restored_bot 'Конфиг Telegram-бота восстановлен')" + fi + + # ── Start services ── + if type is_telemt_installed &>/dev/null && is_telemt_installed; then start_telemt fi systemctl start nginx 2>/dev/null - # Очистка + # ── Cleanup ── rm -rf "$tmp_dir" [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file" log_success "$(_t_or backup_restore_done 'Восстановление завершено!')" - show_proxy_info + + # ── Auto-migrate v1.1 → v2.0 ── + if [ "$bk_version" != "2.0" ]; then + log_info "$(_t_or backup_automigrate 'Конвертирую старый бекап в UBF v2.0...')" + create_backup "" >/dev/null 2>&1 && \ + log_success "$(_t_or backup_migrated 'Свежий UBF v2.0 бекап сохранён в $BACKUP_DIR')" + fi + + type show_proxy_info &>/dev/null && show_proxy_info return 0 } @@ -273,24 +442,26 @@ list_backups() { return 1 fi - echo "" - echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}" - echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" + echo "" >&2 + echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..70})${NC}" >&2 local i=1 for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do [ -f "$f" ] || continue [[ "$f" == *.sha256 ]] && continue - local size date_str name + local size name date_str id_tail encrypted="" size=$(du -h "$f" | cut -f1) name=$(basename "$f") date_str=$(echo "$name" | grep -oE '[0-9]{8}_[0-9]{6}' | head -1) - local encrypted="" + id_tail=$(echo "$name" | grep -oE '_[0-9a-f]{6}\.tar' | head -1 | tr -d '_.tar') [[ "$f" == *.enc ]] && encrypted=" 🔒" - echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}" + local id_display="" + [ -n "$id_tail" ] && id_display=" ${DIM}[...${id_tail}]${NC}" + echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}${id_display}" >&2 ((i++)) done - echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..70})${NC}" >&2 } # ── Очистка старых бекапов ─────────────────────────────────────────────────── @@ -337,7 +508,7 @@ interactive_backup() { fi fi - create_backup "$password" + create_backup "$password" >/dev/null cleanup_old_backups } @@ -371,3 +542,115 @@ interactive_restore() { restore_backup "$backup_file" } + +# ── Manual secret recovery (v2.4.9) ────────────────────────────────────────── +# Parser accepts any of the 3 formats and emits key=value lines on stdout: +# tg://proxy?server=X&port=Y&secret=Z → raw_secret, server, port, domain (if ee-prefix) +# ee<32hex> → raw_secret, domain +# <32hex> → raw_secret only +# Returns 0 on success, 1 on parse failure. +parse_manual_secret() { + local input="$1" + input=$(echo "$input" | tr -d ' \t\n\r') + [ -z "$input" ] && return 1 + + local raw_secret="" domain="" server="" port="" + + if echo "$input" | grep -q '^tg://proxy?'; then + local qs="${input#tg://proxy?}" + local kv k v + local -a kvs + IFS='&' read -ra kvs <<< "$qs" + for kv in "${kvs[@]}"; do + k="${kv%%=*}" + v="${kv#*=}" + case "$k" in + server) server="$v" ;; + port) port="$v" ;; + secret) raw_secret="$v" ;; + esac + done + [ -z "$raw_secret" ] && return 1 + # Strip hex-escapes that Telegram sometimes URL-encodes + raw_secret=$(echo "$raw_secret" | tr -d '%') + fi + + # After pulling from URL (if any), raw_secret might still be ee-prefixed. + # Otherwise, try the raw_secret as the whole input. + local candidate="${raw_secret:-$input}" + + if [[ "$candidate" =~ ^[eE][eE][0-9a-fA-F]{32}[0-9a-fA-F]*$ ]]; then + raw_secret="${candidate:2:32}" + local hex_domain="${candidate:34}" + if [ -n "$hex_domain" ]; then + local decoded + decoded=$(echo "$hex_domain" | xxd -r -p 2>/dev/null) + # Validate decoded looks like a domain + if echo "$decoded" | grep -qE '^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; then + domain="$decoded" + fi + fi + elif [[ "$candidate" =~ ^[0-9a-fA-F]{32}$ ]]; then + raw_secret="$candidate" + else + return 1 + fi + + [[ "$raw_secret" =~ ^[0-9a-fA-F]{32}$ ]] || return 1 + raw_secret=$(echo "$raw_secret" | tr 'A-F' 'a-f') + + echo "raw_secret=$raw_secret" + [ -n "$domain" ] && echo "domain=$domain" + [ -n "$server" ] && echo "server=$server" + [ -n "$port" ] && echo "port=$port" + return 0 +} + +# ── Interactive: user types their old key, we parse it ───────────────────── +manual_secret_input() { + echo "" >&2 + echo -e " ${BOLD}${WHITE}🔑 $(_t_or manual_secret_title 'Ввод существующего ключа')${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + echo -e " ${DIM}$(_t_or manual_secret_help1 'Поддерживаются форматы:')${NC}" >&2 + echo -e " ${DIM} • tg://proxy?server=...&port=...&secret=...${NC}" >&2 + echo -e " ${DIM} • ee<32hex> (fake-TLS)${NC}" >&2 + echo -e " ${DIM} • 32hex (только raw secret)${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + echo -ne " ${WHITE}$(_t_or manual_secret_prompt 'Вставьте ключ'):${NC} " >&2 + read -r user_input + + if [ -z "$user_input" ]; then + log_error "$(_t_or manual_secret_empty 'Ключ не введён')" + return 1 + fi + + local parsed + if ! parsed=$(parse_manual_secret "$user_input"); then + log_error "$(_t_or manual_secret_bad 'Не удалось распознать формат ключа')" + return 1 + fi + + local p_raw="" p_domain="" p_server="" p_port="" + while IFS='=' read -r k v; do + case "$k" in + raw_secret) p_raw="$v" ;; + domain) p_domain="$v" ;; + server) p_server="$v" ;; + port) p_port="$v" ;; + esac + done <<< "$parsed" + + echo "" >&2 + echo -e " ${GREEN}✓ $(_t_or manual_secret_parsed 'Ключ распознан')${NC}" >&2 + echo -e " ${WHITE}raw_secret:${NC} ${DIM}${p_raw:0:8}...${p_raw: -4}${NC}" >&2 + [ -n "$p_domain" ] && echo -e " ${WHITE}domain:${NC} ${CYAN}${p_domain}${NC}" >&2 + [ -n "$p_server" ] && echo -e " ${WHITE}server:${NC} ${CYAN}${p_server}${NC}" >&2 + [ -n "$p_port" ] && echo -e " ${WHITE}port:${NC} ${CYAN}${p_port}${NC}" >&2 + echo "" >&2 + + # Export for the subsequent install flow to pick up + export GOTELEGRAM_EXISTING_SECRET="$p_raw" + export GOTELEGRAM_EXISTING_DOMAIN="$p_domain" + export GOTELEGRAM_EXISTING_PORT="$p_port" + return 0 +} diff --git a/lib/common.sh b/lib/common.sh index 21cc9bc..7860b71 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -3,7 +3,7 @@ # Colors, logging, spinner, system helpers, v1 compat, i18n-aware # ── Version ─────────────────────────────────────────────────────────────────── -GOTELEGRAM_VERSION="2.4.8" +GOTELEGRAM_VERSION="2.4.9" GOTELEGRAM_NAME="GoTelegram" # ── Пути ────────────────────────────────────────────────────────────────────── diff --git a/lib/lang/en.sh b/lib/lang/en.sh index 8163c20..9d08da3 100644 --- a/lib/lang/en.sh +++ b/lib/lang/en.sh @@ -387,3 +387,68 @@ I18N[v1_migration_cancelled]="Migration cancelled. v1 left intact." I18N[v1_stopping]="Stopping v1 container..." I18N[v1_config_saved]="v1 config saved to %s" I18N[v1_port_freed]="v1 stopped. Port %s freed." + +# ── v2.4.9: UBF v2.0 backup + manual secret recovery ───────────────────── +I18N[install_source_title]="Installation source" +I18N[install_source_choice]="Choose source [1-3]:" +I18N[install_menu_new]="Fresh installation" +I18N[install_menu_new_desc]="Generate a new key and set up from scratch" +I18N[install_menu_restore]="Restore from backup" +I18N[install_menu_restore_desc]="Full restore from a .tar.gz[.enc] file" +I18N[install_menu_existing_key]="Use existing key" +I18N[install_menu_existing_key_desc]="Paste a tg://proxy link or a key manually" +I18N[install_hint_pro_mode]="The key contains a domain — this is usually Pro mode" +I18N[install_reuse_secret]="Using the provided key" +I18N[install_reuse_domain]="Using domain from the key" +I18N[install_reuse_port]="Using port from the key" + +I18N[manual_secret_title]="Enter existing key" +I18N[manual_secret_help1]="Supported formats:" +I18N[manual_secret_prompt]="Paste the key" +I18N[manual_secret_empty]="Key is empty" +I18N[manual_secret_bad]="Could not parse the key format" +I18N[manual_secret_parsed]="Key parsed" + +I18N[backup_id_label]="Backup ID" +I18N[backup_file_label]="File" +I18N[backup_size_label]="Size" +I18N[backup_key_label]="Key in backup (fingerprint)" +I18N[backup_format_label]="Format" +I18N[backup_mode_label]="Mode" +I18N[backup_lang_label]="Language" +I18N[backup_date_label]="Date" +I18N[backup_label]="Backup" +I18N[backup_ssl_included]="SSL certificates included (+ chain + renewal)" +I18N[backup_site_included]="Website template included" +I18N[backup_bot_included]="Telegram bot config included" +I18N[backup_restored_bot]="Telegram bot config restored" +I18N[backup_automigrate]="Converting legacy backup to UBF v2.0..." +I18N[backup_migrated]="Fresh UBF v2.0 backup saved" +I18N[backup_collecting]="Collecting configuration..." +I18N[backup_archive_err]="Archive creation failed" +I18N[backup_archive_missing]="Archive was not created" +I18N[backup_encrypt_err]="Encryption failed" +I18N[backup_encrypted]="Backup encrypted (AES-256-CBC)" +I18N[backup_created]="Backup created" +I18N[backup_enter_pass]="Enter password" +I18N[backup_repeat_pass]="Repeat password" +I18N[backup_pass_mismatch]="Passwords do not match" +I18N[backup_pass_short]="Password too short (min 6 chars)" +I18N[backup_bad_pass]="Wrong password or corrupted file" +I18N[backup_extract_err]="Archive extraction failed" +I18N[backup_confirm_restore]="Restore configuration? Current settings will be overwritten." +I18N[backup_restored_telemt]="telemt config restored" +I18N[backup_restored_gotelegram]="GoTelegram config restored" +I18N[backup_restored_lang]="Interface language restored" +I18N[backup_restored_nginx]="nginx config restored" +I18N[backup_restored_ssl]="SSL certificates restored" +I18N[backup_restored_site]="Website template restored" +I18N[backup_restore_done]="Restore completed!" +I18N[backup_create_title]="Create backup" +I18N[backup_encrypt_prompt]="Encrypt the backup with a password?" +I18N[backup_none]="No backups found" +I18N[backup_list_title]="Available backups" +I18N[backup_pick_prompt]="Backup number (or file path)" +I18N[backup_not_found]="Backup not found" +I18N[backup_file_not_found_fmt]="File not found: %s" +I18N[backup_cleanup_fmt]="Deleted %s old backups (kept %s)" diff --git a/lib/lang/ru.sh b/lib/lang/ru.sh index 8e8b3c4..41e02a4 100644 --- a/lib/lang/ru.sh +++ b/lib/lang/ru.sh @@ -387,3 +387,68 @@ I18N[v1_migration_cancelled]="Миграция отменена. v1 оставл I18N[v1_stopping]="Остановка v1 контейнера..." I18N[v1_config_saved]="Конфиг v1 сохранён в %s" I18N[v1_port_freed]="v1 остановлен. Порт %s освобождён." + +# ── v2.4.9: UBF v2.0 backup + manual secret recovery ───────────────────── +I18N[install_source_title]="Источник установки" +I18N[install_source_choice]="Выберите источник [1-3]:" +I18N[install_menu_new]="Новая установка" +I18N[install_menu_new_desc]="Сгенерировать новый ключ и настроить с нуля" +I18N[install_menu_restore]="Восстановить из бекапа" +I18N[install_menu_restore_desc]="Полное восстановление из файла .tar.gz[.enc]" +I18N[install_menu_existing_key]="Использовать существующий ключ" +I18N[install_menu_existing_key_desc]="Ввести ссылку tg://proxy или ключ вручную" +I18N[install_hint_pro_mode]="Ключ содержит домен — обычно это Pro режим" +I18N[install_reuse_secret]="Используется переданный ключ" +I18N[install_reuse_domain]="Используется домен из ключа" +I18N[install_reuse_port]="Используется порт из ключа" + +I18N[manual_secret_title]="Ввод существующего ключа" +I18N[manual_secret_help1]="Поддерживаются форматы:" +I18N[manual_secret_prompt]="Вставьте ключ" +I18N[manual_secret_empty]="Ключ не введён" +I18N[manual_secret_bad]="Не удалось распознать формат ключа" +I18N[manual_secret_parsed]="Ключ распознан" + +I18N[backup_id_label]="Backup ID" +I18N[backup_file_label]="Файл" +I18N[backup_size_label]="Размер" +I18N[backup_key_label]="Ключ в бекапе (fingerprint)" +I18N[backup_format_label]="Формат" +I18N[backup_mode_label]="Режим" +I18N[backup_lang_label]="Язык" +I18N[backup_date_label]="Дата" +I18N[backup_label]="Бекап" +I18N[backup_ssl_included]="SSL-сертификаты включены (+ chain + renewal)" +I18N[backup_site_included]="Шаблон сайта включён" +I18N[backup_bot_included]="Конфиг Telegram-бота включён" +I18N[backup_restored_bot]="Конфиг Telegram-бота восстановлен" +I18N[backup_automigrate]="Конвертирую старый бекап в UBF v2.0..." +I18N[backup_migrated]="Свежий UBF v2.0 бекап сохранён" +I18N[backup_collecting]="Собираю конфигурацию..." +I18N[backup_archive_err]="Ошибка создания архива" +I18N[backup_archive_missing]="Архив не создан" +I18N[backup_encrypt_err]="Ошибка шифрования" +I18N[backup_encrypted]="Бекап зашифрован (AES-256-CBC)" +I18N[backup_created]="Бекап создан" +I18N[backup_enter_pass]="Введите пароль" +I18N[backup_repeat_pass]="Повторите пароль" +I18N[backup_pass_mismatch]="Пароли не совпадают" +I18N[backup_pass_short]="Пароль слишком короткий (минимум 6 символов)" +I18N[backup_bad_pass]="Неверный пароль или повреждённый файл" +I18N[backup_extract_err]="Ошибка распаковки архива" +I18N[backup_confirm_restore]="Восстановить конфигурацию? Текущие настройки будут перезаписаны." +I18N[backup_restored_telemt]="telemt конфиг восстановлен" +I18N[backup_restored_gotelegram]="GoTelegram конфиг восстановлен" +I18N[backup_restored_lang]="Язык интерфейса восстановлен" +I18N[backup_restored_nginx]="nginx конфиг восстановлен" +I18N[backup_restored_ssl]="SSL сертификаты восстановлены" +I18N[backup_restored_site]="Шаблон сайта восстановлен" +I18N[backup_restore_done]="Восстановление завершено!" +I18N[backup_create_title]="Создание бекапа" +I18N[backup_encrypt_prompt]="Зашифровать бекап паролем?" +I18N[backup_none]="Бекапов нет" +I18N[backup_list_title]="Доступные бекапы" +I18N[backup_pick_prompt]="Номер бекапа (или путь к файлу)" +I18N[backup_not_found]="Бекап не найден" +I18N[backup_file_not_found_fmt]="Файл не найден: %s" +I18N[backup_cleanup_fmt]="Удалено %s старых бекапов (оставлено %s)"