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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
157
README.md
Normal 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
445
bin/gniza
Executable 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
34
etc/gniza.conf.example
Normal 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
31
etc/remote.conf.example
Normal 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
20
etc/schedule.conf.example
Normal 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
11
etc/target.conf.example
Normal 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
280
lib/backup.sh
Normal 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
138
lib/config.sh
Normal 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
50
lib/constants.sh
Normal 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
31
lib/locking.sh
Normal 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
64
lib/logging.sh
Normal 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
178
lib/notify.sh
Normal 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
376
lib/rclone.sh
Normal 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
289
lib/remotes.sh
Normal 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
248
lib/restore.sh
Normal 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
75
lib/retention.sh
Normal 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
315
lib/schedule.sh
Normal 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
156
lib/snapshot.sh
Normal 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
80
lib/ssh.sh
Normal 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
171
lib/targets.sh
Normal 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
194
lib/transfer.sh
Normal 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
116
lib/ui_backup.sh
Normal 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
133
lib/ui_common.sh
Normal 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
96
lib/ui_logs.sh
Normal 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
37
lib/ui_main.sh
Normal 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
401
lib/ui_remotes.sh
Normal 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
145
lib/ui_restore.sh
Normal 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
112
lib/ui_retention.sh
Normal 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
170
lib/ui_schedule.sh
Normal 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
123
lib/ui_settings.sh
Normal 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
111
lib/ui_snapshots.sh
Normal 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
180
lib/ui_targets.sh
Normal 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
85
lib/ui_verify.sh
Normal 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
104
lib/utils.sh
Normal 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
196
lib/verify.sh
Normal 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
144
scripts/install.sh
Executable 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
99
scripts/uninstall.sh
Executable 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
167
tests/test_config.sh
Executable 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
147
tests/test_targets.sh
Executable 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
156
tests/test_utils.sh
Executable 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
|
||||
Reference in New Issue
Block a user