#!/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" source "$GNIZA_DIR/lib/snaplog.sh" source "$GNIZA_DIR/lib/source.sh" # ── Help ───────────────────────────────────────────────────── show_help() { cat <.conf Destinations: \$CONFIG_DIR/remotes.d/.conf Schedules: \$CONFIG_DIR/schedules.d/.conf Settings: \$CONFIG_DIR/gniza.conf Root mode: /etc/gniza/ User mode: ~/.config/gniza/ 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|-h) show_help exit 0 ;; --full-help|--docs) show_full_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 # Only create log files for operations that produce meaningful output case "${SUBCOMMAND:-}" in backup|restore|retention|scheduled-run) init_logging ;; esac # ── 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 "--source" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true remote=$(_parse_flag "--destination" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true _has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true trap release_all_target_locks EXIT if [[ "$all" == "true" || -z "$target" ]]; then backup_all_targets "$remote" elif [[ "$target" == *,* ]]; then # Comma-separated targets (e.g. from schedule --target=web,db) local IFS=',' local names read -ra names <<< "$target" local rc=0 for t in "${names[@]}"; do t="${t#"${t%%[![:space:]]*}"}" t="${t%"${t##*[![:space:]]}"}" [[ -z "$t" ]] && continue backup_target "$t" "$remote" || rc=$? done exit "$rc" else backup_target "$target" "$remote" fi ;; restore) local target="" snapshot="" remote="" dest="" folder="" target=$(_parse_flag "--source" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true snapshot=$(_parse_flag "--snapshot" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true remote=$(_parse_flag "--destination" "${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 --source=NAME" if [[ -z "$remote" ]]; then remote=$(list_remotes | head -1) [[ -z "$remote" ]] && die "No destinations configured" fi if [[ -n "$folder" ]]; then restore_folder "$target" "$folder" "${snapshot:-latest}" "$remote" "$dest" else restore_target "$target" "${snapshot:-latest}" "$remote" "$dest" fi ;; sources) 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 sources configured." else echo "$targets" fi ;; add) [[ -z "$name" ]] && die "sources add requires --name=NAME" [[ -z "$folders" ]] && die "sources add requires --folders=PATHS" create_target "$name" "$folders" ;; delete) [[ -z "$name" ]] && die "sources delete requires --name=NAME" delete_target "$name" ;; show) [[ -z "$name" ]] && die "sources show requires --name=NAME" load_target "$name" echo "=== Source: $TARGET_NAME ===" echo "" echo "Name: $TARGET_NAME" echo "Enabled: $TARGET_ENABLED" echo "Folders: $TARGET_FOLDERS" echo "Exclude: ${TARGET_EXCLUDE:-(none)}" echo "Include: ${TARGET_INCLUDE:-(none)}" echo "Destination: ${TARGET_REMOTE:-(all)}" echo "Retention: ${TARGET_RETENTION:-(default)}" echo "Pre-hook: ${TARGET_PRE_HOOK:-(none)}" echo "Post-hook: ${TARGET_POST_HOOK:-(none)}" echo "" echo "--- Source Type ---" echo "Type: $TARGET_SOURCE_TYPE" case "$TARGET_SOURCE_TYPE" in ssh) echo "Host: $TARGET_SOURCE_HOST" echo "Port: $TARGET_SOURCE_PORT" echo "User: $TARGET_SOURCE_USER" echo "Auth method: $TARGET_SOURCE_AUTH_METHOD" if [[ "$TARGET_SOURCE_AUTH_METHOD" == "key" ]]; then echo "Key: ${TARGET_SOURCE_KEY:-(default)}" else echo "Password: ****" fi ;; s3) echo "Bucket: $TARGET_SOURCE_S3_BUCKET" echo "Region: $TARGET_SOURCE_S3_REGION" echo "Endpoint: ${TARGET_SOURCE_S3_ENDPOINT:-(default)}" echo "Access Key: ${TARGET_SOURCE_S3_ACCESS_KEY_ID:+****}" echo "Secret Key: ${TARGET_SOURCE_S3_SECRET_ACCESS_KEY:+****}" ;; gdrive) echo "SA File: $TARGET_SOURCE_GDRIVE_SERVICE_ACCOUNT_FILE" echo "Root Folder ID: ${TARGET_SOURCE_GDRIVE_ROOT_FOLDER_ID:-(root)}" ;; esac if [[ "$TARGET_MYSQL_ENABLED" == "yes" ]]; then echo "" echo "--- MySQL ---" echo "Enabled: yes" echo "Mode: $TARGET_MYSQL_MODE" echo "Databases: ${TARGET_MYSQL_DATABASES:-(all)}" echo "Exclude: ${TARGET_MYSQL_EXCLUDE:-(none)}" echo "User: ${TARGET_MYSQL_USER:-(current)}" echo "Password: ${TARGET_MYSQL_PASSWORD:+****}" echo "Host: $TARGET_MYSQL_HOST" echo "Port: $TARGET_MYSQL_PORT" echo "Extra opts: $TARGET_MYSQL_EXTRA_OPTS" fi ;; *) die "Unknown sources action: $action (expected list|add|delete|show)" ;; esac ;; destinations) 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 destinations configured." else echo "$remotes" fi ;; add) [[ -z "$name" ]] && die "destinations add requires --name=NAME" echo "Use the TUI or manually create $CONFIG_DIR/remotes.d/${name}.conf to configure the destination." ;; delete) [[ -z "$name" ]] && die "destinations delete requires --name=NAME" local conf="$CONFIG_DIR/remotes.d/${name}.conf" [[ ! -f "$conf" ]] && die "Destination config not found: $conf" rm -f "$conf" log_info "Deleted destination config: $conf" ;; show) [[ -z "$name" ]] && die "destinations show requires --name=NAME" load_remote "$name" echo "=== Destination: $name ===" echo "" echo "Type: $REMOTE_TYPE" case "$REMOTE_TYPE" in ssh) echo "Host: $REMOTE_HOST" echo "Port: $REMOTE_PORT" echo "User: $REMOTE_USER" echo "Auth method: $REMOTE_AUTH_METHOD" if [[ "$REMOTE_AUTH_METHOD" == "key" ]]; then echo "Key: ${REMOTE_KEY:-(default)}" else echo "Password: ****" fi echo "Base path: $REMOTE_BASE" ;; local) echo "Base path: $REMOTE_BASE" ;; s3) echo "Bucket: ${S3_BUCKET:-}" echo "Region: ${S3_REGION:-}" echo "Endpoint: ${S3_ENDPOINT:-(default)}" echo "Access Key: ${S3_ACCESS_KEY_ID:+****}" echo "Secret Key: ${S3_SECRET_ACCESS_KEY:+****}" echo "Base path: $REMOTE_BASE" ;; gdrive) echo "SA File: ${GDRIVE_SERVICE_ACCOUNT_FILE:-}" echo "Root Folder ID: ${GDRIVE_ROOT_FOLDER_ID:-(root)}" echo "Base path: $REMOTE_BASE" ;; esac echo "Bandwidth: ${BWLIMIT:-0} KB/s" echo "Retention: $RETENTION_COUNT snapshots" ;; test) [[ -z "$name" ]] && die "destinations test requires --name=NAME" validate_remote "$name" echo "Destination '$name' is valid." ;; disk-info-short) [[ -z "$name" ]] && die "destinations disk-info-short requires --name=NAME" load_remote "$name" remote_disk_info_short ;; *) die "Unknown destinations action: $action (expected list|add|delete|show|test|disk-info-short)" ;; esac ;; snapshots) local action="${SUBCMD_ARGS[0]:-list}" local target="" remote="" target=$(_parse_flag "--source" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true remote=$(_parse_flag "--destination" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true case "$action" in list) if [[ -z "$remote" ]]; then remote=$(list_remotes | head -1) [[ -z "$remote" ]] && die "No destinations configured" fi _save_remote_globals load_remote "$remote" || die "Failed to load destination: $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 --source=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 destinations 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 "--source" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true remote=$(_parse_flag "--destination" "${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 destinations configured" fi _save_remote_globals load_remote "$remote" || die "Failed to load destination: $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 ;; scheduled-run) local sched_name="" target="" remote="" sched_name=$(_parse_flag "--schedule" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true target=$(_parse_flag "--source" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true remote=$(_parse_flag "--destination" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true [[ -z "$sched_name" ]] && die "scheduled-run requires --schedule=NAME" trap release_all_target_locks EXIT local rc=0 if [[ -n "$target" && "$target" == *,* ]]; then local IFS=',' local names read -ra names <<< "$target" for t in "${names[@]}"; do t="${t#"${t%%[![:space:]]*}"}" t="${t%"${t##*[![:space:]]}"}" [[ -z "$t" ]] && continue backup_target "$t" "$remote" || rc=$? done elif [[ -n "$target" ]]; then backup_target "$target" "$remote" || rc=$? else backup_all_targets "$remote" || rc=$? fi # Stamp LAST_RUN on success if (( rc == 0 )); then local conf="$CONFIG_DIR/schedules.d/${sched_name}.conf" if [[ -f "$conf" ]]; then sed -i '/^LAST_RUN=/d' "$conf" echo "LAST_RUN=\"$(date '+%Y-%m-%d %H:%M')\"" >> "$conf" fi fi exit "$rc" ;; test-email) if [[ -z "${NOTIFY_EMAIL:-}" ]]; then echo "Error: NOTIFY_EMAIL is not set. Configure it in Settings first." exit 1 fi if [[ -z "${SMTP_HOST:-}" ]]; then echo "Error: SMTP_HOST is not set. Configure SMTP settings first." exit 1 fi local hostname; hostname=$(hostname -f 2>/dev/null || hostname) local subject="[gniza] [$hostname] Test Email" local body="This is a test email from gniza on $hostname."$'\n' body+="Sent at: $(date '+%Y-%m-%d %H:%M:%S')"$'\n' body+=""$'\n' body+="If you received this message, your SMTP settings are working correctly." if _send_via_smtp "$subject" "$body"; then echo "Test email sent successfully to $NOTIFY_EMAIL" else echo "Failed to send test email. Check your SMTP settings." exit 1 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=(--web) [[ -n "$_web_port" ]] && _web_args+=(--port="$_web_port") [[ -n "$_web_host" ]] && _web_args+=(--host="$_web_host") PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "${_web_args[@]}" ;; install-service) if [[ $EUID -eq 0 ]]; then _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 else _user_service_dir="$HOME/.config/systemd/user" mkdir -p "$_user_service_dir" cat > "$_user_service_dir/gniza-web.service" </dev/null || true systemctl disable gniza-web 2>/dev/null || true rm -f /etc/systemd/system/gniza-web.service systemctl daemon-reload else systemctl --user stop gniza-web 2>/dev/null || true systemctl --user disable gniza-web 2>/dev/null || true rm -f "$HOME/.config/systemd/user/gniza-web.service" systemctl --user daemon-reload 2>/dev/null || true fi echo "GNIZA web service removed." ;; status) if [[ $EUID -eq 0 ]]; then systemctl status gniza-web 2>/dev/null || echo "GNIZA web service is not installed." else systemctl --user status gniza-web 2>/dev/null || echo "GNIZA web service is not installed." fi ;; *) die "Unknown web action: $_web_action (expected start|install-service|remove-service|status)" ;; esac elif [[ "$SUBCOMMAND" == "uninstall" ]]; then _uninstall_script="$GNIZA_DIR/scripts/uninstall.sh" if [[ -f "$_uninstall_script" ]]; then exec bash "$_uninstall_script" else die "Uninstall script not found: $_uninstall_script" fi 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