- Revert from textual-serve back to Flask (textual-serve had WebSocket issues) - Completely redesigned dashboard: modern dark theme, stat cards, clean tables - Redesigned login page to match - Restored API key generation in install script - Keep API key field in TUI settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
447 lines
17 KiB
Bash
Executable File
447 lines
17 KiB
Bash
Executable File
#!/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/mysql.sh"
|
|
source "$GNIZA_DIR/lib/restore.sh"
|
|
source "$GNIZA_DIR/lib/retention.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"
|
|
|
|
# ── 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]
|
|
retention [--target=NAME] [--remote=NAME] [--all]
|
|
schedule install|show|remove
|
|
logs [--last] [--tail=N]
|
|
web start|install-service|remove-service|status [--port=PORT] [--host=HOST]
|
|
version
|
|
|
|
If no command is given, 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
|
|
|
|
# Check for --skip-mysql flag
|
|
local skip_mysql=""
|
|
local arg
|
|
for arg in "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}"; do
|
|
[[ "$arg" == "--skip-mysql" ]] && skip_mysql="yes"
|
|
done
|
|
[[ -n "$skip_mysql" ]] && export SKIP_MYSQL_RESTORE="yes"
|
|
|
|
[[ -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"
|
|
echo "TARGET_MYSQL_ENABLED=$TARGET_MYSQL_ENABLED"
|
|
echo "TARGET_MYSQL_MODE=$TARGET_MYSQL_MODE"
|
|
echo "TARGET_MYSQL_DATABASES=$TARGET_MYSQL_DATABASES"
|
|
echo "TARGET_MYSQL_EXCLUDE=$TARGET_MYSQL_EXCLUDE"
|
|
echo "TARGET_MYSQL_USER=$TARGET_MYSQL_USER"
|
|
echo "TARGET_MYSQL_PASSWORD=****"
|
|
echo "TARGET_MYSQL_HOST=$TARGET_MYSQL_HOST"
|
|
echo "TARGET_MYSQL_PORT=$TARGET_MYSQL_PORT"
|
|
echo "TARGET_MYSQL_EXTRA_OPTS=$TARGET_MYSQL_EXTRA_OPTS"
|
|
;;
|
|
*)
|
|
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
|
|
;;
|
|
browse)
|
|
[[ -z "$target" ]] && die "browse requires --target=NAME"
|
|
local snapshot=""
|
|
snapshot=$(_parse_flag "--snapshot" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
|
|
[[ -z "$snapshot" ]] && die "browse requires --snapshot=TS"
|
|
if [[ -z "$remote" ]]; then
|
|
remote=$(list_remotes | head -1)
|
|
[[ -z "$remote" ]] && die "No remotes configured"
|
|
fi
|
|
list_snapshot_contents "$target" "$snapshot" "$remote"
|
|
;;
|
|
*)
|
|
die "Unknown snapshots action: $action (expected list|browse)"
|
|
;;
|
|
esac
|
|
;;
|
|
|
|
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 [[ "$SUBCOMMAND" == "web" ]]; then
|
|
_web_action="${SUBCMD_ARGS[0]:-start}"
|
|
case "$_web_action" in
|
|
start)
|
|
_web_port="" _web_host=""
|
|
_web_port=$(_parse_flag "--port" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
|
|
_web_host=$(_parse_flag "--host" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
|
|
_web_args=()
|
|
[[ -n "$_web_port" ]] && _web_args+=(--port="$_web_port")
|
|
[[ -n "$_web_host" ]] && _web_args+=(--host="$_web_host")
|
|
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m web "${_web_args[@]}"
|
|
;;
|
|
install-service)
|
|
_service_src="$GNIZA_DIR/etc/gniza-web.service"
|
|
_service_dst="/etc/systemd/system/gniza-web.service"
|
|
if [[ ! -f "$_service_src" ]]; then
|
|
die "Service file not found: $_service_src"
|
|
fi
|
|
cp "$_service_src" "$_service_dst"
|
|
systemctl daemon-reload
|
|
systemctl enable gniza-web
|
|
systemctl restart gniza-web
|
|
echo "GNIZA web service installed and started."
|
|
echo "Access the dashboard at http://$(hostname -I | awk '{print $1}'):8080"
|
|
;;
|
|
remove-service)
|
|
systemctl stop gniza-web 2>/dev/null || true
|
|
systemctl disable gniza-web 2>/dev/null || true
|
|
rm -f /etc/systemd/system/gniza-web.service
|
|
systemctl daemon-reload
|
|
echo "GNIZA web service removed."
|
|
;;
|
|
status)
|
|
systemctl status gniza-web 2>/dev/null || echo "GNIZA web service is not installed."
|
|
;;
|
|
*) die "Unknown web action: $_web_action (expected start|install-service|remove-service|status)" ;;
|
|
esac
|
|
elif [[ -n "$SUBCOMMAND" ]]; then
|
|
# Explicit subcommand: always CLI
|
|
run_cli
|
|
elif [[ "$FORCE_CLI" == "true" ]]; then
|
|
run_cli
|
|
elif python3 -c "import textual" 2>/dev/null && [[ -t 1 ]]; then
|
|
# Python Textual TUI mode
|
|
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "$@"
|
|
else
|
|
# Fallback to CLI help
|
|
run_cli
|
|
fi
|