#!/usr/bin/env bash # backup.sh — 打包源目录并通过 SMB / (预留) SFTP 上传到远端 # 同时支持 Linux 与 macOS set -u RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' log() { printf "${CYAN}[%s]${NC} %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } warn() { printf "${YELLOW}[%s] WARN:${NC} %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } err() { printf "${RED}[%s] ERROR:${NC} %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; } ok() { printf "${GREEN}[%s]${NC} %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONF_FILE="${SCRIPT_DIR}/backup.conf" # ---- 默认值 ---- SOURCE_PATHS="" TMP_DIR="/tmp/backup" ARCHIVE_PREFIX="backup" METHOD="" CLEAN_LOCAL="true" RETENTION_DAYS=0 SMB_HOST="" SMB_SHARE="" SMB_PATH="" SMB_USER="" SMB_PASSWORD="" SMB_DOMAIN="" SMB_VERSION="" SFTP_HOST="" SFTP_PORT="22" SFTP_USER="" SFTP_PASSWORD="" SFTP_KEY="" SFTP_PATH="" OS_NAME="$(uname -s)" usage() { cat <<'EOF' Usage: bash backup.sh [options] Methods: smb 通过 SMB / Samba 协议上传 sftp 通过 SFTP 上传(暂未实现,已预留入口) Common options: -C, --config FILE 指定配置文件路径(默认:脚本所在目录下 backup.conf) -s, --source PATHS 要备份的源路径,多个用空格分隔,需引号包裹 -t, --tmp-dir DIR 本地临时目录(默认:/tmp/backup) -p, --prefix NAME 归档文件名前缀(默认:backup) --keep-local 上传后保留本地归档 --retention DAYS 远端保留天数,0 表示不清理 -h, --help 显示帮助 SMB options: --smb-host HOST --smb-share NAME --smb-path PATH 共享内子目录 --smb-user USER --smb-password PASS --smb-domain DOMAIN --smb-version VER 例如 3.0 SFTP options (预留): --sftp-host HOST --sftp-port PORT --sftp-user USER --sftp-password PASS --sftp-key FILE --sftp-path PATH Examples: bash backup.sh smb bash backup.sh smb --smb-host 192.168.1.10 --smb-share backup --smb-user u --smb-password p bash backup.sh smb -s "/etc /var/log" -C /path/to/backup.conf EOF } load_conf() { if [[ -f "$CONF_FILE" ]]; then # shellcheck disable=SC1090 source "$CONF_FILE" log "已加载配置文件:$CONF_FILE" else warn "配置文件不存在:$CONF_FILE(仅使用命令行参数)" fi } parse_args() { if [[ $# -lt 1 ]]; then usage; exit 1 fi case "$1" in -h|--help) usage; exit 0 ;; smb|sftp) METHOD_ARG="$1"; shift ;; *) err "未知方式:$1"; usage; exit 1 ;; esac # 第一遍只取 --config,使后续参数能覆盖配置 local i=1 local argv=("$@") while [[ $i -le ${#argv[@]} ]]; do local cur="${argv[$((i-1))]}" if [[ "$cur" == "-C" || "$cur" == "--config" ]]; then CONF_FILE="${argv[$i]}" fi i=$((i+1)) done load_conf METHOD="$METHOD_ARG" while [[ $# -gt 0 ]]; do case "$1" in -C|--config) shift 2 ;; # 已处理 -s|--source) SOURCE_PATHS="$2"; shift 2 ;; -t|--tmp-dir) TMP_DIR="$2"; shift 2 ;; -p|--prefix) ARCHIVE_PREFIX="$2"; shift 2 ;; --keep-local) CLEAN_LOCAL="false"; shift ;; --retention) RETENTION_DAYS="$2"; shift 2 ;; --smb-host) SMB_HOST="$2"; shift 2 ;; --smb-share) SMB_SHARE="$2"; shift 2 ;; --smb-path) SMB_PATH="$2"; shift 2 ;; --smb-user) SMB_USER="$2"; shift 2 ;; --smb-password) SMB_PASSWORD="$2"; shift 2 ;; --smb-domain) SMB_DOMAIN="$2"; shift 2 ;; --smb-version) SMB_VERSION="$2"; shift 2 ;; --sftp-host) SFTP_HOST="$2"; shift 2 ;; --sftp-port) SFTP_PORT="$2"; shift 2 ;; --sftp-user) SFTP_USER="$2"; shift 2 ;; --sftp-password) SFTP_PASSWORD="$2"; shift 2 ;; --sftp-key) SFTP_KEY="$2"; shift 2 ;; --sftp-path) SFTP_PATH="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) err "未知参数:$1"; usage; exit 1 ;; esac done } require_cmd() { for c in "$@"; do if ! command -v "$c" >/dev/null 2>&1; then err "缺少依赖命令:$c" return 1 fi done } create_archive() { [[ -z "$SOURCE_PATHS" ]] && { err "未配置 SOURCE_PATHS"; return 1; } mkdir -p "$TMP_DIR" || { err "无法创建临时目录:$TMP_DIR"; return 1; } local ts archive ts="$(date '+%Y%m%d-%H%M%S')" archive="${TMP_DIR}/${ARCHIVE_PREFIX}-${ts}.tar.gz" # shellcheck disable=SC2206 local paths=($SOURCE_PATHS) for p in "${paths[@]}"; do if [[ ! -e "$p" ]]; then warn "源路径不存在,跳过:$p" fi done log "开始打包:$archive" if ! tar -czf "$archive" "${paths[@]}" 2>/dev/null; then err "打包失败" return 1 fi ARCHIVE_FILE="$archive" ARCHIVE_NAME="$(basename "$archive")" ok "打包完成:$archive ($(du -h "$archive" | awk '{print $1}'))" } cleanup_local() { if [[ "$CLEAN_LOCAL" == "true" && -n "${ARCHIVE_FILE:-}" && -f "$ARCHIVE_FILE" ]]; then rm -f "$ARCHIVE_FILE" && log "已删除本地归档:$ARCHIVE_FILE" fi } # ---------- SMB ---------- smb_check_deps() { case "$OS_NAME" in Linux) if command -v smbclient >/dev/null 2>&1; then SMB_TOOL="smbclient" else err "未安装 smbclient。Debian/Ubuntu: apt install smbclient;RHEL/Alma: dnf install samba-client" return 1 fi ;; Darwin) # macOS 自带 smbutil,但上传需要挂载;优先使用 smbclient(brew install samba) if command -v smbclient >/dev/null 2>&1; then SMB_TOOL="smbclient" elif command -v mount_smbfs >/dev/null 2>&1; then SMB_TOOL="mount_smbfs" else err "未找到 smbclient(brew install samba)或 mount_smbfs" return 1 fi ;; *) err "不支持的系统:$OS_NAME"; return 1 ;; esac } smb_validate() { [[ -z "$SMB_HOST" ]] && { err "缺少 SMB_HOST"; return 1; } [[ -z "$SMB_SHARE" ]] && { err "缺少 SMB_SHARE"; return 1; } [[ -z "$SMB_USER" ]] && { err "缺少 SMB_USER"; return 1; } } smb_upload_smbclient() { local remote_dir="${SMB_PATH%/}" local commands="" if [[ -n "$remote_dir" ]]; then # 逐级 mkdir,避免目录不存在 local IFS=/ # shellcheck disable=SC2206 local parts=($remote_dir) local cur="" for seg in "${parts[@]}"; do [[ -z "$seg" ]] && continue cur="${cur}${seg}" commands+="mkdir \"${cur}\";" cur="${cur}/" done commands+="cd \"${remote_dir}\";" fi commands+="put \"${ARCHIVE_FILE}\" \"${ARCHIVE_NAME}\";" local args=( "//${SMB_HOST}/${SMB_SHARE}" "-U" "${SMB_USER}%${SMB_PASSWORD}" ) [[ -n "$SMB_DOMAIN" ]] && args+=( "-W" "$SMB_DOMAIN" ) [[ -n "$SMB_VERSION" ]] && args+=( "-m" "SMB${SMB_VERSION//./}" ) log "通过 smbclient 上传到 //${SMB_HOST}/${SMB_SHARE}/${SMB_PATH}" if ! smbclient "${args[@]}" -c "$commands"; then err "smbclient 上传失败" return 1 fi if [[ "$RETENTION_DAYS" -gt 0 ]]; then smb_retention_smbclient "${args[@]}" fi } smb_retention_smbclient() { local args=( "$@" ) local listing listing="$(smbclient "${args[@]}" -D "${SMB_PATH:-/}" -c "ls" 2>/dev/null)" || return 0 local cutoff if date -v-1d >/dev/null 2>&1; then cutoff=$(date -v-"${RETENTION_DAYS}"d +%s) # macOS BSD date else cutoff=$(date -d "-${RETENTION_DAYS} days" +%s) fi log "清理远端早于 ${RETENTION_DAYS} 天的归档" echo "$listing" | awk '{print $1}' | grep -E "^${ARCHIVE_PREFIX}-[0-9]{8}-[0-9]{6}\\.tar\\.gz\$" | while read -r f; do local fts ftime fts="$(echo "$f" | sed -E "s/^${ARCHIVE_PREFIX}-([0-9]{8})-([0-9]{6})\\.tar\\.gz\$/\\1 \\2/")" local d="${fts% *}" t="${fts#* }" local iso="${d:0:4}-${d:4:2}-${d:6:2} ${t:0:2}:${t:2:2}:${t:4:2}" if date -j -f "%Y-%m-%d %H:%M:%S" "$iso" +%s >/dev/null 2>&1; then ftime=$(date -j -f "%Y-%m-%d %H:%M:%S" "$iso" +%s) # macOS else ftime=$(date -d "$iso" +%s) fi if [[ "$ftime" -lt "$cutoff" ]]; then log "删除远端旧归档:$f" smbclient "${args[@]}" -D "${SMB_PATH:-/}" -c "del \"$f\"" >/dev/null 2>&1 || warn "删除失败:$f" fi done } smb_upload_mount() { # macOS 兜底:通过挂载点拷贝 local mnt mnt="$(mktemp -d /tmp/smbmnt.XXXXXX)" local url="//${SMB_USER}:${SMB_PASSWORD}@${SMB_HOST}/${SMB_SHARE}" log "挂载 SMB:${mnt}" if ! mount_smbfs "$url" "$mnt"; then err "挂载 SMB 失败"; rmdir "$mnt"; return 1 fi local dest="$mnt" if [[ -n "$SMB_PATH" ]]; then dest="$mnt/$SMB_PATH" mkdir -p "$dest" || true fi if cp "$ARCHIVE_FILE" "$dest/"; then ok "已复制到 $dest/" else err "复制失败" umount "$mnt" 2>/dev/null rmdir "$mnt" return 1 fi umount "$mnt" 2>/dev/null rmdir "$mnt" } run_smb() { smb_check_deps || return 1 smb_validate || return 1 create_archive || return 1 if [[ "$SMB_TOOL" == "smbclient" ]]; then smb_upload_smbclient || { cleanup_local; return 1; } else smb_upload_mount || { cleanup_local; return 1; } fi ok "SMB 上传完成" cleanup_local } # ---------- SFTP(预留)---------- run_sftp() { err "SFTP 方式尚未实现,已预留入口(参见 run_sftp 函数与 SFTP_* 配置)" return 1 } # ---------- 入口 ---------- main() { parse_args "$@" case "$METHOD" in smb) run_smb ;; sftp) run_sftp ;; *) err "不支持的方式:$METHOD"; exit 1 ;; esac } main "$@"