#!/usr/bin/env bash # gniza4linux/lib/remotes.sh — Remote discovery and context switching # # Remote destinations are configured in $CONFIG_DIR/remotes.d/.conf. # Each config overrides REMOTE_* globals so existing functions (ssh, # transfer, snapshot, retention) work unchanged. [[ -n "${_GNIZA4LINUX_REMOTES_LOADED:-}" ]] && return 0 _GNIZA4LINUX_REMOTES_LOADED=1 # ── Saved state for legacy globals ───────────────────────────── declare -g _SAVED_REMOTE_HOST="" declare -g _SAVED_REMOTE_PORT="" declare -g _SAVED_REMOTE_USER="" declare -g _SAVED_REMOTE_AUTH_METHOD="" declare -g _SAVED_REMOTE_KEY="" declare -g _SAVED_REMOTE_PASSWORD="" declare -g _SAVED_REMOTE_BASE="" declare -g _SAVED_BWLIMIT="" declare -g _SAVED_RETENTION_COUNT="" declare -g _SAVED_RSYNC_EXTRA_OPTS="" declare -g _SAVED_REMOTE_TYPE="" declare -g _SAVED_S3_ACCESS_KEY_ID="" declare -g _SAVED_S3_SECRET_ACCESS_KEY="" declare -g _SAVED_S3_REGION="" declare -g _SAVED_S3_ENDPOINT="" declare -g _SAVED_S3_BUCKET="" declare -g _SAVED_GDRIVE_SERVICE_ACCOUNT_FILE="" declare -g _SAVED_GDRIVE_ROOT_FOLDER_ID="" declare -g CURRENT_REMOTE_NAME="" _save_remote_globals() { _SAVED_REMOTE_HOST="${REMOTE_HOST:-}" _SAVED_REMOTE_PORT="${REMOTE_PORT:-22}" _SAVED_REMOTE_USER="${REMOTE_USER:-root}" _SAVED_REMOTE_AUTH_METHOD="${REMOTE_AUTH_METHOD:-key}" _SAVED_REMOTE_KEY="${REMOTE_KEY:-}" _SAVED_REMOTE_PASSWORD="${REMOTE_PASSWORD:-}" _SAVED_REMOTE_BASE="${REMOTE_BASE:-/backups}" _SAVED_BWLIMIT="${BWLIMIT:-0}" _SAVED_RETENTION_COUNT="${RETENTION_COUNT:-30}" _SAVED_RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}" _SAVED_REMOTE_TYPE="${REMOTE_TYPE:-ssh}" _SAVED_S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-}" _SAVED_S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-}" _SAVED_S3_REGION="${S3_REGION:-}" _SAVED_S3_ENDPOINT="${S3_ENDPOINT:-}" _SAVED_S3_BUCKET="${S3_BUCKET:-}" _SAVED_GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}" _SAVED_GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}" } _restore_remote_globals() { REMOTE_HOST="$_SAVED_REMOTE_HOST" REMOTE_PORT="$_SAVED_REMOTE_PORT" REMOTE_USER="$_SAVED_REMOTE_USER" REMOTE_AUTH_METHOD="$_SAVED_REMOTE_AUTH_METHOD" REMOTE_KEY="$_SAVED_REMOTE_KEY" REMOTE_PASSWORD="$_SAVED_REMOTE_PASSWORD" REMOTE_BASE="$_SAVED_REMOTE_BASE" BWLIMIT="$_SAVED_BWLIMIT" RETENTION_COUNT="$_SAVED_RETENTION_COUNT" RSYNC_EXTRA_OPTS="$_SAVED_RSYNC_EXTRA_OPTS" REMOTE_TYPE="$_SAVED_REMOTE_TYPE" S3_ACCESS_KEY_ID="$_SAVED_S3_ACCESS_KEY_ID" S3_SECRET_ACCESS_KEY="$_SAVED_S3_SECRET_ACCESS_KEY" S3_REGION="$_SAVED_S3_REGION" S3_ENDPOINT="$_SAVED_S3_ENDPOINT" S3_BUCKET="$_SAVED_S3_BUCKET" GDRIVE_SERVICE_ACCOUNT_FILE="$_SAVED_GDRIVE_SERVICE_ACCOUNT_FILE" GDRIVE_ROOT_FOLDER_ID="$_SAVED_GDRIVE_ROOT_FOLDER_ID" CURRENT_REMOTE_NAME="" } # ── Discovery ────────────────────────────────────────────────── # List remote names (filenames without .conf) sorted alphabetically. list_remotes() { local remotes_dir="$CONFIG_DIR/remotes.d" if [[ ! -d "$remotes_dir" ]]; then return 0 fi local f for f in "$remotes_dir"/*.conf; do [[ -f "$f" ]] || continue basename "$f" .conf done } # Return 0 if at least one remote config exists. has_remotes() { local remotes remotes=$(list_remotes) [[ -n "$remotes" ]] } # ── Context switching ────────────────────────────────────────── # Source a remote config and override REMOTE_* globals. # Usage: load_remote load_remote() { local name="$1" local conf="$CONFIG_DIR/remotes.d/${name}.conf" if [[ ! -f "$conf" ]]; then log_error "Remote config not found: $conf" return 1 fi _safe_source_config "$conf" || { log_error "Failed to parse remote config: $conf" return 1 } # Apply defaults for optional fields REMOTE_TYPE="${REMOTE_TYPE:-$DEFAULT_REMOTE_TYPE}" REMOTE_PORT="${REMOTE_PORT:-$DEFAULT_REMOTE_PORT}" REMOTE_USER="${REMOTE_USER:-$DEFAULT_REMOTE_USER}" REMOTE_AUTH_METHOD="${REMOTE_AUTH_METHOD:-$DEFAULT_REMOTE_AUTH_METHOD}" REMOTE_KEY="${REMOTE_KEY:-}" REMOTE_PASSWORD="${REMOTE_PASSWORD:-}" REMOTE_BASE="${REMOTE_BASE:-$DEFAULT_REMOTE_BASE}" BWLIMIT="${BWLIMIT:-$DEFAULT_BWLIMIT}" RETENTION_COUNT="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}" RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}" # Cloud-specific defaults S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-}" S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-}" S3_REGION="${S3_REGION:-$DEFAULT_S3_REGION}" S3_ENDPOINT="${S3_ENDPOINT:-}" S3_BUCKET="${S3_BUCKET:-}" GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}" GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}" # shellcheck disable=SC2034 # used by callers CURRENT_REMOTE_NAME="$name" case "$REMOTE_TYPE" in ssh) log_debug "Loaded remote '$name': ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} -> ${REMOTE_BASE}" ;; local) log_debug "Loaded remote '$name': type=local -> ${REMOTE_BASE}" ;; *) log_debug "Loaded remote '$name': type=${REMOTE_TYPE} -> ${REMOTE_BASE}" ;; esac } # Load + validate a remote config. validate_remote() { local name="$1" load_remote "$name" || return 1 local errors=0 # Common validations if ! [[ "$RETENTION_COUNT" =~ ^[0-9]+$ ]] || (( RETENTION_COUNT < 1 )); then log_error "Remote '$name': RETENTION_COUNT must be >= 1, got: $RETENTION_COUNT" ((errors++)) || true fi case "${REMOTE_TYPE:-ssh}" in ssh) if [[ -z "$REMOTE_HOST" ]]; then log_error "Remote '$name': REMOTE_HOST is required" ((errors++)) || true fi if [[ "${REMOTE_AUTH_METHOD:-key}" != "key" && "${REMOTE_AUTH_METHOD:-key}" != "password" ]]; then log_error "Remote '$name': REMOTE_AUTH_METHOD must be 'key' or 'password', got: $REMOTE_AUTH_METHOD" ((errors++)) || true fi if [[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]]; then if [[ -z "${REMOTE_PASSWORD:-}" ]]; then log_error "Remote '$name': REMOTE_PASSWORD is required when REMOTE_AUTH_METHOD=password" ((errors++)) || true fi if ! command -v sshpass &>/dev/null; then log_error "Remote '$name': sshpass is required for password authentication (install: apt install sshpass)" ((errors++)) || true fi else if [[ -z "$REMOTE_KEY" ]]; then log_error "Remote '$name': REMOTE_KEY is required" ((errors++)) || true elif [[ ! -f "$REMOTE_KEY" ]]; then log_error "Remote '$name': REMOTE_KEY file not found: $REMOTE_KEY" ((errors++)) || true fi fi if ! [[ "$REMOTE_PORT" =~ ^[0-9]+$ ]] || (( REMOTE_PORT < 1 || REMOTE_PORT > 65535 )); then log_error "Remote '$name': REMOTE_PORT must be 1-65535, got: $REMOTE_PORT" ((errors++)) || true fi ;; local) if [[ -z "${REMOTE_BASE:-}" ]]; then log_error "Remote '$name': REMOTE_BASE is required for local remotes" ((errors++)) || true elif [[ ! -d "$REMOTE_BASE" ]]; then log_error "Remote '$name': REMOTE_BASE directory does not exist: $REMOTE_BASE" ((errors++)) || true fi ;; s3) if ! command -v rclone &>/dev/null; then log_error "Remote '$name': rclone is required for S3 remotes (install: https://rclone.org/install/)" ((errors++)) || true fi if [[ -z "${S3_ACCESS_KEY_ID:-}" ]]; then log_error "Remote '$name': S3_ACCESS_KEY_ID is required" ((errors++)) || true fi if [[ -z "${S3_SECRET_ACCESS_KEY:-}" ]]; then log_error "Remote '$name': S3_SECRET_ACCESS_KEY is required" ((errors++)) || true fi if [[ -z "${S3_BUCKET:-}" ]]; then log_error "Remote '$name': S3_BUCKET is required" ((errors++)) || true fi ;; gdrive) if ! command -v rclone &>/dev/null; then log_error "Remote '$name': rclone is required for Google Drive remotes (install: https://rclone.org/install/)" ((errors++)) || true fi if [[ -z "${GDRIVE_SERVICE_ACCOUNT_FILE:-}" ]]; then log_error "Remote '$name': GDRIVE_SERVICE_ACCOUNT_FILE is required" ((errors++)) || true elif [[ ! -f "${GDRIVE_SERVICE_ACCOUNT_FILE}" ]]; then log_error "Remote '$name': GDRIVE_SERVICE_ACCOUNT_FILE not found: $GDRIVE_SERVICE_ACCOUNT_FILE" ((errors++)) || true fi ;; *) log_error "Remote '$name': REMOTE_TYPE must be 'ssh', 'local', 's3', or 'gdrive', got: $REMOTE_TYPE" ((errors++)) || true ;; esac (( errors > 0 )) && return 1 return 0 } # Resolve which remotes to operate on. # - If --remote=NAME was given, return just that name. # - Otherwise return all remotes from remotes.d/. # - Errors if no remotes are configured. # # Usage: get_target_remotes "$remote_flag_value" # Outputs one name per line. get_target_remotes() { local flag="${1:-}" local remotes_dir="$CONFIG_DIR/remotes.d" if [[ -n "$flag" ]]; then # Split on commas, verify each remote exists local IFS=',' local names read -ra names <<< "$flag" for name in "${names[@]}"; do # Trim whitespace name="${name#"${name%%[![:space:]]*}"}" name="${name%"${name##*[![:space:]]}"}" [[ -z "$name" ]] && continue if [[ ! -f "$remotes_dir/${name}.conf" ]]; then log_error "Remote not found: $name (expected $remotes_dir/${name}.conf)" return 1 fi echo "$name" done return 0 fi if has_remotes; then list_remotes return 0 fi # No remotes configured log_error "No remotes configured. Create one in $CONFIG_DIR/remotes.d/" return 1 } # ── Disk info ──────────────────────────────────────────────── remote_disk_info() { local base="${REMOTE_BASE:-/}" case "${REMOTE_TYPE:-ssh}" in ssh) echo "Disk usage on ${REMOTE_USER}@${REMOTE_HOST}:${base}" echo "──────────────────────────────────────────" remote_exec "df -h '$base' 2>/dev/null && echo '' && df -i '$base' 2>/dev/null" ;; local) echo "Disk usage on ${base}" echo "──────────────────────────────────────────" df -h "$base" 2>/dev/null echo "" df -i "$base" 2>/dev/null ;; *) echo "Disk info not supported for remote type: ${REMOTE_TYPE}" return 1 ;; esac } # ── Speed test ─────────────────────────────────────────────── remote_speed_test() { local test_size="10M" local test_file="/tmp/.gniza_speedtest_$$" local remote_file="${REMOTE_BASE:-.}/.gniza_speedtest_$$" case "${REMOTE_TYPE:-ssh}" in ssh) echo "Speed test to ${REMOTE_USER}@${REMOTE_HOST}" echo "Test file size: ${test_size}" echo "──────────────────────────────────────────" # Create local test file dd if=/dev/urandom of="$test_file" bs=1M count=10 2>/dev/null # Upload test echo "" echo "Upload test..." local ssh_cmd ssh_cmd=$(build_rsync_ssh_cmd) local start_up end_up duration_up speed_up start_up=$(date +%s%N) # shellcheck disable=SC2086 if rsync -e "$ssh_cmd" --progress "$test_file" "${REMOTE_USER}@${REMOTE_HOST}:${remote_file}" 2>/dev/null; then end_up=$(date +%s%N) duration_up=$(( (end_up - start_up) / 1000000 )) if [[ "$duration_up" -gt 0 ]]; then speed_up=$(( 10 * 1000 * 8 / duration_up )) echo " Upload: ${speed_up} Mbps (${duration_up} ms)" else echo " Upload: too fast to measure" fi else echo " Upload: FAILED" fi # Download test echo "" echo "Download test..." local dl_file="${test_file}_dl" local start_dn end_dn duration_dn speed_dn start_dn=$(date +%s%N) # shellcheck disable=SC2086 if rsync -e "$ssh_cmd" --progress "${REMOTE_USER}@${REMOTE_HOST}:${remote_file}" "$dl_file" 2>/dev/null; then end_dn=$(date +%s%N) duration_dn=$(( (end_dn - start_dn) / 1000000 )) if [[ "$duration_dn" -gt 0 ]]; then speed_dn=$(( 10 * 1000 * 8 / duration_dn )) echo " Download: ${speed_dn} Mbps (${duration_dn} ms)" else echo " Download: too fast to measure" fi else echo " Download: FAILED" fi # Cleanup rm -f "$test_file" "$dl_file" 2>/dev/null remote_exec "rm -f '$remote_file'" 2>/dev/null || true echo "" echo "Done." ;; local) echo "Speed test not applicable for local remotes." ;; *) echo "Speed test not supported for remote type: ${REMOTE_TYPE}" return 1 ;; esac }