413 lines
15 KiB
Bash
413 lines
15 KiB
Bash
#!/usr/bin/env bash
|
|
# gniza4linux/lib/remotes.sh — Remote discovery and context switching
|
|
#
|
|
# Remote destinations are configured in $CONFIG_DIR/remotes.d/<name>.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 <name>
|
|
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 ────────────────────────────────────────────────
|
|
|
|
# Compact one-line disk info: "USED/TOTAL (FREE free)"
|
|
remote_disk_info_short() {
|
|
local base="${REMOTE_BASE:-/}"
|
|
local df_out=""
|
|
case "${REMOTE_TYPE:-ssh}" in
|
|
ssh)
|
|
df_out=$(remote_exec "df -h '$base' 2>/dev/null | tail -1") || return 1
|
|
;;
|
|
local)
|
|
df_out=$(df -h "$base" 2>/dev/null | tail -1) || return 1
|
|
;;
|
|
*)
|
|
echo "N/A"
|
|
return 0
|
|
;;
|
|
esac
|
|
# df output: Filesystem Size Used Avail Use% Mount
|
|
local size used avail pct
|
|
size=$(echo "$df_out" | awk '{print $2}')
|
|
used=$(echo "$df_out" | awk '{print $3}')
|
|
avail=$(echo "$df_out" | awk '{print $4}')
|
|
pct=$(echo "$df_out" | awk '{print $5}')
|
|
echo "${used}/${size} (${avail} free) ${pct}"
|
|
}
|
|
|
|
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
|
|
}
|