文件
scripts/backup/backup.sh
2026-04-26 19:22:16 +08:00

346 行
10 KiB
Bash

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

#!/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 <method> [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,但上传需要挂载;优先使用 smbclientbrew 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 "未找到 smbclientbrew 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 "$@"