diff --git a/lib/backup.sh b/lib/backup.sh index 850ce4b..167ce16 100644 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -181,7 +181,8 @@ METAEOF elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then echo "$meta_json" > "$snap_dir/${ts}.partial/meta.json" || log_warn "Failed to write meta.json" else - echo "$meta_json" | remote_exec "cat > '$snap_dir/${ts}.partial/meta.json'" || log_warn "Failed to write meta.json" + local sq_partial; sq_partial="$(shquote "$snap_dir/${ts}.partial")" + echo "$meta_json" | remote_exec "cat > '${sq_partial}/meta.json'" || log_warn "Failed to write meta.json" fi # 11. Generate manifest.txt @@ -193,7 +194,8 @@ METAEOF elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then find "$snap_dir/${ts}.partial" -type f 2>/dev/null > "$snap_dir/${ts}.partial/manifest.txt" || log_warn "Failed to write manifest.txt" else - remote_exec "find '$snap_dir/${ts}.partial' -type f > '$snap_dir/${ts}.partial/manifest.txt'" 2>/dev/null || log_warn "Failed to write manifest.txt" + local sq_partial; sq_partial="$(shquote "$snap_dir/${ts}.partial")" + remote_exec "find '${sq_partial}' -type f > '${sq_partial}/manifest.txt'" 2>/dev/null || log_warn "Failed to write manifest.txt" fi # 12. Finalize snapshot @@ -211,7 +213,8 @@ METAEOF elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then total_size=$(du -sb "$snap_dir/$ts" 2>/dev/null | cut -f1) || total_size=0 else - total_size=$(remote_exec "du -sb '$snap_dir/$ts' 2>/dev/null | cut -f1" 2>/dev/null) || total_size=0 + local sq_snap; sq_snap="$(shquote "$snap_dir/$ts")" + total_size=$(remote_exec "du -sb '$sq_snap' 2>/dev/null | cut -f1" 2>/dev/null) || total_size=0 fi log_info "Backup completed for $target_name: $ts ($(human_size "${total_size:-0}") in $(human_duration "$duration"))" diff --git a/lib/config.sh b/lib/config.sh index b8aa6f7..44f96b4 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -64,7 +64,7 @@ load_config() { export BACKUP_MODE BWLIMIT RETENTION_COUNT export LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON - export SMTP_HOST SMTP_PORT SMTP_USER SMTP_FROM SMTP_SECURITY + export SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM SMTP_SECURITY export SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS DISK_USAGE_THRESHOLD } @@ -128,6 +128,11 @@ validate_config() { ((errors++)) || true fi + if [[ -n "${DISK_USAGE_THRESHOLD:-}" ]] && [[ ! "$DISK_USAGE_THRESHOLD" =~ ^[0-9]+$ ]]; then + log_error "DISK_USAGE_THRESHOLD must be a non-negative integer (0-100), got: $DISK_USAGE_THRESHOLD" + ((errors++)) || true + fi + # Validate RSYNC_EXTRA_OPTS characters (prevent flag injection) if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]] && [[ ! "$RSYNC_EXTRA_OPTS" =~ ^[a-zA-Z0-9\ ._=/,-]+$ ]]; then log_error "RSYNC_EXTRA_OPTS contains invalid characters: $RSYNC_EXTRA_OPTS" diff --git a/lib/remotes.sh b/lib/remotes.sh index d507b8b..3c7819e 100644 --- a/lib/remotes.sh +++ b/lib/remotes.sh @@ -293,7 +293,7 @@ get_target_remotes() { # Return the disk usage percentage (integer, no %) for REMOTE_BASE. # Returns 0 (unknown) on unsupported remote types. remote_disk_usage_pct() { - local base="${REMOTE_BASE:-/}" + local base; base="$(shquote "${REMOTE_BASE:-/}")" local df_line="" case "${REMOTE_TYPE:-ssh}" in ssh) @@ -332,7 +332,7 @@ check_remote_disk_space() { # Compact one-line disk info: "USED/TOTAL (FREE free) PCT" remote_disk_info_short() { - local base="${REMOTE_BASE:-/}" + local base; base="$(shquote "${REMOTE_BASE:-/}")" local df_out="" case "${REMOTE_TYPE:-ssh}" in ssh) diff --git a/lib/ssh.sh b/lib/ssh.sh index 6f5a688..9f7b60b 100644 --- a/lib/ssh.sh +++ b/lib/ssh.sh @@ -64,7 +64,7 @@ test_ssh_connection() { } ensure_remote_dir() { - local dir="$1" + local dir; dir="$(shquote "$1")" remote_exec "mkdir -p '$dir'" || { log_error "Failed to create remote directory: $dir" return 1 @@ -75,6 +75,6 @@ build_rsync_ssh_cmd() { if _is_password_mode; then echo "ssh -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o ConnectTimeout=$SSH_TIMEOUT" else - echo "ssh -i $REMOTE_KEY -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=$SSH_TIMEOUT" + echo "ssh -i \"$REMOTE_KEY\" -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=$SSH_TIMEOUT" fi } diff --git a/lib/transfer.sh b/lib/transfer.sh index fe87da0..09ad4f7 100644 --- a/lib/transfer.sh +++ b/lib/transfer.sh @@ -227,7 +227,9 @@ finalize_snapshot() { return 1 } else - remote_exec "mv '$snap_dir/${timestamp}.partial' '$snap_dir/$timestamp'" || { + local sq_partial; sq_partial="$(shquote "$snap_dir/${timestamp}.partial")" + local sq_final; sq_final="$(shquote "$snap_dir/$timestamp")" + remote_exec "mv '$sq_partial' '$sq_final'" || { log_error "Failed to finalize snapshot for $target_name: $timestamp" return 1 } diff --git a/lib/utils.sh b/lib/utils.sh index f5fc213..0c5651a 100644 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -10,6 +10,12 @@ die() { exit "$code" } +# Escape a string for safe use inside single quotes in shell commands. +# Usage: shquote "$var" → outputs the value with ' escaped to '\'' +shquote() { + printf '%s' "$1" | sed "s/'/'\\\\''/g" +} + timestamp() { date -u +"%Y-%m-%dT%H%M%S" } diff --git a/tui/models.py b/tui/models.py index d7cd108..6680196 100644 --- a/tui/models.py +++ b/tui/models.py @@ -232,7 +232,7 @@ class AppSettings: rsync_extra_opts=data.get("RSYNC_EXTRA_OPTS", ""), disk_usage_threshold=data.get("DISK_USAGE_THRESHOLD", "95"), work_dir=data.get("WORK_DIR", "/usr/local/gniza/workdir"), - web_port=data.get("WEB_PORT", "8080"), + web_port=data.get("WEB_PORT", "2323"), web_host=data.get("WEB_HOST", "0.0.0.0"), web_api_key=data.get("WEB_API_KEY", ""), ) diff --git a/tui/screens/remotes.py b/tui/screens/remotes.py index 41914dd..1601b6e 100644 --- a/tui/screens/remotes.py +++ b/tui/screens/remotes.py @@ -92,7 +92,8 @@ class RemotesScreen(Screen): try: table = self.query_one("#remotes-table", DataTable) table.update_cell(name, self._disk_col_key, disk_text, update_width=True) - except Exception: + except (KeyError, LookupError): + # Row may have been removed if user navigated away and back pass @work diff --git a/tui/screens/settings.py b/tui/screens/settings.py index d3baa72..c20a609 100644 --- a/tui/screens/settings.py +++ b/tui/screens/settings.py @@ -104,7 +104,7 @@ class SettingsScreen(Screen): rsync_extra_opts=self.query_one("#set-rsyncopts", Input).value.strip(), disk_usage_threshold=self.query_one("#set-diskthreshold", Input).value.strip() or "95", work_dir=self.query_one("#set-workdir", Input).value.strip() or "/usr/local/gniza/workdir", - web_port=self.query_one("#set-web-port", Input).value.strip() or "8080", + web_port=self.query_one("#set-web-port", Input).value.strip() or "2323", web_host=self.query_one("#set-web-host", Input).value.strip() or "0.0.0.0", web_api_key=self.query_one("#set-web-key", Input).value, )