diff --git a/backup/README.md b/backup/README.md index d9f4eee..9856bfd 100644 --- a/backup/README.md +++ b/backup/README.md @@ -104,6 +104,7 @@ bash backup.sh smb | `-p, --prefix NAME` | `ARCHIVE_PREFIX` | | `--keep-local` | 等价于 `CLEAN_LOCAL=false` | | `--retention DAYS` | `RETENTION_DAYS` | +| `--debug` | 打印详细调试日志(也可用 `DEBUG=true` 环境变量) | | `-h, --help` | 显示帮助 | ### SMB diff --git a/backup/backup.sh b/backup/backup.sh index 37f7a55..e280e14 100644 --- a/backup/backup.sh +++ b/backup/backup.sh @@ -10,12 +10,16 @@ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' +MAGENTA='\033[0;35m' NC='\033[0m' +DEBUG="${DEBUG:-false}" + 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')" "$*"; } +dbg() { [[ "$DEBUG" == "true" ]] && printf "${MAGENTA}[%s] DEBUG:${NC} %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2 || true; } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONF_FILE="${SCRIPT_DIR}/backup.conf" @@ -44,6 +48,9 @@ SFTP_KEY="" SFTP_PATH="" OS_NAME="$(uname -s)" +ARCHIVE_FILE="" +ARCHIVE_NAME="" +SMB_TOOL="" usage() { cat <<'EOF' @@ -60,6 +67,7 @@ Common options: -p, --prefix NAME 归档文件名前缀(默认:backup) --keep-local 上传后保留本地归档 --retention DAYS 远端保留天数,0 表示不清理 + --debug 打印详细调试日志(也可用环境变量 DEBUG=true) -h, --help 显示帮助 SMB options: @@ -83,44 +91,111 @@ 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 + bash backup.sh smb --debug EOF } -load_conf() { - if [[ -f "$CONF_FILE" ]]; then - # shellcheck disable=SC1090 - source "$CONF_FILE" - log "已加载配置文件:$CONF_FILE" +mask() { + # 给敏感值打码:保留前 1 位,其余替换成 * + local s="$1" + [[ -z "$s" ]] && { echo "(empty)"; return; } + local len=${#s} + if [[ $len -le 2 ]]; then + printf '%s\n' "**" else - warn "配置文件不存在:$CONF_FILE(仅使用命令行参数)" + printf '%s%s\n' "${s:0:1}" "$(printf '%*s' $((len-1)) '' | tr ' ' '*')" fi } +print_runtime_env() { + dbg "===== 运行时环境 =====" + dbg "OS_NAME=$OS_NAME" + dbg "uname -a: $(uname -a 2>/dev/null)" + dbg "BASH_VERSION=$BASH_VERSION" + dbg "PWD=$(pwd)" + dbg "SCRIPT_DIR=$SCRIPT_DIR" + dbg "脚本路径=${BASH_SOURCE[0]}" + dbg "EUID=$EUID USER=${USER:-?}" + dbg "PATH=$PATH" + dbg "原始参数($#): $*" +} + +print_effective_config() { + dbg "===== 生效配置 =====" + dbg "CONF_FILE=$CONF_FILE" + dbg "METHOD=$METHOD" + dbg "SOURCE_PATHS=$SOURCE_PATHS" + dbg "TMP_DIR=$TMP_DIR" + dbg "ARCHIVE_PREFIX=$ARCHIVE_PREFIX" + dbg "CLEAN_LOCAL=$CLEAN_LOCAL" + dbg "RETENTION_DAYS=$RETENTION_DAYS" + dbg "SMB_HOST=$SMB_HOST" + dbg "SMB_SHARE=$SMB_SHARE" + dbg "SMB_PATH=$SMB_PATH" + dbg "SMB_USER=$SMB_USER" + dbg "SMB_PASSWORD=$(mask "$SMB_PASSWORD")" + dbg "SMB_DOMAIN=$SMB_DOMAIN" + dbg "SMB_VERSION=$SMB_VERSION" +} + +load_conf() { + dbg "load_conf: 读取 $CONF_FILE" + if [[ -f "$CONF_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONF_FILE" + local rc=$? + if [[ $rc -ne 0 ]]; then + err "配置文件加载失败(rc=$rc):$CONF_FILE" + return 1 + fi + log "已加载配置文件:$CONF_FILE" + dbg "load_conf: source 成功,rc=$rc" + else + warn "配置文件不存在:$CONF_FILE(仅使用命令行参数)" + fi + return 0 +} + parse_args() { + dbg "parse_args: 入参 ($#) = $*" + if [[ $# -lt 1 ]]; then usage; exit 1 fi + + # 第 0 遍:扫描 --debug,让后续日志能打印 + local a + for a in "$@"; do + if [[ "$a" == "--debug" ]]; then + DEBUG="true" + dbg "已启用 DEBUG 模式" + fi + done + case "$1" in -h|--help) usage; exit 0 ;; smb|sftp) METHOD_ARG="$1"; shift ;; *) err "未知方式:$1"; usage; exit 1 ;; esac + dbg "parse_args: METHOD_ARG=$METHOD_ARG,剩余参数($#)=$*" - # 第一遍只取 --config / -C,使后续参数能覆盖配置 - # 使用 for 循环 + prev 变量,兼容 bash 3.2(避免数组索引在 set -u 下出问题) + # 第 1 遍只取 --config / -C,使后续参数能覆盖配置 local prev="" local arg for arg in "$@"; do if [[ "$prev" == "-C" || "$prev" == "--config" ]]; then CONF_FILE="$arg" + dbg "parse_args: 命令行覆盖 CONF_FILE=$CONF_FILE" fi prev="$arg" done - load_conf + load_conf || return 1 METHOD="$METHOD_ARG" + dbg "parse_args: 进入第 2 遍参数解析" while [[ $# -gt 0 ]]; do + dbg " parse: 当前=$1" case "$1" in -C|--config) shift 2 ;; # 已处理 -s|--source) SOURCE_PATHS="$2"; shift 2 ;; @@ -128,6 +203,7 @@ parse_args() { -p|--prefix) ARCHIVE_PREFIX="$2"; shift 2 ;; --keep-local) CLEAN_LOCAL="false"; shift ;; --retention) RETENTION_DAYS="$2"; shift 2 ;; + --debug) DEBUG="true"; shift ;; --smb-host) SMB_HOST="$2"; shift 2 ;; --smb-share) SMB_SHARE="$2"; shift 2 ;; --smb-path) SMB_PATH="$2"; shift 2 ;; @@ -145,68 +221,89 @@ parse_args() { *) 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 + dbg "parse_args: 解析完成" + return 0 } create_archive() { + dbg "create_archive: 入口 SOURCE_PATHS='$SOURCE_PATHS' TMP_DIR='$TMP_DIR'" [[ -z "$SOURCE_PATHS" ]] && { err "未配置 SOURCE_PATHS"; return 1; } - mkdir -p "$TMP_DIR" || { err "无法创建临时目录:$TMP_DIR"; return 1; } + if ! mkdir -p "$TMP_DIR"; then + err "无法创建临时目录:$TMP_DIR" + return 1 + fi + dbg "create_archive: 临时目录就绪 $TMP_DIR (空间: $(df -h "$TMP_DIR" 2>/dev/null | tail -1))" local ts archive ts="$(date '+%Y%m%d-%H%M%S')" archive="${TMP_DIR}/${ARCHIVE_PREFIX}-${ts}.tar.gz" + dbg "create_archive: 目标归档=$archive" # shellcheck disable=SC2206 local paths=($SOURCE_PATHS) + dbg "create_archive: 解析后路径数=${#paths[@]}" + local valid=0 for p in "${paths[@]}"; do if [[ ! -e "$p" ]]; then warn "源路径不存在,跳过:$p" + else + dbg " 路径就绪:$p" + valid=$((valid+1)) fi done + if [[ $valid -eq 0 ]]; then + err "没有任何有效的源路径" + return 1 + fi log "开始打包:$archive" - if ! tar -czf "$archive" "${paths[@]}" 2>/dev/null; then - err "打包失败" + local tar_err + tar_err="$(tar -czf "$archive" "${paths[@]}" 2>&1)" + local rc=$? + if [[ $rc -ne 0 ]]; then + err "打包失败 (rc=$rc): $tar_err" return 1 fi + if [[ -n "$tar_err" ]]; then + dbg "tar 输出: $tar_err" + fi ARCHIVE_FILE="$archive" ARCHIVE_NAME="$(basename "$archive")" ok "打包完成:$archive ($(du -h "$archive" | awk '{print $1}'))" + dbg "create_archive: ARCHIVE_FILE=$ARCHIVE_FILE ARCHIVE_NAME=$ARCHIVE_NAME" } cleanup_local() { + dbg "cleanup_local: CLEAN_LOCAL=$CLEAN_LOCAL ARCHIVE_FILE=$ARCHIVE_FILE" if [[ "$CLEAN_LOCAL" == "true" && -n "$ARCHIVE_FILE" && -f "$ARCHIVE_FILE" ]]; then rm -f "$ARCHIVE_FILE" && log "已删除本地归档:$ARCHIVE_FILE" + else + dbg "cleanup_local: 跳过删除" fi } # ---------- SMB ---------- smb_check_deps() { + dbg "smb_check_deps: OS=$OS_NAME" case "$OS_NAME" in Linux) if command -v smbclient >/dev/null 2>&1; then SMB_TOOL="smbclient" + dbg "smb_check_deps: 找到 smbclient => $(command -v 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" + dbg "smb_check_deps: 找到 smbclient => $(command -v smbclient)" elif command -v mount_smbfs >/dev/null 2>&1; then SMB_TOOL="mount_smbfs" + dbg "smb_check_deps: 未找到 smbclient,回退 mount_smbfs => $(command -v mount_smbfs)" else err "未找到 smbclient(brew install samba)或 mount_smbfs" return 1 @@ -214,20 +311,25 @@ smb_check_deps() { ;; *) err "不支持的系统:$OS_NAME"; return 1 ;; esac + log "SMB 工具:$SMB_TOOL" + return 0 } smb_validate() { + dbg "smb_validate: HOST='$SMB_HOST' SHARE='$SMB_SHARE' USER='$SMB_USER' PATH='$SMB_PATH'" [[ -z "$SMB_HOST" ]] && { err "缺少 SMB_HOST"; return 1; } [[ -z "$SMB_SHARE" ]] && { err "缺少 SMB_SHARE"; return 1; } [[ -z "$SMB_USER" ]] && { err "缺少 SMB_USER"; return 1; } + [[ -z "$SMB_PASSWORD" ]] && warn "SMB_PASSWORD 为空(如服务器允许匿名/空密码可忽略)" + return 0 } smb_upload_smbclient() { + dbg "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) @@ -241,43 +343,54 @@ smb_upload_smbclient() { commands+="cd \"${remote_dir}\";" fi commands+="put \"${ARCHIVE_FILE}\" \"${ARCHIVE_NAME}\";" + dbg "smb_upload_smbclient: smbclient 命令串=$commands" 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//./}" ) + # debug 模式给 smbclient 也加上 -d 1 + local sm_debug=() + [[ "$DEBUG" == "true" ]] && sm_debug=( "-d" "1" ) log "通过 smbclient 上传到 //${SMB_HOST}/${SMB_SHARE}/${SMB_PATH}" - if ! smbclient "${args[@]}" -c "$commands"; then + dbg "smbclient 参数(脱敏)://${SMB_HOST}/${SMB_SHARE} -U ${SMB_USER}%$(mask "$SMB_PASSWORD") ${SMB_DOMAIN:+-W $SMB_DOMAIN} ${SMB_VERSION:+-m SMB${SMB_VERSION//./}}" + if ! smbclient "${args[@]}" "${sm_debug[@]}" -c "$commands"; then err "smbclient 上传失败" return 1 fi + dbg "smb_upload_smbclient: 上传 OK" if [[ "$RETENTION_DAYS" -gt 0 ]]; then smb_retention_smbclient "${args[@]}" + else + dbg "smb_upload_smbclient: RETENTION_DAYS=0,跳过远端清理" fi } smb_retention_smbclient() { local args=( "$@" ) + dbg "smb_retention_smbclient: RETENTION_DAYS=$RETENTION_DAYS" 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 + cutoff=$(date -v-"${RETENTION_DAYS}"d +%s) else cutoff=$(date -d "-${RETENTION_DAYS} days" +%s) fi log "清理远端早于 ${RETENTION_DAYS} 天的归档" + dbg "cutoff=$cutoff ($(date -r "$cutoff" 2>/dev/null || date -d "@$cutoff" 2>/dev/null))" 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 + ftime=$(date -j -f "%Y-%m-%d %H:%M:%S" "$iso" +%s) else ftime=$(date -d "$iso" +%s) fi + dbg " 远端文件 $f -> ftime=$ftime" if [[ "$ftime" -lt "$cutoff" ]]; then log "删除远端旧归档:$f" smbclient "${args[@]}" -D "${SMB_PATH:-/}" -c "del \"$f\"" >/dev/null 2>&1 || warn "删除失败:$f" @@ -286,11 +399,12 @@ smb_retention_smbclient() { } smb_upload_mount() { - # macOS 兜底:通过挂载点拷贝 + dbg "smb_upload_mount: 使用 mount_smbfs 兜底" local mnt mnt="$(mktemp -d /tmp/smbmnt.XXXXXX)" local url="//${SMB_USER}:${SMB_PASSWORD}@${SMB_HOST}/${SMB_SHARE}" log "挂载 SMB:${mnt}" + dbg "mount_smbfs URL(脱敏)://${SMB_USER}:$(mask "$SMB_PASSWORD")@${SMB_HOST}/${SMB_SHARE}" if ! mount_smbfs "$url" "$mnt"; then err "挂载 SMB 失败"; rmdir "$mnt"; return 1 fi @@ -299,6 +413,7 @@ smb_upload_mount() { dest="$mnt/$SMB_PATH" mkdir -p "$dest" || true fi + dbg "smb_upload_mount: 目标 $dest" if cp "$ARCHIVE_FILE" "$dest/"; then ok "已复制到 $dest/" else @@ -312,8 +427,10 @@ smb_upload_mount() { } run_smb() { + dbg "run_smb: 开始" smb_check_deps || return 1 smb_validate || return 1 + print_effective_config create_archive || return 1 if [[ "$SMB_TOOL" == "smbclient" ]]; then @@ -335,13 +452,26 @@ run_sftp() { # ---------- 入口 ---------- main() { - parse_args "$@" + # 提前处理 --debug,便于尽早开启日志 + local a + for a in "$@"; do + [[ "$a" == "--debug" ]] && DEBUG="true" + done + + print_runtime_env "$@" + + parse_args "$@" || exit 1 + + print_effective_config case "$METHOD" in smb) run_smb ;; sftp) run_sftp ;; *) err "不支持的方式:$METHOD"; exit 1 ;; esac + local rc=$? + dbg "main: 退出码=$rc" + exit $rc } main "$@"