#!/bin/bash #=============================================================================== # VPS 快照备份脚本 v3.15 # 支持: Ubuntu, Debian, CentOS, Alpine # 功能: 智能识别应用 + Docker迁移 + 数据备份 + Telegram通知 #=============================================================================== 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" print_banner() { echo -e "${BLUE}" echo "╔═══════════════════════════════════════════════════════════╗" echo "║ VPS 快照备份脚本 v3.15 ║" echo "║ 智能识别 + Docker迁移 + 数据备份 ║" echo "╚═══════════════════════════════════════════════════════════╝" echo -e "${NC}" } log() { echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}"; echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"; } error() { echo -e "${RED}[ERROR] $1${NC}"; echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE"; } warn() { echo -e "${YELLOW}[WARN] $1${NC}"; } info() { echo -e "${CYAN}[INFO] $1${NC}"; } #=============================================================================== # 系统检测 #=============================================================================== 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_deps() { local os=$(detect_os) log "检测系统: $os" case $os in ubuntu|debian) apt-get update -qq && apt-get install -y -qq rsync sshpass curl tar gzip jq ;; centos|rhel|fedora) yum install -y -q rsync sshpass curl tar gzip jq ;; alpine) apk add --no-cache rsync sshpass curl tar gzip jq ;; *) error "不支持的系统: $os"; exit 1 ;; esac } check_deps() { local missing="" command -v rsync &>/dev/null || missing+=" rsync" # sshpass is optional when using key auth command -v jq &>/dev/null || missing+=" jq" if [ -n "$missing" ]; then warn "缺少依赖:$missing,正在安装..." install_deps fi } #=============================================================================== # 智能识别应用 #=============================================================================== detect_apps() { info "🔍 扫描已安装的应用..." echo "" local apps=() # Docker if command -v docker &>/dev/null && docker info &>/dev/null; then local containers=$(docker ps -q | wc -l) local images=$(docker images -q | wc -l) echo -e " ${GREEN}✓${NC} Docker: $containers 个运行中容器, $images 个镜像" apps+=("docker") fi # Docker Compose if command -v docker-compose &>/dev/null || docker compose version &>/dev/null 2>&1; then local compose_files=$(find /opt /root /home -name "docker-compose*.yml" -o -name "compose*.yml" 2>/dev/null | wc -l) echo -e " ${GREEN}✓${NC} Docker Compose: $compose_files 个配置文件" apps+=("compose") fi # Nginx if command -v nginx &>/dev/null || [ -d /etc/nginx ]; then echo -e " ${GREEN}✓${NC} Nginx" apps+=("nginx") fi # MySQL/MariaDB if command -v mysql &>/dev/null || [ -d /var/lib/mysql ]; then echo -e " ${GREEN}✓${NC} MySQL/MariaDB" apps+=("mysql") fi # PostgreSQL if command -v psql &>/dev/null || [ -d /var/lib/postgresql ]; then echo -e " ${GREEN}✓${NC} PostgreSQL" apps+=("postgresql") fi # Redis if command -v redis-cli &>/dev/null || [ -d /var/lib/redis ]; then echo -e " ${GREEN}✓${NC} Redis" apps+=("redis") fi # MongoDB if command -v mongod &>/dev/null || [ -d /var/lib/mongodb ]; then echo -e " ${GREEN}✓${NC} MongoDB" apps+=("mongodb") fi # Node.js/NPM if command -v node &>/dev/null; then local node_ver=$(node -v 2>/dev/null || echo "unknown") local npm_global=$(npm list -g --depth=0 2>/dev/null | wc -l) echo -e " ${GREEN}✓${NC} Node.js $node_ver ($npm_global 个全局包)" apps+=("nodejs") fi # PM2 if command -v pm2 &>/dev/null; then local pm2_apps=$(pm2 jlist 2>/dev/null | jq length 2>/dev/null || echo "0") echo -e " ${GREEN}✓${NC} PM2: $pm2_apps 个应用" apps+=("pm2") fi # Python/Pip if command -v python3 &>/dev/null; then local py_ver=$(python3 --version 2>/dev/null | cut -d' ' -f2) echo -e " ${GREEN}✓${NC} Python $py_ver" apps+=("python") fi # PHP if command -v php &>/dev/null; then local php_ver=$(php -v 2>/dev/null | head -1 | cut -d' ' -f2) echo -e " ${GREEN}✓${NC} PHP $php_ver" apps+=("php") fi # 1Panel if [ -d /opt/1panel ] || command -v 1pctl &>/dev/null; then echo -e " ${GREEN}✓${NC} 1Panel" apps+=("1panel") fi # 宝塔 if [ -d /www/server/panel ]; then echo -e " ${GREEN}✓${NC} 宝塔面板" apps+=("bt") fi echo "" # 输出应用列表(供脚本解析) echo "APPS:${apps[*]}" } #=============================================================================== # Docker 专用迁移 #=============================================================================== docker_export() { local output_dir="${1:-/var/snapshots}" mkdir -p "$output_dir" log "📦 导出 Docker 数据..." # 导出所有镜像 local images=$(docker images --format "{{.Repository}}:{{.Tag}}" | grep -v "") if [ -n "$images" ]; then log "导出镜像..." docker save $images | gzip > "$output_dir/docker-images.tar.gz" info "镜像已保存: docker-images.tar.gz" fi # 导出容器配置 log "导出容器配置..." docker ps -a --format '{{json .}}' > "$output_dir/docker-containers.json" # 导出 volumes local volumes=$(docker volume ls -q) if [ -n "$volumes" ]; then log "导出 Volumes..." mkdir -p "$output_dir/volumes" for vol in $volumes; do docker run --rm -v "$vol:/data" -v "$output_dir/volumes:/backup" \ alpine tar czf "/backup/${vol}.tar.gz" -C /data . 2>/dev/null || true done info "Volumes 已保存" fi # 导出 docker-compose 文件 log "查找 docker-compose 文件..." find /opt /root /home -name "docker-compose*.yml" -o -name "compose*.yml" 2>/dev/null | while read f; do local dir=$(dirname "$f") local name=$(echo "$dir" | tr '/' '_') mkdir -p "$output_dir/compose" cp -r "$dir" "$output_dir/compose/$name" 2>/dev/null || true done log "Docker 数据导出完成" } docker_import() { local input_dir="${1:-/var/snapshots}" log "📥 导入 Docker 数据..." # 检查 Docker 是否安装 if ! command -v docker &>/dev/null; then warn "Docker 未安装,正在安装..." curl -fsSL https://get.docker.com | sh systemctl start docker systemctl enable docker fi # 导入镜像 if [ -f "$input_dir/docker-images.tar.gz" ]; then log "导入镜像..." gunzip -c "$input_dir/docker-images.tar.gz" | docker load info "镜像导入完成" fi # 恢复 volumes if [ -d "$input_dir/volumes" ]; then log "恢复 Volumes..." for vol_file in "$input_dir/volumes"/*.tar.gz; do [ -f "$vol_file" ] || continue local vol_name=$(basename "$vol_file" .tar.gz) docker volume create "$vol_name" 2>/dev/null || true docker run --rm -v "$vol_name:/data" -v "$input_dir/volumes:/backup" \ alpine tar xzf "/backup/${vol_name}.tar.gz" -C /data 2>/dev/null || true done info "Volumes 恢复完成" fi # 恢复 compose 项目 if [ -d "$input_dir/compose" ]; then log "恢复 Compose 项目..." for proj in "$input_dir/compose"/*; do [ -d "$proj" ] || continue local name=$(basename "$proj") local target="/opt/${name//_//}" mkdir -p "$(dirname "$target")" cp -r "$proj" "$target" 2>/dev/null || true done info "Compose 项目已恢复到 /opt" fi log "Docker 数据导入完成" } #=============================================================================== # 应用数据备份 #=============================================================================== backup_app_data() { local output_dir="${1:-/var/snapshots}" mkdir -p "$output_dir" log "📦 备份应用数据..." local backup_paths="" #--------------------------------------------------------------------------- # 智能扫描系统目录(自动识别有数据的目录) #--------------------------------------------------------------------------- log "🔍 智能扫描系统目录..." # 核心配置目录 - 必备 [ -d /etc ] && backup_paths+=" /etc" # 用户目录 [ -d /root ] && backup_paths+=" /root" [ -d /home ] && [ "$(ls -A /home 2>/dev/null)" ] && backup_paths+=" /home" # 手动安装的程序和数据 [ -d /usr/local/bin ] && [ "$(ls -A /usr/local/bin 2>/dev/null)" ] && backup_paths+=" /usr/local/bin" [ -d /usr/local/sbin ] && [ "$(ls -A /usr/local/sbin 2>/dev/null)" ] && backup_paths+=" /usr/local/sbin" [ -d /usr/local/etc ] && [ "$(ls -A /usr/local/etc 2>/dev/null)" ] && backup_paths+=" /usr/local/etc" # /usr/local 下的应用目录(x-ui, xray 等) if [ -d /usr/local ]; then for dir in /usr/local/*/; do [ -d "$dir" ] || continue local dirname=$(basename "$dir") # 跳过标准目录(bin/sbin/etc/lib/share/include/man/src/games) case "$dirname" in bin|sbin|etc|lib|lib64|share|include|man|src|games|libexec) continue ;; esac backup_paths+=" $dir" done fi # /opt 应用目录(排除 containerd) if [ -d /opt ]; then for dir in /opt/*/; do [ -d "$dir" ] || continue local dirname=$(basename "$dir") # 跳过 containerd(Docker 内部数据) [ "$dirname" = "containerd" ] && continue backup_paths+=" $dir" done fi # /var/www 网站目录 [ -d /var/www ] && [ "$(ls -A /var/www 2>/dev/null)" ] && backup_paths+=" /var/www" # 显示扫描结果 log "📋 将备份以下目录:" for p in $backup_paths; do local size=$(du -sh "$p" 2>/dev/null | cut -f1) echo " $p ($size)" done #--------------------------------------------------------------------------- # 特定应用数据导出(数据库等需要特殊处理) #--------------------------------------------------------------------------- # Nginx [ -d /etc/nginx ] && log " ✓ Nginx 配置" # MySQL if [ -d /var/lib/mysql ]; then log "导出 MySQL 数据库..." mkdir -p "$output_dir/mysql" if command -v mysqldump &>/dev/null; then mysqldump --all-databases > "$output_dir/mysql/all-databases.sql" 2>/dev/null || true fi fi # PostgreSQL if [ -d /var/lib/postgresql ]; then log "导出 PostgreSQL 数据库..." mkdir -p "$output_dir/postgresql" if command -v pg_dumpall &>/dev/null; then sudo -u postgres pg_dumpall > "$output_dir/postgresql/all-databases.sql" 2>/dev/null || true fi fi # Redis [ -d /var/lib/redis ] && backup_paths+=" /var/lib/redis" # MongoDB if [ -d /var/lib/mongodb ]; then log "导出 MongoDB..." mkdir -p "$output_dir/mongodb" if command -v mongodump &>/dev/null; then mongodump --out "$output_dir/mongodb" 2>/dev/null || true fi fi # Node.js/NPM 全局包 if command -v npm &>/dev/null; then log "导出 NPM 全局包列表..." npm list -g --depth=0 --json > "$output_dir/npm-global.json" 2>/dev/null || true fi # PM2 应用 if command -v pm2 &>/dev/null; then log "导出 PM2 配置..." pm2 save 2>/dev/null || true [ -f ~/.pm2/dump.pm2 ] && cp ~/.pm2/dump.pm2 "$output_dir/pm2-dump.json" fi # Python pip if command -v pip3 &>/dev/null; then log "导出 pip 包列表..." pip3 freeze > "$output_dir/pip-requirements.txt" 2>/dev/null || true fi # 1Panel [ -d /opt/1panel ] && log " ✓ 1Panel" # 宝塔 [ -d /www ] && backup_paths+=" /www" && log " ✓ 宝塔面板" # 打包(排除快照目录和无用文件) local snap_dir="${LOCAL_DIR:-/var/snapshots}" if [ -n "$backup_paths" ]; then log "📦 打包数据目录..." tar --exclude='*.sock' --exclude='*.pid' --exclude='node_modules' \ --exclude='.npm' --exclude='.cache' --exclude='__pycache__' \ --exclude='*.log' --exclude='/var/log/*' \ --exclude='/var/cache/*' --exclude='/tmp/*' \ --exclude="$snap_dir" --exclude='/var/snapshots' \ --exclude='/root/.cache' --exclude='/root/.local/share/Trash' \ -czf "$output_dir/app-data.tar.gz" $backup_paths 2>/dev/null || true local data_size=$(du -h "$output_dir/app-data.tar.gz" 2>/dev/null | cut -f1) info "数据已保存: $output_dir/app-data.tar.gz ($data_size)" fi } #=============================================================================== # 一键迁移 #=============================================================================== do_migrate() { print_banner info "🚀 一键迁移向导" echo "" # 检测应用 local apps_output=$(detect_apps) local apps=$(echo "$apps_output" | grep "^APPS:" | sed 's/^APPS://') echo "" read -p "输入目标服务器 IP: " target_ip read -p "输入目标服务器端口 [22]: " target_port target_port=${target_port:-22} read -p "输入目标服务器用户 [root]: " target_user target_user=${target_user:-root} read -s -p "输入目标服务器密码: " target_pass echo "" # 测试连接 log "测试连接..." if ! sshpass -p "$target_pass" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ -p "$target_port" "$target_user@$target_ip" "echo ok" &>/dev/null; then error "无法连接到目标服务器" return 1 fi info "连接成功" local tmp_dir="/tmp/migrate_$$" mkdir -p "$tmp_dir" # Docker 迁移 if [[ " $apps " =~ " docker " ]]; then log "检测到 Docker,开始导出..." docker_export "$tmp_dir" fi # 应用数据备份 log "备份应用数据..." backup_app_data "$tmp_dir" # 打包所有数据 log "打包迁移数据..." local migrate_file="/tmp/migrate_data.tar.gz" tar -czf "$migrate_file" -C "$tmp_dir" . 2>/dev/null local size=$(du -h "$migrate_file" | cut -f1) info "迁移包大小: $size" # 传输到目标服务器 log "传输到目标服务器..." sshpass -p "$target_pass" rsync -avz --progress \ -e "ssh -o StrictHostKeyChecking=no -p $target_port" \ "$migrate_file" "$target_user@$target_ip:/tmp/" # 在目标服务器上恢复 log "在目标服务器上恢复..." sshpass -p "$target_pass" ssh -o StrictHostKeyChecking=no \ -p "$target_port" "$target_user@$target_ip" bash << 'REMOTE_SCRIPT' set -e cd /tmp mkdir -p /tmp/restore_data tar -xzf migrate_data.tar.gz -C /tmp/restore_data # 安装 Docker if [ -f /tmp/restore_data/docker-images.tar.gz ]; then if ! command -v docker &>/dev/null; then echo "安装 Docker..." curl -fsSL https://get.docker.com | sh systemctl start docker systemctl enable docker fi echo "导入 Docker 镜像..." gunzip -c /tmp/restore_data/docker-images.tar.gz | docker load fi # 恢复 volumes if [ -d /tmp/restore_data/volumes ]; then echo "恢复 Docker Volumes..." for vol_file in /tmp/restore_data/volumes/*.tar.gz; do [ -f "$vol_file" ] || continue vol_name=$(basename "$vol_file" .tar.gz) docker volume create "$vol_name" 2>/dev/null || true docker run --rm -v "$vol_name:/data" -v "/tmp/restore_data/volumes:/backup" \ alpine tar xzf "/backup/${vol_name}.tar.gz" -C /data 2>/dev/null || true done fi # 恢复应用数据 if ls /tmp/restore_data/app-data.tar.gz &>/dev/null; then echo "恢复应用数据..." tar -xzf /tmp/restore_data/app-data.tar.gz -C / 2>/dev/null || true fi # 恢复 NPM 全局包 if [ -f /tmp/restore_data/npm-global.json ] && command -v npm &>/dev/null; then echo "恢复 NPM 全局包..." cat /tmp/restore_data/npm-global.json | jq -r '.dependencies | keys[]' | \ xargs -I {} npm install -g {} 2>/dev/null || true fi # 恢复 pip 包 if [ -f /tmp/restore_data/pip-requirements.txt ] && command -v pip3 &>/dev/null; then echo "恢复 pip 包..." pip3 install -r /tmp/restore_data/pip-requirements.txt 2>/dev/null || true fi # 清理 rm -rf /tmp/restore_data /tmp/migrate_data.tar.gz echo "✅ 迁移完成" REMOTE_SCRIPT # 清理本地临时文件 rm -rf "$tmp_dir" "$migrate_file" log "🎉 迁移完成!" echo "" info "请登录目标服务器检查服务状态" read -p "是否关闭当前服务器? (输入 '确认关闭' 执行): " confirm if [ "$confirm" = "确认关闭" ]; then warn "服务器将在 10 秒后关机..." sleep 10 shutdown -h now fi } #=============================================================================== # 快照备份 #=============================================================================== create_snapshot() { local output_dir="${1:-/var/snapshots}" local name="${2:-snapshot}" local timestamp=$(date '+%Y%m%d_%H%M%S') local snapshot_file="$output_dir/${name}_${timestamp}.tar.gz" mkdir -p "$output_dir" log "📸 创建快照..." # TG通知 - 开始 send_tg "🔄 *开始创建系统快照* 🖥️ 本机: ${VPS_NAME:-$(hostname)} ⏰ 时间: $(date '+%Y-%m-%d %H:%M:%S')" # 检测应用并备份 detect_apps > /dev/null # Docker 数据 - 导出到临时目录 local tmp_dir="/tmp/snapshot_$$" mkdir -p "$tmp_dir" # 保存已安装包列表(用于完整恢复) log "保存系统包列表..." if command -v dpkg &>/dev/null; then dpkg --get-selections | grep -v deinstall | awk '{print $1}' > "$tmp_dir/installed-packages.txt" elif command -v rpm &>/dev/null; then rpm -qa --qf '%{NAME}\n' > "$tmp_dir/installed-packages.txt" elif command -v apk &>/dev/null; then apk list -I 2>/dev/null | awk -F' ' '{print $1}' | sed 's/-[0-9].*//' > "$tmp_dir/installed-packages.txt" fi # 保存二进制文件列表(用于完整恢复 - 追踪手动安装的程序) log "保存二进制文件列表..." { ls -1 /usr/local/bin/ 2>/dev/null ls -1 /usr/local/sbin/ 2>/dev/null ls -1 /opt/ 2>/dev/null } | sort -u > "$tmp_dir/binary-files.txt" # 保存 systemd 服务列表 ls -1 /etc/systemd/system/*.service 2>/dev/null | xargs -I{} basename {} > "$tmp_dir/systemd-services.txt" 2>/dev/null || true if command -v docker &>/dev/null && docker info &>/dev/null; then docker_export "$tmp_dir" fi # 应用数据 - 导出到临时目录 backup_app_data "$tmp_dir" # 打包临时目录内容 tar -czf "$snapshot_file" -C "$tmp_dir" . 2>/dev/null || true # 清理临时目录 rm -rf "$tmp_dir" local size=$(du -h "$snapshot_file" | cut -f1) log "快照已创建: $snapshot_file ($size)" # TG通知 - 快照完成 send_tg "📸 *系统快照创建成功* 🖥️ 本机: ${VPS_NAME:-$(hostname)} 📦 文件: $(basename $snapshot_file) 📏 大小: $size 🕒 时间: $(date '+%Y-%m-%d %H:%M:%S')" # 清理本地旧快照 cleanup_local echo "$snapshot_file" } #=============================================================================== # 清理旧快照 #=============================================================================== cleanup_local() { local snap_dir="${LOCAL_DIR:-/var/snapshots}" local keep=${LOCAL_KEEP:-3} local vps_name="${VPS_NAME:-snapshot}" # 删除临时的app-data文件 rm -f "$snap_dir"/app-data.tar.gz 2>/dev/null # 只统计VPS名称开头的快照 local count=$(ls -1 "$snap_dir"/${vps_name}_*.tar.gz 2>/dev/null | wc -l) if [ "$count" -gt "$keep" ]; then log "🧹 清理本地旧快照 (保留$keep个)..." ls -t "$snap_dir"/${vps_name}_*.tar.gz | tail -n +$((keep+1)) | xargs rm -f fi } cleanup_remote() { [ -z "$REMOTE_IP" ] && return local days=${REMOTE_KEEP_DAYS:-30} local remote_path="${REMOTE_DIR:-/backup}/${VPS_NAME:-$(hostname)}" log "🧹 清理远程旧快照 (保留${days}天)..." _ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \ "find $remote_path -name '*.tar.gz' -mtime +$days -delete 2>/dev/null" || true } #=============================================================================== # 本地恢复 #=============================================================================== do_restore_local() { load_config 2>/dev/null || true local snap_dir="${LOCAL_DIR:-/var/snapshots}" echo "" info "📂 本地快照列表:" ls -lh "$snap_dir"/*.tar.gz 2>/dev/null || { error "无快照"; return 1; } echo "" read -p "输入快照文件名: " snap_file [ ! -f "$snap_dir/$snap_file" ] && { error "文件不存在"; return 1; } # 选择恢复模式 echo "" info "选择恢复模式:" echo " 1) 完整恢复 (恢复到快照时状态,删除后来安装的软件)" echo " 2) 仅恢复数据 (只恢复Docker和应用数据,保留现有系统)" echo "" read -p "请选择 [1-2]: " restore_mode case "$restore_mode" in 1) do_full_restore "$snap_dir/$snap_file" ;; 2) do_data_restore "$snap_dir/$snap_file" ;; *) error "无效选择"; return 1 ;; esac } # 完整恢复 - 恢复到快照时的完整状态 do_full_restore() { local snap_file="$1" log "🔄 完整恢复: $(basename $snap_file)" # 解压到临时目录 local tmp_dir="/tmp/restore_$$" mkdir -p "$tmp_dir" if ! tar -xzf "$snap_file" -C "$tmp_dir" 2>/dev/null; then error "快照文件损坏或解压失败" rm -rf "$tmp_dir" return 1 fi # 读取快照时的已安装包列表 if [ -f "$tmp_dir/installed-packages.txt" ]; then log "恢复系统包状态..." # 获取当前已安装的包 local current_pkgs="/tmp/current_pkgs_$$.txt" if command -v dpkg &>/dev/null; then dpkg --get-selections | grep -v deinstall | awk '{print $1}' > "$current_pkgs" elif command -v rpm &>/dev/null; then rpm -qa --qf '%{NAME}\n' > "$current_pkgs" elif command -v apk &>/dev/null; then apk list -I 2>/dev/null | awk -F' ' '{print $1}' | sed 's/-[0-9].*//' > "$current_pkgs" fi # 找出快照后新安装的包 if [ -f "$current_pkgs" ]; then local new_pkgs=$(comm -23 <(sort "$current_pkgs") <(sort "$tmp_dir/installed-packages.txt")) if [ -n "$new_pkgs" ]; then log "删除快照后安装的软件包..." echo "$new_pkgs" | head -20 [ $(echo "$new_pkgs" | wc -l) -gt 20 ] && echo "... 等共 $(echo "$new_pkgs" | wc -l) 个包" if command -v apt-get &>/dev/null; then echo "$new_pkgs" | xargs apt-get purge -y --auto-remove 2>/dev/null || true elif command -v yum &>/dev/null; then echo "$new_pkgs" | xargs yum remove -y 2>/dev/null || true elif command -v apk &>/dev/null; then echo "$new_pkgs" | xargs apk del 2>/dev/null || true fi fi rm -f "$current_pkgs" fi fi # 删除快照后新增的二进制文件和服务 if [ -f "$tmp_dir/binary-files.txt" ]; then log "清理新增的二进制文件..." # 检查 /usr/local/bin/ for f in /usr/local/bin/*; do [ -e "$f" ] || continue local fname=$(basename "$f") if ! grep -qx "$fname" "$tmp_dir/binary-files.txt" 2>/dev/null; then log "删除: /usr/local/bin/$fname" rm -f "$f" fi done # 检查 /usr/local/sbin/ for f in /usr/local/sbin/*; do [ -e "$f" ] || continue local fname=$(basename "$f") if ! grep -qx "$fname" "$tmp_dir/binary-files.txt" 2>/dev/null; then log "删除: /usr/local/sbin/$fname" rm -f "$f" fi done fi # 删除快照后新增的 systemd 服务 if [ -f "$tmp_dir/systemd-services.txt" ]; then log "清理新增的 systemd 服务..." # 只清理用户安装的服务,不动系统服务 # 只检查 /etc/systemd/system (用户服务),不检查 /usr/lib/systemd/system (系统服务) for f in /etc/systemd/system/*.service; do [ -e "$f" ] || continue local fname=$(basename "$f") # 跳过系统核心服务和模板服务 case "$fname" in systemd-*|dbus*|ssh*|cron*|rsyslog*|networking*|getty*|serial-getty*) continue ;; cloud-*|apt-*|apparmor*|console-*|keyboard-*|grub-*) continue ;; docker*|containerd*|snap*|snapd*) continue ;; *@*) continue ;; # 跳过所有模板服务 esac if ! grep -qx "$fname" "$tmp_dir/systemd-services.txt" 2>/dev/null; then log "停止并删除服务: $fname" systemctl stop "$fname" 2>/dev/null || true systemctl disable "$fname" 2>/dev/null || true rm -f "$f" fi done systemctl daemon-reload 2>/dev/null || true fi # 清理相关配置和安装目录 log "清理相关配置..." # sing-box 相关 rm -rf /usr/local/etc/sing-box /etc/sing-box 2>/dev/null || true # x-ui / x-panel 相关 rm -rf /usr/local/x-ui 2>/dev/null || true # 其他常见手动安装程序的配置 rm -rf /usr/local/etc/v2ray /etc/v2ray 2>/dev/null || true rm -rf /usr/local/etc/xray /etc/xray 2>/dev/null || true rm -rf /usr/local/xray 2>/dev/null || true # 停止并删除所有Docker容器 if command -v docker &>/dev/null; then log "清理现有 Docker 环境..." docker stop $(docker ps -aq) 2>/dev/null || true docker rm $(docker ps -aq) 2>/dev/null || true docker system prune -af 2>/dev/null || true fi # 导入Docker镜像 if [ -f "$tmp_dir/docker-images.tar.gz" ]; then log "导入 Docker 镜像..." gunzip -c "$tmp_dir/docker-images.tar.gz" | docker load fi # 恢复Docker volumes if [ -f "$tmp_dir/docker-volumes.tar.gz" ]; then log "恢复 Docker Volumes..." tar -xzf "$tmp_dir/docker-volumes.tar.gz" -C /var/lib/docker/volumes/ 2>/dev/null || true fi # 恢复docker-compose文件 if [ -f "$tmp_dir/docker-compose.yml" ]; then cp "$tmp_dir/docker-compose.yml" /root/ fi if [ -f "$tmp_dir/.env" ]; then cp "$tmp_dir/.env" /root/ fi # 恢复应用数据 if ls "$tmp_dir"/app-data.tar.gz &>/dev/null; then log "恢复应用数据..." tar -xzf "$tmp_dir"/app-data.tar.gz -C / 2>/dev/null || true fi # 启动Docker容器 if [ -f /root/docker-compose.yml ]; then log "启动 Docker 容器..." cd /root && docker compose up -d 2>/dev/null || docker-compose up -d 2>/dev/null || true fi rm -rf "$tmp_dir" log "✅ 完整恢复完成" } # 仅恢复数据 - 保留现有系统 do_data_restore() { local snap_file="$1" log "🔄 恢复数据: $(basename $snap_file)" # 解压到临时目录 local tmp_dir="/tmp/restore_$$" mkdir -p "$tmp_dir" if ! tar -xzf "$snap_file" -C "$tmp_dir" 2>/dev/null; then error "快照文件损坏或解压失败" rm -rf "$tmp_dir" return 1 fi # 导入Docker镜像 if [ -f "$tmp_dir/docker-images.tar.gz" ]; then log "导入 Docker 镜像..." gunzip -c "$tmp_dir/docker-images.tar.gz" | docker load fi # 恢复 Docker Volumes if [ -d "$tmp_dir/volumes" ]; then log "恢复 Docker Volumes..." for vol_file in "$tmp_dir/volumes"/*.tar.gz; do [ -f "$vol_file" ] || continue local vol_name=$(basename "$vol_file" .tar.gz) docker volume create "$vol_name" 2>/dev/null || true docker run --rm -v "$vol_name:/data" -v "$tmp_dir/volumes:/backup" \ alpine sh -c "cd /data && tar -xzf /backup/$(basename $vol_file)" log " 恢复 Volume: $vol_name" done fi # 恢复应用数据 if [ -f "$tmp_dir/app-data.tar.gz" ]; then log "恢复应用数据..." if ! tar -xzf "$tmp_dir/app-data.tar.gz" -C / 2>/dev/null; then warn "应用数据恢复部分失败" fi fi rm -rf "$tmp_dir" log "✅ 数据恢复完成" } #=============================================================================== # 从配置的远程恢复 #=============================================================================== do_restore_config_remote() { load_config 2>/dev/null || true if [ -z "$REMOTE_IP" ]; then error "未配置远程服务器,请先运行配置" return 1 fi local remote_path="${REMOTE_DIR:-/backup}/${VPS_NAME:-$(hostname)}" info "📡 从配置的远程服务器恢复: $REMOTE_IP:$remote_path" echo "" # 获取远程快照列表 local snap_list=$(_ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \ "ls -t $remote_path/*.tar.gz 2>/dev/null") [ -z "$snap_list" ] && { error "无远程快照"; return 1; } # 显示编号列表 info "远程快照列表:" local i=1 local snaps=() while read -r snap; do local name=$(basename "$snap") local size=$(_ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \ "ls -lh '$snap' 2>/dev/null | awk '{print \$5}'" 2>/dev/null) echo " $i) $name ($size)" snaps+=("$snap") ((i++)) done <<< "$snap_list" echo "" read -p "请选择 [1-$((i-1))]: " choice # 验证选择 if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt $((i-1)) ]; then error "无效选择" return 1 fi local selected="${snaps[$((choice-1))]}" log "下载快照: $(basename "$selected")" local local_file="/tmp/remote_snapshot_$$.tar.gz" _ssh_wrap scp "${REMOTE_USER:-root}@$REMOTE_IP:$selected" "$local_file" [ ! -f "$local_file" ] && { error "下载失败"; return 1; } log "🔄 恢复快照..." local tmp_dir="/tmp/restore_$$" mkdir -p "$tmp_dir" tar -xzf "$local_file" -C "$tmp_dir" # 导入Docker if [ -f "$tmp_dir/docker-images.tar.gz" ]; then log "导入 Docker 镜像..." gunzip -c "$tmp_dir/docker-images.tar.gz" | docker load fi # 恢复应用数据 if ls "$tmp_dir"/app-data.tar.gz &>/dev/null; then log "恢复应用数据..." tar -xzf "$tmp_dir"/app-data.tar.gz -C / 2>/dev/null || true fi rm -rf "$tmp_dir" "$local_file" log "✅ 恢复完成" } #=============================================================================== # 远程恢复 #=============================================================================== do_restore_remote() { echo "" info "📡 从远程服务器拉取快照" read -p "远程服务器 IP: " remote_ip read -p "远程端口 [22]: " remote_port remote_port=${remote_port:-22} read -p "远程用户 [root]: " remote_user remote_user=${remote_user:-root} read -s -p "远程密码: " remote_pass echo "" read -p "远程快照路径 (如 /var/snapshots/xxx.tar.gz): " remote_path [ -z "$remote_ip" ] || [ -z "$remote_path" ] && { error "参数不完整"; return 1; } log "下载快照..." local local_file="/tmp/remote_snapshot_$$.tar.gz" sshpass -p "$remote_pass" scp -o StrictHostKeyChecking=no \ -P "$remote_port" "$remote_user@$remote_ip:$remote_path" "$local_file" [ ! -f "$local_file" ] && { error "下载失败"; return 1; } log "🔄 恢复快照..." local tmp_dir="/tmp/restore_$$" mkdir -p "$tmp_dir" tar -xzf "$local_file" -C "$tmp_dir" # 导入Docker if [ -f "$tmp_dir/docker-images.tar.gz" ]; then log "导入 Docker 镜像..." gunzip -c "$tmp_dir/docker-images.tar.gz" | docker load fi # 恢复应用数据 if ls "$tmp_dir"/app-data.tar.gz &>/dev/null; then log "恢复应用数据..." tar -xzf "$tmp_dir"/app-data.tar.gz -C / 2>/dev/null || true fi rm -rf "$tmp_dir" "$local_file" log "✅ 恢复完成" } #=============================================================================== # 同步到远程 #=============================================================================== do_sync_remote() { load_config 2>/dev/null || true if [ -z "$REMOTE_IP" ]; then error "未配置远程服务器,请先运行配置" return 1 fi local snap_dir="${LOCAL_DIR:-/var/snapshots}" local latest=$(ls -t "$snap_dir"/*.tar.gz 2>/dev/null | head -1) [ -z "$latest" ] && { error "无本地快照"; return 1; } # 远程目录:基础目录/VPS名称 local remote_base="${REMOTE_DIR:-/backup}" local remote_path="${remote_base}/${VPS_NAME:-$(hostname)}" log "📤 同步到远程: $REMOTE_IP:$remote_path" # 创建远程目录(以VPS名称命名) _ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \ "mkdir -p $remote_path" # 同步到VPS专属目录 _ssh_wrap rsync -avz --progress \ "$latest" "${REMOTE_USER:-root}@$REMOTE_IP:$remote_path/" # 清理远程旧快照 cleanup_remote log "✅ 同步完成" # TG通知 - 上传成功 send_tg "☁️ *快照上传成功* 🖥️ 本机: ${VPS_NAME:-$(hostname)} 📁 远程: $REMOTE_IP:$remote_path 🕒 时间: $(date '+%Y-%m-%d %H:%M:%S')" } #=============================================================================== # Telegram 通知 #=============================================================================== send_tg() { [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ] && return local msg="$1" # 转义 Markdown 特殊字符 msg=$(echo "$msg" | sed 's/_/\\_/g') curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ -d chat_id="$TG_CHAT_ID" -d text="$msg" -d parse_mode="Markdown" > /dev/null 2>&1 || true } #=============================================================================== # 定时快照 #=============================================================================== setup_cron() { local script_path=$(readlink -f "$0") echo "" info "⏰ 设置定时快照" echo "" echo " 1) 每天一次" echo " 2) 每12小时" echo " 3) 每6小时" echo " 4) 每小时" echo " 5) 自定义cron表达式" echo " 6) 查看当前定时任务" echo " 7) 删除定时任务" echo " 0) 返回" echo "" read -p "请选择: " cron_choice local cron_expr="" case $cron_choice in 1) cron_expr="0 2 * * *" ;; 2) cron_expr="0 */12 * * *" ;; 3) cron_expr="0 */6 * * *" ;; 4) cron_expr="0 * * * *" ;; 5) read -p "输入cron表达式: " cron_expr ;; 6) echo "" crontab -l 2>/dev/null | grep -E "vps-snapshot|快照" || echo "无定时任务" return ;; 7) crontab -l 2>/dev/null | grep -v "vps-snapshot" | crontab - log "定时任务已删除" return ;; 0) return ;; *) error "无效选项"; return ;; esac [ -z "$cron_expr" ] && return # 选择快照类型 echo "" echo " 1) 仅本地快照" echo " 2) 快照并同步远程" read -p "请选择: " snap_type local cmd="" case $snap_type in 1) cmd="$script_path snapshot" ;; 2) cmd="$script_path snapshot-sync" ;; *) error "无效选项"; return ;; esac # 添加到crontab (crontab -l 2>/dev/null | grep -v "vps-snapshot"; echo "$cron_expr $cmd # vps-snapshot") | crontab - log "✅ 定时任务已设置: $cron_expr" send_tg "⏰ *${VPS_NAME}* 定时快照已设置\n$cron_expr" } #=============================================================================== # 更新脚本 #=============================================================================== update_script() { local script_path=$(readlink -f "$0") local url="https://raw.githubusercontent.com/mango082888-bit/vps-snapshot/main/vps-snapshot.sh" log "🔄 检查更新..." curl -sL "$url" -o "${script_path}.new" if [ -f "${script_path}.new" ]; then mv "${script_path}.new" "$script_path" chmod +x "$script_path" log "✅ 更新完成,请重新运行脚本" exit 0 else error "更新失败" fi } #=============================================================================== # 配置管理 #=============================================================================== load_config() { [ -f "$CONFIG_FILE" ] && source "$CONFIG_FILE" } # SSH/SCP/rsync 前缀:有key用key,有密码用sshpass,都没有走默认 _ssh_wrap() { local mode="$1"; shift local key="${REMOTE_KEY:-}" pass="${REMOTE_PASS:-}" local ssh_opts="-o StrictHostKeyChecking=no -p ${REMOTE_PORT:-22}" [ -n "$key" ] && [ -f "$key" ] && ssh_opts="$ssh_opts -i $key" local ssh_cmd="ssh $ssh_opts" local prefix="" [ -z "$key" ] || [ ! -f "$key" ] && [ -n "$pass" ] && prefix="sshpass -p $pass" case "$mode" in ssh) $prefix $ssh_cmd "$@" ;; scp) $prefix scp -o StrictHostKeyChecking=no ${key:+-i "$key"} -P "${REMOTE_PORT:-22}" "$@" ;; rsync) $prefix rsync -e "$ssh_cmd" "$@" ;; esac } save_config() { cat > "$CONFIG_FILE" << EOF VPS_NAME="$VPS_NAME" LOCAL_DIR="$LOCAL_DIR" LOCAL_KEEP="$LOCAL_KEEP" REMOTE_IP="$REMOTE_IP" REMOTE_PORT="$REMOTE_PORT" REMOTE_USER="$REMOTE_USER" REMOTE_PASS="$REMOTE_PASS" REMOTE_KEY="$REMOTE_KEY" REMOTE_DIR="$REMOTE_DIR" REMOTE_KEEP_DAYS="$REMOTE_KEEP_DAYS" TG_BOT_TOKEN="$TG_BOT_TOKEN" TG_CHAT_ID="$TG_CHAT_ID" EOF chmod 600 "$CONFIG_FILE" } do_setup() { print_banner info "⚙️ 配置向导" echo "" read -p "VPS 名称 [$(hostname)]: " VPS_NAME VPS_NAME=${VPS_NAME:-$(hostname)} read -p "本地快照目录 [/var/snapshots]: " LOCAL_DIR LOCAL_DIR=${LOCAL_DIR:-/var/snapshots} read -p "本地保留快照数量 [3]: " LOCAL_KEEP LOCAL_KEEP=${LOCAL_KEEP:-3} echo "" info "远程备份配置 (可选,留空跳过)" read -p "远程服务器 IP: " REMOTE_IP if [ -n "$REMOTE_IP" ]; then read -p "远程端口 [22]: " REMOTE_PORT REMOTE_PORT=${REMOTE_PORT:-22} read -p "远程用户 [root]: " REMOTE_USER REMOTE_USER=${REMOTE_USER:-root} read -s -p "远程密码 (key认证留空): " REMOTE_PASS echo "" read -p "远程密钥路径 (密码认证留空): " REMOTE_KEY read -p "远程目录 [/backup]: " REMOTE_DIR REMOTE_DIR=${REMOTE_DIR:-/backup} read -p "远程保留天数 [30]: " REMOTE_KEEP_DAYS REMOTE_KEEP_DAYS=${REMOTE_KEEP_DAYS:-30} fi echo "" info "Telegram 通知 (可选,留空跳过)" read -p "Bot Token: " TG_BOT_TOKEN [ -n "$TG_BOT_TOKEN" ] && read -p "Chat ID: " TG_CHAT_ID save_config log "配置已保存到 $CONFIG_FILE" } #=============================================================================== # 主菜单 #=============================================================================== show_menu() { # 自动检测并安装依赖 check_deps print_banner load_config 2>/dev/null || true echo -e "${CYAN}当前配置:${NC} ${VPS_NAME:-未配置}" echo "" echo " 1) 首次配置 / 重新配置" echo " 2) 扫描已安装应用" echo " 3) 创建本地快照" echo " 4) 创建快照并同步远程" echo " 5) 从本地快照恢复" echo " 6) 从配置的远程恢复" echo " 7) 自定义远程恢复 (输入任意服务器)" echo " 8) 一键迁移到新服务器" echo " 9) 导出 Docker 数据" echo " 10) 导入 Docker 数据" echo " 11) 查看本地快照" echo " 12) 同步到远程" echo " 13) 设置定时快照" echo " 14) 更新脚本" echo " 15) 安装依赖" echo " 0) 退出" echo "" read -p "请选择 [0-15]: " choice case $choice in 1) do_setup ;; 2) detect_apps ;; 3) load_config 2>/dev/null || true create_snapshot "${LOCAL_DIR:-/var/snapshots}" "${VPS_NAME:-snapshot}" ;; 4) load_config 2>/dev/null || true create_snapshot "${LOCAL_DIR:-/var/snapshots}" "${VPS_NAME:-snapshot}" do_sync_remote ;; 5) do_restore_local ;; 6) do_restore_config_remote ;; 7) do_restore_remote ;; 8) do_migrate ;; 9) read -p "输出目录 [/var/snapshots]: " dir docker_export "${dir:-/var/snapshots}" ;; 10) read -p "输入目录 [/var/snapshots]: " dir docker_import "${dir:-/var/snapshots}" ;; 11) load_config 2>/dev/null || true echo "" ls -lh "${LOCAL_DIR:-/var/snapshots}" 2>/dev/null || echo "无快照" ;; 12) do_sync_remote ;; 13) setup_cron ;; 14) update_script ;; 15) install_deps ;; 0) exit 0 ;; *) error "无效选项" ;; esac echo "" read -p "按回车继续..." show_menu } #=============================================================================== # 入口 #=============================================================================== case "${1:-}" in setup) do_setup ;; scan) detect_apps ;; snapshot) load_config 2>/dev/null || true create_snapshot "${LOCAL_DIR:-/var/snapshots}" "${VPS_NAME:-snapshot}" ;; snapshot-sync) load_config 2>/dev/null || true create_snapshot "${LOCAL_DIR:-/var/snapshots}" "${VPS_NAME:-snapshot}" do_sync_remote ;; migrate) do_migrate ;; docker-export) docker_export "${2:-/var/snapshots}" ;; docker-import) docker_import "${2:-/var/snapshots}" ;; restore) # 命令行恢复: restore <快照文件> [full|data] load_config 2>/dev/null || true _snap_file="${2:-}" _mode="${3:-data}" if [ -z "$_snap_file" ]; then error "用法: $0 restore <快照文件> [full|data]" echo " full = 完整恢复 (删除后来安装的软件)" echo " data = 仅恢复数据 (默认)" exit 1 fi [ ! -f "$_snap_file" ] && { error "文件不存在: $_snap_file"; exit 1; } case "$_mode" in full) do_full_restore "$_snap_file" ;; data|*) do_data_restore "$_snap_file" ;; esac ;; help|--help|-h) echo "用法: $0 [命令]" echo "" echo "命令:" echo " setup 配置向导" echo " scan 扫描已安装应用" echo " snapshot 创建本地快照" echo " snapshot-sync 创建快照并同步远程" echo " restore 恢复快照 (restore <文件> [full|data])" echo " migrate 一键迁移" echo " docker-export 导出 Docker" echo " docker-import 导入 Docker" echo "" echo "无参数运行显示交互菜单" ;; "") show_menu ;; *) error "未知命令: $1"; exit 1 ;; esac