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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-05 21:15:29 +02:00
commit 928d5af54c
42 changed files with 6090 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.swp
*.swo
*~
.DS_Store

21
LICENSE Normal file
View File

@@ -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.

157
README.md Normal file
View File

@@ -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/<hostname>/targets/<target>/snapshots/<YYYY-MM-DDTHHMMSS>/
├── 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.

445
bin/gniza Executable file
View File

@@ -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 <<EOF
gniza v${GNIZA4LINUX_VERSION} - Linux Backup Manager
Usage: gniza [OPTIONS] [COMMAND]
Options:
--cli Force CLI mode (no TUI)
--debug Enable debug logging
--config=FILE Override config file path
--help Show this help
--version Show version
Commands:
backup [--target=NAME] [--remote=NAME] [--all]
restore --target=NAME [--snapshot=TS] [--remote=NAME] [--dest=DIR] [--folder=PATH]
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]
version
If no command is given and whiptail is available, the TUI is launched.
EOF
}
# ── CLI argument parsing ─────────────────────────────────────
FORCE_CLI=false
CONFIG_FILE=""
SUBCOMMAND=""
declare -a SUBCMD_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--cli)
FORCE_CLI=true
shift
;;
--debug)
export GNIZA4LINUX_DEBUG="true"
shift
;;
--config=*)
CONFIG_FILE="${1#--config=}"
shift
;;
--help)
show_help
exit 0
;;
--version)
echo "gniza v${GNIZA4LINUX_VERSION}"
exit 0
;;
-*)
# Unknown flags before subcommand are errors
die "Unknown option: $1 (see --help)"
;;
*)
SUBCOMMAND="$1"
shift
SUBCMD_ARGS=("$@")
break
;;
esac
done
# ── Initialise ───────────────────────────────────────────────
ensure_dirs
# Load config if it exists
if [[ -n "$CONFIG_FILE" ]]; then
load_config "$CONFIG_FILE"
elif [[ -f "$CONFIG_DIR/gniza.conf" ]]; then
load_config
fi
init_logging
# ── Parse subcommand flags helper ────────────────────────────
_parse_flag() {
local flag="$1"
shift
local arg
for arg in "$@"; do
if [[ "$arg" == "${flag}="* ]]; then
echo "${arg#"${flag}="}"
return 0
fi
done
return 1
}
_has_flag() {
local flag="$1"
shift
local arg
for arg in "$@"; do
[[ "$arg" == "$flag" ]] && return 0
done
return 1
}
# ── CLI subcommand dispatch ──────────────────────────────────
run_cli() {
case "${SUBCOMMAND:-}" in
backup)
local target="" remote="" all=false
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true
acquire_lock
trap release_lock EXIT
if [[ "$all" == "true" || -z "$target" ]]; then
backup_all_targets "$remote"
else
backup_target "$target" "$remote"
fi
;;
restore)
local target="" snapshot="" remote="" dest="" folder=""
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
snapshot=$(_parse_flag "--snapshot" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
dest=$(_parse_flag "--dest" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
folder=$(_parse_flag "--folder" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
[[ -z "$target" ]] && die "restore requires --target=NAME"
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No remotes configured"
fi
if [[ -n "$folder" ]]; then
restore_folder "$target" "$folder" "${snapshot:-latest}" "$remote" "$dest"
else
restore_target "$target" "${snapshot:-latest}" "$remote" "$dest"
fi
;;
targets)
local action="${SUBCMD_ARGS[0]:-list}"
local name="" folders=""
name=$(_parse_flag "--name" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
folders=$(_parse_flag "--folders" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
case "$action" in
list)
local targets; targets=$(list_targets)
if [[ -z "$targets" ]]; then
echo "No targets configured."
else
echo "$targets"
fi
;;
add)
[[ -z "$name" ]] && die "targets add requires --name=NAME"
[[ -z "$folders" ]] && die "targets add requires --folders=PATHS"
create_target "$name" "$folders"
;;
delete)
[[ -z "$name" ]] && die "targets delete requires --name=NAME"
delete_target "$name"
;;
show)
[[ -z "$name" ]] && die "targets show requires --name=NAME"
load_target "$name"
echo "TARGET_NAME=$TARGET_NAME"
echo "TARGET_FOLDERS=$TARGET_FOLDERS"
echo "TARGET_EXCLUDE=$TARGET_EXCLUDE"
echo "TARGET_REMOTE=$TARGET_REMOTE"
echo "TARGET_RETENTION=$TARGET_RETENTION"
echo "TARGET_PRE_HOOK=$TARGET_PRE_HOOK"
echo "TARGET_POST_HOOK=$TARGET_POST_HOOK"
echo "TARGET_ENABLED=$TARGET_ENABLED"
;;
*)
die "Unknown targets action: $action (expected list|add|delete|show)"
;;
esac
;;
remotes)
local action="${SUBCMD_ARGS[0]:-list}"
local name=""
name=$(_parse_flag "--name" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
case "$action" in
list)
local remotes; remotes=$(list_remotes)
if [[ -z "$remotes" ]]; then
echo "No remotes configured."
else
echo "$remotes"
fi
;;
add)
[[ -z "$name" ]] && die "remotes add requires --name=NAME"
echo "Use the TUI or manually create $CONFIG_DIR/remotes.d/${name}.conf"
;;
delete)
[[ -z "$name" ]] && die "remotes delete requires --name=NAME"
local conf="$CONFIG_DIR/remotes.d/${name}.conf"
[[ ! -f "$conf" ]] && die "Remote config not found: $conf"
rm -f "$conf"
log_info "Deleted remote config: $conf"
;;
show)
[[ -z "$name" ]] && die "remotes show requires --name=NAME"
load_remote "$name"
echo "REMOTE_TYPE=$REMOTE_TYPE"
echo "REMOTE_HOST=${REMOTE_HOST:-}"
echo "REMOTE_PORT=$REMOTE_PORT"
echo "REMOTE_USER=$REMOTE_USER"
echo "REMOTE_AUTH_METHOD=$REMOTE_AUTH_METHOD"
echo "REMOTE_BASE=$REMOTE_BASE"
echo "BWLIMIT=$BWLIMIT"
echo "RETENTION_COUNT=$RETENTION_COUNT"
;;
test)
[[ -z "$name" ]] && die "remotes test requires --name=NAME"
validate_remote "$name"
echo "Remote '$name' is valid."
;;
*)
die "Unknown remotes action: $action (expected list|add|delete|show|test)"
;;
esac
;;
snapshots)
local action="${SUBCMD_ARGS[0]:-list}"
local target="" remote=""
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
case "$action" in
list)
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No remotes configured"
fi
_save_remote_globals
load_remote "$remote" || die "Failed to load remote: $remote"
if [[ -n "$target" ]]; then
list_remote_snapshots "$target"
else
local targets; targets=$(list_targets)
while IFS= read -r t; do
[[ -z "$t" ]] && continue
echo "=== $t ==="
list_remote_snapshots "$t" || true
done <<< "$targets"
fi
_restore_remote_globals
;;
*)
die "Unknown snapshots action: $action (expected list)"
;;
esac
;;
verify)
local target="" remote="" all=false
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No remotes configured"
fi
_save_remote_globals
load_remote "$remote" || die "Failed to load remote: $remote"
if [[ "$all" == "true" || -z "$target" ]]; then
verify_all_targets
else
verify_target_backup "$target"
fi
_restore_remote_globals
;;
retention)
local target="" remote="" all=false
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No remotes configured"
fi
_save_remote_globals
load_remote "$remote" || die "Failed to load remote: $remote"
if [[ "$all" == "true" || -z "$target" ]]; then
local targets; targets=$(list_targets)
while IFS= read -r t; do
[[ -z "$t" ]] && continue
enforce_retention "$t"
done <<< "$targets"
else
enforce_retention "$target"
fi
_restore_remote_globals
;;
schedule)
local action="${SUBCMD_ARGS[0]:-show}"
case "$action" in
install) install_schedules ;;
show) show_schedules ;;
remove) remove_schedules ;;
*) die "Unknown schedule action: $action (expected install|show|remove)" ;;
esac
;;
logs)
local last=false tail_n=""
_has_flag "--last" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && last=true
tail_n=$(_parse_flag "--tail" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
local log_dir="${LOG_DIR}"
if [[ "$last" == "true" ]]; then
local latest; latest=$(ls -t "$log_dir"/gniza-*.log 2>/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

34
etc/gniza.conf.example Normal file
View File

@@ -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=""

31
etc/remote.conf.example Normal file
View File

@@ -0,0 +1,31 @@
# gniza — Remote Configuration
# Copy to remotes.d/<name>.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=""

20
etc/schedule.conf.example Normal file
View File

@@ -0,0 +1,20 @@
# gniza — Schedule Configuration
# Copy to schedules.d/<name>.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=""

11
etc/target.conf.example Normal file
View File

@@ -0,0 +1,11 @@
# gniza — Target Configuration
# Copy to targets.d/<name>.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"

280
lib/backup.sh Normal file
View File

@@ -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 <target_name> [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 <<METAEOF
{
"target": "$target_name",
"hostname": "$hostname",
"timestamp": "$ts",
"duration": $duration,
"folders": "$(echo "$TARGET_FOLDERS" | sed 's/"/\\"/g')",
"total_size": $total_size,
"mode": "${BACKUP_MODE:-$DEFAULT_BACKUP_MODE}",
"pinned": false
}
METAEOF
)
if _is_rclone_mode; then
local meta_subpath="targets/${target_name}/snapshots/${ts}/meta.json"
rclone_rcat "$meta_subpath" "$meta_json" || log_warn "Failed to write meta.json"
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
echo "$meta_json" > "$snap_dir/${ts}.partial/meta.json" || log_warn "Failed to write meta.json"
else
echo "$meta_json" | remote_exec "cat > '$snap_dir/${ts}.partial/meta.json'" || log_warn "Failed to write meta.json"
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
}

138
lib/config.sh Normal file
View File

@@ -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
}

50
lib/constants.sh Normal file
View File

@@ -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"

31
lib/locking.sh Normal file
View File

@@ -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
}

64
lib/logging.sh Normal file
View File

@@ -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 "$@"; }

178
lib/notify.sh Normal file
View File

@@ -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"
}

376
lib/rclone.sh Normal file
View File

@@ -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" <<EOF
[remote]
type = s3
provider = ${S3_PROVIDER:-AWS}
access_key_id = ${S3_ACCESS_KEY_ID}
secret_access_key = ${S3_SECRET_ACCESS_KEY}
region = ${S3_REGION:-$DEFAULT_S3_REGION}
EOF
if [[ -n "${S3_ENDPOINT:-}" ]]; then
echo "endpoint = ${S3_ENDPOINT}" >> "$tmpfile"
fi
;;
gdrive)
cat > "$tmpfile" <<EOF
[remote]
type = drive
scope = drive
service_account_file = ${GDRIVE_SERVICE_ACCOUNT_FILE}
EOF
if [[ -n "${GDRIVE_ROOT_FOLDER_ID:-}" ]]; then
echo "root_folder_id = ${GDRIVE_ROOT_FOLDER_ID}" >> "$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 <subcmd> [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 <remote_subpath> <local_dir> [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
}

289
lib/remotes.sh Normal file
View File

@@ -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/<name>.conf.
# Each config overrides REMOTE_* globals so existing functions (ssh,
# transfer, snapshot, retention) work unchanged.
[[ -n "${_GNIZA4LINUX_REMOTES_LOADED:-}" ]] && return 0
_GNIZA4LINUX_REMOTES_LOADED=1
# ── Saved state for legacy globals ─────────────────────────────
declare -g _SAVED_REMOTE_HOST=""
declare -g _SAVED_REMOTE_PORT=""
declare -g _SAVED_REMOTE_USER=""
declare -g _SAVED_REMOTE_AUTH_METHOD=""
declare -g _SAVED_REMOTE_KEY=""
declare -g _SAVED_REMOTE_PASSWORD=""
declare -g _SAVED_REMOTE_BASE=""
declare -g _SAVED_BWLIMIT=""
declare -g _SAVED_RETENTION_COUNT=""
declare -g _SAVED_RSYNC_EXTRA_OPTS=""
declare -g _SAVED_REMOTE_TYPE=""
declare -g _SAVED_S3_ACCESS_KEY_ID=""
declare -g _SAVED_S3_SECRET_ACCESS_KEY=""
declare -g _SAVED_S3_REGION=""
declare -g _SAVED_S3_ENDPOINT=""
declare -g _SAVED_S3_BUCKET=""
declare -g _SAVED_GDRIVE_SERVICE_ACCOUNT_FILE=""
declare -g _SAVED_GDRIVE_ROOT_FOLDER_ID=""
declare -g CURRENT_REMOTE_NAME=""
_save_remote_globals() {
_SAVED_REMOTE_HOST="${REMOTE_HOST:-}"
_SAVED_REMOTE_PORT="${REMOTE_PORT:-22}"
_SAVED_REMOTE_USER="${REMOTE_USER:-root}"
_SAVED_REMOTE_AUTH_METHOD="${REMOTE_AUTH_METHOD:-key}"
_SAVED_REMOTE_KEY="${REMOTE_KEY:-}"
_SAVED_REMOTE_PASSWORD="${REMOTE_PASSWORD:-}"
_SAVED_REMOTE_BASE="${REMOTE_BASE:-/backups}"
_SAVED_BWLIMIT="${BWLIMIT:-0}"
_SAVED_RETENTION_COUNT="${RETENTION_COUNT:-30}"
_SAVED_RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}"
_SAVED_REMOTE_TYPE="${REMOTE_TYPE:-ssh}"
_SAVED_S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-}"
_SAVED_S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-}"
_SAVED_S3_REGION="${S3_REGION:-}"
_SAVED_S3_ENDPOINT="${S3_ENDPOINT:-}"
_SAVED_S3_BUCKET="${S3_BUCKET:-}"
_SAVED_GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}"
_SAVED_GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}"
}
_restore_remote_globals() {
REMOTE_HOST="$_SAVED_REMOTE_HOST"
REMOTE_PORT="$_SAVED_REMOTE_PORT"
REMOTE_USER="$_SAVED_REMOTE_USER"
REMOTE_AUTH_METHOD="$_SAVED_REMOTE_AUTH_METHOD"
REMOTE_KEY="$_SAVED_REMOTE_KEY"
REMOTE_PASSWORD="$_SAVED_REMOTE_PASSWORD"
REMOTE_BASE="$_SAVED_REMOTE_BASE"
BWLIMIT="$_SAVED_BWLIMIT"
RETENTION_COUNT="$_SAVED_RETENTION_COUNT"
RSYNC_EXTRA_OPTS="$_SAVED_RSYNC_EXTRA_OPTS"
REMOTE_TYPE="$_SAVED_REMOTE_TYPE"
S3_ACCESS_KEY_ID="$_SAVED_S3_ACCESS_KEY_ID"
S3_SECRET_ACCESS_KEY="$_SAVED_S3_SECRET_ACCESS_KEY"
S3_REGION="$_SAVED_S3_REGION"
S3_ENDPOINT="$_SAVED_S3_ENDPOINT"
S3_BUCKET="$_SAVED_S3_BUCKET"
GDRIVE_SERVICE_ACCOUNT_FILE="$_SAVED_GDRIVE_SERVICE_ACCOUNT_FILE"
GDRIVE_ROOT_FOLDER_ID="$_SAVED_GDRIVE_ROOT_FOLDER_ID"
CURRENT_REMOTE_NAME=""
}
# ── Discovery ──────────────────────────────────────────────────
# List remote names (filenames without .conf) sorted alphabetically.
list_remotes() {
local remotes_dir="$CONFIG_DIR/remotes.d"
if [[ ! -d "$remotes_dir" ]]; then
return 0
fi
local f
for f in "$remotes_dir"/*.conf; do
[[ -f "$f" ]] || continue
basename "$f" .conf
done
}
# Return 0 if at least one remote config exists.
has_remotes() {
local remotes
remotes=$(list_remotes)
[[ -n "$remotes" ]]
}
# ── Context switching ──────────────────────────────────────────
# Source a remote config and override REMOTE_* globals.
# Usage: load_remote <name>
load_remote() {
local name="$1"
local conf="$CONFIG_DIR/remotes.d/${name}.conf"
if [[ ! -f "$conf" ]]; then
log_error "Remote config not found: $conf"
return 1
fi
_safe_source_config "$conf" || {
log_error "Failed to parse remote config: $conf"
return 1
}
# Apply defaults for optional fields
REMOTE_TYPE="${REMOTE_TYPE:-$DEFAULT_REMOTE_TYPE}"
REMOTE_PORT="${REMOTE_PORT:-$DEFAULT_REMOTE_PORT}"
REMOTE_USER="${REMOTE_USER:-$DEFAULT_REMOTE_USER}"
REMOTE_AUTH_METHOD="${REMOTE_AUTH_METHOD:-$DEFAULT_REMOTE_AUTH_METHOD}"
REMOTE_KEY="${REMOTE_KEY:-}"
REMOTE_PASSWORD="${REMOTE_PASSWORD:-}"
REMOTE_BASE="${REMOTE_BASE:-$DEFAULT_REMOTE_BASE}"
BWLIMIT="${BWLIMIT:-$DEFAULT_BWLIMIT}"
RETENTION_COUNT="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}"
RSYNC_EXTRA_OPTS="${RSYNC_EXTRA_OPTS:-}"
# Cloud-specific defaults
S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-}"
S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-}"
S3_REGION="${S3_REGION:-$DEFAULT_S3_REGION}"
S3_ENDPOINT="${S3_ENDPOINT:-}"
S3_BUCKET="${S3_BUCKET:-}"
GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}"
GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}"
# shellcheck disable=SC2034 # used by callers
CURRENT_REMOTE_NAME="$name"
case "$REMOTE_TYPE" in
ssh)
log_debug "Loaded remote '$name': ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} -> ${REMOTE_BASE}"
;;
local)
log_debug "Loaded remote '$name': type=local -> ${REMOTE_BASE}"
;;
*)
log_debug "Loaded remote '$name': type=${REMOTE_TYPE} -> ${REMOTE_BASE}"
;;
esac
}
# Load + validate a remote config.
validate_remote() {
local name="$1"
load_remote "$name" || return 1
local errors=0
# Common validations
if ! [[ "$RETENTION_COUNT" =~ ^[0-9]+$ ]] || (( RETENTION_COUNT < 1 )); then
log_error "Remote '$name': RETENTION_COUNT must be >= 1, got: $RETENTION_COUNT"
((errors++)) || true
fi
case "${REMOTE_TYPE:-ssh}" in
ssh)
if [[ -z "$REMOTE_HOST" ]]; then
log_error "Remote '$name': REMOTE_HOST is required"
((errors++)) || true
fi
if [[ "${REMOTE_AUTH_METHOD:-key}" != "key" && "${REMOTE_AUTH_METHOD:-key}" != "password" ]]; then
log_error "Remote '$name': REMOTE_AUTH_METHOD must be 'key' or 'password', got: $REMOTE_AUTH_METHOD"
((errors++)) || true
fi
if [[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]]; then
if [[ -z "${REMOTE_PASSWORD:-}" ]]; then
log_error "Remote '$name': REMOTE_PASSWORD is required when REMOTE_AUTH_METHOD=password"
((errors++)) || true
fi
if ! command -v sshpass &>/dev/null; then
log_error "Remote '$name': sshpass is required for password authentication (install: apt install sshpass)"
((errors++)) || true
fi
else
if [[ -z "$REMOTE_KEY" ]]; then
log_error "Remote '$name': REMOTE_KEY is required"
((errors++)) || true
elif [[ ! -f "$REMOTE_KEY" ]]; then
log_error "Remote '$name': REMOTE_KEY file not found: $REMOTE_KEY"
((errors++)) || true
fi
fi
if ! [[ "$REMOTE_PORT" =~ ^[0-9]+$ ]] || (( REMOTE_PORT < 1 || REMOTE_PORT > 65535 )); then
log_error "Remote '$name': REMOTE_PORT must be 1-65535, got: $REMOTE_PORT"
((errors++)) || true
fi
;;
local)
if [[ -z "${REMOTE_BASE:-}" ]]; then
log_error "Remote '$name': REMOTE_BASE is required for local remotes"
((errors++)) || true
elif [[ ! -d "$REMOTE_BASE" ]]; then
log_error "Remote '$name': REMOTE_BASE directory does not exist: $REMOTE_BASE"
((errors++)) || true
fi
;;
s3)
if ! command -v rclone &>/dev/null; then
log_error "Remote '$name': rclone is required for S3 remotes (install: https://rclone.org/install/)"
((errors++)) || true
fi
if [[ -z "${S3_ACCESS_KEY_ID:-}" ]]; then
log_error "Remote '$name': S3_ACCESS_KEY_ID is required"
((errors++)) || true
fi
if [[ -z "${S3_SECRET_ACCESS_KEY:-}" ]]; then
log_error "Remote '$name': S3_SECRET_ACCESS_KEY is required"
((errors++)) || true
fi
if [[ -z "${S3_BUCKET:-}" ]]; then
log_error "Remote '$name': S3_BUCKET is required"
((errors++)) || true
fi
;;
gdrive)
if ! command -v rclone &>/dev/null; then
log_error "Remote '$name': rclone is required for Google Drive remotes (install: https://rclone.org/install/)"
((errors++)) || true
fi
if [[ -z "${GDRIVE_SERVICE_ACCOUNT_FILE:-}" ]]; then
log_error "Remote '$name': GDRIVE_SERVICE_ACCOUNT_FILE is required"
((errors++)) || true
elif [[ ! -f "${GDRIVE_SERVICE_ACCOUNT_FILE}" ]]; then
log_error "Remote '$name': GDRIVE_SERVICE_ACCOUNT_FILE not found: $GDRIVE_SERVICE_ACCOUNT_FILE"
((errors++)) || true
fi
;;
*)
log_error "Remote '$name': REMOTE_TYPE must be 'ssh', 'local', 's3', or 'gdrive', got: $REMOTE_TYPE"
((errors++)) || true
;;
esac
(( errors > 0 )) && return 1
return 0
}
# Resolve which remotes to operate on.
# - If --remote=NAME was given, return just that name.
# - Otherwise return all remotes from remotes.d/.
# - Errors if no remotes are configured.
#
# Usage: get_target_remotes "$remote_flag_value"
# Outputs one name per line.
get_target_remotes() {
local flag="${1:-}"
local remotes_dir="$CONFIG_DIR/remotes.d"
if [[ -n "$flag" ]]; then
# Split on commas, verify each remote exists
local IFS=','
local names
read -ra names <<< "$flag"
for name in "${names[@]}"; do
# Trim whitespace
name="${name#"${name%%[![:space:]]*}"}"
name="${name%"${name##*[![:space:]]}"}"
[[ -z "$name" ]] && continue
if [[ ! -f "$remotes_dir/${name}.conf" ]]; then
log_error "Remote not found: $name (expected $remotes_dir/${name}.conf)"
return 1
fi
echo "$name"
done
return 0
fi
if has_remotes; then
list_remotes
return 0
fi
# No remotes configured
log_error "No remotes configured. Create one in $CONFIG_DIR/remotes.d/"
return 1
}

248
lib/restore.sh Normal file
View File

@@ -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 <target_name> <snapshot_timestamp|"latest"> <remote_name> [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 <target_name> <folder_path> <snapshot_timestamp> <remote_name> [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 <target_name> <snapshot_timestamp> <remote_name>
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 <target_name> <snapshot_timestamp> <remote_name>
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
}

75
lib/retention.sh Normal file
View File

@@ -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
}

315
lib/schedule.sh Normal file
View File

@@ -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/<name>.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:<name>" 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 <name>
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."
}

156
lib/snapshot.sh Normal file
View File

@@ -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
}

80
lib/ssh.sh Normal file
View File

@@ -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} '<cmd>'"
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
}

171
lib/targets.sh Normal file
View File

@@ -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 <name>
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 <name>
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 <name> <folders> [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" <<EOF
TARGET_NAME="$name"
TARGET_FOLDERS="$folders"
TARGET_EXCLUDE="$exclude"
TARGET_REMOTE="$remote"
TARGET_RETENTION="$retention"
TARGET_PRE_HOOK="$pre_hook"
TARGET_POST_HOOK="$post_hook"
TARGET_ENABLED="$enabled"
EOF
chmod 600 "$conf"
log_info "Created target config: $conf"
}
# Remove a target .conf file.
# Usage: delete_target <name>
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
}

194
lib/transfer.sh Normal file
View File

@@ -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 <target_name> <folder_path> <timestamp> [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 <target_name> <timestamp>
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"
}

116
lib/ui_backup.sh Normal file
View File

@@ -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"
}

133
lib/ui_common.sh Normal file
View File

@@ -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"
}

96
lib/ui_logs.sh Normal file
View File

@@ -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"
}

37
lib/ui_main.sh Normal file
View File

@@ -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
}

401
lib/ui_remotes.sh Normal file
View File

@@ -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" <<EOF
REMOTE_TYPE="ssh"
REMOTE_HOST="$host"
REMOTE_PORT="$port"
REMOTE_USER="$user"
REMOTE_AUTH_METHOD="$auth_method"
REMOTE_KEY="$key"
REMOTE_PASSWORD="$password"
REMOTE_BASE="$base"
BWLIMIT="$bwlimit"
RETENTION_COUNT="$retention"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' created successfully."
}
_ui_remote_add_local() {
local name="$1" conf="$2"
local base; base=$(ui_inputbox "Local Remote" "Local backup directory:" "/backups") || return 0
[[ -z "$base" ]] && { ui_msgbox "Directory is required."; return 0; }
local retention; retention=$(ui_inputbox "Local Remote" "Retention count:" "$DEFAULT_RETENTION_COUNT") || retention="$DEFAULT_RETENTION_COUNT"
cat > "$conf" <<EOF
REMOTE_TYPE="local"
REMOTE_BASE="$base"
RETENTION_COUNT="$retention"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' created successfully."
}
_ui_remote_add_s3() {
local name="$1" conf="$2"
local bucket; bucket=$(ui_inputbox "S3 Remote" "S3 Bucket name:" "") || return 0
[[ -z "$bucket" ]] && { ui_msgbox "Bucket is required."; return 0; }
local region; region=$(ui_inputbox "S3 Remote" "Region:" "$DEFAULT_S3_REGION") || region="$DEFAULT_S3_REGION"
local endpoint; endpoint=$(ui_inputbox "S3 Remote" "Endpoint (leave empty for AWS):" "") || endpoint=""
local access_key; access_key=$(ui_inputbox "S3 Remote" "Access Key ID:" "") || access_key=""
local secret_key; secret_key=$(ui_password "Secret Access Key:") || secret_key=""
local base; base=$(ui_inputbox "S3 Remote" "Base path in bucket:" "/backups") || base="/backups"
local retention; retention=$(ui_inputbox "S3 Remote" "Retention count:" "$DEFAULT_RETENTION_COUNT") || retention="$DEFAULT_RETENTION_COUNT"
cat > "$conf" <<EOF
REMOTE_TYPE="s3"
S3_BUCKET="$bucket"
S3_REGION="$region"
S3_ENDPOINT="$endpoint"
S3_ACCESS_KEY_ID="$access_key"
S3_SECRET_ACCESS_KEY="$secret_key"
REMOTE_BASE="$base"
RETENTION_COUNT="$retention"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' created successfully."
}
_ui_remote_add_gdrive() {
local name="$1" conf="$2"
local sa_file; sa_file=$(ui_inputbox "Google Drive Remote" "Service account JSON file path:" "") || return 0
[[ -z "$sa_file" ]] && { ui_msgbox "Service account file is required."; return 0; }
local folder_id; folder_id=$(ui_inputbox "Google Drive Remote" "Root folder ID:" "") || folder_id=""
local base; base=$(ui_inputbox "Google Drive Remote" "Base path:" "/backups") || base="/backups"
local retention; retention=$(ui_inputbox "Google Drive Remote" "Retention count:" "$DEFAULT_RETENTION_COUNT") || retention="$DEFAULT_RETENTION_COUNT"
cat > "$conf" <<EOF
REMOTE_TYPE="gdrive"
GDRIVE_SERVICE_ACCOUNT_FILE="$sa_file"
GDRIVE_ROOT_FOLDER_ID="$folder_id"
REMOTE_BASE="$base"
RETENTION_COUNT="$retention"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' created successfully."
}
ui_remote_edit() {
local name="$1"
local conf="$CONFIG_DIR/remotes.d/${name}.conf"
if [[ ! -f "$conf" ]]; then
ui_msgbox "Remote '$name' not found."
return 0
fi
load_remote "$name" || { ui_msgbox "Failed to load remote '$name'."; return 0; }
case "${REMOTE_TYPE:-ssh}" in
ssh)
while true; do
local choice
choice=$(ui_menu "Edit Remote: $name (SSH)" \
"HOST" "Host: ${REMOTE_HOST}" \
"PORT" "Port: ${REMOTE_PORT}" \
"USER" "User: ${REMOTE_USER}" \
"AUTH" "Auth: ${REMOTE_AUTH_METHOD}" \
"BASE" "Base: ${REMOTE_BASE}" \
"BWLIMIT" "BW Limit: ${BWLIMIT} KB/s" \
"RETENTION" "Retention: ${RETENTION_COUNT}" \
"SAVE" "Save and return" \
"BACK" "Cancel") || return 0
case "$choice" in
HOST) local v; v=$(ui_inputbox "Edit" "Hostname:" "$REMOTE_HOST") && REMOTE_HOST="$v" ;;
PORT) local v; v=$(ui_inputbox "Edit" "Port:" "$REMOTE_PORT") && REMOTE_PORT="$v" ;;
USER) local v; v=$(ui_inputbox "Edit" "User:" "$REMOTE_USER") && REMOTE_USER="$v" ;;
AUTH)
local v; v=$(ui_radiolist "Auth Method" \
"key" "SSH key" "$([ "$REMOTE_AUTH_METHOD" = "key" ] && echo ON || echo OFF)" \
"password" "Password" "$([ "$REMOTE_AUTH_METHOD" = "password" ] && echo ON || echo OFF)") && REMOTE_AUTH_METHOD="$v"
if [[ "$REMOTE_AUTH_METHOD" == "key" ]]; then
local k; k=$(ui_inputbox "Edit" "SSH key path:" "$REMOTE_KEY") && REMOTE_KEY="$k"
else
local p; p=$(ui_password "Enter SSH password:") && REMOTE_PASSWORD="$p"
fi
;;
BASE) local v; v=$(ui_inputbox "Edit" "Base path:" "$REMOTE_BASE") && REMOTE_BASE="$v" ;;
BWLIMIT) local v; v=$(ui_inputbox "Edit" "BW Limit (KB/s):" "$BWLIMIT") && BWLIMIT="$v" ;;
RETENTION) local v; v=$(ui_inputbox "Edit" "Retention count:" "$RETENTION_COUNT") && RETENTION_COUNT="$v" ;;
SAVE)
cat > "$conf" <<EOF
REMOTE_TYPE="ssh"
REMOTE_HOST="$REMOTE_HOST"
REMOTE_PORT="$REMOTE_PORT"
REMOTE_USER="$REMOTE_USER"
REMOTE_AUTH_METHOD="$REMOTE_AUTH_METHOD"
REMOTE_KEY="$REMOTE_KEY"
REMOTE_PASSWORD="$REMOTE_PASSWORD"
REMOTE_BASE="$REMOTE_BASE"
BWLIMIT="$BWLIMIT"
RETENTION_COUNT="$RETENTION_COUNT"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' saved."
return 0
;;
BACK) return 0 ;;
esac
done
;;
local)
while true; do
local choice
choice=$(ui_menu "Edit Remote: $name (Local)" \
"BASE" "Directory: ${REMOTE_BASE}" \
"RETENTION" "Retention: ${RETENTION_COUNT}" \
"SAVE" "Save and return" \
"BACK" "Cancel") || return 0
case "$choice" in
BASE) local v; v=$(ui_inputbox "Edit" "Directory:" "$REMOTE_BASE") && REMOTE_BASE="$v" ;;
RETENTION) local v; v=$(ui_inputbox "Edit" "Retention count:" "$RETENTION_COUNT") && RETENTION_COUNT="$v" ;;
SAVE)
cat > "$conf" <<EOF
REMOTE_TYPE="local"
REMOTE_BASE="$REMOTE_BASE"
RETENTION_COUNT="$RETENTION_COUNT"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' saved."
return 0
;;
BACK) return 0 ;;
esac
done
;;
s3)
while true; do
local choice
choice=$(ui_menu "Edit Remote: $name (S3)" \
"BUCKET" "Bucket: ${S3_BUCKET}" \
"REGION" "Region: ${S3_REGION}" \
"ENDPOINT" "Endpoint: ${S3_ENDPOINT:-default}" \
"KEY" "Access Key: ${S3_ACCESS_KEY_ID:+****}" \
"SECRET" "Secret Key: ****" \
"BASE" "Base: ${REMOTE_BASE}" \
"RETENTION" "Retention: ${RETENTION_COUNT}" \
"SAVE" "Save and return" \
"BACK" "Cancel") || return 0
case "$choice" in
BUCKET) local v; v=$(ui_inputbox "Edit" "Bucket:" "$S3_BUCKET") && S3_BUCKET="$v" ;;
REGION) local v; v=$(ui_inputbox "Edit" "Region:" "$S3_REGION") && S3_REGION="$v" ;;
ENDPOINT) local v; v=$(ui_inputbox "Edit" "Endpoint:" "$S3_ENDPOINT") && S3_ENDPOINT="$v" ;;
KEY) local v; v=$(ui_inputbox "Edit" "Access Key ID:" "$S3_ACCESS_KEY_ID") && S3_ACCESS_KEY_ID="$v" ;;
SECRET) local v; v=$(ui_password "Secret Access Key:") && S3_SECRET_ACCESS_KEY="$v" ;;
BASE) local v; v=$(ui_inputbox "Edit" "Base path:" "$REMOTE_BASE") && REMOTE_BASE="$v" ;;
RETENTION) local v; v=$(ui_inputbox "Edit" "Retention count:" "$RETENTION_COUNT") && RETENTION_COUNT="$v" ;;
SAVE)
cat > "$conf" <<EOF
REMOTE_TYPE="s3"
S3_BUCKET="$S3_BUCKET"
S3_REGION="$S3_REGION"
S3_ENDPOINT="$S3_ENDPOINT"
S3_ACCESS_KEY_ID="$S3_ACCESS_KEY_ID"
S3_SECRET_ACCESS_KEY="$S3_SECRET_ACCESS_KEY"
REMOTE_BASE="$REMOTE_BASE"
RETENTION_COUNT="$RETENTION_COUNT"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' saved."
return 0
;;
BACK) return 0 ;;
esac
done
;;
gdrive)
while true; do
local choice
choice=$(ui_menu "Edit Remote: $name (GDrive)" \
"SA" "Service Account: ${GDRIVE_SERVICE_ACCOUNT_FILE}" \
"FOLDER" "Folder ID: ${GDRIVE_ROOT_FOLDER_ID:-none}" \
"BASE" "Base: ${REMOTE_BASE}" \
"RETENTION" "Retention: ${RETENTION_COUNT}" \
"SAVE" "Save and return" \
"BACK" "Cancel") || return 0
case "$choice" in
SA) local v; v=$(ui_inputbox "Edit" "Service account file:" "$GDRIVE_SERVICE_ACCOUNT_FILE") && GDRIVE_SERVICE_ACCOUNT_FILE="$v" ;;
FOLDER) local v; v=$(ui_inputbox "Edit" "Root folder ID:" "$GDRIVE_ROOT_FOLDER_ID") && GDRIVE_ROOT_FOLDER_ID="$v" ;;
BASE) local v; v=$(ui_inputbox "Edit" "Base path:" "$REMOTE_BASE") && REMOTE_BASE="$v" ;;
RETENTION) local v; v=$(ui_inputbox "Edit" "Retention count:" "$RETENTION_COUNT") && RETENTION_COUNT="$v" ;;
SAVE)
cat > "$conf" <<EOF
REMOTE_TYPE="gdrive"
GDRIVE_SERVICE_ACCOUNT_FILE="$GDRIVE_SERVICE_ACCOUNT_FILE"
GDRIVE_ROOT_FOLDER_ID="$GDRIVE_ROOT_FOLDER_ID"
REMOTE_BASE="$REMOTE_BASE"
RETENTION_COUNT="$RETENTION_COUNT"
EOF
chmod 600 "$conf"
ui_msgbox "Remote '$name' saved."
return 0
;;
BACK) return 0 ;;
esac
done
;;
esac
}
ui_remote_delete() {
local name="$1"
local conf="$CONFIG_DIR/remotes.d/${name}.conf"
if [[ ! -f "$conf" ]]; then
ui_msgbox "Remote '$name' not found."
return 0
fi
if ui_yesno "Delete remote '$name'? This cannot be undone."; then
rm -f "$conf"
log_info "Deleted remote config: $conf"
ui_msgbox "Remote '$name' deleted."
fi
}
ui_remote_test() {
local name="$1"
load_remote "$name" || { ui_msgbox "Failed to load remote '$name'."; return 0; }
local result
case "${REMOTE_TYPE:-ssh}" in
ssh)
result=$(ssh -o BatchMode=yes -o ConnectTimeout=10 \
-p "$REMOTE_PORT" -i "$REMOTE_KEY" \
"${REMOTE_USER}@${REMOTE_HOST}" "echo OK" 2>&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
}

145
lib/ui_restore.sh Normal file
View File

@@ -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"
}

112
lib/ui_retention.sh Normal file
View File

@@ -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."
}

170
lib/ui_schedule.sh Normal file
View File

@@ -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" <<EOF
# gniza schedule: $name
SCHEDULE="$stype"
SCHEDULE_TIME="$stime"
SCHEDULE_DAY="$sday"
SCHEDULE_CRON="$scron"
TARGETS="$stargets"
REMOTES="$sremotes"
EOF
chmod 600 "$conf"
ui_msgbox "Schedule '$name' created.\n\nRun 'Install schedules to crontab' to activate."
}
_ui_schedule_delete() {
if ! has_schedules; then
ui_msgbox "No schedules configured."
return 0
fi
local -a items=()
local schedules
schedules=$(list_schedules)
while IFS= read -r s; do
items+=("$s" "Schedule: $s")
done <<< "$schedules"
local selected
selected=$(ui_menu "Delete Schedule" "${items[@]}") || return 0
if ui_yesno "Delete schedule '$selected'?"; then
rm -f "$CONFIG_DIR/schedules.d/${selected}.conf"
ui_msgbox "Schedule '$selected' deleted."
fi
}
_ui_schedule_install() {
if ! has_schedules; then
ui_msgbox "No schedules configured. Add a schedule first."
return 0
fi
ui_yesno "Install all schedules to crontab?" || return 0
local result
if result=$(install_schedules 2>&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"
}

123
lib/ui_settings.sh Normal file
View File

@@ -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
}

111
lib/ui_snapshots.sh Normal file
View File

@@ -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
}

180
lib/ui_targets.sh Normal file
View File

@@ -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
}

85
lib/ui_verify.sh Normal file
View File

@@ -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"
}

104
lib/utils.sh Normal file
View File

@@ -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"
}

196
lib/verify.sh Normal file
View File

@@ -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
}

144
scripts/install.sh Executable file
View File

@@ -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 ""

99
scripts/uninstall.sh Executable file
View File

@@ -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 ""

167
tests/test_config.sh Executable file
View File

@@ -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

147
tests/test_targets.sh Executable file
View File

@@ -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

156
tests/test_utils.sh Executable file
View File

@@ -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