diff --git a/backup/README.md b/backup/README.md new file mode 100644 index 0000000..d9f4eee --- /dev/null +++ b/backup/README.md @@ -0,0 +1,179 @@ +# backup.sh + +把指定目录打包成 `tar.gz`,再通过 SMB / Samba 上传到远端服务器。同时支持 **Linux** 与 **macOS**。 + +> SFTP 等其它方式已在脚本中预留入口(`run_sftp`、`SFTP_*` 配置),目前未实现。 + +## 系统要求 + +- bash 3.2+ / tar / date +- Linux:`smbclient`(包含在 `smbclient` 或 `samba-client` 包中) +- macOS:`smbclient`(推荐,`brew install samba`),或退回到系统自带的 `mount_smbfs` + +### 安装依赖 + +| 系统 | 命令 | +| --- | --- | +| Debian / Ubuntu | `apt install -y smbclient` | +| RHEL / Rocky / Alma | `dnf install -y samba-client` | +| Arch | `pacman -S smbclient` | +| macOS | `brew install samba`(可选,无则使用 `mount_smbfs`) | + +## 文件 + +``` +backup/ +├── backup.sh # 主脚本 +├── backup.conf # 配置文件(与脚本同目录) +└── README.md +``` + +## 下载到本地 + +为避免后续脚本更新导致 `bash <(curl ...)` 形式的执行失效(参数变化、行为不兼容等),建议先下载到本地再执行: + +```bash +# 创建目录并下载脚本与示例配置 +mkdir -p ~/backup && cd ~/backup +curl -fsSL -o backup.sh https://git.suhang.me/suhang/scripts/raw/branch/release/backup/backup.sh +curl -fsSL -o backup.conf https://git.suhang.me/suhang/scripts/raw/branch/release/backup/backup.conf +chmod +x backup.sh +chmod 600 backup.conf + +# 编辑 backup.conf 后执行 +bash backup.sh smb +``` + +也可改用 `wget`: + +```bash +wget -qO backup.sh https://git.suhang.me/suhang/scripts/raw/branch/release/backup/backup.sh +wget -qO backup.conf https://git.suhang.me/suhang/scripts/raw/branch/release/backup/backup.conf +``` + +## 使用方式 + +```bash +bash backup.sh [options] +``` + +`method` 取值: + +| 方式 | 说明 | +| --- | --- | +| `smb` | 通过 SMB / Samba 上传 | +| `sftp` | 已预留入口,暂未实现 | + +最常见的用法是先编辑同目录下的 `backup.conf`,然后执行: + +```bash +bash backup.sh smb +``` + +## 配置文件 `backup.conf` + +脚本默认读取 **脚本所在目录** 下的 `backup.conf`,可用 `-C` / `--config` 指定其它路径。 + +| 字段 | 说明 | +| --- | --- | +| `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_*` | 预留字段,目前未启用 | + +## 命令行参数 + +所有 `backup.conf` 中的字段均可通过命令行参数覆盖;命令行参数优先级最高。 + +### 通用 + +| 参数 | 对应配置 | +| --- | --- | +| `-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` | +| `-h, --help` | 显示帮助 | + +### SMB + +| 参数 | 对应配置 | +| --- | --- | +| `--smb-host HOST` | `SMB_HOST` | +| `--smb-share NAME` | `SMB_SHARE` | +| `--smb-path PATH` | `SMB_PATH` | +| `--smb-user USER` | `SMB_USER` | +| `--smb-password PASS` | `SMB_PASSWORD` | +| `--smb-domain DOMAIN` | `SMB_DOMAIN` | +| `--smb-version VER` | `SMB_VERSION` | + +### SFTP(预留) + +`--sftp-host` `--sftp-port` `--sftp-user` `--sftp-password` `--sftp-key` `--sftp-path` + +## 示例 + +完全依赖配置文件: + +```bash +bash backup.sh smb +``` + +只用命令行参数(不依赖 `backup.conf`): + +```bash +bash backup.sh smb \ + -s "/etc /var/log /home/user/data" \ + --smb-host 192.168.1.10 \ + --smb-share backup \ + --smb-path servers/web1 \ + --smb-user backup \ + --smb-password 'P@ssw0rd' \ + --retention 30 +``` + +混合使用(基础信息读 `backup.conf`,源路径临时覆盖): + +```bash +bash backup.sh smb -s "/var/lib/mysql" +``` + +## 定时任务 + +使用 `>`(单箭头)覆盖写入日志,避免日志文件无限增长——只保留最近一次执行的日志: + +```cron +# 每天 03:10 跑一次 +10 3 * * * /bin/bash /path/to/backup/backup.sh smb > /var/log/backup.log 2>&1 + +# 每天 03:17 跑一次 +17 3 * * * /bin/bash /path/to/backup/backup.sh smb > /var/log/backup.log 2>&1 +``` + +> 如果有多条任务都写到同一个日志文件,请改用不同的日志路径(例如 `/var/log/backup-0310.log`、`/var/log/backup-0317.log`),否则后一次会覆盖前一次。 + +macOS 可用 `launchd` 或 `cron`(需要在「系统设置 → 隐私与安全性 → 完全磁盘访问权限」中授予 `cron` 权限以读取受保护目录)。 + +## 路径与文件 + +| 路径 | 说明 | +| --- | --- | +| `${TMP_DIR}/${ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz` | 本地临时归档(默认上传后删除) | +| 远端 `//${SMB_HOST}/${SMB_SHARE}/${SMB_PATH}/` | 上传目标目录 | + +## 注意事项 + +- 命令行密码会出现在进程列表中,安全敏感场景请优先使用 `backup.conf`,并把权限收紧:`chmod 600 backup.conf`。 +- macOS 上若未安装 `smbclient`,会回退到挂载方式(`mount_smbfs`),此时不支持 `--retention` 远端清理,仅完成上传。 +- 远端清理仅清理符合 `${ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz` 命名规范的文件,避免误删其它内容。 diff --git a/backup/backup.conf b/backup/backup.conf new file mode 100644 index 0000000..b65a9b4 --- /dev/null +++ b/backup/backup.conf @@ -0,0 +1,51 @@ +# backup.sh 配置文件 +# 所有配置项均可通过命令行参数覆盖(参数优先级高于本文件) + +# ===== 通用配置 ===== + +# 要备份的源目录或文件(多个用空格分隔,需引号包裹) +SOURCE_PATHS="/etc /var/log" + +# 本地临时打包目录(用于存放归档文件,备份完成后会清理) +TMP_DIR="/tmp/backup" + +# 归档文件名前缀,最终文件名形如 ${ARCHIVE_PREFIX}-YYYYmmdd-HHMMSS.tar.gz +ARCHIVE_PREFIX="backup" + +# 备份方式:smb / sftp(sftp 暂未实现) +METHOD="smb" + +# 上传完成后是否删除本地归档(true / false) +CLEAN_LOCAL="true" + +# 远端保留天数,0 表示不清理 +RETENTION_DAYS=15 + +# ===== SMB / Samba 配置 ===== + +# 服务器地址,例如 192.168.1.10 或 nas.local +SMB_HOST="" + +# 共享名,例如 backup +SMB_SHARE="" + +# 共享内的子目录(可选),例如 servers/web1 +SMB_PATH="" + +# 用户名 / 密码 +SMB_USER="" +SMB_PASSWORD="" + +# 域 / 工作组(可选) +SMB_DOMAIN="" + +# SMB 协议版本(可选),例如 3.0 +SMB_VERSION="" + +# ===== SFTP 配置(预留,暂未实现)===== +SFTP_HOST="" +SFTP_PORT="22" +SFTP_USER="" +SFTP_PASSWORD="" +SFTP_KEY="" +SFTP_PATH="" diff --git a/backup/backup.sh b/backup/backup.sh new file mode 100644 index 0000000..2ac5cb2 --- /dev/null +++ b/backup/backup.sh @@ -0,0 +1,345 @@ +#!/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 "$@"