#!/bin/bash #=============================================================================== # VPS 快照备份脚本 v2.2 # 支持: Ubuntu, Debian, CentOS, Alpine # 功能: 创建/恢复快照 + rsync 远程同步 + Telegram 通知 + 自动清理 #=============================================================================== set -e RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' CONFIG_FILE="/etc/vps-snapshot.conf" LOG_FILE="/var/log/vps-snapshot.log" SSH_KEY_PATH="/root/.ssh/vps_snapshot_key" print_banner() { echo -e "${BLUE}" echo "╔═══════════════════════════════════════════════════════════╗" echo "║ VPS 快照备份脚本 v2.2 ║" echo "║ 支持 Ubuntu/Debian/CentOS/Alpine ║" echo "╚═══════════════════════════════════════════════════════════╝" echo -e "${NC}" } log() { echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}" >&2; echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"; } error() { echo -e "${RED}[ERROR] $1${NC}" >&2; echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE"; } warn() { echo -e "${YELLOW}[WARN] $1${NC}" >&2; } detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release; echo "$ID" elif [ -f /etc/redhat-release ]; then echo "centos" else echo "unknown"; fi } install_dependencies() { local os=$(detect_os) log "检测到系统: $os" log "安装依赖包..." case $os in ubuntu|debian) apt-get update -qq && apt-get install -y -qq rsync sshpass curl tar gzip openssh-client ;; centos|rhel|fedora) yum install -y -q rsync sshpass curl tar gzip openssh-clients ;; alpine) apk add --no-cache rsync sshpass curl tar gzip openssh-client ;; *) error "不支持的系统: $os"; exit 1 ;; esac log "依赖安装完成" } #=============================================================================== # SSH 连接 #=============================================================================== generate_ssh_key() { log "生成 SSH 密钥对..." if [ -f "$SSH_KEY_PATH" ]; then warn "密钥已存在: $SSH_KEY_PATH" read -p "是否覆盖? [y/N]: " overwrite [[ ! "$overwrite" =~ ^[Yy]$ ]] && return 0 fi ssh-keygen -t ed25519 -f "$SSH_KEY_PATH" -N "" -C "vps-snapshot-${VPS_NAME:-$(hostname)}" chmod 600 "$SSH_KEY_PATH" log "密钥生成完成" } copy_ssh_key_to_remote() { log "复制公钥到远程服务器..." sshpass -p "$1" ssh-copy-id -i "${SSH_KEY_PATH}.pub" -o StrictHostKeyChecking=no -p "$REMOTE_PORT" "${REMOTE_USER}@${REMOTE_IP}" log "公钥复制完成" } test_ssh_connection() { log "测试 SSH 连接..." if ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no -o BatchMode=yes -p "$REMOTE_PORT" "${REMOTE_USER}@${REMOTE_IP}" "echo ok" &>/dev/null; then log "SSH 连接成功" return 0 else error "SSH 连接失败" return 1 fi } ssh_exec() { if [ "$AUTH_METHOD" = "key" ]; then ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no -p "$REMOTE_PORT" "${REMOTE_USER}@${REMOTE_IP}" "$1" else sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no -p "$REMOTE_PORT" "${REMOTE_USER}@${REMOTE_IP}" "$1" fi } send_telegram() { [ -n "$TG_BOT_TOKEN" ] && [ -n "$TG_CHAT_ID" ] && \ curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ -d chat_id="$TG_CHAT_ID" -d text="$1" -d parse_mode="HTML" >/dev/null 2>&1 } #=============================================================================== # 配置管理 #=============================================================================== load_config() { if [ -f "$CONFIG_FILE" ]; then source "$CONFIG_FILE" return 0 else error "未找到配置文件,请先运行: $0 setup" return 1 fi } save_config() { log "保存配置..." cat > "$CONFIG_FILE" << CONF VPS_NAME="$VPS_NAME" AUTH_METHOD="$AUTH_METHOD" SSH_KEY_PATH="$SSH_KEY_PATH" REMOTE_IP="$REMOTE_IP" REMOTE_PORT="$REMOTE_PORT" REMOTE_USER="$REMOTE_USER" REMOTE_PASS="$REMOTE_PASS" REMOTE_BASE="$REMOTE_BASE" REMOTE_DIR="$REMOTE_DIR" LOCAL_DIR="$LOCAL_DIR" LOCAL_KEEP="$LOCAL_KEEP" REMOTE_KEEP_DAYS="$REMOTE_KEEP_DAYS" TG_BOT_TOKEN="$TG_BOT_TOKEN" TG_CHAT_ID="$TG_CHAT_ID" BACKUP_DIRS="$BACKUP_DIRS" CONF chmod 600 "$CONFIG_FILE" log "配置已保存" } interactive_setup() { print_banner echo -e "${CYAN}=== 开始配置 ===${NC}\n" read -p "VPS 名称 (用于区分备份): " VPS_NAME VPS_NAME=${VPS_NAME:-$(hostname)} read -p "远程服务器 IP: " REMOTE_IP read -p "SSH 端口 [22]: " REMOTE_PORT REMOTE_PORT=${REMOTE_PORT:-22} read -p "SSH 用户名 [root]: " REMOTE_USER REMOTE_USER=${REMOTE_USER:-root} echo -e "\n${CYAN}认证方式:${NC}" echo "1) SSH 密钥 (推荐)" echo "2) 密码" read -p "选择 [1]: " auth_choice if [ "${auth_choice:-1}" = "1" ]; then AUTH_METHOD="key" REMOTE_PASS="" [ ! -f "$SSH_KEY_PATH" ] && generate_ssh_key echo "" read -s -p "输入远程密码 (仅用于复制公钥): " temp_pass echo "" copy_ssh_key_to_remote "$temp_pass" test_ssh_connection || exit 1 else AUTH_METHOD="password" read -s -p "SSH 密码: " REMOTE_PASS echo "" fi read -p "远程备份根目录 [/backup]: " REMOTE_BASE REMOTE_BASE=${REMOTE_BASE:-/backup} REMOTE_DIR="${REMOTE_BASE}/${VPS_NAME}" read -p "本地快照目录 [/var/snapshots]: " LOCAL_DIR LOCAL_DIR=${LOCAL_DIR:-/var/snapshots} read -p "本地保留快照数 [3]: " LOCAL_KEEP LOCAL_KEEP=${LOCAL_KEEP:-3} read -p "远程保留天数 [30]: " REMOTE_KEEP_DAYS REMOTE_KEEP_DAYS=${REMOTE_KEEP_DAYS:-30} read -p "启用 Telegram 通知? [y/N]: " enable_tg if [[ "$enable_tg" =~ ^[Yy]$ ]]; then read -p "Bot Token: " TG_BOT_TOKEN read -p "Chat ID: " TG_CHAT_ID fi echo -e "\n${CYAN}备份内容:${NC}" echo "1) 完整系统" echo "2) 仅 /etc /home /root /var/www" echo "3) 自定义" read -p "选择 [1]: " backup_choice case ${backup_choice:-1} in 2) BACKUP_DIRS="/etc /home /root /var/www" ;; 3) read -p "输入目录 (空格分隔): " BACKUP_DIRS ;; *) BACKUP_DIRS="/" ;; esac save_config log "创建远程目录: $REMOTE_DIR" ssh_exec "mkdir -p $REMOTE_DIR" echo -e "\n${GREEN}配置完成!${NC}" } edit_config() { load_config || return 1 echo -e "\n${CYAN}=== 当前配置 ===${NC}" echo "1) VPS名称: $VPS_NAME" echo "2) 远程IP: $REMOTE_IP" echo "3) 远程端口: $REMOTE_PORT" echo "4) 远程用户: $REMOTE_USER" echo "5) 远程目录: $REMOTE_DIR" echo "6) 本地目录: $LOCAL_DIR" echo "7) 本地保留: $LOCAL_KEEP 个" echo "8) 远程保留: $REMOTE_KEEP_DAYS 天" echo "9) Telegram: $([ -n "$TG_BOT_TOKEN" ] && echo '已配置' || echo '未配置')" echo "0) 返回" read -p "选择要修改的项: " choice case $choice in 1) read -p "新VPS名称: " VPS_NAME; REMOTE_DIR="${REMOTE_BASE}/${VPS_NAME}" ;; 2) read -p "新远程IP: " REMOTE_IP ;; 3) read -p "新端口: " REMOTE_PORT ;; 4) read -p "新用户: " REMOTE_USER ;; 5) read -p "新远程目录: " REMOTE_DIR ;; 6) read -p "新本地目录: " LOCAL_DIR ;; 7) read -p "本地保留数: " LOCAL_KEEP ;; 8) read -p "远程保留天数: " REMOTE_KEEP_DAYS ;; 9) read -p "Bot Token: " TG_BOT_TOKEN; read -p "Chat ID: " TG_CHAT_ID ;; 0) return ;; esac save_config echo -e "${GREEN}配置已更新${NC}" } #=============================================================================== # 快照创建 #=============================================================================== create_snapshot() { local timestamp=$(date '+%Y%m%d_%H%M%S') local snapshot_name="${VPS_NAME}_${timestamp}.tar.gz" local snapshot_path="${LOCAL_DIR}/${snapshot_name}" mkdir -p "$LOCAL_DIR" log "创建快照: $snapshot_name" send_telegram "🔄 开始备份%0AVPS: ${VPS_NAME}" local excludes="--exclude=/proc --exclude=/sys --exclude=/dev" excludes+=" --exclude=/run --exclude=/tmp --exclude=/mnt" excludes+=" --exclude=/media --exclude=/lost+found" excludes+=" --exclude=${LOCAL_DIR} --exclude=/var/cache" if [ "$BACKUP_DIRS" = "/" ]; then tar $excludes -czf "$snapshot_path" / 2>/dev/null || true else tar -czf "$snapshot_path" $BACKUP_DIRS 2>/dev/null || true fi local size=$(du -h "$snapshot_path" | cut -f1) log "快照完成: $snapshot_path ($size)" echo "$snapshot_path" } sync_to_remote() { local snapshot_path="$1" log "同步到远程..." if [ "$AUTH_METHOD" = "key" ]; then rsync -avz --progress -e "ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no -p $REMOTE_PORT" \ "$snapshot_path" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/" else sshpass -p "$REMOTE_PASS" rsync -avz --progress \ -e "ssh -o StrictHostKeyChecking=no -p $REMOTE_PORT" \ "$snapshot_path" "${REMOTE_USER}@${REMOTE_IP}:${REMOTE_DIR}/" fi log "同步完成" } cleanup_local() { log "清理本地快照,保留 $LOCAL_KEEP 个" cd "$LOCAL_DIR" ls -1t *.tar.gz 2>/dev/null | tail -n +$((LOCAL_KEEP + 1)) | xargs -r rm -f } cleanup_remote() { log "清理远程 $REMOTE_KEEP_DAYS 天前的快照" ssh_exec "find $REMOTE_DIR -name '*.tar.gz' -mtime +$REMOTE_KEEP_DAYS -delete" 2>/dev/null || true } run_backup() { load_config || exit 1 local start=$(date +%s) log "===== 开始备份 [$VPS_NAME] =====" local snapshot=$(create_snapshot) sync_to_remote "$snapshot" cleanup_local cleanup_remote local dur=$(($(date +%s) - start)) local size=$(du -h "$snapshot" | cut -f1) log "===== 备份完成 =====" send_telegram "✅ 备份完成%0AVPS: ${VPS_NAME}%0A大小: ${size}%0A耗时: ${dur}秒" } #=============================================================================== # 快照列表 #=============================================================================== list_local_snapshots() { echo -e "\n${CYAN}=== 本地快照 ===${NC}" if [ -d "$LOCAL_DIR" ] && ls "$LOCAL_DIR"/*.tar.gz &>/dev/null; then ls -1t "$LOCAL_DIR"/*.tar.gz | nl -w2 -s') ' else echo " (无快照)" fi } list_remote_snapshots() { echo -e "\n${CYAN}=== 远程快照 [$VPS_NAME] ===${NC}" local list=$(ssh_exec "ls -1t $REMOTE_DIR/*.tar.gz 2>/dev/null") if [ -n "$list" ]; then echo "$list" | nl -w2 -s') ' else echo " (无快照)" fi } #=============================================================================== # 快照恢复 #=============================================================================== select_restore_mode() { echo -e "\n${CYAN}=== 恢复模式 ===${NC}" echo "1) 覆盖模式 - 只覆盖文件,保留新增文件 (安全)" echo "2) 完整恢复 - 删除快照中不存在的文件 (危险!)" echo "" read -p "选择模式 [1]: " mode echo "${mode:-1}" } do_restore() { local file="$1" local mode="$2" local temp_dir="/tmp/snapshot_restore_$$" log "解压快照..." mkdir -p "$temp_dir" tar -xzf "$file" -C "$temp_dir" 2>/dev/null if [ "$mode" = "2" ]; then echo -e "\n${RED}!!! 完整恢复模式 !!!${NC}" echo -e "${RED}将删除系统中快照不存在的文件${NC}" echo -e "${YELLOW}排除: /proc /sys /dev /run /tmp /mnt /media${NC}" echo "" read -p "输入 YES 确认: " confirm if [ "$confirm" != "YES" ]; then rm -rf "$temp_dir" echo "已取消" return 1 fi log "完整恢复: rsync --delete" rsync -aAXv --delete \ --exclude='/proc/*' \ --exclude='/sys/*' \ --exclude='/dev/*' \ --exclude='/run/*' \ --exclude='/tmp/*' \ --exclude='/mnt/*' \ --exclude='/media/*' \ --exclude='/lost+found' \ --exclude="$LOCAL_DIR/*" \ --exclude="$temp_dir" \ "$temp_dir/" / else log "覆盖恢复" rsync -aAXv "$temp_dir/" / fi rm -rf "$temp_dir" log "恢复完成,建议重启系统" } restore_local() { load_config || return 1 list_local_snapshots echo "" read -p "选择快照编号: " num local file=$(ls -1t "$LOCAL_DIR"/*.tar.gz 2>/dev/null | sed -n "${num}p") [ -z "$file" ] && { error "无效选择"; return 1; } local mode=$(select_restore_mode) echo -e "\n${RED}警告: 即将恢复快照!${NC}" read -p "确认? [y/N]: " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return do_restore "$file" "$mode" } restore_from_remote() { load_config || return 1 list_remote_snapshots echo "" read -p "选择快照编号: " num local file=$(ssh_exec "ls -1t $REMOTE_DIR/*.tar.gz" 2>/dev/null | sed -n "${num}p") [ -z "$file" ] && { error "无效选择"; return 1; } log "下载快照: $file" local local_file="$LOCAL_DIR/$(basename $file)" mkdir -p "$LOCAL_DIR" if [ "$AUTH_METHOD" = "key" ]; then rsync -avz -e "ssh -i $SSH_KEY_PATH -p $REMOTE_PORT" \ "${REMOTE_USER}@${REMOTE_IP}:${file}" "$local_file" else sshpass -p "$REMOTE_PASS" rsync -avz \ -e "ssh -p $REMOTE_PORT" \ "${REMOTE_USER}@${REMOTE_IP}:${file}" "$local_file" fi local mode=$(select_restore_mode) echo -e "\n${RED}警告: 即将恢复快照!${NC}" read -p "确认? [y/N]: " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return do_restore "$local_file" "$mode" } restore_custom() { load_config || return 1 read -p "输入远程快照完整路径: " remote_file [ -z "$remote_file" ] && { error "路径不能为空"; return 1; } local local_file="$LOCAL_DIR/$(basename $remote_file)" mkdir -p "$LOCAL_DIR" log "下载: $remote_file" if [ "$AUTH_METHOD" = "key" ]; then rsync -avz -e "ssh -i $SSH_KEY_PATH -p $REMOTE_PORT" \ "${REMOTE_USER}@${REMOTE_IP}:${remote_file}" "$local_file" else sshpass -p "$REMOTE_PASS" rsync -avz \ -e "ssh -p $REMOTE_PORT" \ "${REMOTE_USER}@${REMOTE_IP}:${remote_file}" "$local_file" fi local mode=$(select_restore_mode) echo -e "\n${RED}警告: 即将恢复!${NC}" read -p "确认? [y/N]: " confirm [[ ! "$confirm" =~ ^[Yy]$ ]] && return do_restore "$local_file" "$mode" } #=============================================================================== # 定时任务 #=============================================================================== setup_cron() { echo -e "${CYAN}=== 定时备份 ===${NC}" echo "1) 每天凌晨 3 点" echo "2) 每 3 天凌晨 3 点" echo "3) 每周日凌晨 3 点" echo "4) 每月 1 号" read -p "选择 [1]: " choice case ${choice:-1} in 2) expr="0 3 */3 * *" ;; 3) expr="0 3 * * 0" ;; 4) expr="0 3 1 * *" ;; *) expr="0 3 * * *" ;; esac local script_path=$(readlink -f "$0") (crontab -l 2>/dev/null | grep -v "vps-snapshot"; echo "$expr $script_path run") | crontab - log "定时任务已设置: $expr" } #=============================================================================== # 状态显示 #=============================================================================== show_status() { print_banner if [ -f "$CONFIG_FILE" ]; then source "$CONFIG_FILE" echo -e "${GREEN}VPS: $VPS_NAME${NC}" echo "认证方式: $AUTH_METHOD" echo "远程服务器: ${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PORT}" echo "远程目录: $REMOTE_DIR" echo "本地目录: $LOCAL_DIR" echo "保留策略: 本地 ${LOCAL_KEEP} 个 / 远程 ${REMOTE_KEEP_DAYS} 天" echo "Telegram: $([ -n "$TG_BOT_TOKEN" ] && echo '已配置' || echo '未配置')" list_local_snapshots else echo -e "${RED}未配置,请运行: $0 setup${NC}" fi } #=============================================================================== # 主菜单 #=============================================================================== show_menu() { load_config 2>/dev/null print_banner echo -e "${GREEN}VPS: ${VPS_NAME:-未配置}${NC}\n" echo "1) 创建快照并同步" echo "2) 仅创建本地快照" echo "3) 查看本地快照" echo "4) 查看远程快照" echo "5) 恢复本地快照" echo "6) 从远程恢复快照" echo "7) 自定义远程恢复" echo "8) 修改配置" echo "9) 设置定时任务" echo "10) 查看状态" echo "0) 退出" echo "" read -p "请选择: " choice case $choice in 1) run_backup ;; 2) load_config && create_snapshot ;; 3) load_config && list_local_snapshots ;; 4) load_config && list_remote_snapshots ;; 5) restore_local ;; 6) restore_from_remote ;; 7) restore_custom ;; 8) edit_config ;; 9) setup_cron ;; 10) show_status ;; 0) exit 0 ;; *) echo "无效选择" ;; esac echo "" read -p "按回车继续..." show_menu } show_help() { print_banner echo "用法: $0 <命令>" echo "" echo "命令:" echo " setup 初始配置" echo " run 创建快照并同步" echo " menu 交互式菜单" echo " list 查看快照" echo " restore 恢复本地快照" echo " config 修改配置" echo " cron 设置定时任务" echo " status 查看状态" } #=============================================================================== # 入口 #=============================================================================== main() { [ "$EUID" -ne 0 ] && { error "请用 root 运行"; exit 1; } touch "$LOG_FILE" case "${1:-menu}" in setup) install_dependencies; interactive_setup ;; run) run_backup ;; menu) show_menu ;; list) load_config && list_local_snapshots && list_remote_snapshots ;; restore) restore_local ;; config) edit_config ;; cron) setup_cron ;; status) show_status ;; *) show_help ;; esac } main "$@"