From 96be56de864d681dbf0d81fdaa0085b220ba9588 Mon Sep 17 00:00:00 2001 From: mango Date: Mon, 2 Feb 2026 10:33:29 +0800 Subject: [PATCH] =?UTF-8?q?v3.0:=20=E6=99=BA=E8=83=BD=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=BA=94=E7=94=A8=20+=20Docker=E4=B8=93=E7=94=A8=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20+=20NPM/pip=E5=8C=85=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vps-snapshot.sh | 1198 ++++++++++++++++++++++------------------------- 1 file changed, 549 insertions(+), 649 deletions(-) diff --git a/vps-snapshot.sh b/vps-snapshot.sh index 35bcfcd..52cfb63 100755 --- a/vps-snapshot.sh +++ b/vps-snapshot.sh @@ -1,9 +1,9 @@ #!/bin/bash #=============================================================================== -# VPS 快照备份脚本 v2.5 +# VPS 快照备份脚本 v3.0 # 支持: Ubuntu, Debian, CentOS, Alpine -# 功能: 创建/恢复快照 + rsync 远程同步 + Telegram 通知 + 自动清理 +# 功能: 智能识别应用 + Docker迁移 + 数据备份 + Telegram通知 #=============================================================================== set -e @@ -17,20 +17,24 @@ 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.5 ║" - echo "║ 支持 Ubuntu/Debian/CentOS/Alpine ║" + echo "║ VPS 快照备份脚本 v3.0 ║" + echo "║ 智能识别 + Docker迁移 + 数据备份 ║" 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; } +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" @@ -38,64 +42,476 @@ detect_os() { else echo "unknown"; fi } -install_dependencies() { +install_deps() { local os=$(detect_os) - log "检测到系统: $os" - log "安装依赖包..." + log "检测系统: $os" 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 ;; + 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 - 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 +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 - ssh-keygen -t ed25519 -f "$SSH_KEY_PATH" -N "" -C "vps-snapshot-${VPS_NAME:-$(hostname)}" - chmod 600 "$SSH_KEY_PATH" - log "密钥生成完成" + + # 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[@]}" } -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 "公钥复制完成" +#=============================================================================== +# 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 数据导出完成" } -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 连接失败" +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}" + local timestamp=$(date '+%Y%m%d_%H%M%S') + local backup_file="$output_dir/app-data_${timestamp}.tar.gz" + + mkdir -p "$output_dir" + log "📦 备份应用数据..." + + local backup_paths="" + + # Nginx + [ -d /etc/nginx ] && backup_paths+=" /etc/nginx" + [ -d /var/www ] && backup_paths+=" /var/www" + + # 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 ] && backup_paths+=" /opt/1panel" + + # 宝塔 + [ -d /www ] && backup_paths+=" /www" + + # 通用数据目录 + [ -d /opt ] && backup_paths+=" /opt" + [ -d /home ] && backup_paths+=" /home" + [ -d /root/.config ] && backup_paths+=" /root/.config" + + # 打包 + if [ -n "$backup_paths" ]; then + log "打包数据目录..." + tar --exclude='*.sock' --exclude='*.pid' --exclude='node_modules' \ + --exclude='.npm' --exclude='.cache' --exclude='__pycache__' \ + -czf "$backup_file" $backup_paths 2>/dev/null || true + info "数据已保存: $backup_file" + fi + + echo "$backup_file" +} + +#=============================================================================== +# 一键迁移 +#=============================================================================== + +do_migrate() { + print_banner + info "🚀 一键迁移向导" + echo "" + + # 检测应用 + local apps_output=$(detect_apps) + local apps=$(echo "$apps_output" | tail -1) + + 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 -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" +# 安装 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 } -send_telegram() { - [ -n "$TG_BOT_TOKEN" ] && [ -n "$TG_CHAT_ID" ] && \ +#=============================================================================== +# 快照备份 +#=============================================================================== + +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 "📸 创建快照..." + + # 检测应用并备份 + detect_apps > /dev/null + + # Docker 数据 + if command -v docker &>/dev/null && docker info &>/dev/null; then + docker_export "$output_dir/docker_$timestamp" + fi + + # 应用数据 + backup_app_data "$output_dir" + + # 打包 + tar -czf "$snapshot_file" -C "$output_dir" . --exclude="*.tar.gz" 2>/dev/null || true + + # 清理临时文件 + rm -rf "$output_dir/docker_$timestamp" "$output_dir/mysql" "$output_dir/postgresql" "$output_dir/mongodb" + + local size=$(du -h "$snapshot_file" | cut -f1) + log "快照已创建: $snapshot_file ($size)" + echo "$snapshot_file" +} + +#=============================================================================== +# Telegram 通知 +#=============================================================================== + +send_tg() { + [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ] && return + local msg="$1" 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 + -d chat_id="$TG_CHAT_ID" -d text="$msg" -d parse_mode="Markdown" > /dev/null } #=============================================================================== @@ -103,568 +519,56 @@ send_telegram() { #=============================================================================== load_config() { - if [ -f "$CONFIG_FILE" ]; then - source "$CONFIG_FILE" - return 0 - else - error "未找到配置文件,请先运行: $0 setup" - return 1 - fi + [ -f "$CONFIG_FILE" ] && source "$CONFIG_FILE" } save_config() { - log "保存配置..." - cat > "$CONFIG_FILE" << CONF + cat > "$CONFIG_FILE" << EOF VPS_NAME="$VPS_NAME" -AUTH_METHOD="$AUTH_METHOD" -SSH_KEY_PATH="$SSH_KEY_PATH" +TG_BOT_TOKEN="$TG_BOT_TOKEN" +TG_CHAT_ID="$TG_CHAT_ID" 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 +EOF chmod 600 "$CONFIG_FILE" - log "配置已保存" } -interactive_setup() { +do_setup() { print_banner - echo -e "${CYAN}=== 开始配置 ===${NC}\n" - - read -p "VPS 名称 (用于区分备份): " VPS_NAME + info "⚙️ 配置向导" + echo "" + + read -p "VPS 名称 [$(hostname)]: " 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" - - 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" - excludes+=" --exclude=/etc" - - 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] =====" - send_telegram "🔄 开始备份%0AVPS: ${VPS_NAME}%0A时间: $(date '+%Y-%m-%d %H:%M:%S')" - - local snapshot=$(create_snapshot) - if [ ! -f "$snapshot" ]; then - send_telegram "❌ 备份失败%0AVPS: ${VPS_NAME}%0A原因: 快照创建失败" - error "快照创建失败" - return 1 - fi - - local size=$(du -h "$snapshot" | cut -f1) - local filename=$(basename "$snapshot") - - send_telegram "📦 快照完成%0AVPS: ${VPS_NAME}%0A文件: ${filename}%0A大小: ${size}%0A开始同步..." - - if ! sync_to_remote "$snapshot"; then - send_telegram "❌ 同步失败%0AVPS: ${VPS_NAME}%0A原因: 远程同步失败" - error "远程同步失败" - return 1 - fi - - cleanup_local - cleanup_remote - - local dur=$(($(date +%s) - start)) - local remote_path="${REMOTE_DIR}/${filename}" - log "===== 备份完成 =====" - send_telegram "✅ 备份完成%0AVPS: ${VPS_NAME}%0A大小: ${size}%0A耗时: ${dur}秒%0A远程: ${REMOTE_IP}:${remote_path}" -} - -#=============================================================================== -# 快照列表 -#=============================================================================== - -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}" >&2 - echo "1) 覆盖模式 - 只覆盖文件,保留新增文件 (安全)" >&2 - echo "2) 完整恢复 - 删除快照中不存在的文件 (危险!)" >&2 - echo "" >&2 - read -p "请选择恢复模式 [1]: " mode /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 "" + 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 "远程密码: " REMOTE_PASS 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='etc/ssh' \ - --exclude='root/.ssh' \ - --exclude='etc/shadow' \ - --exclude='etc/passwd' \ - --exclude='etc/hostname' \ - --exclude='etc/hosts' \ - --exclude='etc/network' \ - --exclude='etc/netplan' \ - --exclude='var/snapshots' \ - "$temp_dir/" / - else - log "覆盖恢复" - rsync -aAXv \ - --exclude='etc/ssh' \ - --exclude='root/.ssh' \ - --exclude='etc/shadow' \ - --exclude='etc/passwd' \ - --exclude='etc/hostname' \ - --exclude='etc/hosts' \ - --exclude='etc/network' \ - --exclude='etc/netplan' \ - "$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() { - echo -e "\n${CYAN}=== 自定义远程恢复 ===${NC}" - echo "从任意服务器拉取快照恢复" - echo "" - - read -p "远程服务器 IP: " custom_ip - [ -z "$custom_ip" ] && { error "IP 不能为空"; return 1; } - - read -p "SSH 端口 [22]: " custom_port - custom_port=${custom_port:-22} - - read -p "SSH 用户名 [root]: " custom_user - custom_user=${custom_user:-root} - - read -s -p "SSH 密码: " custom_pass - echo "" - - read -p "快照完整路径 (如 /backup/vps/xxx.tar.gz): " remote_file - [ -z "$remote_file" ] && { error "路径不能为空"; return 1; } - - local local_file="/tmp/$(basename $remote_file)" - - log "从 ${custom_user}@${custom_ip} 下载: $remote_file" - sshpass -p "$custom_pass" rsync -avz --progress \ - -e "ssh -o StrictHostKeyChecking=no -p $custom_port" \ - "${custom_user}@${custom_ip}:${remote_file}" "$local_file" - - [ ! -f "$local_file" ] && { error "下载失败"; return 1; } - - local mode=$(select_restore_mode) - - echo -e "\n${RED}警告: 即将恢复!${NC}" - read -p "确认? [y/N]: " confirm - [[ ! "$confirm" =~ ^[Yy]$ ]] && { rm -f "$local_file"; return; } - - do_restore "$local_file" "$mode" - rm -f "$local_file" -} - -#=============================================================================== -# 一键迁移 -#=============================================================================== - -migrate_server() { - echo -e "\n${CYAN}=== 一键迁移 ===${NC}" - echo "从源服务器创建快照,直接恢复到当前服务器" - echo -e "${YELLOW}注意: 会覆盖当前服务器数据!${NC}\n" - - # 源服务器信息 - echo -e "${CYAN}--- 源服务器 (A) ---${NC}" - read -p "源服务器 IP: " src_ip - [ -z "$src_ip" ] && { error "IP 不能为空"; return 1; } - - read -p "SSH 端口 [22]: " src_port - src_port=${src_port:-22} - - read -p "SSH 用户名 [root]: " src_user - src_user=${src_user:-root} - - read -s -p "SSH 密码: " src_pass - echo "" - - # 测试连接 - log "测试源服务器连接..." - if ! sshpass -p "$src_pass" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -p "$src_port" "${src_user}@${src_ip}" "echo ok" &>/dev/null; then - error "无法连接源服务器" - return 1 - fi - log "源服务器连接成功" - - # 备份目录选择 - 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 - - local timestamp=$(date '+%Y%m%d_%H%M%S') - local remote_snapshot="/tmp/migrate_${timestamp}.tar.gz" - local local_snapshot="/tmp/migrate_${timestamp}.tar.gz" - - # 在源服务器创建快照 - log "在源服务器创建快照..." - echo "正在打包,请稍候..." - - local excludes="--exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run --exclude=/tmp --exclude=/mnt --exclude=/media --exclude=/lost+found --exclude=/var/cache" - - if [ "$backup_dirs" = "/" ]; then - sshpass -p "$src_pass" ssh -o StrictHostKeyChecking=no -p "$src_port" "${src_user}@${src_ip}" \ - "tar $excludes -czf $remote_snapshot / 2>/dev/null" - else - sshpass -p "$src_pass" ssh -o StrictHostKeyChecking=no -p "$src_port" "${src_user}@${src_ip}" \ - "tar -czf $remote_snapshot $backup_dirs 2>/dev/null" + read -p "远程目录 [/backup/$VPS_NAME]: " REMOTE_DIR + REMOTE_DIR=${REMOTE_DIR:-/backup/$VPS_NAME} fi - # 获取快照大小 - local size=$(sshpass -p "$src_pass" ssh -o StrictHostKeyChecking=no -p "$src_port" "${src_user}@${src_ip}" "du -h $remote_snapshot | cut -f1") - log "源服务器快照完成: $size" - - # 下载快照 - log "下载快照到本地..." - sshpass -p "$src_pass" rsync -avz --progress \ - -e "ssh -o StrictHostKeyChecking=no -p $src_port" \ - "${src_user}@${src_ip}:${remote_snapshot}" "$local_snapshot" - - # 清理源服务器临时文件 - sshpass -p "$src_pass" ssh -o StrictHostKeyChecking=no -p "$src_port" "${src_user}@${src_ip}" "rm -f $remote_snapshot" - - [ ! -f "$local_snapshot" ] && { error "下载失败"; return 1; } - - # 选择恢复模式 - local mode=$(select_restore_mode) - - echo -e "\n${RED}!!! 警告 !!!${NC}" - echo -e "${RED}即将把源服务器数据恢复到当前服务器${NC}" - echo -e "源: ${src_user}@${src_ip}" - echo -e "目标: 当前服务器" echo "" - read -p "输入 确认迁移 继续: " confirm + info "Telegram 通知 (可选,留空跳过)" + read -p "Bot Token: " TG_BOT_TOKEN + [ -n "$TG_BOT_TOKEN" ] && read -p "Chat ID: " TG_CHAT_ID - if [ "$confirm" != "确认迁移" ]; then - rm -f "$local_snapshot" - echo "已取消" - return 1 - fi - - do_restore "$local_snapshot" "$mode" - rm -f "$local_snapshot" - - echo -e "\n${GREEN}迁移完成!${NC}" - echo -e "${YELLOW}建议操作:${NC}" - echo "1. 重启当前服务器" - echo "2. 关闭源服务器 (${src_ip}) 避免冲突" - echo "" - read -p "是否关闭源服务器? [y/N]: " shutdown_src - if [[ "$shutdown_src" =~ ^[Yy]$ ]]; then - log "正在关闭源服务器..." - sshpass -p "$src_pass" ssh -o StrictHostKeyChecking=no -p "$src_port" "${src_user}@${src_ip}" "shutdown -h now" &>/dev/null || true - echo -e "${GREEN}源服务器关机命令已发送${NC}" - fi - - log "迁移完成!建议重启服务器" -} - -#=============================================================================== -# 定时任务 -#=============================================================================== - -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 + save_config + log "配置已保存到 $CONFIG_FILE" } #=============================================================================== @@ -672,82 +576,78 @@ show_status() { #=============================================================================== 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) 一键迁移 (A→B)" - echo "10) 修改配置" - echo "11) 设置定时任务" - echo "12) 查看状态" - echo "0) 退出" + load_config + + echo -e "${CYAN}当前配置:${NC} ${VPS_NAME:-未配置}" echo "" - read -p "请选择: " choice - + echo " 1) 首次配置 / 重新配置" + echo " 2) 扫描已安装应用" + echo " 3) 创建快照备份" + echo " 4) 一键迁移到新服务器" + echo " 5) 导出 Docker 数据" + echo " 6) 导入 Docker 数据" + echo " 7) 查看本地快照" + echo " 8) 安装依赖" + echo " 0) 退出" + echo "" + read -p "请选择 [0-8]: " choice + case $choice in - 1) install_dependencies; interactive_setup ;; - 2) run_backup ;; - 3) load_config && create_snapshot ;; - 4) load_config && list_local_snapshots ;; - 5) load_config && list_remote_snapshots ;; - 6) restore_local ;; - 7) restore_from_remote ;; - 8) restore_custom ;; - 9) migrate_server ;; - 10) edit_config ;; - 11) setup_cron ;; - 12) show_status ;; + 1) do_setup ;; + 2) detect_apps ;; + 3) + load_config + create_snapshot "$LOCAL_DIR" "$VPS_NAME" + ;; + 4) do_migrate ;; + 5) + read -p "输出目录 [/var/snapshots]: " dir + docker_export "${dir:-/var/snapshots}" + ;; + 6) + read -p "输入目录 [/var/snapshots]: " dir + docker_import "${dir:-/var/snapshots}" + ;; + 7) + load_config + echo "" + ls -lh "${LOCAL_DIR:-/var/snapshots}" 2>/dev/null || echo "无快照" + ;; + 8) install_deps ;; 0) exit 0 ;; - *) echo "无效选择" ;; + *) error "无效选项" ;; 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 "$@" +case "${1:-}" in + setup) do_setup ;; + scan) detect_apps ;; + snapshot) create_snapshot "${2:-/var/snapshots}" "${3:-snapshot}" ;; + migrate) do_migrate ;; + docker-export) docker_export "${2:-/var/snapshots}" ;; + docker-import) docker_import "${2:-/var/snapshots}" ;; + help|--help|-h) + echo "用法: $0 [命令]" + echo "" + echo "命令:" + echo " setup 配置向导" + echo " scan 扫描已安装应用" + echo " snapshot 创建快照" + echo " migrate 一键迁移" + echo " docker-export 导出 Docker" + echo " docker-import 导入 Docker" + echo "" + echo "无参数运行显示交互菜单" + ;; + "") show_menu ;; + *) error "未知命令: $1"; exit 1 ;; +esac