Files
gniza4linux/lib/remotes.sh
shuki c70fb1991c Add Disk Info and Speed Test buttons to Remotes screen
- Disk Info: runs df -h and df -i on remote via SSH (or locally)
- Speed Test: uploads/downloads 10MB test file via rsync, measures Mbps
- Both available as CLI commands: gniza remotes disk-info/speed-test --name=NAME

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:38:15 +02:00

388 lines
14 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 ────────────────────────────────────────────────
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
}