Last active
July 6, 2026 10:03
-
-
Save Yunuuuu/dee09540eb5335bf1b023385a9b77b5c to your computer and use it in GitHub Desktop.
面向弱网和不稳定 SSH 连接的 rsync 自动重试脚本, 支持无限重连, SSH 配置与 ssh-agent, 以及可选临时 SSH 密钥.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # rsync-resume 是一个面向不稳定网络环境的 rsync 传输包装脚本。 | |
| # | |
| # 它负责在 rsync 因网络、SSH 或协议连接问题中断后自动等待并重新执行传输, | |
| # 适合跨公网、弱网、高延迟或连接容易中断的服务器之间进行长时间数据同步。 | |
| # | |
| # 脚本本身不会为 rsync 添加默认同步参数。所有归档、删除、压缩、断点文件、 | |
| # 进度显示、限速和排除规则等传输行为, 均由用户直接通过 rsync 参数控制。 | |
| # | |
| # 对于 SSH 远程传输, 脚本默认使用现有 SSH 配置、IdentityFile 或 ssh-agent | |
| # 进行非交互认证。用户也可以显式启用临时 SSH 密钥模式, 在满足服务器允许 | |
| # 公钥认证且当前用户可以维护 authorized_keys 的情况下, 为本次传输创建临时 | |
| # 密钥, 并在脚本退出时尝试自动撤销。 | |
| # | |
| # 主要功能: | |
| # | |
| # 网络或 SSH 中断后自动重试 rsync | |
| # 默认无限重试, 可配置重试次数和重试间隔 | |
| # 支持本地到本地、本地到远程、远程到本地 | |
| # 尊重 ~/.ssh/config、IdentityFile 和 ssh-agent | |
| # 可选临时 SSH 密钥,并在退出时尝试自动撤销 | |
| # SSH 连接超时和 ServerAlive 参数可配置 | |
| # wrapper 参数支持环境变量和命令行覆盖 | |
| # rsync 参数原样透传, 不注入默认同步选项 | |
| # EXIT、Ctrl+C、SIGTERM 和 SIGHUP 时统一执行清理 | |
| # | |
| # 安装: | |
| # mkdir -p "$HOME/.local/bin" | |
| # curl -fsSL 'https://gist.githubusercontent.com/Yunuuuu/dee09540eb5335bf1b023385a9b77b5c/raw/rsync-resume.sh' -o "$HOME/.local/bin/rsync-resume" | |
| # chmod 0755 "$HOME/.local/bin/rsync-resume" | |
| # | |
| # 卸载: | |
| # rm -f "$HOME/.local/bin/rsync-resume" | |
| # | |
| set -Eeuo pipefail | |
| PROGRAM_NAME=${0##*/} | |
| SOURCE='' | |
| DESTINATION='' | |
| REMOTE_CONN='' | |
| TMP_DIR='' | |
| TMP_KEY='' | |
| KEY_TYPE='' | |
| KEY_BLOB='' | |
| KEY_MARKER='' | |
| TEMP_KEY_INSTALLED=0 | |
| TEMP_KEY_MODE='no' | |
| PORT_WAS_SET=0 | |
| REMOTE_PORT='' | |
| SSH_PORT_LABEL='SSH config/default' | |
| if [[ ${PORT+x} ]]; then | |
| PORT_WAS_SET=1 | |
| REMOTE_PORT=$PORT | |
| SSH_PORT_LABEL=$PORT | |
| fi | |
| RETRY_DELAY=${RETRY_DELAY:-30} | |
| MAX_RETRIES=${MAX_RETRIES:-0} | |
| CONNECT_TIMEOUT=${CONNECT_TIMEOUT:-15} | |
| SERVER_ALIVE_INTERVAL=${SERVER_ALIVE_INTERVAL:-15} | |
| SERVER_ALIVE_COUNT_MAX=${SERVER_ALIVE_COUNT_MAX:-3} | |
| CLEANUP_RETRIES=${CLEANUP_RETRIES:-3} | |
| CLEANUP_RETRY_DELAY=${CLEANUP_RETRY_DELAY:-2} | |
| BOOTSTRAP_SSH_OPTS=() | |
| TEMP_SSH_OPTS=() | |
| EXISTING_SSH_OPTS=() | |
| SSH_RSH='' | |
| RSYNC_EXTRA_ARGS=() | |
| usage() { | |
| cat <<EOF | |
| 面向弱网和不稳定 SSH 连接的 rsync 自动重试脚本, 支持无限重连、SSH 配置与 ssh-agent, 以及可选临时 SSH 密钥。 | |
| homepage: https://gist.github.com/Yunuuuu/dee09540eb5335bf1b023385a9b77b5c | |
| 用法: | |
| $PROGRAM_NAME [脚本参数...] <源路径> <目的路径> [rsync 参数...] | |
| $PROGRAM_NAME [脚本参数...] <源路径> <目的路径> -- [rsync 参数...] | |
| 脚本参数可放在源路径和目的路径之前、之间或之后。 | |
| 遇到 -- 后停止解析脚本参数,后续参数全部原样传递给 rsync。 | |
| 支持: | |
| 本地 -> 本地 | |
| 本地 -> SSH 远程 | |
| SSH 远程 -> 本地 | |
| 示例: | |
| $PROGRAM_NAME /data/src/ /data/dst/ -- -avhP | |
| $PROGRAM_NAME /data/src/ /data/dst/ -- --delete --exclude='*.tmp' --bwlimit=50M | |
| $PROGRAM_NAME /data/src/ user@example.com:/data/dst/ -- -avhP | |
| $PROGRAM_NAME user@example.com:/data/src/ /data/dst/ -- -avhP | |
| $PROGRAM_NAME /data/src/ user@example.com:/data/dst/ --port=49001 --max-retries=100 -- -avhP | |
| $PROGRAM_NAME /data/src/ user@example.com:/data/dst/ --temp-key=yes --retry-delay=10 -- --delete --dry-run | |
| 配置优先级: | |
| 命令行参数 > 环境变量 > 脚本默认值 | |
| 脚本参数: | |
| -h, --help 输出帮助文档并退出 | |
| --temp-key=yes|no 是否创建临时 SSH 密钥; 默认 no | |
| --port=N 对应 PORT; 未设置时尊重 SSH config/默认端口 | |
| --retry-delay=N 对应 RETRY_DELAY; 默认 30 秒 | |
| --max-retries=N 对应 MAX_RETRIES; 默认 0; 0 表示无限重试 | |
| --connect-timeout=N 对应 CONNECT_TIMEOUT; 默认 15 秒 | |
| --server-alive-interval=N 对应 SERVER_ALIVE_INTERVAL; 默认 15 秒 | |
| --server-alive-count-max=N 对应 SERVER_ALIVE_COUNT_MAX; 默认 3 | |
| --cleanup-retries=N 对应 CLEANUP_RETRIES; 默认 3 | |
| --cleanup-retry-delay=N 对应 CLEANUP_RETRY_DELAY; 默认 2 秒 | |
| 上述参数也支持空格写法, 例如: | |
| --port 49001 | |
| --max-retries 100 | |
| --temp-key yes | |
| 说明: | |
| --temp-key=yes 创建并安装临时 SSH 密钥,退出时尝试自动撤销。 | |
| --temp-key=no 不创建临时密钥, 只使用现有 SSH 非交互认证(默认)。 | |
| 其余参数会原样传递给 rsync; 本脚本不为 rsync 添加 -a/-v/-h/--progress/--partial 等默认参数。 | |
| -- 可作为本脚本与 rsync 参数的可选分隔符, 不会传递给 rsync。 | |
| 使用 -- 后, 后续所有参数都只传给 rsync, 不再解析本脚本参数。 | |
| SSH 认证说明: | |
| 默认使用 --temp-key=no, 要求现有 SSH 非交互认证可用。 | |
| 如果私钥带有 passphrase, 请先将密钥加入 ssh-agent: | |
| ssh-add ~/.ssh/id_ed25519 | |
| 如果 ~/.ssh/config 的 IdentityFile 使用自定义密钥, 例如 ~/.ssh/backup_ed25519: | |
| ssh-add ~/.ssh/backup_ed25519 | |
| 建议先验证非交互登录: | |
| ssh -o BatchMode=yes user@server true | |
| 如果当前只能通过密码完成首次登录, 但服务器允许公钥认证, 且当前用户 | |
| 可以维护 ~/.ssh/authorized_keys, 可显式使用 --temp-key=yes。脚本会 | |
| 创建临时 SSH 密钥、安装公钥, 并在退出时尝试自动撤销。 | |
| 如果服务器明确禁用公钥认证, 则 --temp-key=yes 无效。本脚本不会保存、 | |
| 传递或自动重复输入 SSH 密码, 因此无法为这种服务器提供无人值守的无限重连。 | |
| 环境变量: | |
| PORT=<未设置> 未设置时尊重 SSH config/默认端口; 显式设置时覆盖 SSH config 的 Port | |
| RETRY_DELAY=30 可重试错误后的等待秒数 | |
| MAX_RETRIES=0 最大重试次数; 0 表示无限重试 | |
| CONNECT_TIMEOUT=15 SSH 连接超时秒数 | |
| SERVER_ALIVE_INTERVAL=15 SSH ServerAliveInterval | |
| SERVER_ALIVE_COUNT_MAX=3 SSH ServerAliveCountMax | |
| CLEANUP_RETRIES=3 临时密钥撤销最大尝试次数 | |
| CLEANUP_RETRY_DELAY=2 临时密钥撤销重试等待秒数 | |
| EOF | |
| } | |
| now() { | |
| date '+%Y-%m-%d %H:%M:%S' | |
| } | |
| log_info() { | |
| printf '[%s] %s\n' "$(now)" "$*" | |
| } | |
| log_warn() { | |
| printf '[%s] 警告: %s\n' "$(now)" "$*" >&2 | |
| } | |
| log_error() { | |
| printf '[%s] 错误: %s\n' "$(now)" "$*" >&2 | |
| } | |
| usage_error() { | |
| log_error "$*" | |
| printf '\n' >&2 | |
| usage >&2 | |
| exit 64 | |
| } | |
| fatal() { | |
| log_error "$*" | |
| exit 1 | |
| } | |
| validate_uint() { | |
| local name=$1 value=$2 min=$3 max=$4 number | |
| [[ "$value" =~ ^[0-9]+$ ]] || fatal "$name 必须是非负整数, 当前值: $value" | |
| number=$((10#$value)) | |
| (( number >= min )) || fatal "$name 不能小于 $min, 当前值: $value" | |
| if (( max >= 0 && number > max )); then | |
| fatal "$name 不能大于 $max, 当前值: $value" | |
| fi | |
| } | |
| validate_temp_key_mode() { | |
| case "$TEMP_KEY_MODE" in | |
| yes|no) return 0 ;; | |
| *) usage_error "--temp-key 只接受 yes 或 no, 当前值: $TEMP_KEY_MODE" ;; | |
| esac | |
| } | |
| require_command() { | |
| local command_name=$1 | |
| command -v "$command_name" >/dev/null 2>&1 || fatal "缺少必需命令: $command_name" | |
| } | |
| is_rsync_daemon_path() { | |
| local path=$1 | |
| [[ "$path" == rsync://* ]] || [[ "$path" =~ ^([^/@:]+@)?(\[[^]]+\]|[^/:]+):: ]] | |
| } | |
| is_remote_shell_path() { | |
| local path=$1 | |
| is_rsync_daemon_path "$path" && return 1 | |
| [[ "$path" =~ ^([^/@:]+@)?(\[[^]]+\]|[^/:]+): ]] | |
| } | |
| extract_remote_conn() { | |
| local path=$1 | |
| if [[ "$path" =~ ^(([^/@:]+@)?(\[[^]]+\]|[^/:]+)): ]]; then | |
| printf '%s\n' "${BASH_REMATCH[1]}" | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| rsync_exit_description() { | |
| case "$1" in | |
| 0) printf '成功' ;; | |
| 1) printf '语法或用法错误' ;; | |
| 2) printf '协议不兼容' ;; | |
| 3) printf '选择输入/输出文件或目录时出错' ;; | |
| 4) printf '请求的操作不受支持' ;; | |
| 5) printf '启动客户端/服务器协议失败' ;; | |
| 6) printf 'daemon 无法追加日志' ;; | |
| 10) printf 'socket I/O 错误' ;; | |
| 11) printf '文件 I/O 错误' ;; | |
| 12) printf 'rsync 协议数据流错误' ;; | |
| 13) printf '程序诊断数据错误' ;; | |
| 14) printf 'IPC 错误' ;; | |
| 20) printf '收到 SIGUSR1 或 SIGINT' ;; | |
| 21) printf 'waitpid 返回错误' ;; | |
| 22) printf '分配核心内存缓冲区失败' ;; | |
| 23) printf '因错误导致部分传输' ;; | |
| 24) printf '源文件消失导致部分传输' ;; | |
| 25) printf '达到 --max-delete 限制' ;; | |
| 30) printf '数据发送/接收超时' ;; | |
| 35) printf '等待 daemon 连接超时' ;; | |
| 255) printf 'SSH/远程 shell 连接错误' ;; | |
| *) printf '未知错误' ;; | |
| esac | |
| } | |
| is_retryable_rsync_exit() { | |
| case "$1" in | |
| 5|10|12|30|35|255) return 0 ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| install_temp_key() { | |
| local remote_command | |
| remote_command="sh -s -- '$KEY_TYPE' '$KEY_BLOB' '$KEY_MARKER'" | |
| log_info "正在向 $REMOTE_CONN 安装本次传输的临时公钥..." | |
| if ! ssh "${BOOTSTRAP_SSH_OPTS[@]}" "$REMOTE_CONN" "$remote_command" <<'REMOTE_INSTALL' | |
| set -eu | |
| key_type=$1 | |
| key_blob=$2 | |
| key_marker=$3 | |
| ssh_dir=$HOME/.ssh | |
| authorized_keys=$ssh_dir/authorized_keys | |
| umask 077 | |
| mkdir -p "$ssh_dir" | |
| chmod 700 "$ssh_dir" | |
| touch "$authorized_keys" | |
| chmod 600 "$authorized_keys" | |
| if awk -v blob="$key_blob" ' | |
| { | |
| for (i = 1; i <= NF; i++) { | |
| if ($i == blob) { | |
| found = 1 | |
| break | |
| } | |
| } | |
| } | |
| END { exit(found ? 0 : 1) } | |
| ' "$authorized_keys"; then | |
| exit 0 | |
| fi | |
| printf '%s %s %s\n' "$key_type" "$key_blob" "$key_marker" >> "$authorized_keys" | |
| REMOTE_INSTALL | |
| then | |
| fatal "临时公钥安装失败。请检查 SSH 登录、远端 HOME/.ssh 权限和 authorized_keys 配置。" | |
| fi | |
| TEMP_KEY_INSTALLED=1 | |
| } | |
| revoke_temp_key_once() { | |
| local remote_command | |
| remote_command="sh -s -- '$KEY_BLOB'" | |
| ssh "${TEMP_SSH_OPTS[@]}" "$REMOTE_CONN" "$remote_command" <<'REMOTE_CLEANUP' | |
| set -eu | |
| key_blob=$1 | |
| authorized_keys=$HOME/.ssh/authorized_keys | |
| [ -f "$authorized_keys" ] || exit 0 | |
| umask 077 | |
| # rsync-cleanup: exact key-blob removal; the marker is for test/audit visibility. | |
| tmp_file=${authorized_keys}.rsync-cleanup.$$ | |
| trap 'rm -f "$tmp_file"' EXIT HUP INT TERM | |
| awk -v blob="$key_blob" ' | |
| { | |
| remove = 0 | |
| for (i = 1; i <= NF; i++) { | |
| if ($i == blob) { | |
| remove = 1 | |
| break | |
| } | |
| } | |
| if (!remove) print | |
| } | |
| ' "$authorized_keys" > "$tmp_file" | |
| chmod 600 "$tmp_file" | |
| mv -f "$tmp_file" "$authorized_keys" | |
| trap - EXIT HUP INT TERM | |
| REMOTE_CLEANUP | |
| } | |
| revoke_temp_key() { | |
| local attempt=1 | |
| while (( attempt <= CLEANUP_RETRIES )); do | |
| if revoke_temp_key_once; then | |
| return 0 | |
| fi | |
| if (( attempt < CLEANUP_RETRIES )); then | |
| log_warn "临时密钥撤销失败, 第 $attempt/$CLEANUP_RETRIES 次; ${CLEANUP_RETRY_DELAY} 秒后重试..." | |
| sleep "$CLEANUP_RETRY_DELAY" | |
| fi | |
| attempt=$((attempt + 1)) | |
| done | |
| return 1 | |
| } | |
| cleanup() { | |
| local original_status=$? | |
| trap - EXIT INT TERM HUP | |
| set +e | |
| if (( TEMP_KEY_INSTALLED == 1 )); then | |
| if revoke_temp_key; then | |
| log_info "临时密钥已撤销。" | |
| TEMP_KEY_INSTALLED=0 | |
| else | |
| log_error "安全警告: 无法从 $REMOTE_CONN 撤销临时密钥!" | |
| log_error "请恢复网络后手工删除 authorized_keys 中标记为 '$KEY_MARKER' 的公钥。" | |
| fi | |
| fi | |
| if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then | |
| rm -rf -- "$TMP_DIR" | |
| fi | |
| exit "$original_status" | |
| } | |
| run_rsync_with_retry() { | |
| local retries=0 status description | |
| local -a command=(rsync) | |
| command+=("${RSYNC_EXTRA_ARGS[@]}") | |
| # 远程模式下将脚本管理的 SSH transport 放在透传参数之后。 | |
| # rsync 的 -e/--rsh 写入同一个 shell_cmd 选项; 后出现的值生效。 | |
| if [[ -n "$REMOTE_CONN" ]]; then | |
| command+=(-e "$SSH_RSH") | |
| fi | |
| command+=(-- "$SOURCE" "$DESTINATION") | |
| while true; do | |
| if "${command[@]}"; then | |
| return 0 | |
| else | |
| status=$? | |
| fi | |
| description=$(rsync_exit_description "$status") | |
| if ! is_retryable_rsync_exit "$status"; then | |
| log_error "rsync 不可重试错误: exit=$status ($description)。" | |
| return "$status" | |
| fi | |
| if (( MAX_RETRIES > 0 && retries >= MAX_RETRIES )); then | |
| log_error "rsync 可重试错误仍未恢复: exit=$status ($description); 达到最大重试次数 $MAX_RETRIES。" | |
| return "$status" | |
| fi | |
| retries=$((retries + 1)) | |
| if (( MAX_RETRIES == 0 )); then | |
| log_warn "rsync 可重试错误: exit=$status ($description); ${RETRY_DELAY} 秒后进行第 $retries 次重试(无限模式)..." | |
| else | |
| log_warn "rsync 可重试错误: exit=$status ($description); ${RETRY_DELAY} 秒后进行第 $retries/$MAX_RETRIES 次重试..." | |
| fi | |
| sleep "$RETRY_DELAY" | |
| done | |
| } | |
| main() { | |
| local source_is_remote=0 destination_is_remote=0 | |
| local -a positional_args=() | |
| RSYNC_EXTRA_ARGS=() | |
| while (( $# > 0 )); do | |
| case "$1" in | |
| -h|--help) | |
| usage | |
| return 0 | |
| ;; | |
| --) | |
| shift | |
| RSYNC_EXTRA_ARGS+=("$@") | |
| break | |
| ;; | |
| --temp-key=*) | |
| TEMP_KEY_MODE=${1#*=} | |
| shift | |
| ;; | |
| --temp-key) | |
| (( $# >= 2 )) || usage_error '--temp-key 缺少 yes 或 no 参数。' | |
| TEMP_KEY_MODE=$2 | |
| shift 2 | |
| ;; | |
| --port=*) | |
| PORT_WAS_SET=1 | |
| REMOTE_PORT=${1#*=} | |
| SSH_PORT_LABEL=$REMOTE_PORT | |
| shift | |
| ;; | |
| --port) | |
| (( $# >= 2 )) || usage_error '--port 缺少端口号。' | |
| PORT_WAS_SET=1 | |
| REMOTE_PORT=$2 | |
| SSH_PORT_LABEL=$REMOTE_PORT | |
| shift 2 | |
| ;; | |
| --retry-delay=*) | |
| RETRY_DELAY=${1#*=} | |
| shift | |
| ;; | |
| --retry-delay) | |
| (( $# >= 2 )) || usage_error '--retry-delay 缺少秒数。' | |
| RETRY_DELAY=$2 | |
| shift 2 | |
| ;; | |
| --max-retries=*) | |
| MAX_RETRIES=${1#*=} | |
| shift | |
| ;; | |
| --max-retries) | |
| (( $# >= 2 )) || usage_error '--max-retries 缺少次数。' | |
| MAX_RETRIES=$2 | |
| shift 2 | |
| ;; | |
| --connect-timeout=*) | |
| CONNECT_TIMEOUT=${1#*=} | |
| shift | |
| ;; | |
| --connect-timeout) | |
| (( $# >= 2 )) || usage_error '--connect-timeout 缺少秒数。' | |
| CONNECT_TIMEOUT=$2 | |
| shift 2 | |
| ;; | |
| --server-alive-interval=*) | |
| SERVER_ALIVE_INTERVAL=${1#*=} | |
| shift | |
| ;; | |
| --server-alive-interval) | |
| (( $# >= 2 )) || usage_error '--server-alive-interval 缺少秒数。' | |
| SERVER_ALIVE_INTERVAL=$2 | |
| shift 2 | |
| ;; | |
| --server-alive-count-max=*) | |
| SERVER_ALIVE_COUNT_MAX=${1#*=} | |
| shift | |
| ;; | |
| --server-alive-count-max) | |
| (( $# >= 2 )) || usage_error '--server-alive-count-max 缺少次数。' | |
| SERVER_ALIVE_COUNT_MAX=$2 | |
| shift 2 | |
| ;; | |
| --cleanup-retries=*) | |
| CLEANUP_RETRIES=${1#*=} | |
| shift | |
| ;; | |
| --cleanup-retries) | |
| (( $# >= 2 )) || usage_error '--cleanup-retries 缺少次数。' | |
| CLEANUP_RETRIES=$2 | |
| shift 2 | |
| ;; | |
| --cleanup-retry-delay=*) | |
| CLEANUP_RETRY_DELAY=${1#*=} | |
| shift | |
| ;; | |
| --cleanup-retry-delay) | |
| (( $# >= 2 )) || usage_error '--cleanup-retry-delay 缺少秒数。' | |
| CLEANUP_RETRY_DELAY=$2 | |
| shift 2 | |
| ;; | |
| *) | |
| if (( ${#positional_args[@]} < 2 )); then | |
| positional_args+=("$1") | |
| else | |
| RSYNC_EXTRA_ARGS+=("$1") | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| if (( ${#positional_args[@]} < 2 )); then | |
| usage | |
| exit 64 | |
| fi | |
| SOURCE=${positional_args[0]} | |
| DESTINATION=${positional_args[1]} | |
| validate_temp_key_mode | |
| [[ -n "$SOURCE" ]] || usage_error '源路径不能为空。' | |
| [[ -n "$DESTINATION" ]] || usage_error '目的路径不能为空。' | |
| if (( PORT_WAS_SET == 1 )); then | |
| validate_uint PORT "$REMOTE_PORT" 1 65535 | |
| fi | |
| validate_uint RETRY_DELAY "$RETRY_DELAY" 0 -1 | |
| validate_uint MAX_RETRIES "$MAX_RETRIES" 0 -1 | |
| validate_uint CONNECT_TIMEOUT "$CONNECT_TIMEOUT" 1 -1 | |
| validate_uint SERVER_ALIVE_INTERVAL "$SERVER_ALIVE_INTERVAL" 1 -1 | |
| validate_uint SERVER_ALIVE_COUNT_MAX "$SERVER_ALIVE_COUNT_MAX" 1 -1 | |
| validate_uint CLEANUP_RETRIES "$CLEANUP_RETRIES" 1 -1 | |
| validate_uint CLEANUP_RETRY_DELAY "$CLEANUP_RETRY_DELAY" 0 -1 | |
| require_command rsync | |
| if is_rsync_daemon_path "$SOURCE" || is_rsync_daemon_path "$DESTINATION"; then | |
| usage_error '本脚本不支持 rsync daemon(host::module 或 rsync://)模式; 临时 SSH 密钥流程只适用于 SSH remote-shell 模式。' | |
| fi | |
| is_remote_shell_path "$SOURCE" && source_is_remote=1 | |
| is_remote_shell_path "$DESTINATION" && destination_is_remote=1 | |
| if (( source_is_remote == 1 && destination_is_remote == 1 )); then | |
| usage_error 'rsync 不支持两个远程端点之间直接复制。请至少让源或目的其中一端为本机。' | |
| fi | |
| if (( source_is_remote == 0 && destination_is_remote == 0 )); then | |
| log_info '检测到纯本地传输, 开始 rsync 传输。' | |
| run_rsync_with_retry | |
| log_info '✨ 本地数据传输完成!' | |
| return 0 | |
| fi | |
| require_command ssh | |
| if (( source_is_remote == 1 )); then | |
| REMOTE_CONN=$(extract_remote_conn "$SOURCE") || fatal '无法解析远程源连接信息。' | |
| else | |
| REMOTE_CONN=$(extract_remote_conn "$DESTINATION") || fatal '无法解析远程目的连接信息。' | |
| fi | |
| EXISTING_SSH_OPTS=() | |
| if (( PORT_WAS_SET == 1 )); then | |
| EXISTING_SSH_OPTS+=(-p "$REMOTE_PORT") | |
| fi | |
| EXISTING_SSH_OPTS+=( | |
| -o BatchMode=yes | |
| -o "ConnectTimeout=$CONNECT_TIMEOUT" | |
| -o "ServerAliveInterval=$SERVER_ALIVE_INTERVAL" | |
| -o "ServerAliveCountMax=$SERVER_ALIVE_COUNT_MAX" | |
| ) | |
| if [[ "$TEMP_KEY_MODE" == yes ]]; then | |
| require_command ssh-keygen | |
| require_command mktemp | |
| umask 077 | |
| TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rsync-transfer.XXXXXXXX") || fatal '无法创建安全临时目录。' | |
| TMP_KEY=$TMP_DIR/id_ed25519 | |
| KEY_MARKER="rsync-temp-$(date -u '+%Y%m%dT%H%M%SZ')-$$-${RANDOM}${RANDOM}" | |
| log_info '正在生成本次传输专用 Ed25519 临时密钥...' | |
| if ! ssh-keygen -q -t ed25519 -f "$TMP_KEY" -N '' -C "$KEY_MARKER"; then | |
| fatal '临时 SSH 密钥生成失败。' | |
| fi | |
| read -r KEY_TYPE KEY_BLOB _ < "${TMP_KEY}.pub" | |
| [[ "$KEY_TYPE" =~ ^[A-Za-z0-9@._+-]+$ ]] || fatal '生成的公钥类型格式异常。' | |
| [[ "$KEY_BLOB" =~ ^[A-Za-z0-9+/=]+$ ]] || fatal '生成的公钥内容格式异常。' | |
| [[ "$KEY_MARKER" =~ ^[A-Za-z0-9._-]+$ ]] || fatal '临时密钥标记格式异常。' | |
| BOOTSTRAP_SSH_OPTS=() | |
| TEMP_SSH_OPTS=() | |
| if (( PORT_WAS_SET == 1 )); then | |
| BOOTSTRAP_SSH_OPTS+=(-p "$REMOTE_PORT") | |
| TEMP_SSH_OPTS+=(-p "$REMOTE_PORT") | |
| fi | |
| BOOTSTRAP_SSH_OPTS+=( | |
| -o BatchMode=no | |
| -o "ConnectTimeout=$CONNECT_TIMEOUT" | |
| -o "ServerAliveInterval=$SERVER_ALIVE_INTERVAL" | |
| -o "ServerAliveCountMax=$SERVER_ALIVE_COUNT_MAX" | |
| ) | |
| TEMP_SSH_OPTS+=( | |
| -i "$TMP_KEY" | |
| -o BatchMode=yes | |
| -o IdentitiesOnly=yes | |
| -o "ConnectTimeout=$CONNECT_TIMEOUT" | |
| -o "ServerAliveInterval=$SERVER_ALIVE_INTERVAL" | |
| -o "ServerAliveCountMax=$SERVER_ALIVE_COUNT_MAX" | |
| ) | |
| install_temp_key | |
| log_info '正在验证临时密钥登录...' | |
| if ! ssh "${TEMP_SSH_OPTS[@]}" "$REMOTE_CONN" true; then | |
| fatal '临时密钥验证失败; 将尝试自动撤销已安装的临时公钥。' | |
| fi | |
| log_info '正在检查远端 rsync...' | |
| if ! ssh "${TEMP_SSH_OPTS[@]}" "$REMOTE_CONN" 'command -v rsync >/dev/null 2>&1'; then | |
| fatal '远端未找到 rsync 命令。请先在远端安装 rsync。' | |
| fi | |
| if (( PORT_WAS_SET == 1 )); then | |
| printf -v SSH_RSH 'ssh -i %q -p %q -o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=%q -o ServerAliveInterval=%q -o ServerAliveCountMax=%q' \ | |
| "$TMP_KEY" "$REMOTE_PORT" "$CONNECT_TIMEOUT" "$SERVER_ALIVE_INTERVAL" "$SERVER_ALIVE_COUNT_MAX" | |
| else | |
| printf -v SSH_RSH 'ssh -i %q -o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=%q -o ServerAliveInterval=%q -o ServerAliveCountMax=%q' \ | |
| "$TMP_KEY" "$CONNECT_TIMEOUT" "$SERVER_ALIVE_INTERVAL" "$SERVER_ALIVE_COUNT_MAX" | |
| fi | |
| else | |
| log_info '临时密钥已禁用; 正在验证 SSH config/ssh-agent/已有密钥的非交互认证...' | |
| if ! ssh "${EXISTING_SSH_OPTS[@]}" "$REMOTE_CONN" true; then | |
| fatal $'现有 SSH 非交互认证失败。\n\n如果私钥带有 passphrase, 请先运行:\n ssh-add <IdentityFile>\n\n例如:\n ssh-add ~/.ssh/id_ed25519\n\n然后重新运行本脚本。\n\n如果当前只能通过密码完成首次登录, 但服务器允许公钥认证, 且当前用户可以维护 ~/.ssh/authorized_keys, 可显式使用 --temp-key=yes。\n\n如果服务器明确禁用公钥认证, 则 --temp-key=yes 无效; 本脚本无法为该场景提供无人值守的无限重连。' | |
| fi | |
| log_info '正在检查远端 rsync...' | |
| if ! ssh "${EXISTING_SSH_OPTS[@]}" "$REMOTE_CONN" 'command -v rsync >/dev/null 2>&1'; then | |
| fatal '远端未找到 rsync 命令。请先在远端安装 rsync。' | |
| fi | |
| if (( PORT_WAS_SET == 1 )); then | |
| printf -v SSH_RSH 'ssh -p %q -o BatchMode=yes -o ConnectTimeout=%q -o ServerAliveInterval=%q -o ServerAliveCountMax=%q' \ | |
| "$REMOTE_PORT" "$CONNECT_TIMEOUT" "$SERVER_ALIVE_INTERVAL" "$SERVER_ALIVE_COUNT_MAX" | |
| else | |
| printf -v SSH_RSH 'ssh -o BatchMode=yes -o ConnectTimeout=%q -o ServerAliveInterval=%q -o ServerAliveCountMax=%q' \ | |
| "$CONNECT_TIMEOUT" "$SERVER_ALIVE_INTERVAL" "$SERVER_ALIVE_COUNT_MAX" | |
| fi | |
| fi | |
| log_info "开始 rsync 传输; 远程端点: $REMOTE_CONN, SSH 端口: $SSH_PORT_LABEL。" | |
| run_rsync_with_retry | |
| log_info '✨ 数据传输完成; 正在执行退出清理。' | |
| } | |
| trap cleanup EXIT | |
| trap 'exit 130' INT | |
| trap 'exit 143' TERM | |
| trap 'exit 129' HUP | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment