commit 928d5af54c8b448e4d147d69cdaece264c93b06c Author: shuki Date: Thu Mar 5 21:15:29 2026 +0200 Initial implementation of gniza4linux backup tool Complete Linux backup manager with Whiptail TUI and CLI interface. Adapted from gniza4cp (cPanel backup tool) with target/profile-based system replacing cPanel-specific features. - 14 core engine modules (backup, restore, targets, remotes, transfer, etc.) - 11 Whiptail TUI screens (full CRUD for targets/remotes/schedules) - CLI entrypoint with subcommands for scripting/cron - Support for SSH, local, S3, and Google Drive remotes - rsync --link-dest incremental snapshots - Root and user mode (XDG paths) - 70 passing tests - Config templates, installer, uninstaller Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44fa520 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.swp +*.swo +*~ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f690a6f --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# gniza - Linux Backup Manager + +A generic Linux backup tool with a Whiptail TUI and CLI interface. Define named backup targets (sets of directories), configure remote destinations (SSH, local, S3, Google Drive), and run incremental backups with rsync `--link-dest` deduplication. + +``` + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓ + ▓▓▓▓▓ + ▓▓ +``` + +## Features + +- **Target-based backups** - Define named profiles with sets of directories to back up +- **Multiple remote types** - SSH, local (USB/NFS), S3, Google Drive +- **Incremental snapshots** - rsync `--link-dest` for space-efficient deduplication +- **Whiptail TUI** - Full terminal UI for interactive management +- **CLI interface** - Scriptable commands for automation and cron +- **Atomic snapshots** - `.partial` directory during backup, renamed on success +- **Retention policies** - Automatic pruning of old snapshots +- **Pre/post hooks** - Run custom commands before/after backups +- **Email notifications** - SMTP or system mail on success/failure +- **Root and user mode** - Works as root (system-wide) or regular user (per-user) +- **Cron scheduling** - Manage cron jobs through TUI or CLI + +## Installation + +```bash +git clone https://github.com/shukiv/gniza4linux.git +cd gniza4linux +bash scripts/install.sh +``` + +Root mode installs to `/usr/local/gniza`. User mode installs to `~/.local/share/gniza`. + +### Dependencies + +- **Required**: bash 4+, rsync +- **Optional**: ssh, whiptail (TUI), curl (SMTP notifications), rclone (S3/GDrive) + +## Quick Start + +```bash +# Launch TUI +gniza + +# Or use CLI +gniza targets add --name=mysite --folders=/var/www,/etc/nginx +gniza remotes add --name=backup-server # (edit config manually) +gniza --cli backup --target=mysite +gniza --cli backup --all +``` + +## Usage + +``` +gniza [OPTIONS] [COMMAND] + +Options: + --cli Force CLI mode (no TUI) + --debug Enable debug logging + --config=FILE Override config file path + --help Show help + --version Show version + +Commands: + backup [--target=NAME] [--remote=NAME] [--all] + restore --target=NAME [--snapshot=TS] [--remote=NAME] [--dest=DIR] + targets list|add|delete|show [--name=NAME] [--folders=PATHS] + remotes list|add|delete|show|test [--name=NAME] + snapshots list [--target=NAME] [--remote=NAME] + verify [--target=NAME] [--remote=NAME] [--all] + retention [--target=NAME] [--remote=NAME] [--all] + schedule install|show|remove + logs [--last] [--tail=N] +``` + +## Configuration + +| Mode | Config | Logs | Lock | +|------|--------|------|------| +| Root | `/etc/gniza/` | `/var/log/gniza/` | `/var/run/gniza.lock` | +| User | `~/.config/gniza/` | `~/.local/state/gniza/log/` | `$XDG_RUNTIME_DIR/gniza-$UID.lock` | + +Config subdirectories: `targets.d/*.conf`, `remotes.d/*.conf`, `schedules.d/*.conf` + +### Target Config (`targets.d/mysite.conf`) + +```ini +TARGET_NAME="mysite" +TARGET_FOLDERS="/var/www,/etc/nginx" +TARGET_EXCLUDE="*.log,*.tmp" +TARGET_REMOTE="" +TARGET_RETENTION="" +TARGET_PRE_HOOK="" +TARGET_POST_HOOK="" +TARGET_ENABLED="yes" +``` + +### Remote Config (`remotes.d/backup-server.conf`) + +```ini +REMOTE_TYPE="ssh" +REMOTE_HOST="backup.example.com" +REMOTE_PORT=22 +REMOTE_USER="root" +REMOTE_AUTH_METHOD="key" +REMOTE_KEY="/root/.ssh/backup_key" +REMOTE_BASE="/backups" +BWLIMIT=0 +RETENTION_COUNT=30 +``` + +For local remotes (USB/NFS): + +```ini +REMOTE_TYPE="local" +REMOTE_BASE="/mnt/backup-drive" +``` + +## Snapshot Structure + +``` +$BASE//targets//snapshots// +├── meta.json +├── manifest.txt +├── var/www/ +└── etc/nginx/ +``` + +## Testing + +```bash +bash tests/test_utils.sh +bash tests/test_config.sh +bash tests/test_targets.sh +``` + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/bin/gniza b/bin/gniza new file mode 100755 index 0000000..1c40276 --- /dev/null +++ b/bin/gniza @@ -0,0 +1,445 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── Resolve GNIZA_DIR ──────────────────────────────────────── +GNIZA_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +export GNIZA_DIR + +# ── Source libraries in dependency order ───────────────────── +source "$GNIZA_DIR/lib/constants.sh" +source "$GNIZA_DIR/lib/utils.sh" +detect_mode +source "$GNIZA_DIR/lib/logging.sh" +source "$GNIZA_DIR/lib/config.sh" +source "$GNIZA_DIR/lib/locking.sh" +source "$GNIZA_DIR/lib/targets.sh" +source "$GNIZA_DIR/lib/remotes.sh" +source "$GNIZA_DIR/lib/backup.sh" +source "$GNIZA_DIR/lib/restore.sh" +source "$GNIZA_DIR/lib/retention.sh" +source "$GNIZA_DIR/lib/verify.sh" +source "$GNIZA_DIR/lib/schedule.sh" +source "$GNIZA_DIR/lib/notify.sh" +source "$GNIZA_DIR/lib/ssh.sh" +source "$GNIZA_DIR/lib/rclone.sh" +source "$GNIZA_DIR/lib/snapshot.sh" +source "$GNIZA_DIR/lib/transfer.sh" +source "$GNIZA_DIR/lib/ui_common.sh" +source "$GNIZA_DIR/lib/ui_main.sh" +source "$GNIZA_DIR/lib/ui_targets.sh" +source "$GNIZA_DIR/lib/ui_remotes.sh" +source "$GNIZA_DIR/lib/ui_backup.sh" +source "$GNIZA_DIR/lib/ui_restore.sh" +source "$GNIZA_DIR/lib/ui_snapshots.sh" +source "$GNIZA_DIR/lib/ui_verify.sh" +source "$GNIZA_DIR/lib/ui_retention.sh" +source "$GNIZA_DIR/lib/ui_logs.sh" +source "$GNIZA_DIR/lib/ui_schedule.sh" +source "$GNIZA_DIR/lib/ui_settings.sh" + +# ── ASCII Logo ─────────────────────────────────────────────── +show_logo() { + echo "${C_GREEN}" + cat <<'LOGO' + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓ + ▓▓▓▓▓ + ▓▓ +LOGO + echo "${C_RESET}" + echo " gniza v${GNIZA4LINUX_VERSION} - Linux Backup Manager" + echo "" +} + +# ── Help ───────────────────────────────────────────────────── +show_help() { + cat </dev/null | head -1) + if [[ -z "$latest" ]]; then + echo "No log files found." + else + if [[ -n "$tail_n" ]]; then + tail -n "$tail_n" "$latest" + else + cat "$latest" + fi + fi + else + ls -lt "$log_dir"/gniza-*.log 2>/dev/null || echo "No log files found." + fi + ;; + + version) + echo "gniza v${GNIZA4LINUX_VERSION}" + ;; + + "") + show_help + ;; + + *) + die "Unknown command: $SUBCOMMAND (see --help)" + ;; + esac +} + +# ── Mode selection ─────────────────────────────────────────── +if [[ -n "$SUBCOMMAND" ]]; then + # Explicit subcommand: always CLI + run_cli +elif [[ "$FORCE_CLI" == "true" ]]; then + run_cli +elif command -v whiptail &>/dev/null && [[ -t 1 ]]; then + # TUI mode + show_logo + ui_main_menu +else + # Fallback to CLI help + run_cli +fi diff --git a/etc/gniza.conf.example b/etc/gniza.conf.example new file mode 100644 index 0000000..7f0c5e8 --- /dev/null +++ b/etc/gniza.conf.example @@ -0,0 +1,34 @@ +# gniza — Main Configuration +# Copy to ~/.config/gniza/gniza.conf (user) or /etc/gniza/gniza.conf (root) + +# Backup mode: full or incremental +BACKUP_MODE="full" + +# Default bandwidth limit in KB/s (0 = unlimited) +BWLIMIT=0 + +# Default retention: number of snapshots to keep per target +RETENTION_COUNT=30 + +# Logging +LOG_LEVEL="info" +LOG_RETAIN=90 + +# Notifications +NOTIFY_EMAIL="" +NOTIFY_ON="failure" + +# SMTP Settings (leave SMTP_HOST empty to use system mail command) +SMTP_HOST="" +SMTP_PORT=587 +SMTP_USER="" +SMTP_PASSWORD="" +SMTP_FROM="" +SMTP_SECURITY="tls" + +# SSH defaults +SSH_TIMEOUT=30 +SSH_RETRIES=3 + +# Extra rsync options (careful: validated for safe characters) +RSYNC_EXTRA_OPTS="" diff --git a/etc/remote.conf.example b/etc/remote.conf.example new file mode 100644 index 0000000..b3f022f --- /dev/null +++ b/etc/remote.conf.example @@ -0,0 +1,31 @@ +# gniza — Remote Configuration +# Copy to remotes.d/.conf + +# Remote type: ssh, local, s3, gdrive +REMOTE_TYPE="ssh" + +# SSH settings +REMOTE_HOST="" +REMOTE_PORT=22 +REMOTE_USER="root" +REMOTE_AUTH_METHOD="key" +REMOTE_KEY="" +REMOTE_PASSWORD="" +REMOTE_BASE="/backups" + +# Bandwidth limit override (KB/s, 0 = use global) +BWLIMIT=0 + +# Retention override +RETENTION_COUNT=30 + +# S3 settings (when REMOTE_TYPE=s3) +# S3_ACCESS_KEY_ID="" +# S3_SECRET_ACCESS_KEY="" +# S3_REGION="us-east-1" +# S3_ENDPOINT="" +# S3_BUCKET="" + +# Google Drive settings (when REMOTE_TYPE=gdrive) +# GDRIVE_SERVICE_ACCOUNT_FILE="" +# GDRIVE_ROOT_FOLDER_ID="" diff --git a/etc/schedule.conf.example b/etc/schedule.conf.example new file mode 100644 index 0000000..37cf435 --- /dev/null +++ b/etc/schedule.conf.example @@ -0,0 +1,20 @@ +# gniza — Schedule Configuration +# Copy to schedules.d/.conf + +# Schedule type: hourly, daily, weekly, monthly, custom +SCHEDULE="daily" + +# Time (HH:MM format, 24h) +SCHEDULE_TIME="02:00" + +# Day: day of week (0-6, 0=Sun) for weekly, day of month (1-28) for monthly +SCHEDULE_DAY="" + +# Custom cron expression (only used when SCHEDULE=custom) +SCHEDULE_CRON="" + +# Comma-separated remote names (empty = all remotes) +REMOTES="" + +# Comma-separated target names (empty = all targets) +TARGETS="" diff --git a/etc/target.conf.example b/etc/target.conf.example new file mode 100644 index 0000000..599c508 --- /dev/null +++ b/etc/target.conf.example @@ -0,0 +1,11 @@ +# gniza — Target Configuration +# Copy to targets.d/.conf + +TARGET_NAME="example" +TARGET_FOLDERS="/var/www,/etc/nginx" +TARGET_EXCLUDE="" +TARGET_REMOTE="" +TARGET_RETENTION="" +TARGET_PRE_HOOK="" +TARGET_POST_HOOK="" +TARGET_ENABLED="yes" diff --git a/lib/backup.sh b/lib/backup.sh new file mode 100644 index 0000000..7e0f5bc --- /dev/null +++ b/lib/backup.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# gniza4linux/lib/backup.sh — Backup orchestration per target + +[[ -n "${_GNIZA4LINUX_BACKUP_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_BACKUP_LOADED=1 + +# Backup a single target to a remote. +# Usage: backup_target [remote_name] +backup_target() { + local target_name="$1" + local remote_name="${2:-}" + + # 1. Load and validate target + load_target "$target_name" || { + log_error "Failed to load target: $target_name" + return 1 + } + + if [[ "${TARGET_ENABLED:-yes}" != "yes" ]]; then + log_info "Target '$target_name' is disabled, skipping" + return 0 + fi + + # 2. Determine which remote to use + if [[ -z "$remote_name" ]]; then + if [[ -n "${TARGET_REMOTE:-}" ]]; then + remote_name="$TARGET_REMOTE" + else + remote_name=$(list_remotes | head -1) + fi + fi + + if [[ -z "$remote_name" ]]; then + log_error "No remote specified and none configured" + return 1 + fi + + # 3. Save/load remote context + _save_remote_globals + load_remote "$remote_name" || { + log_error "Failed to load remote: $remote_name" + _restore_remote_globals + return 1 + } + + local rc=0 + _backup_target_impl "$target_name" "$remote_name" || rc=$? + + # 15. Restore remote globals + _restore_remote_globals + return "$rc" +} + +# Internal implementation after remote context is loaded. +_backup_target_impl() { + local target_name="$1" + local remote_name="$2" + + # 4. Test remote connectivity + case "${REMOTE_TYPE:-ssh}" in + ssh) + test_ssh_connection || { + log_error "Cannot connect to remote '$remote_name'" + return 1 + } + ;; + local) + if [[ ! -d "$REMOTE_BASE" ]]; then + log_error "Remote base directory does not exist: $REMOTE_BASE" + return 1 + fi + ;; + s3|gdrive) + test_rclone_connection || { + log_error "Cannot connect to remote '$remote_name' (${REMOTE_TYPE})" + return 1 + } + ;; + esac + + local start_time; start_time=$(date +%s) + + # 5. Get timestamp + local ts; ts=$(timestamp) + + # 6. Get previous snapshot for --link-dest + local prev; prev=$(get_latest_snapshot "$target_name") || prev="" + if [[ -n "$prev" ]]; then + log_debug "Previous snapshot for $target_name: $prev" + fi + + # 7. Clean partial snapshots + clean_partial_snapshots "$target_name" + + # 8. Run pre-hook + if [[ -n "${TARGET_PRE_HOOK:-}" ]]; then + log_info "Running pre-hook for $target_name..." + if ! bash -c "$TARGET_PRE_HOOK"; then + log_error "Pre-hook failed for $target_name" + return 1 + fi + fi + + # 9. Transfer each folder + local folder + local transfer_failed=false + while IFS= read -r folder; do + [[ -z "$folder" ]] && continue + if ! transfer_folder "$target_name" "$folder" "$ts" "$prev"; then + log_error "Transfer failed for folder: $folder" + transfer_failed=true + fi + done < <(get_target_folders) + + if [[ "$transfer_failed" == "true" ]]; then + log_error "One or more folder transfers failed for $target_name" + return 1 + fi + + # 10. Generate meta.json + local end_time; end_time=$(date +%s) + local duration=$(( end_time - start_time )) + local hostname; hostname=$(hostname -f) + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local total_size=0 + + local meta_json + meta_json=$(cat < "$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" + fi + + # 11. Generate manifest.txt + if _is_rclone_mode; then + local manifest; manifest=$(rclone_list_files "targets/${target_name}/snapshots/${ts}" 2>/dev/null) || manifest="" + if [[ -n "$manifest" ]]; then + rclone_rcat "targets/${target_name}/snapshots/${ts}/manifest.txt" "$manifest" || log_warn "Failed to write manifest.txt" + fi + 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" + fi + + # 12. Finalize snapshot + if ! finalize_snapshot "$target_name" "$ts"; then + log_error "Failed to finalize snapshot for $target_name" + return 1 + fi + + # Calculate total_size after finalization for accurate reporting + if _is_rclone_mode; then + local size_json; size_json=$(rclone_size "targets/${target_name}/snapshots/${ts}" 2>/dev/null) || true + if [[ -n "$size_json" ]]; then + total_size=$(echo "$size_json" | grep -oP '"bytes":\s*\K[0-9]+' || echo 0) + fi + 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 + fi + + log_info "Backup completed for $target_name: $ts ($(human_size "${total_size:-0}") in $(human_duration "$duration"))" + + # 13. Run post-hook + if [[ -n "${TARGET_POST_HOOK:-}" ]]; then + log_info "Running post-hook for $target_name..." + bash -c "$TARGET_POST_HOOK" || log_warn "Post-hook failed for $target_name" + fi + + # 14. Enforce retention + enforce_retention "$target_name" + + return 0 +} + +# Backup all enabled targets. +# Usage: backup_all_targets [remote_flag] +backup_all_targets() { + local remote_flag="${1:-}" + + local targets; targets=$(list_targets) + if [[ -z "$targets" ]]; then + log_error "No targets configured" + return 1 + fi + + # Resolve remotes + local remotes="" + remotes=$(get_target_remotes "$remote_flag") || { + log_error "Invalid remote specification" + return 1 + } + + local start_time; start_time=$(date +%s) + local total=0 succeeded=0 failed=0 + local failed_targets="" + + while IFS= read -r target_name; do + [[ -z "$target_name" ]] && continue + + load_target "$target_name" || { log_warn "Cannot load target: $target_name"; continue; } + if [[ "${TARGET_ENABLED:-yes}" != "yes" ]]; then + log_debug "Target '$target_name' is disabled, skipping" + continue + fi + + ((total++)) || true + log_info "=== Backing up target: $target_name ($total) ===" + + local target_failed=false + while IFS= read -r rname; do + [[ -z "$rname" ]] && continue + log_info "--- Transferring $target_name to remote '$rname' ---" + if ! backup_target "$target_name" "$rname"; then + log_error "Backup to remote '$rname' failed for $target_name" + failed_targets+=" - $target_name ($rname: failed)"$'\n' + target_failed=true + fi + done <<< "$remotes" + + if [[ "$target_failed" == "true" ]]; then + ((failed++)) || true + else + ((succeeded++)) || true + log_info "Backup completed for $target_name (all remotes)" + fi + done <<< "$targets" + + local end_time; end_time=$(date +%s) + local duration=$(( end_time - start_time )) + + # Print summary + echo "" + echo "============================================" + echo "Backup Summary" + echo "============================================" + echo "Timestamp: $(timestamp)" + echo "Duration: $(human_duration $duration)" + echo "Remotes: $(echo "$remotes" | tr '\n' ' ')" + echo "Total: $total" + echo "Succeeded: ${C_GREEN}${succeeded}${C_RESET}" + if (( failed > 0 )); then + echo "Failed: ${C_RED}${failed}${C_RESET}" + echo "" + echo "Failed targets:" + echo "$failed_targets" + else + echo "Failed: 0" + fi + echo "============================================" + + # Send notification + send_backup_report "$total" "$succeeded" "$failed" "$duration" "$failed_targets" + + if (( failed > 0 && succeeded > 0 )); then + return "$EXIT_PARTIAL" + elif (( failed > 0 )); then + return 1 + fi + return 0 +} diff --git a/lib/config.sh b/lib/config.sh new file mode 100644 index 0000000..00ebb44 --- /dev/null +++ b/lib/config.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# gniza4linux/lib/config.sh — Shell-variable config loading & validation + +[[ -n "${_GNIZA4LINUX_CONFIG_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_CONFIG_LOADED=1 + +# Safe config parser — reads KEY=VALUE lines without executing arbitrary code. +# Only processes lines matching ^[A-Z_][A-Z_0-9]*= and strips surrounding quotes. +_safe_source_config() { + local filepath="$1" + local line key value + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + # Match KEY=VALUE (optional quotes) + if [[ "$line" =~ ^([A-Z_][A-Z_0-9]*)=(.*) ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + # Strip surrounding double or single quotes + if [[ "$value" =~ ^\"(.*)\"$ ]]; then + value="${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^\'(.*)\'$ ]]; then + value="${BASH_REMATCH[1]}" + fi + declare -g "$key=$value" + fi + done < "$filepath" +} + +load_config() { + local config_file="${1:-$CONFIG_DIR/gniza.conf}" + + if [[ ! -f "$config_file" ]]; then + die "Config file not found: $config_file (copy gniza.conf.example to $CONFIG_DIR/gniza.conf)" + fi + + # Parse the config (safe key=value reader, no code execution) + _safe_source_config "$config_file" || die "Failed to parse config file: $config_file" + + # Apply defaults for optional settings + BACKUP_MODE="${BACKUP_MODE:-$DEFAULT_BACKUP_MODE}" + BWLIMIT="${BWLIMIT:-$DEFAULT_BWLIMIT}" + RETENTION_COUNT="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}" + LOG_LEVEL="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}" + LOG_RETAIN="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}" + NOTIFY_EMAIL="${NOTIFY_EMAIL:-}" + NOTIFY_ON="${NOTIFY_ON:-$DEFAULT_NOTIFY_ON}" + SMTP_HOST="${SMTP_HOST:-}" + SMTP_PORT="${SMTP_PORT:-$DEFAULT_SMTP_PORT}" + SMTP_USER="${SMTP_USER:-}" + SMTP_PASSWORD="${SMTP_PASSWORD:-}" + SMTP_FROM="${SMTP_FROM:-}" + SMTP_SECURITY="${SMTP_SECURITY:-$DEFAULT_SMTP_SECURITY}" + SSH_TIMEOUT="${SSH_TIMEOUT:-$DEFAULT_SSH_TIMEOUT}" + SSH_RETRIES="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" + RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}" + + # --debug flag overrides config + [[ "${GNIZA4LINUX_DEBUG:-false}" == "true" ]] && LOG_LEVEL="debug" + + 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 SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS +} + +validate_config() { + local errors=0 + + # Per-remote validation is handled by validate_remote() in remotes.sh. + # Here we only validate local/global settings. + + case "$BACKUP_MODE" in + full|incremental) ;; + *) log_error "BACKUP_MODE must be full|incremental, got: $BACKUP_MODE"; ((errors++)) || true ;; + esac + + case "$NOTIFY_ON" in + always|failure|never) ;; + *) log_error "NOTIFY_ON must be always|failure|never, got: $NOTIFY_ON"; ((errors++)) || true ;; + esac + + case "$LOG_LEVEL" in + debug|info|warn|error) ;; + *) log_error "LOG_LEVEL must be debug|info|warn|error, got: $LOG_LEVEL"; ((errors++)) || true ;; + esac + + # SMTP validation (only when SMTP_HOST is set) + if [[ -n "${SMTP_HOST:-}" ]]; then + case "$SMTP_SECURITY" in + tls|ssl|none) ;; + *) log_error "SMTP_SECURITY must be tls|ssl|none, got: $SMTP_SECURITY"; ((errors++)) || true ;; + esac + + if [[ -n "${SMTP_PORT:-}" ]] && { [[ ! "$SMTP_PORT" =~ ^[0-9]+$ ]] || (( SMTP_PORT < 1 || SMTP_PORT > 65535 )); }; then + log_error "SMTP_PORT must be 1-65535, got: $SMTP_PORT" + ((errors++)) || true + fi + fi + + # Validate numeric fields + if [[ -n "${SSH_TIMEOUT:-}" ]] && [[ ! "$SSH_TIMEOUT" =~ ^[0-9]+$ ]]; then + log_error "SSH_TIMEOUT must be a non-negative integer, got: $SSH_TIMEOUT" + ((errors++)) || true + fi + + if [[ -n "${SSH_RETRIES:-}" ]] && [[ ! "$SSH_RETRIES" =~ ^[0-9]+$ ]]; then + log_error "SSH_RETRIES must be a non-negative integer, got: $SSH_RETRIES" + ((errors++)) || true + fi + + if [[ -n "${LOG_RETAIN:-}" ]] && [[ ! "$LOG_RETAIN" =~ ^[0-9]+$ ]]; then + log_error "LOG_RETAIN must be a non-negative integer, got: $LOG_RETAIN" + ((errors++)) || true + fi + + if [[ -n "${BWLIMIT:-}" ]] && [[ ! "$BWLIMIT" =~ ^[0-9]+$ ]]; then + log_error "BWLIMIT must be a non-negative integer (KB/s), got: $BWLIMIT" + ((errors++)) || true + fi + + if [[ -n "${RETENTION_COUNT:-}" ]] && [[ ! "$RETENTION_COUNT" =~ ^[0-9]+$ ]]; then + log_error "RETENTION_COUNT must be a non-negative integer, got: $RETENTION_COUNT" + ((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" + ((errors++)) || true + fi + + if (( errors > 0 )); then + log_error "Configuration has $errors error(s)" + return 1 + fi + return 0 +} diff --git a/lib/constants.sh b/lib/constants.sh new file mode 100644 index 0000000..a81a308 --- /dev/null +++ b/lib/constants.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# gniza4linux/lib/constants.sh — Version, exit codes, colors, defaults +# shellcheck disable=SC2034 # constants are used by sourcing scripts + +[[ -n "${_GNIZA4LINUX_CONSTANTS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_CONSTANTS_LOADED=1 + +readonly GNIZA4LINUX_VERSION="0.1.0" +readonly GNIZA4LINUX_NAME="gniza4linux" + +# Exit codes +readonly EXIT_OK=0 +readonly EXIT_FATAL=1 +readonly EXIT_LOCKED=2 +readonly EXIT_PARTIAL=5 + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + readonly C_RED=$'\033[0;31m' + readonly C_GREEN=$'\033[0;32m' + readonly C_YELLOW=$'\033[0;33m' + readonly C_BLUE=$'\033[0;34m' + readonly C_BOLD=$'\033[1m' + readonly C_RESET=$'\033[0m' +else + readonly C_RED="" + readonly C_GREEN="" + readonly C_YELLOW="" + readonly C_BLUE="" + readonly C_BOLD="" + readonly C_RESET="" +fi + +# Defaults +readonly DEFAULT_BACKUP_MODE="full" +readonly DEFAULT_REMOTE_AUTH_METHOD="key" +readonly DEFAULT_REMOTE_PORT=22 +readonly DEFAULT_REMOTE_USER="root" +readonly DEFAULT_REMOTE_BASE="/backups" +readonly DEFAULT_BWLIMIT=0 +readonly DEFAULT_RETENTION_COUNT=30 +readonly DEFAULT_LOG_LEVEL="info" +readonly DEFAULT_LOG_RETAIN=90 +readonly DEFAULT_NOTIFY_ON="failure" +readonly DEFAULT_SSH_TIMEOUT=30 +readonly DEFAULT_SSH_RETRIES=3 +readonly DEFAULT_REMOTE_TYPE="ssh" +readonly DEFAULT_S3_REGION="us-east-1" +readonly DEFAULT_SMTP_PORT=587 +readonly DEFAULT_SMTP_SECURITY="tls" diff --git a/lib/locking.sh b/lib/locking.sh new file mode 100644 index 0000000..fcc0459 --- /dev/null +++ b/lib/locking.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# gniza4linux/lib/locking.sh — flock-based concurrency control + +[[ -n "${_GNIZA4LINUX_LOCKING_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_LOCKING_LOADED=1 + +declare -g LOCK_FD="" + +acquire_lock() { + local lock_file="${LOCK_FILE:-/var/run/gniza.lock}" + local lock_dir; lock_dir=$(dirname "$lock_file") + mkdir -p "$lock_dir" || die "Cannot create lock directory: $lock_dir" + + exec {LOCK_FD}>"$lock_file" + + if ! flock -n "$LOCK_FD"; then + die "Another gniza process is running (lock: $lock_file)" "$EXIT_LOCKED" + fi + + echo $$ >&"$LOCK_FD" + log_debug "Lock acquired: $lock_file (PID $$)" +} + +release_lock() { + if [[ -n "$LOCK_FD" ]]; then + flock -u "$LOCK_FD" 2>/dev/null + exec {LOCK_FD}>&- 2>/dev/null + LOCK_FD="" + log_debug "Lock released" + fi +} diff --git a/lib/logging.sh b/lib/logging.sh new file mode 100644 index 0000000..f25900f --- /dev/null +++ b/lib/logging.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# gniza4linux/lib/logging.sh — Per-run log files, log_info/warn/error/debug + +[[ -n "${_GNIZA4LINUX_LOGGING_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_LOGGING_LOADED=1 + +declare -g LOG_FILE="" + +_log_level_num() { + case "$1" in + debug) echo 0 ;; + info) echo 1 ;; + warn) echo 2 ;; + error) echo 3 ;; + *) echo 1 ;; + esac +} + +init_logging() { + local log_dir="${LOG_DIR:-/var/log/gniza}" + mkdir -p "$log_dir" || die "Cannot create log directory: $log_dir" + + LOG_FILE="$log_dir/gniza-$(date -u +%Y%m%d-%H%M%S).log" + touch "$LOG_FILE" || die "Cannot write to log file: $LOG_FILE" + + # Clean old logs + local retain="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}" + find "$log_dir" -name "gniza-*.log" -mtime +"$retain" -delete 2>/dev/null || true +} + +_log() { + local level="$1"; shift + local msg="$*" + + local ts; ts=$(date -u +"%d/%m/%Y %H:%M:%S") + local upper; upper=$(echo "$level" | tr '[:lower:]' '[:upper:]') + local line="[$ts] [$upper] $msg" + + local configured_level="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}" + local level_num; level_num=$(_log_level_num "$level") + local configured_num; configured_num=$(_log_level_num "$configured_level") + + # Log file: always write info/warn/error; debug only when LOG_LEVEL=debug + if [[ -n "$LOG_FILE" ]]; then + if [[ "$level" != "debug" ]] || (( level_num >= configured_num )); then + echo "$line" >> "$LOG_FILE" + fi + fi + + # Console: only print if level meets configured threshold + (( level_num < configured_num )) && return 0 + + case "$level" in + error) echo "${C_RED}${line}${C_RESET}" >&2 ;; + warn) echo "${C_YELLOW}${line}${C_RESET}" >&2 ;; + info) echo "${line}" >&2 ;; + debug) echo "${C_BLUE}${line}${C_RESET}" >&2 ;; + esac +} + +log_info() { _log info "$@"; } +log_warn() { _log warn "$@"; } +log_error() { _log error "$@"; } +log_debug() { _log debug "$@"; } diff --git a/lib/notify.sh b/lib/notify.sh new file mode 100644 index 0000000..3700eec --- /dev/null +++ b/lib/notify.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# gniza4linux/lib/notify.sh — Email notifications (SMTP via curl or legacy mail/sendmail) + +[[ -n "${_GNIZA4LINUX_NOTIFY_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_NOTIFY_LOADED=1 + +_send_via_smtp() { + local subject="$1" + local body="$2" + + local from="${SMTP_FROM:-$SMTP_USER}" + if [[ -z "$from" ]]; then + log_error "SMTP_FROM or SMTP_USER must be set for SMTP delivery" + return 1 + fi + + # Build the RFC 2822 message + local message="" + message+="From: $from"$'\r\n' + message+="To: $NOTIFY_EMAIL"$'\r\n' + message+="Subject: $subject"$'\r\n' + message+="Content-Type: text/plain; charset=UTF-8"$'\r\n' + message+="Date: $(date -R)"$'\r\n' + message+=$'\r\n' + message+="$body" + + # Build curl command + local -a curl_args=( + 'curl' '--silent' '--show-error' + '--connect-timeout' '30' + '--max-time' '60' + ) + + # Protocol URL based on security setting + case "${SMTP_SECURITY:-tls}" in + ssl) + curl_args+=("--url" "smtps://${SMTP_HOST}:${SMTP_PORT}") + ;; + tls) + curl_args+=("--url" "smtp://${SMTP_HOST}:${SMTP_PORT}" "--ssl-reqd") + ;; + none) + curl_args+=("--url" "smtp://${SMTP_HOST}:${SMTP_PORT}") + ;; + esac + + # Auth credentials + if [[ -n "${SMTP_USER:-}" ]]; then + curl_args+=("--user" "${SMTP_USER}:${SMTP_PASSWORD}") + fi + + # Sender + curl_args+=("--mail-from" "$from") + + # Recipients (split NOTIFY_EMAIL on commas) + local -a recipients + IFS=',' read -ra recipients <<< "$NOTIFY_EMAIL" + local rcpt + for rcpt in "${recipients[@]}"; do + rcpt="${rcpt## }" # trim leading space + rcpt="${rcpt%% }" # trim trailing space + [[ -n "$rcpt" ]] && curl_args+=("--mail-rcpt" "$rcpt") + done + + # Upload the message from stdin + curl_args+=("-T" "-") + + log_debug "Sending via SMTP to ${SMTP_HOST}:${SMTP_PORT} (${SMTP_SECURITY})" + + local curl_output + curl_output=$(echo "$message" | "${curl_args[@]}" 2>&1) + local rc=$? + + if (( rc == 0 )); then + return 0 + else + log_error "SMTP delivery failed (curl exit code: $rc): $curl_output" + return 1 + fi +} + +_send_via_legacy() { + local subject="$1" + local body="$2" + + # Split comma-separated emails for mail command + local -a recipients + IFS=',' read -ra recipients <<< "$NOTIFY_EMAIL" + + if command -v mail &>/dev/null; then + echo "$body" | mail -s "$subject" "${recipients[@]}" + elif command -v sendmail &>/dev/null; then + { + echo "To: $NOTIFY_EMAIL" + echo "Subject: $subject" + echo "Content-Type: text/plain; charset=UTF-8" + echo "" + echo "$body" + } | sendmail -t + else + log_warn "No mail command available, cannot send notification" + return 1 + fi + return 0 +} + +send_notification() { + local subject="$1" + local body="$2" + local success="${3:-true}" + + # Check if notifications are configured + [[ -z "${NOTIFY_EMAIL:-}" ]] && return 0 + + case "${NOTIFY_ON:-$DEFAULT_NOTIFY_ON}" in + never) return 0 ;; + failure) [[ "$success" == "true" ]] && return 0 ;; + always) ;; + esac + + local hostname; hostname=$(hostname -f) + local full_subject="[gniza] [$hostname] $subject" + + log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject" + + if [[ -n "${SMTP_HOST:-}" ]]; then + _send_via_smtp "$full_subject" "$body" + else + _send_via_legacy "$full_subject" "$body" + fi + + local rc=$? + if (( rc == 0 )); then + log_debug "Notification sent" + fi + return $rc +} + +send_backup_report() { + local total="$1" + local succeeded="$2" + local failed="$3" + local duration="$4" + local failed_targets="$5" + + local success="true" + local status="SUCCESS" + if (( failed > 0 )); then + if (( succeeded > 0 )); then + status="PARTIAL FAILURE" + else + status="FAILURE" + fi + success="false" + fi + + local body="" + body+="Backup Report: $status"$'\n' + body+="=============================="$'\n' + body+="Hostname: $(hostname -f)"$'\n' + body+="Timestamp: $(date -u +"%d/%m/%Y %H:%M:%S UTC")"$'\n' + body+="Duration: $(human_duration "$duration")"$'\n' + body+=""$'\n' + body+="Targets: $total total, $succeeded succeeded, $failed failed"$'\n' + + if [[ -n "$failed_targets" ]]; then + body+=""$'\n' + body+="Failed targets:"$'\n' + body+="$failed_targets"$'\n' + fi + + if [[ -n "$LOG_FILE" ]]; then + body+=""$'\n' + body+="Log file: $LOG_FILE"$'\n' + fi + + send_notification "Backup $status ($succeeded/$total)" "$body" "$success" +} diff --git a/lib/rclone.sh b/lib/rclone.sh new file mode 100644 index 0000000..a7bf45c --- /dev/null +++ b/lib/rclone.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# gniza4linux/lib/rclone.sh — Rclone transport layer for S3 and Google Drive remotes + +[[ -n "${_GNIZA4LINUX_RCLONE_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_RCLONE_LOADED=1 + +# ── Mode Detection ──────────────────────────────────────────── + +_is_rclone_mode() { + [[ "${REMOTE_TYPE:-ssh}" == "s3" || "${REMOTE_TYPE:-ssh}" == "gdrive" ]] +} + +# ── Rclone Config Generation ───────────────────────────────── + +_build_rclone_config() { + local tmpfile + local old_umask + old_umask=$(umask) + umask 077 + tmpfile=$(mktemp /tmp/gniza-rclone-XXXXXX.conf) || { + umask "$old_umask" + log_error "Failed to create temp rclone config" + return 1 + } + umask "$old_umask" + + case "${REMOTE_TYPE}" in + s3) + cat > "$tmpfile" <> "$tmpfile" + fi + ;; + gdrive) + cat > "$tmpfile" <> "$tmpfile" + fi + ;; + *) + rm -f "$tmpfile" + log_error "Unknown REMOTE_TYPE for rclone: ${REMOTE_TYPE}" + return 1 + ;; + esac + + echo "$tmpfile" +} + +_cleanup_rclone_config() { + local path="$1" + [[ -n "$path" && -f "$path" ]] && rm -f "$path" +} + +# ── Path Construction ───────────────────────────────────────── + +_rclone_remote_path() { + local subpath="${1:-}" + local hostname; hostname=$(hostname -f) + + case "${REMOTE_TYPE}" in + s3) + echo "remote:${S3_BUCKET}${REMOTE_BASE}/${hostname}${subpath:+/$subpath}" + ;; + gdrive) + echo "remote:${REMOTE_BASE}/${hostname}${subpath:+/$subpath}" + ;; + esac +} + +# ── Core Command Runner ────────────────────────────────────── + +# Run an rclone subcommand with auto config lifecycle. +# Usage: _rclone_cmd [args...] +_rclone_cmd() { + local subcmd="$1"; shift + local conf + conf=$(_build_rclone_config) || return 1 + + local rclone_opts=(--config "$conf") + if [[ "${BWLIMIT:-0}" -gt 0 ]]; then + rclone_opts+=(--bwlimit "${BWLIMIT}k") + fi + + log_debug "rclone $subcmd ${rclone_opts[*]} $*" + local rc=0 + rclone "$subcmd" "${rclone_opts[@]}" "$@" || rc=$? + + _cleanup_rclone_config "$conf" + return "$rc" +} + +# ── Transfer Functions ──────────────────────────────────────── + +rclone_to_remote() { + local source_dir="$1" + local remote_subpath="$2" + local attempt=0 + local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" + local remote_dest; remote_dest=$(_rclone_remote_path "$remote_subpath") + + [[ "$source_dir" != */ ]] && source_dir="$source_dir/" + + while (( attempt < max_retries )); do + ((attempt++)) || true + log_debug "rclone copy attempt $attempt/$max_retries: $source_dir -> $remote_dest" + + if _rclone_cmd copy "$source_dir" "$remote_dest"; then + log_debug "rclone copy succeeded on attempt $attempt" + return 0 + fi + + log_warn "rclone copy failed, attempt $attempt/$max_retries" + if (( attempt < max_retries )); then + local backoff=$(( attempt * 10 )) + log_info "Retrying in ${backoff}s..." + sleep "$backoff" + fi + done + + log_error "rclone copy failed after $max_retries attempts" + return 1 +} + +rclone_from_remote() { + local remote_subpath="$1" + local local_dir="$2" + local attempt=0 + local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" + local remote_src; remote_src=$(_rclone_remote_path "$remote_subpath") + + mkdir -p "$local_dir" || { + log_error "Failed to create local dir: $local_dir" + return 1 + } + + while (( attempt < max_retries )); do + ((attempt++)) || true + log_debug "rclone copy attempt $attempt/$max_retries: $remote_src -> $local_dir" + + if _rclone_cmd copy "$remote_src" "$local_dir"; then + log_debug "rclone download succeeded on attempt $attempt" + return 0 + fi + + log_warn "rclone download failed, attempt $attempt/$max_retries" + if (( attempt < max_retries )); then + local backoff=$(( attempt * 10 )) + log_info "Retrying in ${backoff}s..." + sleep "$backoff" + fi + done + + log_error "rclone download failed after $max_retries attempts" + return 1 +} + +# Like rclone_from_remote but passes extra args (e.g. --exclude) to rclone copy. +# Usage: rclone_from_remote_filtered [extra_args...] +rclone_from_remote_filtered() { + local remote_subpath="$1" + local local_dir="$2" + shift 2 + local -a extra_args=("$@") + local attempt=0 + local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" + local remote_src; remote_src=$(_rclone_remote_path "$remote_subpath") + + mkdir -p "$local_dir" || { + log_error "Failed to create local dir: $local_dir" + return 1 + } + + while (( attempt < max_retries )); do + ((attempt++)) || true + log_debug "rclone copy (filtered) attempt $attempt/$max_retries: $remote_src -> $local_dir" + + if _rclone_cmd copy "$remote_src" "$local_dir" "${extra_args[@]}"; then + log_debug "rclone download succeeded on attempt $attempt" + return 0 + fi + + log_warn "rclone download failed, attempt $attempt/$max_retries" + if (( attempt < max_retries )); then + local backoff=$(( attempt * 10 )) + log_info "Retrying in ${backoff}s..." + sleep "$backoff" + fi + done + + log_error "rclone download failed after $max_retries attempts" + return 1 +} + +# ── Snapshot Management ─────────────────────────────────────── + +rclone_list_dirs() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd lsf --dirs-only "$remote_path" 2>/dev/null | sed 's|/$||' +} + +rclone_list_remote_snapshots() { + local target_name="$1" + local snap_subpath="targets/${target_name}/snapshots" + local all_dirs; all_dirs=$(rclone_list_dirs "$snap_subpath") || true + [[ -z "$all_dirs" ]] && return 0 + + # Filter to dirs with .complete marker, sorted newest first + local completed="" + while IFS= read -r dir; do + [[ -z "$dir" ]] && continue + if rclone_exists "${snap_subpath}/${dir}/.complete"; then + completed+="${dir}"$'\n' + fi + done <<< "$all_dirs" + + [[ -n "$completed" ]] && echo "$completed" | sort -r +} + +rclone_get_latest_snapshot() { + local target_name="$1" + local snap_subpath="targets/${target_name}/snapshots" + + # Try reading latest.txt first + local latest; latest=$(rclone_cat "${snap_subpath}/latest.txt" 2>/dev/null) || true + if [[ -n "$latest" ]]; then + # Verify it still exists with .complete marker + if rclone_exists "${snap_subpath}/${latest}/.complete"; then + echo "$latest" + return 0 + fi + fi + + # Fall back to sorted list + rclone_list_remote_snapshots "$target_name" | head -1 +} + +rclone_clean_partial_snapshots() { + local target_name="$1" + local snap_subpath="targets/${target_name}/snapshots" + local all_dirs; all_dirs=$(rclone_list_dirs "$snap_subpath") || true + [[ -z "$all_dirs" ]] && return 0 + + while IFS= read -r dir; do + [[ -z "$dir" ]] && continue + if ! rclone_exists "${snap_subpath}/${dir}/.complete"; then + log_info "Purging incomplete snapshot for $target_name: $dir" + rclone_purge "${snap_subpath}/${dir}" || { + log_warn "Failed to purge incomplete snapshot: $dir" + } + fi + done <<< "$all_dirs" +} + +rclone_finalize_snapshot() { + local target_name="$1" + local ts="$2" + local snap_subpath="targets/${target_name}/snapshots" + + # Create .complete marker + rclone_rcat "${snap_subpath}/${ts}/.complete" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" || { + log_error "Failed to create .complete marker for $target_name/$ts" + return 1 + } + + # Update latest.txt + rclone_update_latest "$target_name" "$ts" +} + +rclone_update_latest() { + local target_name="$1" + local ts="$2" + local snap_subpath="targets/${target_name}/snapshots" + + rclone_rcat "${snap_subpath}/latest.txt" "$ts" || { + log_warn "Failed to update latest.txt for $target_name" + return 1 + } + log_debug "Updated latest.txt for $target_name -> $ts" +} + +rclone_resolve_snapshot() { + local target_name="$1" + local requested="$2" + local snap_subpath="targets/${target_name}/snapshots" + + if rclone_exists "${snap_subpath}/${requested}/.complete"; then + echo "$requested" + else + log_error "Snapshot not found or incomplete for $target_name: $requested" + return 1 + fi +} + +# ── Remote Operations ───────────────────────────────────────── + +rclone_ensure_dir() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd mkdir "$remote_path" +} + +rclone_purge() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd purge "$remote_path" +} + +rclone_exists() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd lsf "$remote_path" &>/dev/null +} + +rclone_size() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd size --json "$remote_path" 2>/dev/null +} + +rclone_list_files() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd lsf "$remote_path" 2>/dev/null +} + +rclone_cat() { + local remote_subpath="$1" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + _rclone_cmd cat "$remote_path" 2>/dev/null +} + +rclone_rcat() { + local remote_subpath="$1" + local content="$2" + local remote_path; remote_path=$(_rclone_remote_path "$remote_subpath") + echo -n "$content" | _rclone_cmd rcat "$remote_path" +} + +test_rclone_connection() { + local remote_path + case "${REMOTE_TYPE}" in + s3) + remote_path="remote:${S3_BUCKET}" + ;; + gdrive) + remote_path="remote:${REMOTE_BASE}" + ;; + *) + log_error "Unknown REMOTE_TYPE: ${REMOTE_TYPE}" + return 1 + ;; + esac + + log_debug "Testing rclone connection to ${REMOTE_TYPE}..." + if _rclone_cmd lsd "$remote_path" &>/dev/null; then + log_debug "Rclone connection test passed" + return 0 + else + log_error "Rclone connection test failed for ${REMOTE_TYPE}" + return 1 + fi +} diff --git a/lib/remotes.sh b/lib/remotes.sh new file mode 100644 index 0000000..ff83772 --- /dev/null +++ b/lib/remotes.sh @@ -0,0 +1,289 @@ +#!/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 +} diff --git a/lib/restore.sh b/lib/restore.sh new file mode 100644 index 0000000..fa38386 --- /dev/null +++ b/lib/restore.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# gniza4linux/lib/restore.sh — Restore flows for targets + +[[ -n "${_GNIZA4LINUX_RESTORE_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_RESTORE_LOADED=1 + +# Helper: rsync download from SSH remote to local. +_rsync_download() { + local remote_path="$1" + local local_path="$2" + local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd) + if _is_password_mode; then + export SSHPASS="$REMOTE_PASSWORD" + sshpass -e rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \ + -e "$rsync_ssh" \ + "${REMOTE_USER}@${REMOTE_HOST}:${remote_path}" \ + "$local_path" + else + rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \ + -e "$rsync_ssh" \ + "${REMOTE_USER}@${REMOTE_HOST}:${remote_path}" \ + "$local_path" + fi +} + +# Restore all folders from a snapshot. +# Usage: restore_target [dest_dir] +restore_target() { + local target_name="$1" + local snapshot_ts="${2:-latest}" + local remote_name="$3" + local dest_dir="${4:-}" + + load_target "$target_name" || { + log_error "Failed to load target: $target_name" + return 1 + } + + _save_remote_globals + load_remote "$remote_name" || { + log_error "Failed to load remote: $remote_name" + _restore_remote_globals + return 1 + } + + # Resolve snapshot timestamp + local ts; ts=$(resolve_snapshot_timestamp "$target_name" "$snapshot_ts") || { + log_error "Cannot resolve snapshot: $snapshot_ts" + _restore_remote_globals + return 1 + } + + log_info "Restoring target '$target_name' from snapshot $ts (remote: $remote_name)" + + if [[ -z "$dest_dir" ]]; then + log_warn "No destination specified; restoring to original locations (IN-PLACE)" + fi + + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local folder + local errors=0 + + while IFS= read -r folder; do + [[ -z "$folder" ]] && continue + local rel_path="${folder#/}" + local restore_dest + if [[ -n "$dest_dir" ]]; then + restore_dest="$dest_dir/$rel_path" + else + restore_dest="$folder" + fi + + mkdir -p "$restore_dest" || { + log_error "Failed to create destination: $restore_dest" + ((errors++)) || true + continue + } + + log_info "Restoring $rel_path -> $restore_dest" + + if _is_rclone_mode; then + local snap_subpath="targets/${target_name}/snapshots/${ts}/${rel_path}" + rclone_from_remote "$snap_subpath" "$restore_dest" || { + log_error "Restore failed for folder: $folder" + ((errors++)) || true + } + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local source_path="$snap_dir/$ts/$rel_path/" + rsync -aHAX --numeric-ids "$source_path" "$restore_dest/" || { + log_error "Restore failed for folder: $folder" + ((errors++)) || true + } + else + local source_path="$snap_dir/$ts/$rel_path/" + _rsync_download "$source_path" "$restore_dest/" || { + log_error "Restore failed for folder: $folder" + ((errors++)) || true + } + fi + done < <(get_target_folders) + + _restore_remote_globals + + if (( errors > 0 )); then + log_error "Restore completed with $errors error(s)" + return 1 + fi + + log_info "Restore completed successfully for $target_name" + return 0 +} + +# Restore a single folder from a snapshot. +# Usage: restore_folder [dest_dir] +restore_folder() { + local target_name="$1" + local folder_path="$2" + local snapshot_ts="${3:-latest}" + local remote_name="$4" + local dest_dir="${5:-}" + + load_target "$target_name" || { + log_error "Failed to load target: $target_name" + return 1 + } + + _save_remote_globals + load_remote "$remote_name" || { + log_error "Failed to load remote: $remote_name" + _restore_remote_globals + return 1 + } + + local ts; ts=$(resolve_snapshot_timestamp "$target_name" "$snapshot_ts") || { + log_error "Cannot resolve snapshot: $snapshot_ts" + _restore_remote_globals + return 1 + } + + local rel_path="${folder_path#/}" + local restore_dest + if [[ -n "$dest_dir" ]]; then + restore_dest="$dest_dir/$rel_path" + else + restore_dest="$folder_path" + log_warn "No destination specified; restoring to original location (IN-PLACE): $folder_path" + fi + + mkdir -p "$restore_dest" || { + log_error "Failed to create destination: $restore_dest" + _restore_remote_globals + return 1 + } + + log_info "Restoring $rel_path -> $restore_dest (snapshot: $ts)" + + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local rc=0 + + if _is_rclone_mode; then + local snap_subpath="targets/${target_name}/snapshots/${ts}/${rel_path}" + rclone_from_remote "$snap_subpath" "$restore_dest" || rc=$? + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local source_path="$snap_dir/$ts/$rel_path/" + rsync -aHAX --numeric-ids "$source_path" "$restore_dest/" || rc=$? + else + local source_path="$snap_dir/$ts/$rel_path/" + _rsync_download "$source_path" "$restore_dest/" || rc=$? + fi + + _restore_remote_globals + + if (( rc != 0 )); then + log_error "Restore failed for $folder_path" + return 1 + fi + + log_info "Restore completed for $folder_path" + return 0 +} + +# List files in a snapshot. +# Usage: list_snapshot_contents +list_snapshot_contents() { + local target_name="$1" + local snapshot_ts="${2:-latest}" + local remote_name="$3" + + _save_remote_globals + load_remote "$remote_name" || { + log_error "Failed to load remote: $remote_name" + _restore_remote_globals + return 1 + } + + local ts; ts=$(resolve_snapshot_timestamp "$target_name" "$snapshot_ts") || { + log_error "Cannot resolve snapshot: $snapshot_ts" + _restore_remote_globals + return 1 + } + + if _is_rclone_mode; then + local snap_subpath="targets/${target_name}/snapshots/${ts}" + rclone_list_files "$snap_subpath" + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + find "$snap_dir/$ts" -type f 2>/dev/null + else + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + remote_exec "find '$snap_dir/$ts' -type f 2>/dev/null" 2>/dev/null + fi + + _restore_remote_globals +} + +# Read meta.json from a snapshot. +# Usage: get_snapshot_meta +get_snapshot_meta() { + local target_name="$1" + local snapshot_ts="${2:-latest}" + local remote_name="$3" + + _save_remote_globals + load_remote "$remote_name" || { + log_error "Failed to load remote: $remote_name" + _restore_remote_globals + return 1 + } + + local ts; ts=$(resolve_snapshot_timestamp "$target_name" "$snapshot_ts") || { + log_error "Cannot resolve snapshot: $snapshot_ts" + _restore_remote_globals + return 1 + } + + if _is_rclone_mode; then + local snap_subpath="targets/${target_name}/snapshots/${ts}/meta.json" + rclone_cat "$snap_subpath" + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + cat "$snap_dir/$ts/meta.json" 2>/dev/null + else + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + remote_exec "cat '$snap_dir/$ts/meta.json'" 2>/dev/null + fi + + _restore_remote_globals +} diff --git a/lib/retention.sh b/lib/retention.sh new file mode 100644 index 0000000..2dfbf86 --- /dev/null +++ b/lib/retention.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# gniza4linux/lib/retention.sh — Delete old snapshots beyond RETENTION_COUNT on remote + +[[ -n "${_GNIZA4LINUX_RETENTION_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_RETENTION_LOADED=1 + +enforce_retention() { + local target_name="$1" + local keep="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}" + + log_debug "Enforcing retention for $target_name: keeping $keep snapshots" + + # Get completed snapshots sorted newest first + local snapshots; snapshots=$(list_remote_snapshots "$target_name") + if [[ -z "$snapshots" ]]; then + log_debug "No snapshots found for $target_name, nothing to prune" + return 0 + fi + + local count=0 + local pruned=0 + while IFS= read -r snap; do + [[ -z "$snap" ]] && continue + ((count++)) || true + if (( count > keep )); then + # Skip pinned snapshots + local is_pinned=false + if _is_rclone_mode; then + local meta; meta=$(rclone_cat "targets/${target_name}/snapshots/${snap}/meta.json" 2>/dev/null) || true + if [[ -n "$meta" ]] && echo "$meta" | grep -q '"pinned":\s*true'; then + is_pinned=true + fi + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local meta_file="$snap_dir/$snap/meta.json" + if [[ -f "$meta_file" ]] && grep -q '"pinned":\s*true' "$meta_file" 2>/dev/null; then + is_pinned=true + fi + else + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local meta_content; meta_content=$(remote_exec "cat '$snap_dir/$snap/meta.json' 2>/dev/null" 2>/dev/null) || true + if [[ -n "$meta_content" ]] && echo "$meta_content" | grep -q '"pinned":\s*true'; then + is_pinned=true + fi + fi + + if [[ "$is_pinned" == "true" ]]; then + log_info "Skipping pinned snapshot for $target_name: $snap" + continue + fi + + log_info "Pruning old snapshot for $target_name: $snap" + if _is_rclone_mode; then + rclone_purge "targets/${target_name}/snapshots/${snap}" || { + log_warn "Failed to purge snapshot: $snap" + } + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + rm -rf "$snap_dir/$snap" || { + log_warn "Failed to prune snapshot: $snap_dir/$snap" + } + else + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + remote_exec "rm -rf '$snap_dir/$snap'" || { + log_warn "Failed to prune snapshot: $snap_dir/$snap" + } + fi + ((pruned++)) || true + fi + done <<< "$snapshots" + + if (( pruned > 0 )); then + log_info "Pruned $pruned old snapshot(s) for $target_name" + fi +} diff --git a/lib/schedule.sh b/lib/schedule.sh new file mode 100644 index 0000000..ff2153d --- /dev/null +++ b/lib/schedule.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +# gniza4linux/lib/schedule.sh — Cron management for decoupled schedules +# +# Schedules are defined in $CONFIG_DIR/schedules.d/.conf: +# SCHEDULE="hourly|daily|weekly|monthly|custom" +# SCHEDULE_TIME="HH:MM" +# SCHEDULE_DAY="" # dow (0-6) for weekly, dom (1-28) for monthly +# SCHEDULE_CRON="" # full 5-field cron expr for custom +# REMOTES="" # comma-separated remote names (empty = all) +# TARGETS="" # comma-separated target names (empty = all) +# +# Cron lines are tagged with "# gniza4linux:" for clean install/remove. + +[[ -n "${_GNIZA4LINUX_SCHEDULE_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_SCHEDULE_LOADED=1 + +readonly GNIZA4LINUX_CRON_TAG="# gniza4linux:" +SCHEDULES_DIR="$CONFIG_DIR/schedules.d" + +# ── Discovery ───────────────────────────────────────────────── + +# List schedule names (filenames without .conf) sorted alphabetically. +list_schedules() { + if [[ ! -d "$SCHEDULES_DIR" ]]; then + return 0 + fi + local f + for f in "$SCHEDULES_DIR"/*.conf; do + [[ -f "$f" ]] || continue + basename "$f" .conf + done +} + +# Return 0 if at least one schedule config exists. +has_schedules() { + local schedules + schedules=$(list_schedules) + [[ -n "$schedules" ]] +} + +# ── Loading ─────────────────────────────────────────────────── + +# Source a schedule config and set SCHEDULE/REMOTES/TARGETS globals. +# Usage: load_schedule +load_schedule() { + local name="$1" + local conf="$SCHEDULES_DIR/${name}.conf" + + if [[ ! -f "$conf" ]]; then + log_error "Schedule config not found: $conf" + return 1 + fi + + # Reset schedule globals before sourcing + SCHEDULE="" + SCHEDULE_TIME="" + SCHEDULE_DAY="" + SCHEDULE_CRON="" + SCHEDULE_REMOTES="" + SCHEDULE_TARGETS="" + + _safe_source_config "$conf" || { + log_error "Failed to parse schedule config: $conf" + return 1 + } + + # Map REMOTES/TARGETS to SCHEDULE_* to avoid conflicts + SCHEDULE_REMOTES="${REMOTES:-}" + SCHEDULE_TARGETS="${TARGETS:-}" + + log_debug "Loaded schedule '$name': ${SCHEDULE} at ${SCHEDULE_TIME:-02:00}, remotes=${SCHEDULE_REMOTES:-all}, targets=${SCHEDULE_TARGETS:-all}" +} + +# ── Cron Generation ─────────────────────────────────────────── + +# Convert schedule vars to a 5-field cron expression. +# Must be called after load_schedule() sets SCHEDULE/SCHEDULE_TIME/etc. +schedule_to_cron() { + local name="$1" + local schedule="${SCHEDULE:-}" + local stime="${SCHEDULE_TIME:-02:00}" + local sday="${SCHEDULE_DAY:-}" + local scron="${SCHEDULE_CRON:-}" + + if [[ -z "$schedule" ]]; then + return 1 # no schedule configured + fi + + local hour minute + hour="${stime%%:*}" + minute="${stime##*:}" + # Strip leading zeros for cron + hour=$((10#$hour)) + minute=$((10#$minute)) + + case "$schedule" in + hourly) + if [[ -n "$sday" && "$sday" -gt 1 ]] 2>/dev/null; then + echo "$minute */$sday * * *" + else + echo "$minute * * * *" + fi + ;; + daily) + echo "$minute $hour * * *" + ;; + weekly) + if [[ -z "$sday" ]]; then + log_error "Schedule '$name': SCHEDULE_DAY required for weekly schedule" + return 1 + fi + echo "$minute $hour * * $sday" + ;; + monthly) + if [[ -z "$sday" ]]; then + log_error "Schedule '$name': SCHEDULE_DAY required for monthly schedule" + return 1 + fi + echo "$minute $hour $sday * *" + ;; + custom) + if [[ -z "$scron" ]]; then + log_error "Schedule '$name': SCHEDULE_CRON required for custom schedule" + return 1 + fi + echo "$scron" + ;; + *) + log_error "Schedule '$name': unknown SCHEDULE value: $schedule" + return 1 + ;; + esac +} + +# Resolve the installed binary path. +_gniza4linux_bin() { + if command -v gniza &>/dev/null; then + command -v gniza + else + # Fall back to the project's bin directory + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + echo "$(dirname "$script_dir")/bin/gniza" + fi +} + +# Build the full cron line for a schedule. +# Uses SCHEDULE_REMOTES and SCHEDULE_TARGETS if set. +build_cron_line() { + local name="$1" + local cron_expr + cron_expr=$(schedule_to_cron "$name") || return 1 + + local bin_path; bin_path=$(_gniza4linux_bin) + local extra_flags="" + if [[ -n "$SCHEDULE_REMOTES" ]]; then + extra_flags+=" --remote=$SCHEDULE_REMOTES" + fi + if [[ -n "$SCHEDULE_TARGETS" ]]; then + extra_flags+=" --target=$SCHEDULE_TARGETS" + fi + + echo "$cron_expr $bin_path backup${extra_flags} >/dev/null 2>&1" +} + +# ── Crontab Management ──────────────────────────────────────── + +# Install cron entries for all schedules in schedules.d/. +# Strips any existing gniza4linux entries first, then appends new ones. +install_schedules() { + if ! has_schedules; then + log_error "No schedules configured in $SCHEDULES_DIR" + return 1 + fi + + # Collect new cron lines + local new_lines="" + local count=0 + local schedules; schedules=$(list_schedules) + + while IFS= read -r sname; do + [[ -z "$sname" ]] && continue + load_schedule "$sname" || { log_error "Skipping schedule '$sname': failed to load"; continue; } + + if [[ -z "${SCHEDULE:-}" ]]; then + log_debug "Schedule '$sname' has no SCHEDULE type, skipping" + continue + fi + + local cron_line + cron_line=$(build_cron_line "$sname") || { log_error "Skipping schedule '$sname': invalid schedule"; continue; } + + new_lines+="${GNIZA4LINUX_CRON_TAG}${sname}"$'\n' + new_lines+="${cron_line}"$'\n' + ((count++)) || true + done <<< "$schedules" + + if (( count == 0 )); then + log_warn "No valid schedules found" + return 1 + fi + + # Get current crontab, strip old gniza4linux lines + local current_crontab="" + current_crontab=$(crontab -l 2>/dev/null) || true + + local filtered="" + local skip_next=false + while IFS= read -r line; do + if [[ "$line" == "${GNIZA4LINUX_CRON_TAG}"* ]]; then + skip_next=true + continue + fi + if [[ "$skip_next" == "true" ]]; then + skip_next=false + continue + fi + filtered+="$line"$'\n' + done <<< "$current_crontab" + + # Append new lines + local final="${filtered}${new_lines}" + + # Install + echo "$final" | crontab - || { + log_error "Failed to install crontab" + return 1 + } + + echo "Installed $count schedule(s):" + echo "" + + # Show what was installed + while IFS= read -r sname; do + [[ -z "$sname" ]] && continue + load_schedule "$sname" 2>/dev/null || continue + [[ -z "${SCHEDULE:-}" ]] && continue + local cron_line; cron_line=$(build_cron_line "$sname" 2>/dev/null) || continue + echo " [$sname] $cron_line" + done <<< "$schedules" +} + +# Display current gniza4linux cron entries. +show_schedules() { + local current_crontab="" + current_crontab=$(crontab -l 2>/dev/null) || true + + if [[ -z "$current_crontab" ]]; then + echo "No crontab entries found." + return 0 + fi + + local found=false + local next_is_command=false + local current_tag="" + while IFS= read -r line; do + if [[ "$line" == "${GNIZA4LINUX_CRON_TAG}"* ]]; then + current_tag="${line#"$GNIZA4LINUX_CRON_TAG"}" + next_is_command=true + continue + fi + if [[ "$next_is_command" == "true" ]]; then + next_is_command=false + if [[ "$found" == "false" ]]; then + echo "Current gniza schedules:" + echo "" + found=true + fi + echo " [$current_tag] $line" + fi + done <<< "$current_crontab" + + if [[ "$found" == "false" ]]; then + echo "No gniza schedule entries in crontab." + fi +} + +# Remove all gniza4linux cron entries. +remove_schedules() { + local current_crontab="" + current_crontab=$(crontab -l 2>/dev/null) || true + + if [[ -z "$current_crontab" ]]; then + echo "No crontab entries to remove." + return 0 + fi + + local filtered="" + local skip_next=false + local removed=0 + while IFS= read -r line; do + if [[ "$line" == "${GNIZA4LINUX_CRON_TAG}"* ]]; then + skip_next=true + ((removed++)) || true + continue + fi + if [[ "$skip_next" == "true" ]]; then + skip_next=false + continue + fi + filtered+="$line"$'\n' + done <<< "$current_crontab" + + if (( removed == 0 )); then + echo "No gniza schedule entries found in crontab." + return 0 + fi + + echo "$filtered" | crontab - || { + log_error "Failed to update crontab" + return 1 + } + + echo "Removed $removed gniza schedule(s) from crontab." +} diff --git a/lib/snapshot.sh b/lib/snapshot.sh new file mode 100644 index 0000000..0061f4d --- /dev/null +++ b/lib/snapshot.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# gniza4linux/lib/snapshot.sh — Timestamp naming, list/resolve snapshots, latest symlink + +[[ -n "${_GNIZA4LINUX_SNAPSHOT_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_SNAPSHOT_LOADED=1 + +get_remote_target_base() { + local target_name="$1" + local hostname; hostname=$(hostname -f) + echo "${REMOTE_BASE}/${hostname}/targets/${target_name}" +} + +get_snapshot_dir() { + local target_name="$1" + echo "$(get_remote_target_base "$target_name")/snapshots" +} + +list_remote_snapshots() { + local target_name="$1" + + if _is_rclone_mode; then + rclone_list_remote_snapshots "$target_name" + return + fi + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local raw + raw=$(ls -1d "$snap_dir"/[0-9]* 2>/dev/null | grep -v '\.partial$' | sort -r) || true + if [[ -n "$raw" ]]; then + echo "$raw" | xargs -I{} basename {} | sort -r + fi + return + fi + + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + + # List completed snapshots (no .partial suffix), sorted newest first + local raw; raw=$(remote_exec "ls -1d '$snap_dir'/[0-9]* 2>/dev/null | grep -v '\\.partial$' | sort -r" 2>/dev/null) || true + if [[ -n "$raw" ]]; then + echo "$raw" | xargs -I{} basename {} | sort -r + fi +} + +get_latest_snapshot() { + local target_name="$1" + + if _is_rclone_mode; then + rclone_get_latest_snapshot "$target_name" + return + fi + + list_remote_snapshots "$target_name" | head -1 +} + +resolve_snapshot_timestamp() { + local target_name="$1" + local requested="$2" + + if [[ -z "$requested" || "$requested" == "LATEST" || "$requested" == "latest" ]]; then + get_latest_snapshot "$target_name" + elif _is_rclone_mode; then + rclone_resolve_snapshot "$target_name" "$requested" + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + if [[ -d "$snap_dir/$requested" ]]; then + echo "$requested" + else + log_error "Snapshot not found for $target_name: $requested" + return 1 + fi + else + # Verify it exists on SSH remote + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + if remote_exec "test -d '$snap_dir/$requested'" 2>/dev/null; then + echo "$requested" + else + log_error "Snapshot not found for $target_name: $requested" + return 1 + fi + fi +} + +update_latest_symlink() { + local target_name="$1" + local timestamp="$2" + + if _is_rclone_mode; then + rclone_update_latest "$target_name" "$timestamp" + return + fi + + local base; base=$(get_remote_target_base "$target_name") + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + ln -sfn "$snap_dir/$timestamp" "$base/latest" || { + log_warn "Failed to update latest symlink for $target_name" + return 1 + } + else + remote_exec "ln -sfn '$snap_dir/$timestamp' '$base/latest'" || { + log_warn "Failed to update latest symlink for $target_name" + return 1 + } + fi + log_debug "Updated latest symlink for $target_name -> $timestamp" +} + +clean_partial_snapshots() { + local target_name="$1" + + if _is_rclone_mode; then + rclone_clean_partial_snapshots "$target_name" + return + fi + + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + local partials + partials=$(ls -1d "$snap_dir"/*.partial 2>/dev/null) || true + if [[ -n "$partials" ]]; then + log_info "Cleaning partial snapshots for $target_name..." + rm -rf "$snap_dir"/*.partial || { + log_warn "Failed to clean partial snapshots for $target_name" + } + fi + return + fi + + local partials; partials=$(remote_exec "ls -1d '$snap_dir'/*.partial 2>/dev/null" 2>/dev/null) || true + if [[ -n "$partials" ]]; then + log_info "Cleaning partial snapshots for $target_name..." + remote_exec "rm -rf '$snap_dir'/*.partial" || { + log_warn "Failed to clean partial snapshots for $target_name" + } + fi +} + +list_remote_targets() { + if _is_rclone_mode; then + rclone_list_dirs "targets" + return + fi + + local hostname; hostname=$(hostname -f) + local targets_dir="${REMOTE_BASE}/${hostname}/targets" + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + ls -1 "$targets_dir" 2>/dev/null || true + return + fi + + remote_exec "ls -1 '$targets_dir' 2>/dev/null" 2>/dev/null || true +} diff --git a/lib/ssh.sh b/lib/ssh.sh new file mode 100644 index 0000000..6f5a688 --- /dev/null +++ b/lib/ssh.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ssh.sh — SSH connectivity, remote exec, ssh_opts builder + +[[ -n "${_GNIZA_SSH_LOADED:-}" ]] && return 0 +_GNIZA_SSH_LOADED=1 + +_is_password_mode() { + [[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]] +} + +build_ssh_opts() { + local opts=() + opts+=(-n) + if _is_password_mode; then + opts+=(-o "StrictHostKeyChecking=yes") + else + opts+=(-i "$REMOTE_KEY") + opts+=(-o "StrictHostKeyChecking=yes") + opts+=(-o "BatchMode=yes") + fi + opts+=(-p "$REMOTE_PORT") + opts+=(-o "ConnectTimeout=$SSH_TIMEOUT") + opts+=(-o "ServerAliveInterval=60") + opts+=(-o "ServerAliveCountMax=3") + echo "${opts[*]}" +} + +build_ssh_cmd() { + if _is_password_mode; then + echo "sshpass -e ssh $(build_ssh_opts)" + else + echo "ssh $(build_ssh_opts)" + fi +} + +remote_exec() { + local cmd="$1" + local ssh_opts; ssh_opts=$(build_ssh_opts) + if _is_password_mode; then + log_debug "CMD: sshpass -e ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} ''" + export SSHPASS="$REMOTE_PASSWORD" + # shellcheck disable=SC2086 + sshpass -e ssh $ssh_opts "${REMOTE_USER}@${REMOTE_HOST}" "$cmd" + else + log_debug "CMD: ssh $ssh_opts ${REMOTE_USER}@${REMOTE_HOST} '$cmd'" + # shellcheck disable=SC2086 + ssh $ssh_opts "${REMOTE_USER}@${REMOTE_HOST}" "$cmd" + fi +} + +remote_exec_quiet() { + remote_exec "$1" 2>/dev/null +} + +test_ssh_connection() { + log_info "Testing SSH connection to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}..." + if remote_exec "echo ok" &>/dev/null; then + log_info "SSH connection successful" + return 0 + else + log_error "SSH connection failed to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}" + return 1 + fi +} + +ensure_remote_dir() { + local dir="$1" + remote_exec "mkdir -p '$dir'" || { + log_error "Failed to create remote directory: $dir" + return 1 + } +} + +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" + fi +} diff --git a/lib/targets.sh b/lib/targets.sh new file mode 100644 index 0000000..7550bac --- /dev/null +++ b/lib/targets.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# gniza4linux/lib/targets.sh — Target CRUD for managing backup profiles + +[[ -n "${_GNIZA4LINUX_TARGETS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_TARGETS_LOADED=1 + +# ── Discovery ───────────────────────────────────────────────── + +# List target names (filenames without .conf) sorted alphabetically. +list_targets() { + local targets_dir="$CONFIG_DIR/targets.d" + if [[ ! -d "$targets_dir" ]]; then + return 0 + fi + local f + for f in "$targets_dir"/*.conf; do + [[ -f "$f" ]] || continue + basename "$f" .conf + done +} + +# Return 0 if at least one target config exists. +has_targets() { + local targets + targets=$(list_targets) + [[ -n "$targets" ]] +} + +# ── Loading ─────────────────────────────────────────────────── + +# Source a target config and set TARGET_* globals. +# Usage: load_target +load_target() { + local name="$1" + local conf="$CONFIG_DIR/targets.d/${name}.conf" + + if [[ ! -f "$conf" ]]; then + log_error "Target config not found: $conf" + return 1 + fi + + _safe_source_config "$conf" || { + log_error "Failed to parse target config: $conf" + return 1 + } + + # Apply defaults for optional fields + TARGET_NAME="${TARGET_NAME:-$name}" + TARGET_FOLDERS="${TARGET_FOLDERS:-}" + TARGET_EXCLUDE="${TARGET_EXCLUDE:-}" + TARGET_REMOTE="${TARGET_REMOTE:-}" + TARGET_RETENTION="${TARGET_RETENTION:-}" + TARGET_PRE_HOOK="${TARGET_PRE_HOOK:-}" + TARGET_POST_HOOK="${TARGET_POST_HOOK:-}" + TARGET_ENABLED="${TARGET_ENABLED:-yes}" + + log_debug "Loaded target '$name': folders=${TARGET_FOLDERS} enabled=${TARGET_ENABLED}" +} + +# ── Validation ──────────────────────────────────────────────── + +# Validate a loaded target config. +# Usage: validate_target +validate_target() { + local name="$1" + load_target "$name" || return 1 + + local errors=0 + + if [[ -z "$TARGET_NAME" ]]; then + log_error "Target '$name': TARGET_NAME is required" + ((errors++)) || true + fi + + if [[ -z "$TARGET_FOLDERS" ]]; then + log_error "Target '$name': TARGET_FOLDERS is required" + ((errors++)) || true + else + # Validate each folder exists + local -a folders + IFS=',' read -ra folders <<< "$TARGET_FOLDERS" + local folder + for folder in "${folders[@]}"; do + # Trim whitespace + folder="${folder#"${folder%%[![:space:]]*}"}" + folder="${folder%"${folder##*[![:space:]]}"}" + [[ -z "$folder" ]] && continue + if [[ "$folder" != /* ]]; then + log_error "Target '$name': folder path must be absolute: $folder" + ((errors++)) || true + elif [[ ! -d "$folder" ]]; then + log_error "Target '$name': folder does not exist: $folder" + ((errors++)) || true + fi + done + fi + + if [[ -n "$TARGET_ENABLED" && "$TARGET_ENABLED" != "yes" && "$TARGET_ENABLED" != "no" ]]; then + log_error "Target '$name': TARGET_ENABLED must be 'yes' or 'no', got: $TARGET_ENABLED" + ((errors++)) || true + fi + + (( errors > 0 )) && return 1 + return 0 +} + +# ── CRUD ────────────────────────────────────────────────────── + +# Write a target .conf file. +# Usage: create_target [exclude] [remote] [retention] [pre_hook] [post_hook] [enabled] +create_target() { + local name="$1" + local folders="$2" + local exclude="${3:-}" + local remote="${4:-}" + local retention="${5:-}" + local pre_hook="${6:-}" + local post_hook="${7:-}" + local enabled="${8:-yes}" + + validate_target_name "$name" || return 1 + + local conf="$CONFIG_DIR/targets.d/${name}.conf" + + cat > "$conf" < +delete_target() { + local name="$1" + local conf="$CONFIG_DIR/targets.d/${name}.conf" + + if [[ ! -f "$conf" ]]; then + log_error "Target config not found: $conf" + return 1 + fi + + rm -f "$conf" + log_info "Deleted target config: $conf" +} + +# ── Helpers ─────────────────────────────────────────────────── + +# Parse TARGET_FOLDERS into an array (IFS=,). +# Usage: get_target_folders +# Reads from the current TARGET_FOLDERS global. +# Outputs one folder per line (trimmed). +get_target_folders() { + local -a folders + IFS=',' read -ra folders <<< "$TARGET_FOLDERS" + local folder + for folder in "${folders[@]}"; do + # Trim whitespace + folder="${folder#"${folder%%[![:space:]]*}"}" + folder="${folder%"${folder##*[![:space:]]}"}" + [[ -n "$folder" ]] && echo "$folder" + done +} diff --git a/lib/transfer.sh b/lib/transfer.sh new file mode 100644 index 0000000..2663c5a --- /dev/null +++ b/lib/transfer.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# gniza4linux/lib/transfer.sh — rsync --link-dest to remote, .partial atomicity, retries + +[[ -n "${_GNIZA4LINUX_TRANSFER_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_TRANSFER_LOADED=1 + +rsync_to_remote() { + local source_dir="$1" + local remote_dest="$2" + local link_dest="${3:-}" + local attempt=0 + local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" + local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd) + + local rsync_opts=(-aHAX --numeric-ids --delete --rsync-path="rsync --fake-super") + + if [[ -n "$link_dest" ]]; then + rsync_opts+=(--link-dest="$link_dest") + fi + + if [[ "${BWLIMIT:-0}" -gt 0 ]]; then + rsync_opts+=(--bwlimit="$BWLIMIT") + fi + + if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]]; then + # shellcheck disable=SC2206 + rsync_opts+=($RSYNC_EXTRA_OPTS) + fi + + rsync_opts+=(-e "$rsync_ssh") + + # Ensure source ends with / + [[ "$source_dir" != */ ]] && source_dir="$source_dir/" + + while (( attempt < max_retries )); do + ((attempt++)) || true + log_debug "rsync attempt $attempt/$max_retries: $source_dir -> $remote_dest" + + log_debug "CMD: rsync ${rsync_opts[*]} $source_dir ${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}" + local rsync_cmd=(rsync "${rsync_opts[@]}" "$source_dir" "${REMOTE_USER}@${REMOTE_HOST}:${remote_dest}") + if _is_password_mode; then + export SSHPASS="$REMOTE_PASSWORD" + rsync_cmd=(sshpass -e "${rsync_cmd[@]}") + fi + local rc=0 + "${rsync_cmd[@]}" || rc=$? + if (( rc == 0 )); then + log_debug "rsync succeeded on attempt $attempt" + return 0 + fi + + log_warn "rsync failed (exit $rc), attempt $attempt/$max_retries" + + if (( attempt < max_retries )); then + local backoff=$(( attempt * 10 )) + log_info "Retrying in ${backoff}s..." + sleep "$backoff" + fi + done + + log_error "rsync failed after $max_retries attempts" + return 1 +} + +# rsync locally (no SSH), with --link-dest support. +# Used for REMOTE_TYPE=local remotes (USB, NFS mount, etc.). +rsync_local() { + local source_dir="$1" + local local_dest="$2" + local link_dest="${3:-}" + local attempt=0 + local max_retries="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" + + local rsync_opts=(-aHAX --numeric-ids --delete) + + if [[ -n "$link_dest" ]]; then + rsync_opts+=(--link-dest="$link_dest") + fi + + if [[ "${BWLIMIT:-0}" -gt 0 ]]; then + rsync_opts+=(--bwlimit="$BWLIMIT") + fi + + if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]]; then + # shellcheck disable=SC2206 + rsync_opts+=($RSYNC_EXTRA_OPTS) + fi + + # Ensure source ends with / + [[ "$source_dir" != */ ]] && source_dir="$source_dir/" + + while (( attempt < max_retries )); do + ((attempt++)) || true + log_debug "rsync (local) attempt $attempt/$max_retries: $source_dir -> $local_dest" + + local rc=0 + rsync "${rsync_opts[@]}" "$source_dir" "$local_dest" || rc=$? + if (( rc == 0 )); then + log_debug "rsync (local) succeeded on attempt $attempt" + return 0 + fi + + log_warn "rsync (local) failed (exit $rc), attempt $attempt/$max_retries" + + if (( attempt < max_retries )); then + local backoff=$(( attempt * 10 )) + log_info "Retrying in ${backoff}s..." + sleep "$backoff" + fi + done + + log_error "rsync (local) failed after $max_retries attempts" + return 1 +} + +# Transfer a single folder to a remote snapshot. +# Usage: transfer_folder [prev_snapshot] +transfer_folder() { + local target_name="$1" + local folder_path="$2" + local timestamp="$3" + local prev_snapshot="${4:-}" + + if [[ ! -d "$folder_path" ]]; then + log_warn "Folder not found, skipping: $folder_path" + return 1 + fi + + # Strip leading / to create relative subpath in snapshot + local rel_path="${folder_path#/}" + + if _is_rclone_mode; then + local snap_subpath="targets/${target_name}/snapshots/${timestamp}/${rel_path}" + log_info "Transferring $folder_path for $target_name (rclone)..." + rclone_to_remote "$folder_path" "$snap_subpath" + return + fi + + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local dest="$snap_dir/${timestamp}.partial/${rel_path}/" + local link_dest="" + + if [[ -n "$prev_snapshot" ]]; then + link_dest="$snap_dir/$prev_snapshot/${rel_path}" + fi + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + mkdir -p "$dest" || { + log_error "Failed to create local destination directory: $dest" + return 1 + } + + log_info "Transferring $folder_path for $target_name (local)..." + rsync_local "$folder_path" "$dest" "$link_dest" + return + fi + + # SSH remote + ensure_remote_dir "$dest" || return 1 + + log_info "Transferring $folder_path for $target_name..." + rsync_to_remote "$folder_path" "$dest" "$link_dest" +} + +# Finalize a snapshot: rename .partial -> final, update latest symlink. +# Usage: finalize_snapshot +finalize_snapshot() { + local target_name="$1" + local timestamp="$2" + + if _is_rclone_mode; then + log_info "Finalizing snapshot for $target_name: $timestamp (rclone)" + rclone_finalize_snapshot "$target_name" "$timestamp" + return + fi + + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + + log_info "Finalizing snapshot for $target_name: $timestamp" + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + mv "$snap_dir/${timestamp}.partial" "$snap_dir/$timestamp" || { + log_error "Failed to finalize snapshot for $target_name: $timestamp" + return 1 + } + else + remote_exec "mv '$snap_dir/${timestamp}.partial' '$snap_dir/$timestamp'" || { + log_error "Failed to finalize snapshot for $target_name: $timestamp" + return 1 + } + fi + + update_latest_symlink "$target_name" "$timestamp" +} diff --git a/lib/ui_backup.sh b/lib/ui_backup.sh new file mode 100644 index 0000000..49ec23b --- /dev/null +++ b/lib/ui_backup.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_backup.sh — Backup TUI + +[[ -n "${_GNIZA4LINUX_UI_BACKUP_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_BACKUP_LOADED=1 + +ui_backup_menu() { + while true; do + local choice + choice=$(ui_menu "Backup" \ + "SINGLE" "Backup single target" \ + "ALL" "Backup all targets" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + SINGLE) ui_backup_wizard ;; + ALL) _ui_backup_all ;; + BACK) return 0 ;; + esac + done +} + +ui_backup_wizard() { + if ! has_targets; then + ui_msgbox "No targets configured. Please add a target first." + return 0 + fi + + local -a items=() + local targets + targets=$(list_targets) + while IFS= read -r t; do + items+=("$t" "Target: $t") + done <<< "$targets" + + local target + target=$(ui_menu "Select Target" "${items[@]}") || return 0 + + local remote="" + if has_remotes; then + local -a ritems=("DEFAULT" "Use default/all remotes") + local remotes + remotes=$(list_remotes) + while IFS= read -r r; do + ritems+=("$r" "Remote: $r") + done <<< "$remotes" + + remote=$(ui_menu "Select Remote" "${ritems[@]}") || return 0 + [[ "$remote" == "DEFAULT" ]] && remote="" + fi + + local confirm_msg="Run backup?\n\nTarget: $target" + [[ -n "$remote" ]] && confirm_msg+="\nRemote: $remote" + confirm_msg+="\n" + + ui_yesno "$confirm_msg" || return 0 + + _ui_run_backup "$target" "$remote" +} + +_ui_backup_all() { + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + ui_yesno "Backup ALL targets now?" || return 0 + + local targets + targets=$(list_targets) + local count=0 total=0 + total=$(echo "$targets" | wc -l) + + local output="" + while IFS= read -r t; do + ((count++)) + local pct=$(( count * 100 / total )) + echo "$pct" + local result + if result=$(gniza --cli backup --target="$t" 2>&1); then + output+="$t: OK\n" + else + output+="$t: FAILED\n$result\n" + fi + done <<< "$targets" | ui_gauge "Backing up all targets..." + + ui_msgbox "Backup Results:\n\n$output" +} + +_ui_run_backup() { + local target="$1" + local remote="$2" + + local -a cmd_args=(gniza --cli backup "--target=$target") + [[ -n "$remote" ]] && cmd_args+=("--remote=$remote") + + local tmpfile + tmpfile=$(mktemp /tmp/gniza-backup-XXXXXX.log) + + ( + echo "10" + if "${cmd_args[@]}" > "$tmpfile" 2>&1; then + echo "100" + else + echo "100" + fi + ) | ui_gauge "Backing up target: $target" + + if [[ -s "$tmpfile" ]]; then + ui_textbox "$tmpfile" + else + ui_msgbox "Backup of '$target' completed." + fi + + rm -f "$tmpfile" +} diff --git a/lib/ui_common.sh b/lib/ui_common.sh new file mode 100644 index 0000000..c3b6c59 --- /dev/null +++ b/lib/ui_common.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_common.sh — Whiptail TUI wrappers with consistent sizing + +[[ -n "${_GNIZA4LINUX_UI_COMMON_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_COMMON_LOADED=1 + +readonly WHIPTAIL_TITLE="gniza Backup Manager" + +ui_calc_size() { + local term_h="${LINES:-24}" + local term_w="${COLUMNS:-80}" + local h w + h=$(( term_h - 4 )) + w=$(( term_w - 4 )) + (( h > 20 )) && h=20 + (( w > 76 )) && w=76 + echo "$h" "$w" +} + +_ui_backtitle() { + echo "gniza v${GNIZA4LINUX_VERSION}" +} + +ui_menu() { + local title="$1"; shift + local -a items=("$@") + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + local menu_h=$(( h - 7 )) + (( menu_h < 3 )) && menu_h=3 + + local result + result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --menu "$title" "$h" "$w" "$menu_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 + echo "$result" +} + +ui_checklist() { + local title="$1"; shift + local -a items=("$@") + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + local list_h=$(( h - 7 )) + (( list_h < 3 )) && list_h=3 + + local result + result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --checklist "$title" "$h" "$w" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 + echo "$result" +} + +ui_radiolist() { + local title="$1"; shift + local -a items=("$@") + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + local list_h=$(( h - 7 )) + (( list_h < 3 )) && list_h=3 + + local result + result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --radiolist "$title" "$h" "$w" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 + echo "$result" +} + +ui_inputbox() { + local title="$1" + local prompt="$2" + local default="${3:-}" + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + + local result + result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --inputbox "$prompt" "$h" "$w" "$default" 3>&1 1>&2 2>&3) || return 1 + echo "$result" +} + +ui_yesno() { + local prompt="$1" + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + + whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --yesno "$prompt" "$h" "$w" 3>&1 1>&2 2>&3 +} + +ui_msgbox() { + local msg="$1" + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + + whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --msgbox "$msg" "$h" "$w" 3>&1 1>&2 2>&3 +} + +ui_gauge() { + local prompt="$1" + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + + whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --gauge "$prompt" "$h" "$w" 0 3>&1 1>&2 2>&3 +} + +ui_textbox() { + local filepath="$1" + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + + whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --textbox "$filepath" "$h" "$w" 3>&1 1>&2 2>&3 +} + +ui_password() { + local prompt="$1" + local size; size=$(ui_calc_size) + local h w + read -r h w <<< "$size" + + local result + result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ + --passwordbox "$prompt" "$h" "$w" 3>&1 1>&2 2>&3) || return 1 + echo "$result" +} diff --git a/lib/ui_logs.sh b/lib/ui_logs.sh new file mode 100644 index 0000000..b598561 --- /dev/null +++ b/lib/ui_logs.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_logs.sh — Log viewer TUI + +[[ -n "${_GNIZA4LINUX_UI_LOGS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_LOGS_LOADED=1 + +ui_logs_menu() { + while true; do + local choice + choice=$(ui_menu "Logs" \ + "VIEW" "View log files" \ + "STATUS" "Show backup status" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + VIEW) _ui_logs_view ;; + STATUS) ui_logs_status ;; + BACK) return 0 ;; + esac + done +} + +_ui_logs_view() { + local log_dir="${LOG_DIR:-/var/log/gniza}" + + if [[ ! -d "$log_dir" ]]; then + ui_msgbox "Log directory does not exist: $log_dir" + return 0 + fi + + local -a items=() + local logs + logs=$(ls -1t "$log_dir"/gniza-*.log 2>/dev/null | head -20) + + if [[ -z "$logs" ]]; then + ui_msgbox "No log files found." + return 0 + fi + + while IFS= read -r f; do + local fname + fname=$(basename "$f") + local fsize + fsize=$(stat -c%s "$f" 2>/dev/null || echo "0") + items+=("$fname" "$(human_size "$fsize")") + done <<< "$logs" + items+=("BACK" "Return") + + local selected + selected=$(ui_menu "Log Files (recent first)" "${items[@]}") || return 0 + + [[ "$selected" == "BACK" ]] && return 0 + + local filepath="$log_dir/$selected" + if [[ -f "$filepath" ]]; then + ui_textbox "$filepath" + else + ui_msgbox "Log file not found: $filepath" + fi +} + +ui_logs_status() { + local log_dir="${LOG_DIR:-/var/log/gniza}" + local status_msg="Backup Status Overview\n" + status_msg+="=====================\n\n" + + # Last backup time + local latest_log + latest_log=$(ls -1t "$log_dir"/gniza-*.log 2>/dev/null | head -1) + if [[ -n "$latest_log" ]]; then + local log_date + log_date=$(stat -c%y "$latest_log" 2>/dev/null | cut -d. -f1) + status_msg+="Last log: $log_date\n" + + # Last result + local last_line + last_line=$(tail -1 "$latest_log" 2>/dev/null) + status_msg+="Last entry: $last_line\n" + else + status_msg+="No backup logs found.\n" + fi + + # Disk usage + if [[ -d "$log_dir" ]]; then + local du_output + du_output=$(du -sh "$log_dir" 2>/dev/null | cut -f1) + status_msg+="\nLog disk usage: ${du_output:-unknown}\n" + fi + + # Log count + local log_count + log_count=$(ls -1 "$log_dir"/gniza-*.log 2>/dev/null | wc -l) + status_msg+="Log files: $log_count\n" + + ui_msgbox "$status_msg" +} diff --git a/lib/ui_main.sh b/lib/ui_main.sh new file mode 100644 index 0000000..b95b942 --- /dev/null +++ b/lib/ui_main.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_main.sh — Main menu loop + +[[ -n "${_GNIZA4LINUX_UI_MAIN_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_MAIN_LOADED=1 + +ui_main_menu() { + while true; do + local choice + choice=$(ui_menu "Main Menu" \ + "1" "Backup" \ + "2" "Restore" \ + "3" "Targets" \ + "4" "Remotes" \ + "5" "Snapshots" \ + "6" "Verify" \ + "7" "Retention" \ + "8" "Schedules" \ + "9" "Logs" \ + "10" "Settings" \ + "Q" "Quit") || break + + case "$choice" in + 1) ui_backup_menu ;; + 2) ui_restore_menu ;; + 3) ui_targets_menu ;; + 4) ui_remotes_menu ;; + 5) ui_snapshots_menu ;; + 6) ui_verify_menu ;; + 7) ui_retention_menu ;; + 8) ui_schedule_menu ;; + 9) ui_logs_menu ;; + 10) ui_settings_menu ;; + Q) break ;; + esac + done +} diff --git a/lib/ui_remotes.sh b/lib/ui_remotes.sh new file mode 100644 index 0000000..a8fa5dd --- /dev/null +++ b/lib/ui_remotes.sh @@ -0,0 +1,401 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_remotes.sh — Remote management TUI + +[[ -n "${_GNIZA4LINUX_UI_REMOTES_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_REMOTES_LOADED=1 + +ui_remotes_menu() { + while true; do + local -a items=() + local remotes + remotes=$(list_remotes) + if [[ -n "$remotes" ]]; then + while IFS= read -r r; do + items+=("$r" "Remote: $r") + done <<< "$remotes" + fi + items+=("ADD" "Add new remote") + items+=("BACK" "Return to main menu") + + local choice + choice=$(ui_menu "Remotes" "${items[@]}") || return 0 + + case "$choice" in + ADD) ui_remote_add ;; + BACK) return 0 ;; + *) + local action + action=$(ui_menu "Remote: $choice" \ + "EDIT" "Edit remote" \ + "DELETE" "Delete remote" \ + "TEST" "Test connection" \ + "BACK" "Back") || continue + case "$action" in + EDIT) ui_remote_edit "$choice" ;; + DELETE) ui_remote_delete "$choice" ;; + TEST) ui_remote_test "$choice" ;; + BACK) continue ;; + esac + ;; + esac + done +} + +ui_remote_add() { + local name + name=$(ui_inputbox "Add Remote" "Enter remote name (letters, digits, _ -):" "") || return 0 + [[ -z "$name" ]] && return 0 + + if ! validate_target_name "$name" 2>/dev/null; then + ui_msgbox "Invalid remote name. Must start with a letter and contain only letters, digits, underscore, or hyphen (max 32 chars)." + return 0 + fi + + if [[ -f "$CONFIG_DIR/remotes.d/${name}.conf" ]]; then + ui_msgbox "Remote '$name' already exists." + return 0 + fi + + local rtype + rtype=$(ui_radiolist "Remote Type" \ + "ssh" "SSH remote" "ON" \ + "local" "Local directory" "OFF" \ + "s3" "Amazon S3 / compatible" "OFF" \ + "gdrive" "Google Drive" "OFF") || return 0 + + local conf="$CONFIG_DIR/remotes.d/${name}.conf" + + case "$rtype" in + ssh) _ui_remote_add_ssh "$name" "$conf" ;; + local) _ui_remote_add_local "$name" "$conf" ;; + s3) _ui_remote_add_s3 "$name" "$conf" ;; + gdrive) _ui_remote_add_gdrive "$name" "$conf" ;; + esac +} + +_ui_remote_add_ssh() { + local name="$1" conf="$2" + + local host; host=$(ui_inputbox "SSH Remote" "Hostname or IP:" "") || return 0 + [[ -z "$host" ]] && { ui_msgbox "Host is required."; return 0; } + + local port; port=$(ui_inputbox "SSH Remote" "Port:" "$DEFAULT_REMOTE_PORT") || port="$DEFAULT_REMOTE_PORT" + local user; user=$(ui_inputbox "SSH Remote" "Username:" "$DEFAULT_REMOTE_USER") || user="$DEFAULT_REMOTE_USER" + local base; base=$(ui_inputbox "SSH Remote" "Base path on remote:" "$DEFAULT_REMOTE_BASE") || base="$DEFAULT_REMOTE_BASE" + + local auth_method + auth_method=$(ui_radiolist "Authentication" \ + "key" "SSH key" "ON" \ + "password" "Password" "OFF") || auth_method="key" + + local key="" password="" + if [[ "$auth_method" == "key" ]]; then + key=$(ui_inputbox "SSH Remote" "Path to SSH key:" "$HOME/.ssh/id_rsa") || key="" + else + password=$(ui_password "Enter SSH password:") || password="" + fi + + local bwlimit; bwlimit=$(ui_inputbox "SSH Remote" "Bandwidth limit (KB/s, 0=unlimited):" "$DEFAULT_BWLIMIT") || bwlimit="$DEFAULT_BWLIMIT" + local retention; retention=$(ui_inputbox "SSH Remote" "Retention count:" "$DEFAULT_RETENTION_COUNT") || retention="$DEFAULT_RETENTION_COUNT" + + cat > "$conf" < "$conf" < "$conf" < "$conf" < "$conf" < "$conf" < "$conf" < "$conf" <&1) \ + && ui_msgbox "Connection to '$name' successful.\n\nResponse: $result" \ + || ui_msgbox "Connection to '$name' failed.\n\nError: $result" + ;; + local) + if [[ -d "$REMOTE_BASE" ]]; then + ui_msgbox "Local directory '$REMOTE_BASE' exists and is accessible." + else + ui_msgbox "Local directory '$REMOTE_BASE' does NOT exist." + fi + ;; + s3|gdrive) + if command -v rclone &>/dev/null; then + # Use the proper rclone transport layer (temp config file, not CLI args) + load_remote "$name" || { ui_msgbox "Failed to load remote."; break; } + if result=$(test_rclone_connection 2>&1); then + ui_msgbox "${REMOTE_TYPE} connection to '$name' successful." + else + ui_msgbox "${REMOTE_TYPE} connection to '$name' failed.\n\nError: $result" + fi + else + ui_msgbox "rclone is not installed. Cannot test ${REMOTE_TYPE} connection." + fi + ;; + *) + ui_msgbox "Unknown remote type: ${REMOTE_TYPE}" + ;; + esac +} diff --git a/lib/ui_restore.sh b/lib/ui_restore.sh new file mode 100644 index 0000000..bdb32e1 --- /dev/null +++ b/lib/ui_restore.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_restore.sh — Restore TUI + +[[ -n "${_GNIZA4LINUX_UI_RESTORE_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_RESTORE_LOADED=1 + +ui_restore_menu() { + while true; do + local choice + choice=$(ui_menu "Restore" \ + "TARGET" "Restore full target" \ + "FOLDER" "Restore single folder" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + TARGET) ui_restore_wizard "full" ;; + FOLDER) ui_restore_wizard "folder" ;; + BACK) return 0 ;; + esac + done +} + +ui_restore_wizard() { + local mode="$1" + + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + # Select target + local -a titems=() + local targets + targets=$(list_targets) + while IFS= read -r t; do + titems+=("$t" "Target: $t") + done <<< "$targets" + + local target + target=$(ui_menu "Select Target to Restore" "${titems[@]}") || return 0 + + # Select remote + local remote="" + if has_remotes; then + local -a ritems=() + local remotes + remotes=$(list_remotes) + while IFS= read -r r; do + ritems+=("$r" "Remote: $r") + done <<< "$remotes" + + remote=$(ui_menu "Select Remote" "${ritems[@]}") || return 0 + else + ui_msgbox "No remotes configured." + return 0 + fi + + # Load remote for snapshot listing + load_remote "$remote" || { ui_msgbox "Failed to load remote '$remote'."; return 0; } + + # Select snapshot + local snapshots + snapshots=$(list_remote_snapshots "$target" 2>/dev/null) + if [[ -z "$snapshots" ]]; then + ui_msgbox "No snapshots found for target '$target' on remote '$remote'." + return 0 + fi + + local -a sitems=() + while IFS= read -r s; do + sitems+=("$s" "Snapshot: $s") + done <<< "$snapshots" + + local snapshot + snapshot=$(ui_menu "Select Snapshot" "${sitems[@]}") || return 0 + + # Restore location + local restore_dest="" + local restore_type + restore_type=$(ui_radiolist "Restore Location" \ + "inplace" "Restore in-place (original location)" "ON" \ + "directory" "Restore to a different directory" "OFF") || return 0 + + if [[ "$restore_type" == "directory" ]]; then + restore_dest=$(ui_inputbox "Restore" "Enter destination directory:" "/tmp/restore") || return 0 + [[ -z "$restore_dest" ]] && { ui_msgbox "Destination is required."; return 0; } + fi + + # Folder selection for single-folder mode + local folder_arg="" + if [[ "$mode" == "folder" ]]; then + load_target "$target" || { ui_msgbox "Failed to load target."; return 0; } + local -a fitems=() + local folders + folders=$(get_target_folders) + while IFS= read -r f; do + [[ -z "$f" ]] && continue + fitems+=("$f" "$f") + done <<< "$folders" + + if [[ ${#fitems[@]} -eq 0 ]]; then + ui_msgbox "No folders defined in target '$target'." + return 0 + fi + + folder_arg=$(ui_menu "Select Folder to Restore" "${fitems[@]}") || return 0 + fi + + # Confirm + local confirm_msg="Restore snapshot?\n\nTarget: $target\nRemote: $remote\nSnapshot: $snapshot" + [[ -n "$folder_arg" ]] && confirm_msg+="\nFolder: $folder_arg" + if [[ "$restore_type" == "inplace" ]]; then + confirm_msg+="\nLocation: In-place (original)" + else + confirm_msg+="\nLocation: $restore_dest" + fi + confirm_msg+="\n" + + ui_yesno "$confirm_msg" || return 0 + + # Run restore + local -a cmd_args=(gniza --cli restore "--target=$target" "--remote=$remote" "--snapshot=$snapshot") + [[ -n "$restore_dest" ]] && cmd_args+=("--dest=$restore_dest") + [[ -n "$folder_arg" ]] && cmd_args+=("--folder=$folder_arg") + + local tmpfile + tmpfile=$(mktemp /tmp/gniza-restore-XXXXXX.log) + + ( + echo "10" + if "${cmd_args[@]}" > "$tmpfile" 2>&1; then + echo "100" + else + echo "100" + fi + ) | ui_gauge "Restoring target: $target" + + if [[ -s "$tmpfile" ]]; then + ui_textbox "$tmpfile" + else + ui_msgbox "Restore of '$target' completed." + fi + + rm -f "$tmpfile" +} diff --git a/lib/ui_retention.sh b/lib/ui_retention.sh new file mode 100644 index 0000000..77b3580 --- /dev/null +++ b/lib/ui_retention.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_retention.sh — Retention cleanup TUI + +[[ -n "${_GNIZA4LINUX_UI_RETENTION_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_RETENTION_LOADED=1 + +ui_retention_menu() { + while true; do + local choice + choice=$(ui_menu "Retention" \ + "SINGLE" "Run cleanup for single target" \ + "ALL" "Run cleanup for all targets" \ + "CONFIG" "Configure retention count" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + SINGLE) _ui_retention_single ;; + ALL) _ui_retention_all ;; + CONFIG) _ui_retention_config ;; + BACK) return 0 ;; + esac + done +} + +_ui_retention_single() { + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + local -a items=() + local targets + targets=$(list_targets) + while IFS= read -r t; do + items+=("$t" "Target: $t") + done <<< "$targets" + + local target + target=$(ui_menu "Select Target for Cleanup" "${items[@]}") || return 0 + + ui_yesno "Run retention cleanup for target '$target'?" || return 0 + + local tmpfile + tmpfile=$(mktemp /tmp/gniza-retention-XXXXXX.log) + + ( + echo "10" + if gniza --cli retention --target="$target" > "$tmpfile" 2>&1; then + echo "100" + else + echo "100" + fi + ) | ui_gauge "Running retention cleanup: $target" + + if [[ -s "$tmpfile" ]]; then + ui_textbox "$tmpfile" + else + ui_msgbox "Retention cleanup for '$target' completed." + fi + + rm -f "$tmpfile" +} + +_ui_retention_all() { + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + ui_yesno "Run retention cleanup for ALL targets?" || return 0 + + local targets + targets=$(list_targets) + local count=0 total=0 + total=$(echo "$targets" | wc -l) + + local output="" + while IFS= read -r t; do + ((count++)) + local pct=$(( count * 100 / total )) + echo "$pct" + local result + if result=$(gniza --cli retention --target="$t" 2>&1); then + output+="$t: OK\n" + else + output+="$t: FAILED\n$result\n" + fi + done <<< "$targets" | ui_gauge "Running retention cleanup..." + + ui_msgbox "Retention Results:\n\n$output" +} + +_ui_retention_config() { + local current="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}" + local new_count + new_count=$(ui_inputbox "Retention Config" "Number of snapshots to keep (current: $current):" "$current") || return 0 + + if [[ ! "$new_count" =~ ^[0-9]+$ ]] || (( new_count < 1 )); then + ui_msgbox "Retention count must be a positive integer." + return 0 + fi + + local config_file="$CONFIG_DIR/gniza.conf" + if [[ -f "$config_file" ]] && grep -q "^RETENTION_COUNT=" "$config_file"; then + sed -i "s/^RETENTION_COUNT=.*/RETENTION_COUNT=\"$new_count\"/" "$config_file" + else + echo "RETENTION_COUNT=\"$new_count\"" >> "$config_file" + fi + + RETENTION_COUNT="$new_count" + ui_msgbox "Retention count set to $new_count." +} diff --git a/lib/ui_schedule.sh b/lib/ui_schedule.sh new file mode 100644 index 0000000..975e712 --- /dev/null +++ b/lib/ui_schedule.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_schedule.sh — Schedule management TUI + +[[ -n "${_GNIZA4LINUX_UI_SCHEDULE_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_SCHEDULE_LOADED=1 + +ui_schedule_menu() { + while true; do + local choice + choice=$(ui_menu "Schedules" \ + "LIST" "Show current schedules" \ + "ADD" "Add schedule" \ + "DELETE" "Delete schedule" \ + "INSTALL" "Install schedules to crontab" \ + "REMOVE" "Remove schedules from crontab" \ + "SHOW" "Show crontab entries" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + LIST) _ui_schedule_list ;; + ADD) _ui_schedule_add ;; + DELETE) _ui_schedule_delete ;; + INSTALL) _ui_schedule_install ;; + REMOVE) _ui_schedule_remove ;; + SHOW) _ui_schedule_show_cron ;; + BACK) return 0 ;; + esac + done +} + +_ui_schedule_list() { + if ! has_schedules; then + ui_msgbox "No schedules configured." + return 0 + fi + + local schedules + schedules=$(list_schedules) + local info="Configured Schedules:\n\n" + + while IFS= read -r sname; do + [[ -z "$sname" ]] && continue + load_schedule "$sname" 2>/dev/null || continue + info+="[$sname]\n" + info+=" Type: ${SCHEDULE:-not set}\n" + info+=" Time: ${SCHEDULE_TIME:-02:00}\n" + [[ -n "${SCHEDULE_DAY:-}" ]] && info+=" Day: $SCHEDULE_DAY\n" + [[ -n "${SCHEDULE_REMOTES:-}" ]] && info+=" Remotes: $SCHEDULE_REMOTES\n" + [[ -n "${SCHEDULE_TARGETS:-}" ]] && info+=" Targets: $SCHEDULE_TARGETS\n" + info+="\n" + done <<< "$schedules" + + ui_msgbox "$info" +} + +_ui_schedule_add() { + local name + name=$(ui_inputbox "Add Schedule" "Schedule name:" "") || return 0 + [[ -z "$name" ]] && return 0 + + if ! validate_target_name "$name" 2>/dev/null; then + ui_msgbox "Invalid name. Must start with a letter, max 32 chars, [a-zA-Z0-9_-]." + return 0 + fi + + local conf="$CONFIG_DIR/schedules.d/${name}.conf" + if [[ -f "$conf" ]]; then + ui_msgbox "Schedule '$name' already exists." + return 0 + fi + + local stype + stype=$(ui_radiolist "Schedule Type" \ + "hourly" "Every hour" "OFF" \ + "daily" "Once a day" "ON" \ + "weekly" "Once a week" "OFF" \ + "monthly" "Once a month" "OFF" \ + "custom" "Custom cron expression" "OFF") || return 0 + + local stime="02:00" + if [[ "$stype" != "hourly" && "$stype" != "custom" ]]; then + stime=$(ui_inputbox "Schedule Time" "Time (HH:MM, 24h format):" "02:00") || return 0 + fi + + local sday="" + if [[ "$stype" == "weekly" ]]; then + sday=$(ui_inputbox "Day of Week" "Day (0=Sun, 1=Mon, ..., 6=Sat):" "0") || return 0 + elif [[ "$stype" == "monthly" ]]; then + sday=$(ui_inputbox "Day of Month" "Day (1-28):" "1") || return 0 + fi + + local scron="" + if [[ "$stype" == "custom" ]]; then + scron=$(ui_inputbox "Custom Cron" "Enter 5-field cron expression:" "0 2 * * *") || return 0 + fi + + local stargets="" + stargets=$(ui_inputbox "Targets" "Target names (comma-separated, empty=all):" "") || return 0 + + local sremotes="" + sremotes=$(ui_inputbox "Remotes" "Remote names (comma-separated, empty=all):" "") || return 0 + + cat > "$conf" <&1); then + ui_msgbox "Schedules installed.\n\n$result" + else + ui_msgbox "Failed to install schedules.\n\n$result" + fi +} + +_ui_schedule_remove() { + ui_yesno "Remove all gniza schedule entries from crontab?" || return 0 + + local result + if result=$(remove_schedules 2>&1); then + ui_msgbox "$result" + else + ui_msgbox "Failed to remove schedules.\n\n$result" + fi +} + +_ui_schedule_show_cron() { + local result + result=$(show_schedules 2>&1) + ui_msgbox "$result" +} diff --git a/lib/ui_settings.sh b/lib/ui_settings.sh new file mode 100644 index 0000000..cd85e23 --- /dev/null +++ b/lib/ui_settings.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_settings.sh — Settings editor TUI + +[[ -n "${_GNIZA4LINUX_UI_SETTINGS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_SETTINGS_LOADED=1 + +ui_settings_menu() { + local config_file="$CONFIG_DIR/gniza.conf" + + while true; do + local choice + choice=$(ui_menu "Settings" \ + "LOGLEVEL" "Log level: ${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}" \ + "EMAIL" "Notification email: ${NOTIFY_EMAIL:-none}" \ + "SMTP_HOST" "SMTP host: ${SMTP_HOST:-none}" \ + "SMTP_PORT" "SMTP port: ${SMTP_PORT:-$DEFAULT_SMTP_PORT}" \ + "SMTP_USER" "SMTP user: ${SMTP_USER:-none}" \ + "SMTP_PASS" "SMTP password: ****" \ + "SMTP_FROM" "SMTP from: ${SMTP_FROM:-none}" \ + "SMTP_SEC" "SMTP security: ${SMTP_SECURITY:-$DEFAULT_SMTP_SECURITY}" \ + "RETENTION" "Default retention: ${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}" \ + "BWLIMIT" "Default BW limit: ${BWLIMIT:-$DEFAULT_BWLIMIT} KB/s" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + LOGLEVEL) + local val + val=$(ui_radiolist "Log Level" \ + "debug" "Debug" "$([ "${LOG_LEVEL:-info}" = "debug" ] && echo ON || echo OFF)" \ + "info" "Info" "$([ "${LOG_LEVEL:-info}" = "info" ] && echo ON || echo OFF)" \ + "warn" "Warning" "$([ "${LOG_LEVEL:-info}" = "warn" ] && echo ON || echo OFF)" \ + "error" "Error" "$([ "${LOG_LEVEL:-info}" = "error" ] && echo ON || echo OFF)") || continue + LOG_LEVEL="$val" + _ui_settings_save "LOG_LEVEL" "$val" "$config_file" + ;; + EMAIL) + local val + val=$(ui_inputbox "Settings" "Notification email:" "${NOTIFY_EMAIL:-}") || continue + NOTIFY_EMAIL="$val" + _ui_settings_save "NOTIFY_EMAIL" "$val" "$config_file" + ;; + SMTP_HOST) + local val + val=$(ui_inputbox "Settings" "SMTP host:" "${SMTP_HOST:-}") || continue + SMTP_HOST="$val" + _ui_settings_save "SMTP_HOST" "$val" "$config_file" + ;; + SMTP_PORT) + local val + val=$(ui_inputbox "Settings" "SMTP port:" "${SMTP_PORT:-$DEFAULT_SMTP_PORT}") || continue + SMTP_PORT="$val" + _ui_settings_save "SMTP_PORT" "$val" "$config_file" + ;; + SMTP_USER) + local val + val=$(ui_inputbox "Settings" "SMTP user:" "${SMTP_USER:-}") || continue + SMTP_USER="$val" + _ui_settings_save "SMTP_USER" "$val" "$config_file" + ;; + SMTP_PASS) + local val + val=$(ui_password "SMTP password:") || continue + SMTP_PASSWORD="$val" + _ui_settings_save "SMTP_PASSWORD" "$val" "$config_file" + ;; + SMTP_FROM) + local val + val=$(ui_inputbox "Settings" "SMTP from address:" "${SMTP_FROM:-}") || continue + SMTP_FROM="$val" + _ui_settings_save "SMTP_FROM" "$val" "$config_file" + ;; + SMTP_SEC) + local val + val=$(ui_radiolist "SMTP Security" \ + "tls" "TLS" "$([ "${SMTP_SECURITY:-tls}" = "tls" ] && echo ON || echo OFF)" \ + "ssl" "SSL" "$([ "${SMTP_SECURITY:-tls}" = "ssl" ] && echo ON || echo OFF)" \ + "none" "None" "$([ "${SMTP_SECURITY:-tls}" = "none" ] && echo ON || echo OFF)") || continue + SMTP_SECURITY="$val" + _ui_settings_save "SMTP_SECURITY" "$val" "$config_file" + ;; + RETENTION) + local val + val=$(ui_inputbox "Settings" "Default retention count:" "${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}") || continue + if [[ ! "$val" =~ ^[0-9]+$ ]] || (( val < 1 )); then + ui_msgbox "Retention count must be a positive integer." + continue + fi + RETENTION_COUNT="$val" + _ui_settings_save "RETENTION_COUNT" "$val" "$config_file" + ;; + BWLIMIT) + local val + val=$(ui_inputbox "Settings" "Default bandwidth limit (KB/s, 0=unlimited):" "${BWLIMIT:-$DEFAULT_BWLIMIT}") || continue + if [[ ! "$val" =~ ^[0-9]+$ ]]; then + ui_msgbox "Bandwidth limit must be a non-negative integer." + continue + fi + BWLIMIT="$val" + _ui_settings_save "BWLIMIT" "$val" "$config_file" + ;; + BACK) return 0 ;; + esac + done +} + +_ui_settings_save() { + local key="$1" + local value="$2" + local config_file="$3" + + # Ensure config file exists + [[ -f "$config_file" ]] || touch "$config_file" + + if grep -q "^${key}=" "$config_file"; then + # Use awk to avoid sed delimiter injection issues + local tmpconf + tmpconf=$(mktemp) + awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{print k "=\"" v "\""; next} {print}' "$config_file" > "$tmpconf" + mv "$tmpconf" "$config_file" + else + printf '%s="%s"\n' "$key" "$value" >> "$config_file" + fi +} diff --git a/lib/ui_snapshots.sh b/lib/ui_snapshots.sh new file mode 100644 index 0000000..3dca049 --- /dev/null +++ b/lib/ui_snapshots.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_snapshots.sh — Snapshot browsing TUI + +[[ -n "${_GNIZA4LINUX_UI_SNAPSHOTS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_SNAPSHOTS_LOADED=1 + +ui_snapshots_menu() { + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + # Select target + local -a titems=() + local targets + targets=$(list_targets) + while IFS= read -r t; do + titems+=("$t" "Target: $t") + done <<< "$targets" + + local target + target=$(ui_menu "Select Target" "${titems[@]}") || return 0 + + # Select remote + if ! has_remotes; then + ui_msgbox "No remotes configured." + return 0 + fi + + local -a ritems=() + local remotes + remotes=$(list_remotes) + while IFS= read -r r; do + ritems+=("$r" "Remote: $r") + done <<< "$remotes" + + local remote + remote=$(ui_menu "Select Remote" "${ritems[@]}") || return 0 + + load_remote "$remote" || { ui_msgbox "Failed to load remote '$remote'."; return 0; } + + # List snapshots + while true; do + local snapshots + snapshots=$(list_remote_snapshots "$target" 2>/dev/null) + if [[ -z "$snapshots" ]]; then + ui_msgbox "No snapshots found for target '$target' on remote '$remote'." + return 0 + fi + + local -a sitems=() + while IFS= read -r s; do + sitems+=("$s" "Snapshot: $s") + done <<< "$snapshots" + sitems+=("BACK" "Return") + + local snapshot + snapshot=$(ui_menu "Snapshots: $target @ $remote" "${sitems[@]}") || return 0 + + [[ "$snapshot" == "BACK" ]] && return 0 + + # Snapshot detail menu + while true; do + local snap_dir + snap_dir=$(get_snapshot_dir "$target") + local meta_info="Snapshot: $snapshot\nTarget: $target\nRemote: $remote" + + # Try to read meta.json + local meta_content="" + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + if [[ -f "$snap_dir/$snapshot/meta.json" ]]; then + meta_content=$(cat "$snap_dir/$snapshot/meta.json" 2>/dev/null) + fi + fi + [[ -n "$meta_content" ]] && meta_info+="\n\n$meta_content" + + local action + action=$(ui_menu "Snapshot: $snapshot" \ + "DETAILS" "View details" \ + "DELETE" "Delete snapshot" \ + "BACK" "Back to list") || break + + case "$action" in + DETAILS) + ui_msgbox "$meta_info" + ;; + DELETE) + if ui_yesno "Delete snapshot '$snapshot'?\nThis cannot be undone."; then + local snap_path_del + snap_path_del=$(get_snapshot_dir "$target") + local del_ok=false + if _is_rclone_mode; then + rclone_purge "targets/${target}/snapshots/${snapshot}" 2>/dev/null && del_ok=true + elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + rm -rf "$snap_path_del/$snapshot" 2>/dev/null && del_ok=true + else + remote_exec "rm -rf '$snap_path_del/$snapshot'" 2>/dev/null && del_ok=true + fi + if [[ "$del_ok" == "true" ]]; then + ui_msgbox "Snapshot '$snapshot' deleted." + else + ui_msgbox "Failed to delete snapshot '$snapshot'." + fi + break + fi + ;; + BACK) break ;; + esac + done + done +} diff --git a/lib/ui_targets.sh b/lib/ui_targets.sh new file mode 100644 index 0000000..4fdaec5 --- /dev/null +++ b/lib/ui_targets.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_targets.sh — Target management TUI + +[[ -n "${_GNIZA4LINUX_UI_TARGETS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_TARGETS_LOADED=1 + +ui_targets_menu() { + while true; do + local -a items=() + local targets + targets=$(list_targets) + if [[ -n "$targets" ]]; then + while IFS= read -r t; do + items+=("$t" "Target: $t") + done <<< "$targets" + fi + items+=("ADD" "Add new target") + items+=("BACK" "Return to main menu") + + local choice + choice=$(ui_menu "Targets" "${items[@]}") || return 0 + + case "$choice" in + ADD) ui_target_add ;; + BACK) return 0 ;; + *) + local action + action=$(ui_menu "Target: $choice" \ + "EDIT" "Edit target" \ + "DELETE" "Delete target" \ + "BACK" "Back") || continue + case "$action" in + EDIT) ui_target_edit "$choice" ;; + DELETE) ui_target_delete "$choice" ;; + BACK) continue ;; + esac + ;; + esac + done +} + +ui_target_add() { + local name + name=$(ui_inputbox "Add Target" "Enter target name (letters, digits, _ -):" "") || return 0 + [[ -z "$name" ]] && return 0 + + if ! validate_target_name "$name" 2>/dev/null; then + ui_msgbox "Invalid target name. Must start with a letter and contain only letters, digits, underscore, or hyphen (max 32 chars)." + return 0 + fi + + if [[ -f "$CONFIG_DIR/targets.d/${name}.conf" ]]; then + ui_msgbox "Target '$name' already exists." + return 0 + fi + + local folders + folders=$(ui_target_folder_picker) || return 0 + [[ -z "$folders" ]] && { ui_msgbox "No folders selected. Target not created."; return 0; } + + local exclude + exclude=$(ui_inputbox "Add Target" "Exclude patterns (comma-separated, e.g. *.log,*.tmp):" "") || exclude="" + + local remote + remote=$(ui_inputbox "Add Target" "Remote override (leave empty for default):" "") || remote="" + + create_target "$name" "$folders" "$exclude" "$remote" + ui_msgbox "Target '$name' created successfully." +} + +ui_target_edit() { + local name="$1" + load_target "$name" || { ui_msgbox "Failed to load target '$name'."; return 0; } + + while true; do + local choice + choice=$(ui_menu "Edit Target: $name" \ + "FOLDERS" "Folders: ${TARGET_FOLDERS}" \ + "EXCLUDE" "Exclude: ${TARGET_EXCLUDE:-none}" \ + "REMOTE" "Remote: ${TARGET_REMOTE:-default}" \ + "ENABLED" "Enabled: ${TARGET_ENABLED}" \ + "SAVE" "Save and return" \ + "BACK" "Cancel") || return 0 + + case "$choice" in + FOLDERS) + local folders + folders=$(ui_target_folder_picker "$TARGET_FOLDERS") || continue + [[ -n "$folders" ]] && TARGET_FOLDERS="$folders" + ;; + EXCLUDE) + local exclude + exclude=$(ui_inputbox "Edit Exclude" "Exclude patterns (comma-separated):" "$TARGET_EXCLUDE") || continue + TARGET_EXCLUDE="$exclude" + ;; + REMOTE) + local remote + remote=$(ui_inputbox "Edit Remote" "Remote override (leave empty for default):" "$TARGET_REMOTE") || continue + TARGET_REMOTE="$remote" + ;; + ENABLED) + if ui_yesno "Enable this target?"; then + TARGET_ENABLED="yes" + else + TARGET_ENABLED="no" + fi + ;; + SAVE) + create_target "$name" "$TARGET_FOLDERS" "$TARGET_EXCLUDE" "$TARGET_REMOTE" \ + "$TARGET_RETENTION" "$TARGET_PRE_HOOK" "$TARGET_POST_HOOK" "$TARGET_ENABLED" + ui_msgbox "Target '$name' saved." + return 0 + ;; + BACK) return 0 ;; + esac + done +} + +ui_target_delete() { + local name="$1" + if ui_yesno "Delete target '$name'? This cannot be undone."; then + delete_target "$name" + ui_msgbox "Target '$name' deleted." + fi +} + +ui_target_folder_picker() { + local existing="${1:-}" + local -a folders=() + + if [[ -n "$existing" ]]; then + IFS=',' read -ra folders <<< "$existing" + fi + + while true; do + local -a items=() + local i=1 + for f in "${folders[@]}"; do + items+=("$i" "$f") + ((i++)) + done + items+=("ADD" "Add folder") + [[ ${#folders[@]} -gt 0 ]] && items+=("REMOVE" "Remove folder") + items+=("DONE" "Finish selection") + + local choice + choice=$(ui_menu "Folder Picker" "${items[@]}") || return 1 + + case "$choice" in + ADD) + local path + path=$(ui_inputbox "Add Folder" "Enter absolute folder path:" "/") || continue + [[ -z "$path" ]] && continue + if [[ "$path" != /* ]]; then + ui_msgbox "Path must be absolute (start with /)." + continue + fi + folders+=("$path") + ;; + REMOVE) + local -a rm_items=() + local j=0 + for f in "${folders[@]}"; do + rm_items+=("$j" "$f") + ((j++)) + done + local idx + idx=$(ui_menu "Remove Folder" "${rm_items[@]}") || continue + unset 'folders[idx]' + folders=("${folders[@]}") + ;; + DONE) + local result + result=$(IFS=','; echo "${folders[*]}") + echo "$result" + return 0 + ;; + esac + done +} diff --git a/lib/ui_verify.sh b/lib/ui_verify.sh new file mode 100644 index 0000000..2e95554 --- /dev/null +++ b/lib/ui_verify.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# gniza4linux/lib/ui_verify.sh — Backup verification TUI + +[[ -n "${_GNIZA4LINUX_UI_VERIFY_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UI_VERIFY_LOADED=1 + +ui_verify_menu() { + while true; do + local choice + choice=$(ui_menu "Verify" \ + "SINGLE" "Verify single target" \ + "ALL" "Verify all targets" \ + "BACK" "Return to main menu") || return 0 + + case "$choice" in + SINGLE) _ui_verify_single ;; + ALL) _ui_verify_all ;; + BACK) return 0 ;; + esac + done +} + +_ui_verify_single() { + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + local -a items=() + local targets + targets=$(list_targets) + while IFS= read -r t; do + items+=("$t" "Target: $t") + done <<< "$targets" + + local target + target=$(ui_menu "Select Target to Verify" "${items[@]}") || return 0 + + local tmpfile + tmpfile=$(mktemp /tmp/gniza-verify-XXXXXX.log) + + ( + echo "10" + if gniza --cli verify --target="$target" > "$tmpfile" 2>&1; then + echo "100" + else + echo "100" + fi + ) | ui_gauge "Verifying target: $target" + + if [[ -s "$tmpfile" ]]; then + ui_textbox "$tmpfile" + else + ui_msgbox "Verification of '$target' completed successfully." + fi + + rm -f "$tmpfile" +} + +_ui_verify_all() { + if ! has_targets; then + ui_msgbox "No targets configured." + return 0 + fi + + local targets + targets=$(list_targets) + local count=0 total=0 + total=$(echo "$targets" | wc -l) + + local output="" + while IFS= read -r t; do + ((count++)) + local pct=$(( count * 100 / total )) + echo "$pct" + local result + if result=$(gniza --cli verify --target="$t" 2>&1); then + output+="$t: OK\n" + else + output+="$t: FAILED\n$result\n" + fi + done <<< "$targets" | ui_gauge "Verifying all targets..." + + ui_msgbox "Verification Results:\n\n$output" +} diff --git a/lib/utils.sh b/lib/utils.sh new file mode 100644 index 0000000..2ac9c1c --- /dev/null +++ b/lib/utils.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# gniza4linux/lib/utils.sh — Core utility functions + +[[ -n "${_GNIZA4LINUX_UTILS_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_UTILS_LOADED=1 + +die() { + local code="${2:-$EXIT_FATAL}" + echo "${C_RED}FATAL: $1${C_RESET}" >&2 + exit "$code" +} + +timestamp() { + date -u +"%Y-%m-%dT%H%M%S" +} + +human_size() { + local bytes="$1" + if (( bytes >= 1073741824 )); then + local whole=$(( bytes / 1073741824 )) + local frac=$(( (bytes % 1073741824) * 10 / 1073741824 )) + printf "%d.%d GB" "$whole" "$frac" + elif (( bytes >= 1048576 )); then + local whole=$(( bytes / 1048576 )) + local frac=$(( (bytes % 1048576) * 10 / 1048576 )) + printf "%d.%d MB" "$whole" "$frac" + elif (( bytes >= 1024 )); then + local whole=$(( bytes / 1024 )) + local frac=$(( (bytes % 1024) * 10 / 1024 )) + printf "%d.%d KB" "$whole" "$frac" + else + printf "%d B" "$bytes" + fi +} + +human_duration() { + local seconds="$1" + if (( seconds >= 3600 )); then + printf "%dh %dm %ds" $((seconds/3600)) $((seconds%3600/60)) $((seconds%60)) + elif (( seconds >= 60 )); then + printf "%dm %ds" $((seconds/60)) $((seconds%60)) + else + printf "%ds" "$seconds" + fi +} + +require_cmd() { + command -v "$1" &>/dev/null || die "Required command not found: $1" +} + +validate_timestamp() { + local ts="$1" + if [[ ! "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}$ ]]; then + log_error "Invalid timestamp format: $ts (expected YYYY-MM-DDTHHMMSS)" + return 1 + fi +} + +validate_target_name() { + local name="$1" + if [[ ! "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]{0,31}$ ]]; then + log_error "Invalid target name: $name (must match ^[a-zA-Z][a-zA-Z0-9_-]{0,31}\$)" + return 1 + fi +} + +validate_path() { + local path="$1" + if [[ "$path" != /* ]]; then + log_error "Path must be absolute: $path" + return 1 + fi + if [[ "$path" == *..* ]]; then + log_error "Path must not contain '..': $path" + return 1 + fi + if [[ ! -e "$path" ]]; then + log_error "Path does not exist: $path" + return 1 + fi +} + +detect_mode() { + if [[ $EUID -eq 0 ]]; then + GNIZA_MODE="root" + CONFIG_DIR="/etc/gniza" + LOG_DIR="/var/log/gniza" + LOCK_FILE="/var/run/gniza.lock" + else + GNIZA_MODE="user" + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gniza" + LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/gniza/log" + LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/gniza-${EUID}.lock" + fi + export GNIZA_MODE CONFIG_DIR LOG_DIR LOCK_FILE +} + +ensure_dirs() { + mkdir -p "$CONFIG_DIR" || die "Cannot create config directory: $CONFIG_DIR" + mkdir -p "$CONFIG_DIR/targets.d" || die "Cannot create targets.d directory" + mkdir -p "$CONFIG_DIR/remotes.d" || die "Cannot create remotes.d directory" + mkdir -p "$CONFIG_DIR/schedules.d" || die "Cannot create schedules.d directory" + mkdir -p "$LOG_DIR" || die "Cannot create log directory: $LOG_DIR" +} diff --git a/lib/verify.sh b/lib/verify.sh new file mode 100644 index 0000000..43b3e11 --- /dev/null +++ b/lib/verify.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# gniza4linux/lib/verify.sh — Remote backup integrity checks + +[[ -n "${_GNIZA4LINUX_VERIFY_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_VERIFY_LOADED=1 + +verify_target_backup() { + local target_name="$1" + local snapshot_ts="${2:-}" + local errors=0 + + # Resolve timestamp + local ts; ts=$(resolve_snapshot_timestamp "$target_name" "$snapshot_ts") || return 1 + + log_info "Verifying backup for $target_name (snapshot: $ts)..." + + if _is_rclone_mode; then + local snap_subpath="targets/${target_name}/snapshots/${ts}" + + # Check .complete marker + if ! rclone_exists "${snap_subpath}/.complete"; then + log_error "Snapshot missing .complete marker: $snap_subpath" + return 1 + fi + + # Check meta.json + local meta; meta=$(rclone_cat "${snap_subpath}/meta.json" 2>/dev/null) || true + if [[ -z "$meta" ]]; then + log_warn "meta.json not found in snapshot" + ((errors++)) || true + else + log_info " meta.json: present" + fi + + # Check manifest.txt + if rclone_exists "${snap_subpath}/manifest.txt"; then + log_info " manifest.txt: present" + else + log_warn " manifest.txt: missing" + ((errors++)) || true + fi + + # Count files + local file_list; file_list=$(rclone_list_files "$snap_subpath" 2>/dev/null) || true + local file_count=0 + [[ -n "$file_list" ]] && file_count=$(echo "$file_list" | wc -l) + if (( file_count == 0 )); then + log_warn "No files found in snapshot" + ((errors++)) || true + else + log_info " files: $file_count file(s)" + fi + + # Report size + local size_json; size_json=$(rclone_size "$snap_subpath" 2>/dev/null) || true + local bytes=0 + if [[ -n "$size_json" ]]; then + bytes=$(echo "$size_json" | grep -oP '"bytes":\s*\K[0-9]+' || echo 0) + fi + log_info " size: $(human_size "$bytes")" + + # Check latest.txt + local latest; latest=$(rclone_cat "targets/${target_name}/snapshots/latest.txt" 2>/dev/null) || true + if [[ -n "$latest" ]]; then + log_info " latest -> $latest" + else + log_warn " latest.txt not set" + fi + else + local snap_dir; snap_dir=$(get_snapshot_dir "$target_name") + local snap_path="$snap_dir/$ts" + + if [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then + # Check snapshot directory exists + if [[ ! -d "$snap_path" ]]; then + log_error "Snapshot directory not found: $snap_path" + return 1 + fi + + # Check meta.json + if [[ -f "$snap_path/meta.json" ]]; then + log_info " meta.json: present" + else + log_warn " meta.json: missing" + ((errors++)) || true + fi + + # Check manifest.txt + if [[ -f "$snap_path/manifest.txt" ]]; then + log_info " manifest.txt: present" + else + log_warn " manifest.txt: missing" + ((errors++)) || true + fi + + # Count files + local file_count; file_count=$(find "$snap_path" -type f 2>/dev/null | wc -l) + if (( file_count == 0 )); then + log_warn "No files found in snapshot" + ((errors++)) || true + else + log_info " files: $file_count file(s)" + fi + + # Report size + local total_size; total_size=$(du -sb "$snap_path" 2>/dev/null | cut -f1) || total_size=0 + log_info " size: $(human_size "${total_size:-0}")" + + # Check latest symlink + local base; base=$(get_remote_target_base "$target_name") + if [[ -L "$base/latest" ]]; then + local latest_target; latest_target=$(readlink "$base/latest" 2>/dev/null) + log_info " latest -> $(basename "$latest_target")" + else + log_warn " latest symlink not set" + fi + else + # SSH remote + if ! remote_exec "test -d '$snap_path'" 2>/dev/null; then + log_error "Snapshot directory not found: $snap_path" + return 1 + fi + + # Check meta.json + if remote_exec "test -f '$snap_path/meta.json'" 2>/dev/null; then + log_info " meta.json: present" + else + log_warn " meta.json: missing" + ((errors++)) || true + fi + + # Check manifest.txt + if remote_exec "test -f '$snap_path/manifest.txt'" 2>/dev/null; then + log_info " manifest.txt: present" + else + log_warn " manifest.txt: missing" + ((errors++)) || true + fi + + # Count files + local file_count; file_count=$(remote_exec "find '$snap_path' -type f | wc -l" 2>/dev/null) + if [[ "${file_count:-0}" -eq 0 ]]; then + log_warn "No files found in snapshot" + ((errors++)) || true + else + log_info " files: $file_count file(s)" + fi + + # Report size + local total_size; total_size=$(remote_exec "du -sb '$snap_path' | cut -f1" 2>/dev/null) + log_info " size: $(human_size "${total_size:-0}")" + + # Check latest symlink + local base; base=$(get_remote_target_base "$target_name") + local latest_target; latest_target=$(remote_exec "readlink '$base/latest' 2>/dev/null" 2>/dev/null) + if [[ -n "$latest_target" ]]; then + log_info " latest -> $(basename "$latest_target")" + else + log_warn " latest symlink not set" + fi + fi + fi + + if (( errors > 0 )); then + log_error "Verification found $errors issue(s) for $target_name" + return 1 + fi + + log_info "Verification passed for $target_name" + return 0 +} + +verify_all_targets() { + local targets; targets=$(list_remote_targets) + local total=0 passed=0 failed=0 + + if [[ -z "$targets" ]]; then + log_warn "No remote targets found to verify" + return 0 + fi + + while IFS= read -r target_name; do + [[ -z "$target_name" ]] && continue + ((total++)) || true + if verify_target_backup "$target_name"; then + ((passed++)) || true + else + ((failed++)) || true + fi + done <<< "$targets" + + echo "" + log_info "Verification complete: $passed/$total passed, $failed failed" + (( failed > 0 )) && return 1 + return 0 +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..fa73e0b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -eo pipefail + +REPO_URL="https://github.com/shukiv/gniza4linux.git" + +# Colors +if [[ -t 1 ]]; then + C_GREEN=$'\033[0;32m' + C_RED=$'\033[0;31m' + C_YELLOW=$'\033[0;33m' + C_BOLD=$'\033[1m' + C_RESET=$'\033[0m' +else + C_GREEN="" C_RED="" C_YELLOW="" C_BOLD="" C_RESET="" +fi + +info() { echo "${C_GREEN}[INFO]${C_RESET} $*"; } +warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*" >&2; } +error() { echo "${C_RED}[ERROR]${C_RESET} $*" >&2; } +die() { error "$1"; exit 1; } + +# ── Determine install mode ─────────────────────────────────── +if [[ $EUID -eq 0 ]]; then + MODE="root" + INSTALL_DIR="/usr/local/gniza" + BIN_LINK="/usr/local/bin/gniza" + CONFIG_DIR="/etc/gniza" + LOG_DIR="/var/log/gniza" +else + MODE="user" + INSTALL_DIR="$HOME/.local/share/gniza" + BIN_LINK="$HOME/.local/bin/gniza" + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gniza" + LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/gniza/log" +fi + +info "Install mode: ${C_BOLD}${MODE}${C_RESET}" +info "Install dir: $INSTALL_DIR" +info "Config dir: $CONFIG_DIR" +info "Log dir: $LOG_DIR" +echo "" + +# ── Determine source ──────────────────────────────────────── +SOURCE_DIR="" + +# Check if running from a local clone +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/../lib/constants.sh" && -f "$SCRIPT_DIR/../bin/gniza" ]]; then + SOURCE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + info "Installing from local clone: $SOURCE_DIR" +else + # Clone from git + if ! command -v git &>/dev/null; then + die "git is required to install gniza4linux (or run from a local clone)" + fi + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + info "Cloning from $REPO_URL..." + git clone --depth 1 "$REPO_URL" "$TMPDIR/gniza4linux" || die "Failed to clone repository" + SOURCE_DIR="$TMPDIR/gniza4linux" +fi + +# ── Check dependencies ────────────────────────────────────── +info "Checking dependencies..." +for cmd in bash rsync; do + if ! command -v "$cmd" &>/dev/null; then + die "Required dependency not found: $cmd" + fi +done + +for cmd in ssh whiptail curl; do + if ! command -v "$cmd" &>/dev/null; then + warn "Optional dependency not found: $cmd" + fi +done + +# ── Install files ──────────────────────────────────────────── +info "Installing to $INSTALL_DIR..." +mkdir -p "$INSTALL_DIR" + +# Copy project files +cp -r "$SOURCE_DIR/bin" "$INSTALL_DIR/" +cp -r "$SOURCE_DIR/lib" "$INSTALL_DIR/" +cp -r "$SOURCE_DIR/etc" "$INSTALL_DIR/" + +if [[ -d "$SOURCE_DIR/scripts" ]]; then + cp -r "$SOURCE_DIR/scripts" "$INSTALL_DIR/" +fi + +if [[ -d "$SOURCE_DIR/tests" ]]; then + cp -r "$SOURCE_DIR/tests" "$INSTALL_DIR/" +fi + +# Make entrypoint executable +chmod +x "$INSTALL_DIR/bin/gniza" + +# ── Create symlink ─────────────────────────────────────────── +info "Creating symlink: $BIN_LINK -> $INSTALL_DIR/bin/gniza" +mkdir -p "$(dirname "$BIN_LINK")" +ln -sf "$INSTALL_DIR/bin/gniza" "$BIN_LINK" + +# ── Create config directories ─────────────────────────────── +info "Setting up config directory: $CONFIG_DIR" +mkdir -p "$CONFIG_DIR/targets.d" +mkdir -p "$CONFIG_DIR/remotes.d" +mkdir -p "$CONFIG_DIR/schedules.d" + +if [[ "$MODE" == "root" ]]; then + chmod 700 "$CONFIG_DIR" + chmod 700 "$CONFIG_DIR/targets.d" + chmod 700 "$CONFIG_DIR/remotes.d" + chmod 700 "$CONFIG_DIR/schedules.d" +fi + +# ── Create log directory ───────────────────────────────────── +info "Setting up log directory: $LOG_DIR" +mkdir -p "$LOG_DIR" + +# ── Copy example configs (if not already present) ──────────── +if [[ ! -f "$CONFIG_DIR/gniza.conf" ]]; then + cp "$INSTALL_DIR/etc/gniza.conf.example" "$CONFIG_DIR/gniza.conf" + info "Created default config: $CONFIG_DIR/gniza.conf" +else + info "Config already exists, not overwriting: $CONFIG_DIR/gniza.conf" +fi + +for example in target.conf.example remote.conf.example schedule.conf.example; do + if [[ -f "$INSTALL_DIR/etc/$example" ]]; then + cp "$INSTALL_DIR/etc/$example" "$CONFIG_DIR/$example" + fi +done + +# ── Done ───────────────────────────────────────────────────── +echo "" +echo "${C_GREEN}${C_BOLD}Installation complete!${C_RESET}" +echo "" +echo " Binary: $BIN_LINK" +echo " Config: $CONFIG_DIR/gniza.conf" +echo " Logs: $LOG_DIR" +echo "" +echo "Get started:" +echo " gniza --help Show CLI help" +echo " gniza Launch TUI (requires whiptail)" +echo "" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..06d1c67 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -eo pipefail + +# Colors +if [[ -t 1 ]]; then + C_GREEN=$'\033[0;32m' + C_RED=$'\033[0;31m' + C_YELLOW=$'\033[0;33m' + C_BOLD=$'\033[1m' + C_RESET=$'\033[0m' +else + C_GREEN="" C_RED="" C_YELLOW="" C_BOLD="" C_RESET="" +fi + +info() { echo "${C_GREEN}[INFO]${C_RESET} $*"; } +warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*" >&2; } +error() { echo "${C_RED}[ERROR]${C_RESET} $*" >&2; } + +# ── Determine uninstall mode ──────────────────────────────── +if [[ $EUID -eq 0 ]]; then + MODE="root" + INSTALL_DIR="/usr/local/gniza" + BIN_LINK="/usr/local/bin/gniza" + CONFIG_DIR="/etc/gniza" + LOG_DIR="/var/log/gniza" +else + MODE="user" + INSTALL_DIR="$HOME/.local/share/gniza" + BIN_LINK="$HOME/.local/bin/gniza" + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gniza" + LOG_DIR="$HOME/.local/share/gniza/logs" +fi + +info "Uninstall mode: ${C_BOLD}${MODE}${C_RESET}" +echo "" + +# ── Remove cron entries ────────────────────────────────────── +info "Removing gniza cron entries..." +CRON_TAG="# gniza4linux:" +current_crontab=$(crontab -l 2>/dev/null) || current_crontab="" + +if [[ -n "$current_crontab" ]]; then + filtered="" + skip_next=false + removed=0 + while IFS= read -r line; do + if [[ "$line" == "${CRON_TAG}"* ]]; then + skip_next=true + ((removed++)) || true + continue + fi + if [[ "$skip_next" == "true" ]]; then + skip_next=false + continue + fi + filtered+="$line"$'\n' + done <<< "$current_crontab" + + if (( removed > 0 )); then + echo "$filtered" | crontab - 2>/dev/null || warn "Failed to update crontab" + info "Removed $removed cron entry/entries" + else + info "No gniza cron entries found" + fi +else + info "No crontab entries to check" +fi + +# ── Remove symlink ─────────────────────────────────────────── +if [[ -L "$BIN_LINK" ]]; then + rm -f "$BIN_LINK" + info "Removed symlink: $BIN_LINK" +elif [[ -f "$BIN_LINK" ]]; then + warn "Expected symlink but found regular file: $BIN_LINK (not removing)" +fi + +# ── Remove install directory ───────────────────────────────── +if [[ -d "$INSTALL_DIR" ]]; then + rm -rf "$INSTALL_DIR" + info "Removed install directory: $INSTALL_DIR" +else + info "Install directory not found: $INSTALL_DIR" +fi + +# ── Notify about config and logs ───────────────────────────── +echo "" +echo "${C_GREEN}${C_BOLD}Uninstall complete!${C_RESET}" +echo "" +echo "The following directories were ${C_YELLOW}NOT${C_RESET} removed (may contain your data):" +echo "" +if [[ -d "$CONFIG_DIR" ]]; then + echo " Config: $CONFIG_DIR" + echo " To remove: rm -rf $CONFIG_DIR" +fi +if [[ -d "$LOG_DIR" ]]; then + echo " Logs: $LOG_DIR" + echo " To remove: rm -rf $LOG_DIR" +fi +echo "" diff --git a/tests/test_config.sh b/tests/test_config.sh new file mode 100755 index 0000000..31f5a91 --- /dev/null +++ b/tests/test_config.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# gniza4linux/tests/test_config.sh — Unit tests for lib/config.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Source libraries in order +source "$PROJECT_DIR/lib/constants.sh" +source "$PROJECT_DIR/lib/utils.sh" +detect_mode +source "$PROJECT_DIR/lib/logging.sh" +source "$PROJECT_DIR/lib/config.sh" + +PASS=0 +FAIL=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo " PASS: $desc" + ((PASS++)) || true + else + echo " FAIL: $desc (expected='$expected', got='$actual')" + ((FAIL++)) || true + fi +} + +assert_ok() { + local desc="$1"; shift + if "$@" 2>/dev/null; then + echo " PASS: $desc" + ((PASS++)) || true + else + echo " FAIL: $desc (expected success)" + ((FAIL++)) || true + fi +} + +assert_fail() { + local desc="$1"; shift + if "$@" 2>/dev/null; then + echo " FAIL: $desc (expected failure)" + ((FAIL++)) || true + else + echo " PASS: $desc" + ((PASS++)) || true + fi +} + +# ── load_config ────────────────────────────────────────────── +echo "=== load_config ===" + +TMPCONF=$(mktemp) +cat > "$TMPCONF" <<'EOF' +BACKUP_MODE="incremental" +BWLIMIT=500 +RETENTION_COUNT=10 +LOG_LEVEL="debug" +LOG_RETAIN=30 +NOTIFY_EMAIL="test@example.com" +NOTIFY_ON="always" +SSH_TIMEOUT=60 +SSH_RETRIES=5 +RSYNC_EXTRA_OPTS="--compress" +EOF + +# Override CONFIG_DIR so load_config can find it +OLD_CONFIG_DIR="$CONFIG_DIR" +CONFIG_DIR=$(dirname "$TMPCONF") +cp "$TMPCONF" "$CONFIG_DIR/gniza.conf" + +# Reset loaded flag so we can re-source +_GNIZA4LINUX_CONFIG_LOADED="" +source "$PROJECT_DIR/lib/config.sh" + +load_config "$CONFIG_DIR/gniza.conf" + +assert_eq "BACKUP_MODE loaded" "incremental" "$BACKUP_MODE" +assert_eq "BWLIMIT loaded" "500" "$BWLIMIT" +assert_eq "RETENTION_COUNT loaded" "10" "$RETENTION_COUNT" +assert_eq "LOG_LEVEL loaded" "debug" "$LOG_LEVEL" +assert_eq "LOG_RETAIN loaded" "30" "$LOG_RETAIN" +assert_eq "NOTIFY_EMAIL loaded" "test@example.com" "$NOTIFY_EMAIL" +assert_eq "NOTIFY_ON loaded" "always" "$NOTIFY_ON" +assert_eq "SSH_TIMEOUT loaded" "60" "$SSH_TIMEOUT" +assert_eq "SSH_RETRIES loaded" "5" "$SSH_RETRIES" +assert_eq "RSYNC_EXTRA_OPTS loaded" "--compress" "$RSYNC_EXTRA_OPTS" + +rm -f "$TMPCONF" "$CONFIG_DIR/gniza.conf" +CONFIG_DIR="$OLD_CONFIG_DIR" + +# ── validate_config: valid ─────────────────────────────────── +echo "" +echo "=== validate_config (valid) ===" + +BACKUP_MODE="full" +BWLIMIT=0 +RETENTION_COUNT=30 +LOG_LEVEL="info" +LOG_RETAIN=90 +NOTIFY_ON="failure" +SMTP_HOST="" +SSH_TIMEOUT=30 +SSH_RETRIES=3 +RSYNC_EXTRA_OPTS="" + +assert_ok "valid config passes" validate_config + +# ── validate_config: invalid values ────────────────────────── +echo "" +echo "=== validate_config (invalid) ===" + +BACKUP_MODE="snapshot" +assert_fail "rejects bad BACKUP_MODE" validate_config +BACKUP_MODE="full" + +NOTIFY_ON="sometimes" +assert_fail "rejects bad NOTIFY_ON" validate_config +NOTIFY_ON="failure" + +LOG_LEVEL="verbose" +assert_fail "rejects bad LOG_LEVEL" validate_config +LOG_LEVEL="info" + +SSH_TIMEOUT="abc" +assert_fail "rejects non-numeric SSH_TIMEOUT" validate_config +SSH_TIMEOUT=30 + +BWLIMIT="fast" +assert_fail "rejects non-numeric BWLIMIT" validate_config +BWLIMIT=0 + +RSYNC_EXTRA_OPTS='--delete; rm -rf /' +assert_fail "rejects unsafe RSYNC_EXTRA_OPTS" validate_config +RSYNC_EXTRA_OPTS="" + +# ── _safe_source_config security ───────────────────────────── +echo "" +echo "=== _safe_source_config security ===" + +INJECT_FILE=$(mktemp) +cat > "$INJECT_FILE" <<'CONF' +SAFE_VALUE="ok" +evil_lowercase="should be ignored" +$(whoami) +`id` +CONF + +# Unset to test +unset SAFE_VALUE 2>/dev/null || true +unset evil_lowercase 2>/dev/null || true + +_safe_source_config "$INJECT_FILE" +assert_eq "uppercase key loaded" "ok" "${SAFE_VALUE:-}" +assert_eq "lowercase key ignored" "" "${evil_lowercase:-}" + +rm -f "$INJECT_FILE" + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo "============================================" +echo "Results: $PASS passed, $FAIL failed" +echo "============================================" + +(( FAIL > 0 )) && exit 1 +exit 0 diff --git a/tests/test_targets.sh b/tests/test_targets.sh new file mode 100755 index 0000000..0af051b --- /dev/null +++ b/tests/test_targets.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# gniza4linux/tests/test_targets.sh — Unit tests for lib/targets.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Source libraries in order +source "$PROJECT_DIR/lib/constants.sh" +source "$PROJECT_DIR/lib/utils.sh" +detect_mode +source "$PROJECT_DIR/lib/logging.sh" +source "$PROJECT_DIR/lib/config.sh" +source "$PROJECT_DIR/lib/targets.sh" + +PASS=0 +FAIL=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo " PASS: $desc" + ((PASS++)) || true + else + echo " FAIL: $desc (expected='$expected', got='$actual')" + ((FAIL++)) || true + fi +} + +assert_ok() { + local desc="$1"; shift + if "$@" 2>/dev/null; then + echo " PASS: $desc" + ((PASS++)) || true + else + echo " FAIL: $desc (expected success)" + ((FAIL++)) || true + fi +} + +assert_fail() { + local desc="$1"; shift + if "$@" 2>/dev/null; then + echo " FAIL: $desc (expected failure)" + ((FAIL++)) || true + else + echo " PASS: $desc" + ((PASS++)) || true + fi +} + +# ── Setup temp CONFIG_DIR ──────────────────────────────────── +ORIG_CONFIG_DIR="$CONFIG_DIR" +CONFIG_DIR=$(mktemp -d) +mkdir -p "$CONFIG_DIR/targets.d" + +cleanup() { + rm -rf "$CONFIG_DIR" + CONFIG_DIR="$ORIG_CONFIG_DIR" +} +trap cleanup EXIT + +# ── create_target ──────────────────────────────────────────── +echo "=== create_target ===" + +assert_ok "create target 'webserver'" create_target "webserver" "/tmp,/var" + +if [[ -f "$CONFIG_DIR/targets.d/webserver.conf" ]]; then + echo " PASS: config file created" + ((PASS++)) || true +else + echo " FAIL: config file not created" + ((FAIL++)) || true +fi + +assert_fail "rejects invalid name '123bad'" create_target "123bad" "/tmp" +assert_fail "rejects invalid name '../evil'" create_target "../evil" "/tmp" + +# ── load_target ────────────────────────────────────────────── +echo "" +echo "=== load_target ===" + +assert_ok "load 'webserver'" load_target "webserver" +assert_eq "TARGET_NAME set" "webserver" "$TARGET_NAME" +assert_eq "TARGET_FOLDERS set" "/tmp,/var" "$TARGET_FOLDERS" +assert_eq "TARGET_ENABLED default" "yes" "$TARGET_ENABLED" + +assert_fail "load nonexistent target" load_target "nonexistent" + +# ── list_targets ───────────────────────────────────────────── +echo "" +echo "=== list_targets ===" + +create_target "dbserver" "/tmp" 2>/dev/null + +local_list=$(list_targets) +if echo "$local_list" | grep -q "webserver" && echo "$local_list" | grep -q "dbserver"; then + echo " PASS: list_targets returns both targets" + ((PASS++)) || true +else + echo " FAIL: list_targets missing targets (got: $local_list)" + ((FAIL++)) || true +fi + +# ── delete_target ──────────────────────────────────────────── +echo "" +echo "=== delete_target ===" + +assert_ok "delete 'dbserver'" delete_target "dbserver" + +if [[ ! -f "$CONFIG_DIR/targets.d/dbserver.conf" ]]; then + echo " PASS: config file removed" + ((PASS++)) || true +else + echo " FAIL: config file still exists" + ((FAIL++)) || true +fi + +assert_fail "delete nonexistent target" delete_target "nonexistent" + +# ── validate_target ────────────────────────────────────────── +echo "" +echo "=== validate_target ===" + +# webserver has /tmp,/var which exist +assert_ok "valid target 'webserver'" validate_target "webserver" + +# Create a target with non-existent folder +create_target "badfolders" "/nonexistent_xyz_12345" 2>/dev/null +assert_fail "rejects target with non-existent folder" validate_target "badfolders" + +# Create a target with empty folders +cat > "$CONFIG_DIR/targets.d/emptyfolders.conf" <<'EOF' +TARGET_NAME="emptyfolders" +TARGET_FOLDERS="" +TARGET_ENABLED="yes" +EOF +assert_fail "rejects target with empty folders" validate_target "emptyfolders" + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo "============================================" +echo "Results: $PASS passed, $FAIL failed" +echo "============================================" + +(( FAIL > 0 )) && exit 1 +exit 0 diff --git a/tests/test_utils.sh b/tests/test_utils.sh new file mode 100755 index 0000000..c078c6d --- /dev/null +++ b/tests/test_utils.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# gniza4linux/tests/test_utils.sh — Unit tests for lib/utils.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Source libraries in order +source "$PROJECT_DIR/lib/constants.sh" +source "$PROJECT_DIR/lib/utils.sh" +detect_mode +source "$PROJECT_DIR/lib/logging.sh" + +PASS=0 +FAIL=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo " PASS: $desc" + ((PASS++)) || true + else + echo " FAIL: $desc (expected='$expected', got='$actual')" + ((FAIL++)) || true + fi +} + +assert_ok() { + local desc="$1"; shift + if "$@" 2>/dev/null; then + echo " PASS: $desc" + ((PASS++)) || true + else + echo " FAIL: $desc (expected success)" + ((FAIL++)) || true + fi +} + +assert_fail() { + local desc="$1"; shift + if "$@" 2>/dev/null; then + echo " FAIL: $desc (expected failure)" + ((FAIL++)) || true + else + echo " PASS: $desc" + ((PASS++)) || true + fi +} + +# ── validate_target_name ───────────────────────────────────── +echo "=== validate_target_name ===" + +assert_ok "accepts 'mysite'" validate_target_name "mysite" +assert_ok "accepts 'web-server'" validate_target_name "web-server" +assert_ok "accepts 'db_backup1'" validate_target_name "db_backup1" +assert_ok "accepts single char 'a'" validate_target_name "a" + +assert_fail "rejects empty string" validate_target_name "" +assert_fail "rejects '123bad'" validate_target_name "123bad" +assert_fail "rejects '../evil'" validate_target_name "../evil" +assert_fail "rejects 'a]b'" validate_target_name "a]b" +assert_fail "rejects name >32 chars" validate_target_name "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +assert_fail "rejects name with space" validate_target_name "a b" + +# ── validate_path ──────────────────────────────────────────── +echo "" +echo "=== validate_path ===" + +assert_ok "accepts /tmp" validate_path "/tmp" +assert_fail "rejects relative path" validate_path "relative/path" +assert_fail "rejects path with .." validate_path "/tmp/../etc" +assert_fail "rejects non-existent path" validate_path "/nonexistent_path_xyz_12345" + +# ── validate_timestamp ─────────────────────────────────────── +echo "" +echo "=== validate_timestamp ===" + +assert_ok "accepts 2026-01-15T020000" validate_timestamp "2026-01-15T020000" +assert_ok "accepts 2025-12-31T235959" validate_timestamp "2025-12-31T235959" +assert_fail "rejects empty" validate_timestamp "" +assert_fail "rejects bad format" validate_timestamp "2026-01-15 02:00:00" +assert_fail "rejects partial" validate_timestamp "2026-01-15T02" + +# ── human_size ─────────────────────────────────────────────── +echo "" +echo "=== human_size ===" + +assert_eq "0 bytes" "0 B" "$(human_size 0)" +assert_eq "500 bytes" "500 B" "$(human_size 500)" +assert_eq "1 KB" "1.0 KB" "$(human_size 1024)" +assert_eq "1 MB" "1.0 MB" "$(human_size 1048576)" +assert_eq "1 GB" "1.0 GB" "$(human_size 1073741824)" +assert_eq "1.5 GB" "1.5 GB" "$(human_size 1610612736)" + +# ── human_duration ─────────────────────────────────────────── +echo "" +echo "=== human_duration ===" + +assert_eq "0 seconds" "0s" "$(human_duration 0)" +assert_eq "45 seconds" "45s" "$(human_duration 45)" +assert_eq "2 minutes" "2m 0s" "$(human_duration 120)" +assert_eq "1h 5m 3s" "1h 5m 3s" "$(human_duration 3903)" + +# ── _safe_source_config ────────────────────────────────────── +echo "" +echo "=== _safe_source_config ===" + +source "$PROJECT_DIR/lib/config.sh" + +TMPFILE=$(mktemp) +cat > "$TMPFILE" <<'EOF' +TEST_KEY1="hello" +TEST_KEY2='world' +TEST_KEY3=noquotes +# This is a comment + # Indented comment + +TEST_KEY4="has spaces" +EOF + +_safe_source_config "$TMPFILE" +assert_eq "double-quoted value" "hello" "$TEST_KEY1" +assert_eq "single-quoted value" "world" "$TEST_KEY2" +assert_eq "unquoted value" "noquotes" "$TEST_KEY3" +assert_eq "value with spaces" "has spaces" "$TEST_KEY4" + +# Test that code injection is not executed +INJECTION_FILE=$(mktemp) +cat > "$INJECTION_FILE" <<'EOF' +SAFE_VAR="safe" +$(touch /tmp/gniza_test_injection) +`touch /tmp/gniza_test_injection2` +EOF + +_safe_source_config "$INJECTION_FILE" +assert_eq "safe var loaded" "safe" "$SAFE_VAR" + +if [[ ! -f /tmp/gniza_test_injection ]] && [[ ! -f /tmp/gniza_test_injection2 ]]; then + echo " PASS: code injection blocked" + ((PASS++)) || true +else + echo " FAIL: code injection was executed" + ((FAIL++)) || true + rm -f /tmp/gniza_test_injection /tmp/gniza_test_injection2 +fi + +rm -f "$TMPFILE" "$INJECTION_FILE" + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo "============================================" +echo "Results: $PASS passed, $FAIL failed" +echo "============================================" + +(( FAIL > 0 )) && exit 1 +exit 0