diff --git a/backup/README.md b/backup/README.md index 9856bfd..b08b2f2 100644 --- a/backup/README.md +++ b/backup/README.md @@ -6,7 +6,8 @@ ## 系统要求 -- bash 3.2+ / tar / date +- bash 3.2+ / tar / date / split +- `sha256sum`(Linux 自带,属于 coreutils)或 `shasum`(macOS 自带) - Linux:`smbclient`(包含在 `smbclient` 或 `samba-client` 包中) - macOS:`smbclient`(推荐,`brew install samba`),或退回到系统自带的 `mount_smbfs` @@ -74,21 +75,33 @@ bash backup.sh smb 脚本默认读取 **脚本所在目录** 下的 `backup.conf`,可用 `-C` / `--config` 指定其它路径。 +配置分三段:**公共配置**(`COMMON_*`,所有方式都用)、**SMB 段**、**SFTP 段**。备份方式由命令行第一个位置参数决定(`smb` / `sftp`),脚本只会读取该方式对应段的配置。`backup.conf` 里没有 `METHOD` 字段。 + +### 公共配置(COMMON_*) + +| 字段 | 说明 | +| --- | --- | +| `COMMON_SOURCE_PATHS` | 要备份的源路径,多个用空格分隔,需引号包裹 | +| `COMMON_TMP_DIR` | 本地临时打包目录,默认 `/tmp/backup` | +| `COMMON_ARCHIVE_PREFIX` | 归档/远端目录命名前缀,最终形如 `prefix-YYYYmmdd-HHMMSS/` | +| `COMMON_CLEAN_LOCAL` | 上传后是否删除本地归档(`true` / `false`) | +| `COMMON_RETENTION_DAYS` | 远端保留天数,`0` 表示不清理;按目录整体清理 | +| `COMMON_SPLIT_SIZE` | 分卷大小(默认 `1G`;`500M` / `100k` 等),留空字符串不分卷 | + +### SMB 段 + | 字段 | 说明 | | --- | --- | -| `SOURCE_PATHS` | 要备份的源路径,多个用空格分隔,需引号包裹 | -| `TMP_DIR` | 本地临时打包目录,默认 `/tmp/backup` | -| `ARCHIVE_PREFIX` | 归档文件名前缀,最终形如 `prefix-YYYYmmdd-HHMMSS.tar.gz` | -| `METHOD` | 默认上传方式(`smb` / `sftp`),命令行参数会覆盖 | -| `CLEAN_LOCAL` | 上传后是否删除本地归档(`true` / `false`) | -| `RETENTION_DAYS` | 远端保留天数,`0` 表示不清理 | | `SMB_HOST` | SMB 服务器地址 | | `SMB_SHARE` | 共享名 | | `SMB_PATH` | 共享内的子目录(可选) | | `SMB_USER` / `SMB_PASSWORD` | 凭据 | | `SMB_DOMAIN` | 域 / 工作组(可选) | | `SMB_VERSION` | SMB 协议版本,如 `3.0`(可选) | -| `SFTP_*` | 预留字段,目前未启用 | + +### SFTP 段(预留,暂未实现) + +`SFTP_HOST` / `SFTP_PORT` / `SFTP_USER` / `SFTP_PASSWORD` / `SFTP_KEY` / `SFTP_PATH` ## 命令行参数 @@ -99,11 +112,12 @@ bash backup.sh smb | 参数 | 对应配置 | | --- | --- | | `-C, --config FILE` | 指定配置文件路径 | -| `-s, --source "P1 P2"` | `SOURCE_PATHS` | -| `-t, --tmp-dir DIR` | `TMP_DIR` | -| `-p, --prefix NAME` | `ARCHIVE_PREFIX` | -| `--keep-local` | 等价于 `CLEAN_LOCAL=false` | -| `--retention DAYS` | `RETENTION_DAYS` | +| `-s, --source "P1 P2"` | `COMMON_SOURCE_PATHS` | +| `-t, --tmp-dir DIR` | `COMMON_TMP_DIR` | +| `-p, --prefix NAME` | `COMMON_ARCHIVE_PREFIX` | +| `--keep-local` | 等价于 `COMMON_CLEAN_LOCAL=false` | +| `--retention DAYS` | `COMMON_RETENTION_DAYS` | +| `--split-size SIZE` | `COMMON_SPLIT_SIZE` | | `--debug` | 打印详细调试日志(也可用 `DEBUG=true` 环境变量) | | `-h, --help` | 显示帮助 | @@ -160,21 +174,111 @@ bash backup.sh smb -s "/var/lib/mysql" # 每天 03:17 跑一次 17 3 * * * /bin/bash /path/to/backup/backup.sh smb > /var/log/backup.log 2>&1 + +# 每两天 03:10 跑一次(按月内的奇数日触发:1、3、5……29、31) +10 3 */2 * * /bin/bash /path/to/backup/backup.sh smb > /var/log/backup.log 2>&1 ``` +> `*/2` 是按月内日期号取模,并不是严格意义的「每 48 小时」。在 31 天月份的月末会出现 31 → 次月 1 号连续两天都触发的情况;如对间隔严格要求,建议改用 systemd timer 的 `OnUnitActiveSec=2d`。 + > 如果有多条任务都写到同一个日志文件,请改用不同的日志路径(例如 `/var/log/backup-0310.log`、`/var/log/backup-0317.log`),否则后一次会覆盖前一次。 macOS 可用 `launchd` 或 `cron`(需要在「系统设置 → 隐私与安全性 → 完全磁盘访问权限」中授予 `cron` 权限以读取受保护目录)。 -## 路径与文件 +## 远端目录结构 + +每次备份在远端创建一个独立子目录,内含分卷文件与 SHA256 清单: + +``` +//${SMB_HOST}/${SMB_SHARE}/${SMB_PATH}/ +└── backup-20260426-031000/ # 本次备份目录 + ├── backup-20260426-031000.tar.gz.0 # 分卷 0 + ├── backup-20260426-031000.tar.gz.1 # 分卷 1 + ├── backup-20260426-031000.tar.gz.2 + ├── ... + └── backup-20260426-031000.sha256 # SHA256 清单(覆盖所有分卷) +``` + +未启用分卷(`COMMON_SPLIT_SIZE=""`)时目录里只有完整归档加清单: + +``` +backup-20260426-031000/ +├── backup-20260426-031000.tar.gz +└── backup-20260426-031000.sha256 +``` + +本地路径: | 路径 | 说明 | | --- | --- | -| `${TMP_DIR}/${ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz` | 本地临时归档(默认上传后删除) | -| 远端 `//${SMB_HOST}/${SMB_SHARE}/${SMB_PATH}/` | 上传目标目录 | +| `${COMMON_TMP_DIR}/${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz` | 未分卷时的归档;分卷后会被删除 | +| `${COMMON_TMP_DIR}/${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz.0` ... | 分卷文件(默认上传后删除) | +| `${COMMON_TMP_DIR}/${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.sha256` | 校验清单 | + +## 分卷(COMMON_SPLIT_SIZE) + +当单个归档过大、SMB 上传容易中途失败、或目标文件系统有单文件大小限制时启用分卷。**默认 `1G`。** + +```bash +# 配置文件 +COMMON_SPLIT_SIZE="1G" + +# 或命令行 +bash backup.sh smb --split-size 500M + +# 关闭分卷 +bash backup.sh smb --split-size '' +``` + +注意: + +- 分卷需要本地临时空间约为「归档体积 × 2」(先生成完整 `.tar.gz`,再 split)。磁盘紧张时把 `COMMON_TMP_DIR` 指到大盘。 +- 远端清理(`COMMON_RETENTION_DAYS`)按 **目录** 清理:以 `${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS` 命名的目录里所有内容(含 sha256)会一并删除。 + +## 校验与恢复 + +每次备份都会生成 `.sha256` 清单(标准 `sha256sum` 格式:` `),命名都用相对文件名,因此**恢复时进入备份目录直接校验即可**。 + +进入备份目录: + +```bash +cd /path/to/backup-20260426-031000/ +``` + +校验完整性: + +```bash +# Linux +sha256sum -c backup-20260426-031000.sha256 + +# macOS +shasum -a 256 -c backup-20260426-031000.sha256 +``` + +合并分卷并解压(一步到位): + +```bash +cat $(ls *.tar.gz.* | sort -V) | tar -xzf - +``` + +> `sort -V` 是 version-sort(自然数序),可正确排序 `.tar.gz.0 .tar.gz.1 ... .tar.gz.10 .tar.gz.11`。 +> 直接 `cat *.tar.gz.*` 走的是 shell 字典序,会把 `.10` 排在 `.2` 前面,**会损坏归档**。 + +如果只有一个文件(未分卷): + +```bash +tar -xzf backup-20260426-031000.tar.gz +``` + +或者先合并成一个文件再解压: + +```bash +cat $(ls *.tar.gz.* | sort -V) > backup-20260426-031000.tar.gz +tar -xzf backup-20260426-031000.tar.gz +``` ## 注意事项 - 命令行密码会出现在进程列表中,安全敏感场景请优先使用 `backup.conf`,并把权限收紧:`chmod 600 backup.conf`。 - macOS 上若未安装 `smbclient`,会回退到挂载方式(`mount_smbfs`),此时不支持 `--retention` 远端清理,仅完成上传。 -- 远端清理仅清理符合 `${ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz` 命名规范的文件,避免误删其它内容。 +- 远端清理仅清理符合 `${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS` 命名规范的目录,避免误删其它内容。 diff --git a/backup/backup.conf b/backup/backup.conf index b65a9b4..9c76f66 100644 --- a/backup/backup.conf +++ b/backup/backup.conf @@ -1,27 +1,37 @@ # backup.sh 配置文件 -# 所有配置项均可通过命令行参数覆盖(参数优先级高于本文件) +# 所有配置项均可通过命令行参数覆盖(参数优先级高于本文件)。 +# +# 备份方式由命令行第一个位置参数决定: +# bash backup.sh smb -> 使用本文件 SMB 段配置 +# bash backup.sh sftp -> 使用本文件 SFTP 段配置(暂未实现) -# ===== 通用配置 ===== +# ===== 公共配置(COMMON_*)===== # 要备份的源目录或文件(多个用空格分隔,需引号包裹) -SOURCE_PATHS="/etc /var/log" +COMMON_SOURCE_PATHS="/opt" -# 本地临时打包目录(用于存放归档文件,备份完成后会清理) -TMP_DIR="/tmp/backup" +# 本地临时打包目录(用于存放归档/分卷/sha 文件,备份完成后会清理) +COMMON_TMP_DIR="/tmp/backup" -# 归档文件名前缀,最终文件名形如 ${ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz -ARCHIVE_PREFIX="backup" - -# 备份方式:smb / sftp(sftp 暂未实现) -METHOD="smb" +# 归档/远端目录命名前缀,最终形如 ${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS/ +COMMON_ARCHIVE_PREFIX="backup" # 上传完成后是否删除本地归档(true / false) -CLEAN_LOCAL="true" +COMMON_CLEAN_LOCAL="true" -# 远端保留天数,0 表示不清理 -RETENTION_DAYS=15 +# 远端保留天数,0 表示不清理;按 ${COMMON_ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS 目录整体清理 +COMMON_RETENTION_DAYS=15 -# ===== SMB / Samba 配置 ===== +# 分卷大小,默认 1G;留空表示不分卷;非空时形如 1G / 500M / 100k +# 文件名形如 backup-YYYYmmdd-HHMMSS.tar.gz.0 / .1 / .2 ... +# 同目录下还会生成 backup-YYYYmmdd-HHMMSS.sha256(覆盖所有分卷的校验清单) +# +# 恢复(在备份目录里执行): +# sha256sum -c backup-YYYYmmdd-HHMMSS.sha256 # 校验 +# cat $(ls *.tar.gz.* | sort -V) | tar -xzf - # 合并并解压 +COMMON_SPLIT_SIZE="1G" + +# ===== SMB / Samba 配置(仅 bash backup.sh smb 使用)===== # 服务器地址,例如 192.168.1.10 或 nas.local SMB_HOST="" @@ -42,7 +52,7 @@ SMB_DOMAIN="" # SMB 协议版本(可选),例如 3.0 SMB_VERSION="" -# ===== SFTP 配置(预留,暂未实现)===== +# ===== SFTP 配置(仅 bash backup.sh sftp 使用,预留,暂未实现)===== SFTP_HOST="" SFTP_PORT="22" SFTP_USER="" diff --git a/backup/backup.sh b/backup/backup.sh index e280e14..ef38078 100644 --- a/backup/backup.sh +++ b/backup/backup.sh @@ -25,13 +25,15 @@ 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 +# 公共配置(统一以 COMMON_ 前缀,与 backup.conf 一致) +COMMON_SOURCE_PATHS="" +COMMON_TMP_DIR="/tmp/backup" +COMMON_ARCHIVE_PREFIX="backup" +COMMON_CLEAN_LOCAL="true" +COMMON_RETENTION_DAYS=0 +COMMON_SPLIT_SIZE="1G" # 留空 = 不分卷 +# SMB 专属 SMB_HOST="" SMB_SHARE="" SMB_PATH="" @@ -40,6 +42,7 @@ SMB_PASSWORD="" SMB_DOMAIN="" SMB_VERSION="" +# SFTP 专属(预留) SFTP_HOST="" SFTP_PORT="22" SFTP_USER="" @@ -47,9 +50,14 @@ SFTP_PASSWORD="" SFTP_KEY="" SFTP_PATH="" +# 由命令行第一个位置参数决定(smb / sftp),不从 conf 读取 +METHOD="" + OS_NAME="$(uname -s)" -ARCHIVE_FILE="" -ARCHIVE_NAME="" +ARCHIVE_TS="" # 当次备份时间戳,例如 20260426-031000 +ARCHIVE_BASENAME="" # 当次备份的文件夹/文件名,例如 backup-20260426-031000 +ARCHIVE_FILES=() # 上传时遍历的本地路径(分卷 .tar.gz.0/.1/... + .sha256) +SHA_TOOL="" # sha256sum / shasum -a 256 SMB_TOOL="" usage() { @@ -67,6 +75,7 @@ Common options: -p, --prefix NAME 归档文件名前缀(默认:backup) --keep-local 上传后保留本地归档 --retention DAYS 远端保留天数,0 表示不清理 + --split-size SIZE 分卷大小(默认 1G;2G / 500M / 100k 等),传空字符串不分卷 --debug 打印详细调试日志(也可用环境变量 DEBUG=true) -h, --help 显示帮助 @@ -124,18 +133,31 @@ 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" + dbg "COMMON_SOURCE_PATHS=$COMMON_SOURCE_PATHS" + dbg "COMMON_TMP_DIR=$COMMON_TMP_DIR" + dbg "COMMON_ARCHIVE_PREFIX=$COMMON_ARCHIVE_PREFIX" + dbg "COMMON_CLEAN_LOCAL=$COMMON_CLEAN_LOCAL" + dbg "COMMON_RETENTION_DAYS=$COMMON_RETENTION_DAYS" + dbg "COMMON_SPLIT_SIZE=$COMMON_SPLIT_SIZE" + case "$METHOD" in + smb) + 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" + ;; + sftp) + dbg "SFTP_HOST=$SFTP_HOST" + dbg "SFTP_PORT=$SFTP_PORT" + dbg "SFTP_USER=$SFTP_USER" + dbg "SFTP_PASSWORD=$(mask "$SFTP_PASSWORD")" + dbg "SFTP_KEY=$SFTP_KEY" + dbg "SFTP_PATH=$SFTP_PATH" + ;; + esac } load_conf() { @@ -198,11 +220,12 @@ parse_args() { dbg " parse: 当前=$1" 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 ;; + -s|--source) COMMON_SOURCE_PATHS="$2"; shift 2 ;; + -t|--tmp-dir) COMMON_TMP_DIR="$2"; shift 2 ;; + -p|--prefix) COMMON_ARCHIVE_PREFIX="$2"; shift 2 ;; + --keep-local) COMMON_CLEAN_LOCAL="false"; shift ;; + --retention) COMMON_RETENTION_DAYS="$2"; shift 2 ;; + --split-size) COMMON_SPLIT_SIZE="$2"; shift 2 ;; --debug) DEBUG="true"; shift ;; --smb-host) SMB_HOST="$2"; shift 2 ;; --smb-share) SMB_SHARE="$2"; shift 2 ;; @@ -225,23 +248,37 @@ 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; } - - if ! mkdir -p "$TMP_DIR"; then - err "无法创建临时目录:$TMP_DIR" +detect_sha_tool() { + if command -v sha256sum >/dev/null 2>&1; then + SHA_TOOL="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + SHA_TOOL="shasum -a 256" + else + err "找不到 sha256sum / shasum,请安装 coreutils(macOS 自带 shasum)" return 1 fi - dbg "create_archive: 临时目录就绪 $TMP_DIR (空间: $(df -h "$TMP_DIR" 2>/dev/null | tail -1))" + dbg "detect_sha_tool: 使用 $SHA_TOOL" +} - local ts archive - ts="$(date '+%Y%m%d-%H%M%S')" - archive="${TMP_DIR}/${ARCHIVE_PREFIX}-${ts}.tar.gz" - dbg "create_archive: 目标归档=$archive" +create_archive() { + dbg "create_archive: 入口 COMMON_SOURCE_PATHS='$COMMON_SOURCE_PATHS' COMMON_TMP_DIR='$COMMON_TMP_DIR'" + [[ -z "$COMMON_SOURCE_PATHS" ]] && { err "未配置 COMMON_SOURCE_PATHS"; return 1; } + detect_sha_tool || return 1 + + if ! mkdir -p "$COMMON_TMP_DIR"; then + err "无法创建临时目录:$COMMON_TMP_DIR" + return 1 + fi + dbg "create_archive: 临时目录就绪 $COMMON_TMP_DIR (空间: $(df -h "$COMMON_TMP_DIR" 2>/dev/null | tail -1))" + + ARCHIVE_TS="$(date '+%Y%m%d-%H%M%S')" + ARCHIVE_BASENAME="${COMMON_ARCHIVE_PREFIX}-${ARCHIVE_TS}" + local archive="${COMMON_TMP_DIR}/${ARCHIVE_BASENAME}.tar.gz" + local sha_file="${COMMON_TMP_DIR}/${ARCHIVE_BASENAME}.sha256" + dbg "create_archive: 备份名=$ARCHIVE_BASENAME 目标归档=$archive" # shellcheck disable=SC2206 - local paths=($SOURCE_PATHS) + local paths=($COMMON_SOURCE_PATHS) dbg "create_archive: 解析后路径数=${#paths[@]}" local valid=0 for p in "${paths[@]}"; do @@ -268,19 +305,77 @@ create_archive() { 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" + + ARCHIVE_FILES=() + if [[ -n "$COMMON_SPLIT_SIZE" ]]; then + if ! command -v split >/dev/null 2>&1; then + err "未找到 split 命令,无法分卷" + return 1 + fi + log "分卷:单卷上限 $COMMON_SPLIT_SIZE" + # 先用字母后缀切片(split 默认行为,BSD/GNU 都支持),再批量重命名为 + # .tar.gz.0 / .1 / .2 ...,避免直接依赖 split -d(macOS 旧版本不支持)。 + # -a 5 给 26^5 ≈ 1200 万片的余量,1G 单卷可覆盖 ~12PB 总量。 + local tmp_prefix="${archive}.split-tmp." + if ! split -a 5 -b "$COMMON_SPLIT_SIZE" "$archive" "$tmp_prefix"; then + err "split 分卷失败(请检查 COMMON_SPLIT_SIZE 格式,例如 1G / 500M / 100k)" + rm -f "${tmp_prefix}"* + return 1 + fi + rm -f "$archive" + + # 字母后缀按 sort 是单调递增 → 直接顺次映射为 0,1,2,... + local f i=0 + # shellcheck disable=SC2012 + while IFS= read -r f; do + mv "$f" "${archive}.${i}" || { err "重命名分卷失败:$f"; return 1; } + ARCHIVE_FILES+=( "${archive}.${i}" ) + i=$((i+1)) + done < <(ls "${tmp_prefix}"* 2>/dev/null | sort) + + if [[ ${#ARCHIVE_FILES[@]} -eq 0 ]]; then + err "split 后未找到分卷文件(前缀=$tmp_prefix)" + return 1 + fi + ok "已分成 ${#ARCHIVE_FILES[@]} 卷:${ARCHIVE_BASENAME}.tar.gz.0 ... ${ARCHIVE_BASENAME}.tar.gz.$((i-1))" + dbg "分卷列表: ${ARCHIVE_FILES[*]}" + else + ARCHIVE_FILES=( "$archive" ) + fi + + # 生成 sha256 清单文件,路径用相对名(恢复时在备份目录里直接 sha256sum -c) + log "生成 SHA256 清单:$sha_file" + local cwd + cwd="$(pwd)" + cd "$COMMON_TMP_DIR" || { err "无法进入 $COMMON_TMP_DIR"; return 1; } + local rel_files=() + for f in "${ARCHIVE_FILES[@]}"; do + rel_files+=( "$(basename "$f")" ) + done + if ! $SHA_TOOL "${rel_files[@]}" > "$sha_file"; then + err "生成 SHA256 失败" + cd "$cwd" + return 1 + fi + cd "$cwd" + ARCHIVE_FILES+=( "$sha_file" ) + dbg "create_archive: 完整待上传文件列表 (${#ARCHIVE_FILES[@]}):${ARCHIVE_FILES[*]}" + ok "SHA256 清单完成:$sha_file" } 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: COMMON_CLEAN_LOCAL=$COMMON_CLEAN_LOCAL 文件数=${#ARCHIVE_FILES[@]}" + if [[ "$COMMON_CLEAN_LOCAL" != "true" ]]; then dbg "cleanup_local: 跳过删除" + return 0 fi + local p + for p in "${ARCHIVE_FILES[@]}"; do + if [[ -f "$p" ]]; then + rm -f "$p" && log "已删除本地:$p" + fi + done } # ---------- SMB ---------- @@ -325,24 +420,33 @@ smb_validate() { } smb_upload_smbclient() { - dbg "smb_upload_smbclient: 进入" - local remote_dir="${SMB_PATH%/}" + dbg "smb_upload_smbclient: 进入,本次文件数=${#ARCHIVE_FILES[@]} 备份目录=$ARCHIVE_BASENAME" + local remote_base="${SMB_PATH%/}" local commands="" - if [[ -n "$remote_dir" ]]; then + # 逐级 mkdir 共享内的 SMB_PATH(可能不存在) + if [[ -n "$remote_base" ]]; then local IFS=/ # shellcheck disable=SC2206 - local parts=($remote_dir) + local segs=($remote_base) + unset IFS local cur="" - for seg in "${parts[@]}"; do + for seg in "${segs[@]}"; do [[ -z "$seg" ]] && continue cur="${cur}${seg}" commands+="mkdir \"${cur}\";" cur="${cur}/" done - commands+="cd \"${remote_dir}\";" + commands+="cd \"${remote_base}\";" fi - commands+="put \"${ARCHIVE_FILE}\" \"${ARCHIVE_NAME}\";" + # 为本次备份创建独立子目录 + commands+="mkdir \"${ARCHIVE_BASENAME}\";cd \"${ARCHIVE_BASENAME}\";" + + local pp pname + for pp in "${ARCHIVE_FILES[@]}"; do + pname="$(basename "$pp")" + commands+="put \"${pp}\" \"${pname}\";" + done dbg "smb_upload_smbclient: smbclient 命令串=$commands" local args=( "//${SMB_HOST}/${SMB_SHARE}" "-U" "${SMB_USER}%${SMB_PASSWORD}" ) @@ -352,7 +456,7 @@ smb_upload_smbclient() { local sm_debug=() [[ "$DEBUG" == "true" ]] && sm_debug=( "-d" "1" ) - log "通过 smbclient 上传到 //${SMB_HOST}/${SMB_SHARE}/${SMB_PATH}" + log "通过 smbclient 上传到 //${SMB_HOST}/${SMB_SHARE}/${SMB_PATH%/}/${ARCHIVE_BASENAME}/" 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 上传失败" @@ -360,40 +464,46 @@ smb_upload_smbclient() { fi dbg "smb_upload_smbclient: 上传 OK" - if [[ "$RETENTION_DAYS" -gt 0 ]]; then + if [[ "$COMMON_RETENTION_DAYS" -gt 0 ]]; then smb_retention_smbclient "${args[@]}" else - dbg "smb_upload_smbclient: RETENTION_DAYS=0,跳过远端清理" + dbg "smb_upload_smbclient: COMMON_RETENTION_DAYS=0,跳过远端清理" fi } smb_retention_smbclient() { local args=( "$@" ) - dbg "smb_retention_smbclient: RETENTION_DAYS=$RETENTION_DAYS" + dbg "smb_retention_smbclient: COMMON_RETENTION_DAYS=$COMMON_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) + cutoff=$(date -v-"${COMMON_RETENTION_DAYS}"d +%s) else - cutoff=$(date -d "-${RETENTION_DAYS} days" +%s) + cutoff=$(date -d "-${COMMON_RETENTION_DAYS} days" +%s) fi - log "清理远端早于 ${RETENTION_DAYS} 天的归档" + log "清理远端早于 ${COMMON_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#* }" + + # smbclient ls 输出形如: name FLAGS size weekday mon dd hh:mm:ss yyyy + # 目录会带 D 标志;这里直接以名字模式 + D 标志双重匹配,减少误删风险。 + echo "$listing" | awk '$2 ~ /D/ {print $1}' | grep -E "^${COMMON_ARCHIVE_PREFIX}-[0-9]{8}-[0-9]{6}\$" | while read -r dir; do + local d="${dir#${COMMON_ARCHIVE_PREFIX}-}" + d="${d%%-*}" # YYYYmmdd + local t="${dir##*-}" # HHMMSS local iso="${d:0:4}-${d:4:2}-${d:6:2} ${t:0:2}:${t:2:2}:${t:4:2}" + local ftime 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) else ftime=$(date -d "$iso" +%s) fi - dbg " 远端文件 $f -> ftime=$ftime" + dbg " 远端目录 $dir -> ftime=$ftime" if [[ "$ftime" -lt "$cutoff" ]]; then - log "删除远端旧归档:$f" - smbclient "${args[@]}" -D "${SMB_PATH:-/}" -c "del \"$f\"" >/dev/null 2>&1 || warn "删除失败:$f" + log "删除远端旧备份目录:$dir" + # 先清空目录内文件,再 rmdir + smbclient "${args[@]}" -D "${SMB_PATH:-/}/${dir}" -c "prompt OFF; mask *; del *" >/dev/null 2>&1 + smbclient "${args[@]}" -D "${SMB_PATH:-/}" -c "rmdir \"${dir}\"" >/dev/null 2>&1 || warn "rmdir 失败:$dir" fi done } @@ -411,17 +521,26 @@ smb_upload_mount() { local dest="$mnt" if [[ -n "$SMB_PATH" ]]; then dest="$mnt/$SMB_PATH" - mkdir -p "$dest" || true fi - dbg "smb_upload_mount: 目标 $dest" - if cp "$ARCHIVE_FILE" "$dest/"; then - ok "已复制到 $dest/" - else - err "复制失败" + dest="$dest/$ARCHIVE_BASENAME" + if ! mkdir -p "$dest"; then + err "无法创建远端目录:$dest" umount "$mnt" 2>/dev/null rmdir "$mnt" return 1 fi + dbg "smb_upload_mount: 目标 $dest,待上传=${#ARCHIVE_FILES[@]}" + local pp + for pp in "${ARCHIVE_FILES[@]}"; do + log "复制:$(basename "$pp")" + if ! cp "$pp" "$dest/"; then + err "复制失败:$pp" + umount "$mnt" 2>/dev/null + rmdir "$mnt" + return 1 + fi + done + ok "已复制全部 ${#ARCHIVE_FILES[@]} 个文件到 $dest/" umount "$mnt" 2>/dev/null rmdir "$mnt" }